Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

56
scripts/e2e/Dockerfile Normal file
View File

@@ -0,0 +1,56 @@
# syntax=docker/dockerfile:1.7
#
# Shared Docker E2E image.
# `bare` is a clean Node/Git runner for install/update lanes. `functional`
# installs the prepared OpenClaw npm tarball into /app for built-app lanes.
FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf AS e2e-runner
# python3 covers package/plugin install paths that execute helper scripts.
# procps provides pgrep for E2E watchdogs that assert no package-manager work is
# still running after Gateway readiness.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git procps python3 \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable
RUN npm install -g tsx@4.21.0 --no-fund --no-audit
RUN useradd --create-home --shell /bin/bash appuser \
&& mkdir -p /app \
&& chown appuser:appuser /app
ENV HOME="/home/appuser"
ENV PATH="/home/appuser/.local/bin:${PATH}"
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
# Docker E2E lanes start many loopback gateways concurrently; mDNS advertising
# is unrelated to those checks and can flap under container CPU/network load.
ENV OPENCLAW_DISABLE_BONJOUR="1"
USER appuser
WORKDIR /app
FROM e2e-runner AS bare
CMD ["bash"]
FROM bare AS build
CMD ["bash"]
FROM bare AS functional
# The app under test enters through the named BuildKit context, not by copying
# checkout sources into the image.
COPY --from=openclaw_package --chown=appuser:appuser openclaw-current.tgz /tmp/openclaw-current.tgz
# Preserve package self-reference imports such as openclaw/plugin-sdk/* after
# copying the installed package out of npm's global node_modules tree.
RUN npm install -g --prefix /tmp/openclaw-prefix /tmp/openclaw-current.tgz --no-fund --no-audit \
&& cp -a /tmp/openclaw-prefix/lib/node_modules/openclaw/. /app/ \
&& mkdir -p "$HOME/.local/bin" \
&& ln -sf /app/openclaw.mjs "$HOME/.local/bin/openclaw" \
&& rm -rf /app/node_modules/openclaw \
&& ln -sf /app /app/node_modules/openclaw \
&& rm -rf /tmp/openclaw-prefix /tmp/openclaw-current.tgz
CMD ["bash"]

View File

@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1.7
FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf
RUN corepack enable
RUN useradd --create-home --shell /bin/bash appuser \
&& mkdir -p /app \
&& chown appuser:appuser /app
ENV HOME="/home/appuser"
USER appuser
WORKDIR /app
COPY --chown=appuser:appuser package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY --chown=appuser:appuser ui/package.json ./ui/package.json
COPY --chown=appuser:appuser patches ./patches
# This image only exercises the root QR runtime dependency path.
# Keep the pre-install copy set limited to the manifests needed for root
# workspace resolution so unrelated extension edits do not bust the layer.
ARG OPENCLAW_QR_INSTALL_CACHE_BUSTER=stable
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/home/appuser/.local/share/pnpm/store,sharing=locked \
printf '%s\n' "$OPENCLAW_QR_INSTALL_CACHE_BUSTER" >/tmp/openclaw-qr-install-cache-buster && \
if ! pnpm install --frozen-lockfile --ignore-scripts >/tmp/openclaw-qr-pnpm-install.log 2>&1; then \
cat /tmp/openclaw-qr-pnpm-install.log; \
exit 1; \
fi
COPY --chown=appuser:appuser . .

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Verifies embedded OpenClaw bundle MCP tool materialization and tool-policy behavior
# inside the package-installed functional E2E image.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-agent-bundle-mcp-tools-e2e" OPENCLAW_IMAGE)"
CONTAINER_NAME="openclaw-agent-bundle-mcp-tools-e2e-$$"
RUN_LOG="$(mktemp -t openclaw-agent-bundle-mcp-tools-log.XXXXXX)"
cleanup() {
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
rm -f "$RUN_LOG"
}
trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" agent-bundle-mcp-tools
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 agent-bundle-mcp-tools empty)"
echo "Running in-container OpenClaw bundle MCP tool availability smoke..."
# Harness files are mounted read-only; the app under test comes from /app/dist.
set +e
docker_e2e_run_with_harness \
--name "$CONTAINER_NAME" \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"$IMAGE_NAME" \
bash -lc "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}\"
tsx test/e2e/qa-lab/runtime/agent-bundle-mcp-tools-docker-client.ts
" >"$RUN_LOG" 2>&1
status=${PIPESTATUS[0]}
set -e
if [ "$status" -ne 0 ]; then
echo "Docker OpenClaw bundle MCP tool availability smoke failed"
docker_e2e_print_log "$RUN_LOG"
exit "$status"
fi
docker_e2e_print_log "$RUN_LOG"
echo "OK"

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-agents-delete-shared-workspace-e2e:local" OPENCLAW_AGENTS_DELETE_SHARED_WORKSPACE_E2E_IMAGE)"
SKIP_BUILD="${OPENCLAW_AGENTS_DELETE_SHARED_WORKSPACE_E2E_SKIP_BUILD:-0}"
DOCKER_COMMAND_TIMEOUT="${OPENCLAW_AGENTS_DELETE_SHARED_WORKSPACE_DOCKER_COMMAND_TIMEOUT:-300s}"
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 agents-delete-shared-workspace empty)"
docker_e2e_build_or_reuse "$IMAGE_NAME" agents-delete-shared-workspace "$ROOT_DIR/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD"
docker_e2e_harness_mount_args
run_logged agents-delete-shared-workspace docker_e2e_docker_cmd run --rm \
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
--entrypoint bash \
-e OPENCLAW_SKIP_CHANNELS=1 \
-e OPENCLAW_SKIP_PROVIDERS=1 \
-e OPENCLAW_SKIP_GMAIL_WATCHER=1 \
-e OPENCLAW_SKIP_CRON=1 \
-e OPENCLAW_SKIP_CANVAS_HOST=1 \
-e OPENCLAW_SKIP_BROWSER_CONTROL_SERVER=1 \
-e OPENCLAW_SKIP_ACPX_RUNTIME=1 \
-e OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1 \
-e OPENCLAW_GATEWAY_TOKEN=agents-delete-shared-workspace-token \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"$IMAGE_NAME" \
-lc '
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
run_openclaw() {
if command -v openclaw >/dev/null 2>&1; then
openclaw "$@"
return
fi
if [ -f /app/openclaw.mjs ]; then
node /app/openclaw.mjs "$@"
return
fi
echo "openclaw CLI not found in Docker image" >&2
exit 1
}
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
export SHARED_WORKSPACE="$HOME/workspace-shared"
output_file="$HOME/delete.json"
trap '\''rm -rf "$HOME"'\'' EXIT
mkdir -p "$OPENCLAW_STATE_DIR" "$SHARED_WORKSPACE"
node scripts/e2e/lib/fixture.mjs agents-delete-config
run_openclaw agents delete ops --force --json > "$output_file"
node scripts/e2e/lib/fixture.mjs agents-delete-assert "$output_file"
'

View File

@@ -0,0 +1,115 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
BASE_IMAGE="$(docker_e2e_resolve_image "openclaw-browser-cdp-base-e2e" OPENCLAW_BROWSER_CDP_BASE_E2E_IMAGE)"
if [ -n "${OPENCLAW_BROWSER_CDP_SNAPSHOT_E2E_IMAGE:-}" ]; then
IMAGE_NAME="$OPENCLAW_BROWSER_CDP_SNAPSHOT_E2E_IMAGE"
DERIVED_SHARED_IMAGE="0"
elif [ -n "${OPENCLAW_DOCKER_E2E_IMAGE:-}" ]; then
IMAGE_NAME="openclaw-browser-cdp-snapshot-e2e:${OPENCLAW_DOCKER_ALL_LANE_NAME:-shared}"
DERIVED_SHARED_IMAGE="1"
else
IMAGE_NAME="openclaw-browser-cdp-snapshot-e2e"
DERIVED_SHARED_IMAGE="0"
fi
SKIP_BUILD="${OPENCLAW_BROWSER_CDP_SNAPSHOT_E2E_SKIP_BUILD:-0}"
PORT="18789"
CDP_PORT="19222"
FIXTURE_PORT="18080"
TOKEN="browser-cdp-e2e-token"
CONTAINER_NAME="openclaw-browser-cdp-e2e-$$"
DOCKER_COMMAND_TIMEOUT="${OPENCLAW_BROWSER_CDP_SNAPSHOT_DOCKER_COMMAND_TIMEOUT:-900s}"
SNAPSHOT_MAX_BYTES="$(docker_e2e_read_positive_int_env OPENCLAW_BROWSER_CDP_SNAPSHOT_MAX_BYTES 524288)"
cleanup() {
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
}
trap cleanup EXIT
# Targeted Docker runs reuse the shared functional image as the base, but this
# lane still needs a derived image with Chromium installed.
if [ "$SKIP_BUILD" = "1" ] || { [ "$DERIVED_SHARED_IMAGE" = "0" ] && [ "${OPENCLAW_SKIP_DOCKER_BUILD:-0}" = "1" ]; }; then
echo "Reusing Docker image: $IMAGE_NAME"
docker_e2e_docker_cmd image inspect "$IMAGE_NAME" >/dev/null
else
docker_e2e_build_or_reuse "$BASE_IMAGE" browser-cdp-base "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "0"
build_dir="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-browser-cdp-build.XXXXXX")"
trap 'cleanup; rm -rf "$build_dir"' EXIT
cat >"$build_dir/Dockerfile" <<EOF
FROM $BASE_IMAGE
USER root
RUN apt-get update \\
&& apt-get install -y --no-install-recommends chromium fonts-liberation procps \\
&& rm -rf /var/lib/apt/lists/*
USER appuser
EOF
echo "Building Docker image: $IMAGE_NAME"
docker_build_run browser-cdp-snapshot-build -t "$IMAGE_NAME" -f "$build_dir/Dockerfile" "$build_dir"
fi
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_e2e_docker_cmd run -d \
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
--name "$CONTAINER_NAME" \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e OPENCLAW_GATEWAY_TOKEN="$TOKEN" \
-e OPENCLAW_DISABLE_BONJOUR=1 \
-e OPENCLAW_SKIP_CHANNELS=1 \
-e OPENCLAW_SKIP_PROVIDERS=1 \
-e OPENCLAW_SKIP_GMAIL_WATCHER=1 \
-e OPENCLAW_SKIP_CRON=1 \
-e OPENCLAW_SKIP_CANVAS_HOST=1 \
-e "OPENCLAW_BROWSER_CDP_SNAPSHOT_MAX_BYTES=$SNAPSHOT_MAX_BYTES" \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"$IMAGE_NAME" \
bash -lc "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}\"
openclaw_e2e_write_state_env
entry=\"\$(openclaw_e2e_resolve_entrypoint)\"
mkdir -p /tmp/openclaw-browser-cdp/chrome
find dist -maxdepth 1 -type f -name 'pw-ai-*.js' ! -name 'pw-ai-state-*' -exec mv {} /tmp/openclaw-browser-cdp/ \;
PORT=$PORT CDP_PORT=$CDP_PORT node scripts/e2e/lib/fixture.mjs browser-cdp
chromium --headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage \\
--remote-debugging-address=127.0.0.1 \\
--remote-debugging-port=$CDP_PORT \\
--user-data-dir=/tmp/openclaw-browser-cdp/chrome \\
about:blank >/tmp/browser-cdp-chromium.log 2>&1 &
FIXTURE_PORT=$FIXTURE_PORT node scripts/e2e/lib/browser-cdp-snapshot/fixture-server.mjs >/tmp/browser-cdp-fixture.log 2>&1 &
openclaw_e2e_exec_gateway \"\$entry\" $PORT loopback /tmp/browser-cdp-gateway.log" >/dev/null
echo "Waiting for Chromium and Gateway..."
if ! docker_e2e_wait_container_bash "$CONTAINER_NAME" 180 0.5 "
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_probe_http_status http://127.0.0.1:$CDP_PORT/json/version
openclaw_e2e_probe_tcp 127.0.0.1 $PORT
"; then
echo "Browser CDP snapshot container failed to become ready"
docker_e2e_tail_container_file_if_running "$CONTAINER_NAME" "/tmp/browser-cdp-chromium.log /tmp/browser-cdp-gateway.log /tmp/browser-cdp-fixture.log" 120
exit 1
fi
echo "Running browser CDP snapshot smoke..."
if ! docker_e2e_docker_cmd exec "$CONTAINER_NAME" bash -lc "
set -euo pipefail
source /tmp/openclaw-test-state-env
source scripts/lib/openclaw-e2e-instance.sh
entry=\"\$(openclaw_e2e_resolve_entrypoint)\"
base_args=(--url ws://127.0.0.1:$PORT --token '$TOKEN')
node \"\$entry\" browser \"\${base_args[@]}\" --browser-profile docker-cdp doctor --deep >/tmp/browser-cdp-doctor.txt
grep -q 'OK live-snapshot' /tmp/browser-cdp-doctor.txt
node \"\$entry\" browser \"\${base_args[@]}\" --browser-profile docker-cdp open http://127.0.0.1:$FIXTURE_PORT/ >/tmp/browser-cdp-open.txt
node \"\$entry\" browser \"\${base_args[@]}\" --browser-profile docker-cdp snapshot --interactive --urls --out /tmp/browser-cdp-snapshot.txt >/tmp/browser-cdp-snapshot.out
node scripts/e2e/lib/browser-cdp-snapshot/assert-snapshot.mjs /tmp/browser-cdp-snapshot.txt
"; then
echo "Browser CDP snapshot smoke failed"
docker_e2e_tail_container_file_if_running "$CONTAINER_NAME" "/tmp/browser-cdp-doctor.txt /tmp/browser-cdp-open.txt /tmp/browser-cdp-snapshot.out /tmp/browser-cdp-snapshot.txt /tmp/browser-cdp-chromium.log /tmp/browser-cdp-gateway.log /tmp/browser-cdp-fixture.log" 200
exit 1
fi
echo "Browser CDP snapshot Docker E2E passed."

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-docker-e2e-functional:local")"
DOCKER_TARGET="${OPENCLAW_DOCKER_E2E_TARGET:-functional}"
docker_e2e_build_or_reuse "$IMAGE_NAME" docker-e2e "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "$DOCKER_TARGET"

View File

@@ -0,0 +1,218 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
read_positive_int_env() {
local name="${1:?missing environment variable name}"
local fallback="${2:?missing fallback value}"
local value="${!name-}"
if [ -z "${!name+x}" ]; then
value="$fallback"
fi
if [[ ! "$value" =~ ^[0-9]+$ ]] || (( 10#$value < 1 )); then
echo "invalid $name: $value" >&2
return 2
fi
printf "%s\n" "$((10#$value))"
}
BUN_BIN="${BUN_BIN:-bun}"
HOST_BUILD="${OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD:-1}"
DIST_IMAGE="${OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE:-}"
PACKAGE_TGZ="${OPENCLAW_BUN_GLOBAL_SMOKE_PACKAGE_TGZ:-}"
COMMAND_TIMEOUT_MS="$(read_positive_int_env OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_MS 180000)"
DOCKER_COMMAND_TIMEOUT="${DOCKER_COMMAND_TIMEOUT:-${OPENCLAW_BUN_GLOBAL_SMOKE_DOCKER_COMMAND_TIMEOUT:-600s}}"
SMOKE_DIR=""
PACK_DIR=""
cleanup() {
if [ -n "${SMOKE_DIR:-}" ]; then
rm -rf "$SMOKE_DIR"
fi
if [ -n "${PACK_DIR:-}" ]; then
rm -rf "$PACK_DIR"
fi
}
trap cleanup EXIT
run_with_timeout() {
local timeout_ms="$1"
shift
node scripts/e2e/lib/bun-global-install/assertions.mjs run-with-timeout "$timeout_ms" "$@"
}
resolve_pack_tarball_path() {
local pack_json_file="$1"
local pack_dir="$2"
node -e '
const fs = require("node:fs");
const path = require("node:path");
const raw = fs.readFileSync(process.argv[1], "utf8") || "[]";
const parsed = JSON.parse(raw);
const last = Array.isArray(parsed) ? parsed.at(-1) : null;
const filename = typeof last?.filename === "string" ? last.filename.trim() : "";
if (
!filename.endsWith(".tgz") ||
filename.includes("\0") ||
filename !== path.basename(filename) ||
filename !== path.win32.basename(filename)
) {
console.error(`ERROR: npm pack reported unsafe tarball filename ${JSON.stringify(filename)}`);
process.exit(1);
}
process.stdout.write(path.resolve(process.argv[2], filename));
' "$pack_json_file" "$pack_dir"
}
restore_dist_from_image() {
local image="$1"
local backup_dir=""
local container_id=""
local swapped=0
local temp_dir=""
cleanup_restore_dist() {
if [ -n "$container_id" ]; then
docker_e2e_docker_cmd rm -f "$container_id" >/dev/null 2>&1 || true
fi
if [ "$swapped" != "1" ] && [ -n "$backup_dir" ] && [ -d "$backup_dir" ]; then
rm -rf "$ROOT_DIR/dist" >/dev/null 2>&1 || true
if [ ! -e "$ROOT_DIR/dist" ] && mv "$backup_dir" "$ROOT_DIR/dist" >/dev/null 2>&1; then
backup_dir=""
fi
fi
if [ -n "$temp_dir" ]; then
rm -rf "$temp_dir"
fi
if [ "$swapped" = "1" ] && [ -n "$backup_dir" ]; then
rm -rf "$backup_dir"
fi
}
echo "==> Reuse dist/ from Docker image: $image"
if ! container_id="$(docker_e2e_docker_cmd create "$image")"; then
cleanup_restore_dist
return 1
fi
if ! temp_dir="$(mktemp -d "$ROOT_DIR/.bun-dist.XXXXXX")"; then
cleanup_restore_dist
return 1
fi
if ! docker_e2e_docker_cmd cp "${container_id}:/app/dist" "$temp_dir/dist"; then
cleanup_restore_dist
return 1
fi
if [ -e "$ROOT_DIR/dist" ]; then
if ! backup_dir="$(mktemp -d "$ROOT_DIR/.dist-backup.XXXXXX")"; then
cleanup_restore_dist
return 1
fi
if ! rmdir "$backup_dir"; then
cleanup_restore_dist
return 1
fi
if ! mv "$ROOT_DIR/dist" "$backup_dir"; then
cleanup_restore_dist
return 1
fi
fi
if ! mv "$temp_dir/dist" "$ROOT_DIR/dist"; then
cleanup_restore_dist
return 1
fi
swapped=1
cleanup_restore_dist
}
resolve_package_tgz() {
if [ -n "$PACKAGE_TGZ" ]; then
if [ ! -f "$PACKAGE_TGZ" ]; then
echo "OPENCLAW_BUN_GLOBAL_SMOKE_PACKAGE_TGZ does not exist: $PACKAGE_TGZ" >&2
exit 1
fi
PACKAGE_TGZ="$(cd "$(dirname "$PACKAGE_TGZ")" && pwd)/$(basename "$PACKAGE_TGZ")"
return 0
fi
if [ -n "$DIST_IMAGE" ]; then
restore_dist_from_image "$DIST_IMAGE"
elif [ "$HOST_BUILD" != "0" ]; then
echo "==> Build host package artifacts"
pnpm build
else
echo "==> Skipping host build (OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD=0)"
fi
if [ ! -d "$ROOT_DIR/dist" ]; then
echo "dist/ is missing; run pnpm build or set OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE" >&2
exit 1
fi
echo "==> Write package inventory"
node --import tsx scripts/write-package-dist-inventory.ts
local pack_json_file
PACK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-bun-pack.XXXXXX")"
pack_json_file="$PACK_DIR/pack.json"
echo "==> Pack OpenClaw tarball"
npm pack --ignore-scripts --json --pack-destination "$PACK_DIR" >"$pack_json_file"
PACKAGE_TGZ="$(resolve_pack_tarball_path "$pack_json_file" "$PACK_DIR")"
if [ -z "$PACKAGE_TGZ" ] || [ ! -f "$PACKAGE_TGZ" ]; then
echo "missing packed OpenClaw tarball" >&2
exit 1
fi
}
main() {
cd "$ROOT_DIR"
if ! command -v "$BUN_BIN" >/dev/null 2>&1; then
echo "Bun is required for bun global install smoke; set BUN_BIN or install bun." >&2
exit 1
fi
resolve_package_tgz
local bun_path
local openclaw_bin
bun_path="$(command -v "$BUN_BIN")"
SMOKE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-bun-global.XXXXXX")"
export HOME="$SMOKE_DIR/home"
export BUN_INSTALL="$HOME/.bun"
export XDG_CACHE_HOME="$SMOKE_DIR/cache"
export OPENCLAW_NO_ONBOARD=1
export OPENCLAW_DISABLE_UPDATE_CHECK=1
export NO_COLOR=1
mkdir -p "$HOME" "$BUN_INSTALL/bin" "$XDG_CACHE_HOME"
export PATH="$BUN_INSTALL/bin:$(dirname "$(command -v node)"):$PATH"
echo "==> Bun version"
"$bun_path" --version
echo "==> Bun global install packed OpenClaw"
"$bun_path" install -g "$PACKAGE_TGZ" --no-progress
openclaw_bin="$BUN_INSTALL/bin/openclaw"
if [ ! -x "$openclaw_bin" ]; then
openclaw_bin="$(command -v openclaw || true)"
fi
if [ -z "$openclaw_bin" ] || [ ! -x "$openclaw_bin" ]; then
echo "Bun global install did not create an executable openclaw binary" >&2
exit 1
fi
echo "==> OpenClaw version through Bun global install"
run_with_timeout "$COMMAND_TIMEOUT_MS" "$openclaw_bin" --version
echo "==> OpenClaw image providers through Bun global install"
local providers_json
providers_json="$(run_with_timeout "$COMMAND_TIMEOUT_MS" "$openclaw_bin" infer image providers --json)"
OPENCLAW_IMAGE_PROVIDERS_JSON="$providers_json" node scripts/e2e/lib/bun-global-install/assertions.mjs assert-image-providers
}
main "$@"

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-bundled-plugin-install-uninstall-e2e" OPENCLAW_BUNDLED_PLUGIN_INSTALL_UNINSTALL_E2E_IMAGE)"
LIST_TIMEOUT_MS="$(
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_LIST_TIMEOUT_MS 30000
)"
LIST_MAX_BUFFER_BYTES="$(
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_LIST_MAX_BUFFER_BYTES 4194304
)"
RUNTIME_PORT_BASE="$(docker_e2e_read_tcp_port_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_PORT_BASE 19000)"
RUNTIME_OUTPUT_CHARS="$(
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_OUTPUT_CHARS 1048576
)"
RUNTIME_LOG_SCAN_BYTES="$(
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_LOG_SCAN_BYTES 262144
)"
RUNTIME_GATEWAY_LOG_BYTES="$(
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_GATEWAY_LOG_BYTES 16777216
)"
RUNTIME_READY_MS="$(
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_READY_MS 900000
)"
RUNTIME_RPC_MS="$(docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_RPC_MS 60000)"
RUNTIME_RPC_READY_MS="$(
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_RPC_READY_MS 210000
)"
RUNTIME_WATCHDOG_MS="$(
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_WATCHDOG_MS 1000
)"
RUNTIME_COMMAND_MS="$(
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_COMMAND_MS 120000
)"
RUNTIME_HTTP_MS="$(docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_HTTP_MS 5000)"
RUNTIME_TEARDOWN_GRACE_MS="$(
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_GRACE_MS 10000
)"
RUNTIME_TEARDOWN_KILL_GRACE_MS="$(
docker_e2e_read_positive_int_env OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_KILL_GRACE_MS 1000
)"
docker_e2e_build_or_reuse "$IMAGE_NAME" bundled-plugin-install-uninstall
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 bundled-plugin-install-uninstall empty)"
DOCKER_ENV_ARGS=(
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0
-e "OPENCLAW_BUNDLED_PLUGIN_LIST_TIMEOUT_MS=$LIST_TIMEOUT_MS"
-e "OPENCLAW_BUNDLED_PLUGIN_LIST_MAX_BUFFER_BYTES=$LIST_MAX_BUFFER_BYTES"
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_PORT_BASE=$RUNTIME_PORT_BASE"
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_OUTPUT_CHARS=$RUNTIME_OUTPUT_CHARS"
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_LOG_SCAN_BYTES=$RUNTIME_LOG_SCAN_BYTES"
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_GATEWAY_LOG_BYTES=$RUNTIME_GATEWAY_LOG_BYTES"
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_READY_MS=$RUNTIME_READY_MS"
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_RPC_MS=$RUNTIME_RPC_MS"
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_RPC_READY_MS=$RUNTIME_RPC_READY_MS"
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_WATCHDOG_MS=$RUNTIME_WATCHDOG_MS"
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_COMMAND_MS=$RUNTIME_COMMAND_MS"
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_HTTP_MS=$RUNTIME_HTTP_MS"
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_GRACE_MS=$RUNTIME_TEARDOWN_GRACE_MS"
-e "OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_KILL_GRACE_MS=$RUNTIME_TEARDOWN_KILL_GRACE_MS"
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64"
)
for env_name in \
OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL \
OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX \
OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS \
OPENCLAW_BUNDLED_PLUGIN_SWEEP_COMMAND_TIMEOUT \
OPENCLAW_BUNDLED_PLUGIN_RUNTIME_SMOKE \
OPENCLAW_BUNDLED_PLUGIN_TTS_LIVE_PROVIDER \
OPENCLAW_PLUGIN_LIFECYCLE_TRACE \
OPENAI_API_KEY; do
env_value="${!env_name:-}"
if [[ -n "$env_value" && "$env_value" != "undefined" && "$env_value" != "null" ]]; then
DOCKER_ENV_ARGS+=(-e "$env_name")
fi
done
echo "Running bundled plugin install/uninstall Docker E2E..."
RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-bundled-plugin-install-uninstall.XXXXXX")"
cleanup() {
rm -f "$RUN_LOG"
}
trap cleanup EXIT
if ! docker_e2e_run_with_harness \
"${DOCKER_ENV_ARGS[@]}" \
"$IMAGE_NAME" \
bash scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh 2>&1 |
tee "$RUN_LOG"
then
exit 1
fi
echo "OK"

View File

@@ -0,0 +1,324 @@
#!/usr/bin/env bash
set -euo pipefail
# Definition:
# Docker/package E2E proof for local channel plugin trust gating. The host
# mode builds or reuses the functional Docker image, then runs the container
# mode against the installed OpenClaw package.
#
# Parameters:
# --container: run the in-container scenario. Host mode is the default.
# OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_IMAGE: override the Docker image name.
# OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD=1: reuse/pull the image.
#
# Outputs:
# stdout logs each case and prints "Channel plugin trust Docker E2E passed."
# Exit 0 means both representative package-environment cases passed.
# Exit non-zero means the package build, Docker run, or trust assertion failed.
usage() {
cat <<'EOF'
Usage:
bash scripts/e2e/channel-plugin-trust-docker.sh [--container]
Description:
Proves the packaged OpenClaw CLI enforces local channel plugin trust for
plugins.load.paths entries in a clean Docker/package environment.
Options:
--container Run the in-container scenario. Used by the host wrapper.
-h, --help Show this help.
Environment:
OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_IMAGE Override Docker image name.
OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD Reuse/pull image instead of building.
OPENCLAW_TEST_STATE_SCRIPT_B64 Required in --container mode.
Outputs:
Prints case progress and PASS lines to stdout. Exits non-zero on assertion
failure and leaves the failing command output in the container log.
Examples:
bash scripts/e2e/channel-plugin-trust-docker.sh
OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD=1 bash scripts/e2e/channel-plugin-trust-docker.sh
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
run_openclaw() {
if command -v openclaw >/dev/null 2>&1; then
openclaw "$@"
return
fi
if [ -f /app/openclaw.mjs ]; then
node /app/openclaw.mjs "$@"
return
fi
echo "openclaw CLI not found in Docker image" >&2
exit 1
}
write_load_paths_fixture() {
local plugin_dir="${1:?missing plugin dir}"
local origin="${2:?missing origin}"
local plugin_id="e2e-load-paths-shadow"
local channel_id="e2e-load-paths"
mkdir -p "$plugin_dir"
cat >"$plugin_dir/package.json" <<EOF
{
"name": "@openclaw-e2e/$plugin_id",
"version": "0.0.0-e2e",
"private": true,
"openclaw": {
"extensions": ["./index.cjs"],
"setupEntry": "./setup-entry.cjs",
"channel": {
"id": "$channel_id",
"label": "E2E Load Paths",
"selectionLabel": "E2E Load Paths",
"docsPath": "/channels/$channel_id",
"blurb": "Docker E2E local trust fixture."
}
}
}
EOF
cat >"$plugin_dir/openclaw.plugin.json" <<EOF
{
"id": "$plugin_id",
"name": "E2E load-paths Shadow",
"description": "Docker E2E local trust fixture.",
"activation": { "onStartup": false },
"channels": ["$channel_id"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
EOF
cat >"$plugin_dir/index.cjs" <<EOF
const fs = require("node:fs");
const path = require("node:path");
const importMarker = process.env.PLUGINTRUST_IMPORT_MARKER;
const registerMarker = process.env.PLUGINTRUST_REGISTER_MARKER;
const canary = process.env.PLUGINTRUST_CANARY ?? "<no-canary>";
function writeMarker(target, payload) {
if (!target) return;
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, payload, "utf8");
}
writeMarker(importMarker, "imported|origin=$origin|canary=" + canary + "\\n");
module.exports = {
id: "$plugin_id",
register(api) {
writeMarker(registerMarker, "registered|origin=$origin|canary=" + canary + "\\n");
api.registerChannel({
plugin: {
id: "$channel_id",
meta: {
id: "$channel_id",
label: "E2E Load Paths",
selectionLabel: "E2E Load Paths",
docsPath: "/channels/$channel_id",
blurb: "Docker E2E local trust fixture.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
},
});
},
};
EOF
cat >"$plugin_dir/setup-entry.cjs" <<EOF
const fs = require("node:fs");
const path = require("node:path");
const importMarker = process.env.PLUGINTRUST_SETUP_IMPORT_MARKER;
const registerMarker = process.env.PLUGINTRUST_SETUP_REGISTER_MARKER;
const canary = process.env.PLUGINTRUST_CANARY ?? "<no-canary>";
function writeMarker(target, payload) {
if (!target) return;
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, payload, "utf8");
}
writeMarker(importMarker, "setup-imported|origin=$origin|canary=" + canary + "\\n");
module.exports = {
plugin: {
id: "$channel_id",
meta: {
id: "$channel_id",
label: "E2E Load Paths setup",
selectionLabel: "E2E Load Paths setup",
docsPath: "/channels/$channel_id",
blurb: "Docker E2E local trust setup fixture.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
setup: {
validateInput: ({ input }) => {
writeMarker(
registerMarker,
"setup-registered|origin=$origin|canary=" + canary + "|token=" + (input?.token ?? "<no-token>") + "\\n",
);
return null;
},
applyAccountConfig: ({ cfg }) => cfg,
},
},
};
EOF
}
write_case_config() {
local plugin_dir="${1:?missing plugin dir}"
local trusted="${2:?missing trusted flag}"
local plugin_id="e2e-load-paths-shadow"
mkdir -p "$(dirname "$OPENCLAW_CONFIG_PATH")"
if [ "$trusted" = "1" ]; then
cat >"$OPENCLAW_CONFIG_PATH" <<EOF
{
"plugins": {
"enabled": true,
"allow": ["$plugin_id"],
"load": {
"paths": ["$plugin_dir"]
}
}
}
EOF
else
cat >"$OPENCLAW_CONFIG_PATH" <<EOF
{
"plugins": {
"enabled": true,
"load": {
"paths": ["$plugin_dir"]
}
}
}
EOF
fi
}
run_case() {
local case_id="${1:?missing case id}"
local trusted="${2:?missing trusted flag}"
local scratch
scratch="$(mktemp -d "/tmp/openclaw-channel-plugin-trust-$case_id.XXXXXX")"
local plugin_dir="$scratch/e2e-load-paths-shadow"
local marker_dir="$scratch/markers"
local stdout_file="$scratch/stdout.log"
local stderr_file="$scratch/stderr.log"
local canary="$case_id-canary"
mkdir -p "$marker_dir"
write_load_paths_fixture "$plugin_dir" "config"
write_case_config "$plugin_dir" "$trusted"
echo "[CASE $case_id] plugins.load.paths trusted=$trusted"
set +e
PLUGINTRUST_IMPORT_MARKER="$marker_dir/import.marker" \
PLUGINTRUST_REGISTER_MARKER="$marker_dir/register.marker" \
PLUGINTRUST_SETUP_IMPORT_MARKER="$marker_dir/setup-import.marker" \
PLUGINTRUST_SETUP_REGISTER_MARKER="$marker_dir/setup-register.marker" \
PLUGINTRUST_CANARY="$canary" \
run_openclaw channels add --channel e2e-load-paths --token "$canary" \
>"$stdout_file" 2>"$stderr_file"
local status=$?
set -e
if [ "$trusted" = "1" ] && [ "$status" -ne 0 ]; then
echo "Expected trusted case to succeed; exit=$status" >&2
cat "$stderr_file" >&2 || true
exit 1
fi
if [ "$trusted" = "1" ]; then
for marker in setup-import setup-register; do
local marker_path="$marker_dir/$marker.marker"
if [ ! -f "$marker_path" ]; then
echo "Expected $marker marker for trusted case" >&2
cat "$stderr_file" >&2 || true
exit 1
fi
if ! grep -qF "canary=$canary" "$marker_path"; then
echo "$marker marker did not include canary $canary" >&2
cat "$marker_path" >&2 || true
exit 1
fi
done
echo "PASS: $case_id trusted load-paths setup entry executed"
else
for marker in setup-import setup-register import register; do
if [ -e "$marker_dir/$marker.marker" ]; then
echo "Expected $marker marker to be absent for untrusted case" >&2
cat "$marker_dir/$marker.marker" >&2 || true
exit 1
fi
done
echo "PASS: $case_id untrusted load-paths setup entry blocked"
fi
}
run_container() {
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_WORKSPACE_DIR="$HOME/.openclaw/workspace"
run_openclaw --version
run_case untrusted-load-paths 0
run_case trusted-load-paths 1
echo "Channel plugin trust Docker E2E passed."
}
run_host() {
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
local image_name
image_name="$(
docker_e2e_resolve_image \
"openclaw-channel-plugin-trust-e2e:local" \
OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_IMAGE
)"
local skip_build="${OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD:-0}"
docker_e2e_build_or_reuse "$image_name" channel-plugin-trust "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$skip_build"
local state_script_b64
state_script_b64="$(docker_e2e_test_state_shell_b64 channel-plugin-trust minimal)"
echo "Running channel plugin trust Docker E2E..."
docker_e2e_run_logged_print_with_harness \
channel-plugin-trust \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \
"$image_name" \
bash scripts/e2e/channel-plugin-trust-docker.sh --container
}
case "${1:-}" in
-h | --help)
usage
;;
--container)
run_container
;;
"")
run_host
;;
*)
echo "Unknown argument: $1" >&2
echo >&2
usage >&2
exit 1
;;
esac

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-codex-media-path-e2e" OPENCLAW_CODEX_MEDIA_PATH_E2E_IMAGE)"
PORT="$(docker_e2e_read_tcp_port_env OPENCLAW_CODEX_MEDIA_PATH_PORT 18790)"
TIMEOUT_SECONDS="$(docker_e2e_read_positive_int_env OPENCLAW_CODEX_MEDIA_PATH_TIMEOUT_SECONDS 180)"
LOG_TAIL_MAX_BYTES="$(docker_e2e_read_positive_int_env OPENCLAW_CODEX_MEDIA_PATH_LOG_TAIL_MAX_BYTES 2097152)"
TOKEN="codex-media-path-e2e-$$"
CODEX_PLUGIN_SPEC="${OPENCLAW_CODEX_MEDIA_PATH_PLUGIN_SPEC:-npm:@openclaw/codex}"
docker_e2e_build_or_reuse "$IMAGE_NAME" codex-media-path "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 codex-media-path empty)"
echo "Running Codex media-path Docker E2E..."
docker_e2e_run_logged_with_harness codex-media-path \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e "OPENCLAW_CODEX_MEDIA_PATH_PLUGIN_SPEC=$CODEX_PLUGIN_SPEC" \
-e "OPENCLAW_CODEX_MEDIA_PATH_LOG_TAIL_MAX_BYTES=$LOG_TAIL_MAX_BYTES" \
-e "OPENCLAW_CODEX_MEDIA_PATH_TIMEOUT_SECONDS=$TIMEOUT_SECONDS" \
-e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \
-e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
-e "PORT=$PORT" \
-v "$ROOT_DIR/src:/app/src:ro" \
-v "$ROOT_DIR/test/helpers:/app/test/helpers:ro" \
"$IMAGE_NAME" \
bash scripts/e2e/lib/codex-media-path/scenario.sh

View File

@@ -0,0 +1,365 @@
#!/usr/bin/env bash
# Installs OpenClaw from a prepared package tarball, installs @openclaw/codex
# from a registry/git/tarball spec, and verifies a live Codex app-server turn.
set -Eeuo pipefail
SCRIPT_ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
TRUSTED_HARNESS_DIR="${OPENCLAW_LIVE_DOCKER_TRUSTED_HARNESS_DIR:-$SCRIPT_ROOT_DIR}"
CANDIDATE_ROOT="${OPENCLAW_LIVE_DOCKER_REPO_ROOT:-$SCRIPT_ROOT_DIR}"
TRUSTED_HARNESS_DIR="$(cd "$TRUSTED_HARNESS_DIR" && pwd)"
CANDIDATE_ROOT="$(cd "$CANDIDATE_ROOT" && pwd)"
ROOT_DIR="$TRUSTED_HARNESS_DIR"
source "$TRUSTED_HARNESS_DIR/scripts/lib/docker-e2e-image.sh"
source "$TRUSTED_HARNESS_DIR/scripts/lib/docker-e2e-package.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-codex-npm-plugin-live-e2e" OPENCLAW_CODEX_NPM_PLUGIN_E2E_IMAGE)"
DOCKER_TARGET="${OPENCLAW_CODEX_NPM_PLUGIN_DOCKER_TARGET:-bare}"
HOST_BUILD="${OPENCLAW_CODEX_NPM_PLUGIN_HOST_BUILD:-1}"
PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}"
PROFILE_FILE="${OPENCLAW_CODEX_NPM_PLUGIN_PROFILE_FILE:-${OPENCLAW_TESTBOX_PROFILE_FILE:-$HOME/.openclaw-testbox-live.profile}}"
CODEX_PLUGIN_SPEC="${OPENCLAW_CODEX_NPM_PLUGIN_SPEC:-}"
CODEX_PLUGIN_MOUNT=()
CODEX_PLUGIN_PACK_DIR=""
ASSERT_MAX_TEXT_FILE_BYTES="$(
docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TEXT_FILE_BYTES 1048576
)"
ASSERT_MAX_ERROR_TAIL_BYTES="$(
docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_ERROR_TAIL_BYTES 65536
)"
ASSERT_MAX_TRANSCRIPT_FILES="$(
docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_FILES 64
)"
ASSERT_MAX_TRANSCRIPT_WALK_ENTRIES="$(
docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_WALK_ENTRIES 4096
)"
ASSERT_MAX_TRANSCRIPT_SCAN_BYTES="$(
docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_SCAN_BYTES 2097152
)"
AGENT_TURN_TIMEOUT_SECONDS="$(
docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS 420
)"
run_log=""
cleanup() {
if [ -n "${CODEX_PLUGIN_PACK_DIR:-}" ]; then
rm -rf "$CODEX_PLUGIN_PACK_DIR"
fi
if [ -n "${PACKAGE_TGZ:-}" ]; then
docker_e2e_cleanup_package_tgz "$PACKAGE_TGZ"
fi
if [ -n "${run_log:-}" ]; then
rm -f "$run_log"
fi
}
trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" codex-npm-plugin-live "$CANDIDATE_ROOT/scripts/e2e/Dockerfile" "$CANDIDATE_ROOT" "$DOCKER_TARGET"
prepare_package_tgz() {
if [ -n "$PACKAGE_TGZ" ]; then
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz codex-npm-plugin-live "$PACKAGE_TGZ")"
return 0
fi
if [ "$HOST_BUILD" = "0" ] && [ -z "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" ]; then
echo "OPENCLAW_CODEX_NPM_PLUGIN_HOST_BUILD=0 requires OPENCLAW_CURRENT_PACKAGE_TGZ" >&2
exit 1
fi
local harness_root="$ROOT_DIR"
ROOT_DIR="$CANDIDATE_ROOT"
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz codex-npm-plugin-live)"
ROOT_DIR="$harness_root"
}
prepare_package_tgz
prepare_codex_plugin_spec() {
local source_path
local container_path
local pack_output
if [ -z "$CODEX_PLUGIN_SPEC" ]; then
CODEX_PLUGIN_PACK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-codex-plugin-pack.XXXXXX")"
(
cd "$CANDIDATE_ROOT"
node scripts/lib/plugin-npm-runtime-build.mjs extensions/codex
node scripts/lib/plugin-npm-package-manifest.mjs --run extensions/codex -- \
npm pack --json --ignore-scripts --pack-destination "$CODEX_PLUGIN_PACK_DIR"
) >/tmp/openclaw-codex-plugin-pack.log 2>&1
pack_output=()
while IFS= read -r packed_file; do
pack_output+=("$packed_file")
done < <(find "$CODEX_PLUGIN_PACK_DIR" -maxdepth 1 -type f -name '*.tgz' | sort)
if [ "${#pack_output[@]}" -ne 1 ]; then
echo "Expected one packed Codex plugin tarball; found ${#pack_output[@]}." >&2
docker_e2e_print_log /tmp/openclaw-codex-plugin-pack.log >&2
exit 1
fi
source_path="${pack_output[0]}"
container_path="/tmp/$(basename "$source_path")"
CODEX_PLUGIN_MOUNT=(-v "$source_path":"$container_path":ro)
CODEX_PLUGIN_SPEC="npm-pack:$container_path"
return 0
fi
if [[ "$CODEX_PLUGIN_SPEC" == npm-pack:* ]]; then
source_path="${CODEX_PLUGIN_SPEC#npm-pack:}"
if [[ "$source_path" != /* ]]; then
source_path="$CANDIDATE_ROOT/$source_path"
fi
if [ ! -f "$source_path" ]; then
echo "Codex plugin npm-pack tarball not found: $source_path" >&2
exit 1
fi
container_path="/tmp/$(basename "$source_path")"
CODEX_PLUGIN_MOUNT=(-v "$source_path":"$container_path":ro)
CODEX_PLUGIN_SPEC="npm-pack:$container_path"
fi
}
prepare_codex_plugin_spec
PROFILE_MOUNT=()
PROFILE_STATUS="none"
if [ -f "$PROFILE_FILE" ] && [ -r "$PROFILE_FILE" ]; then
set -a
# shellcheck disable=SC1090
source "$PROFILE_FILE"
set +a
PROFILE_MOUNT=(-v "$PROFILE_FILE":/home/appuser/.profile:ro)
PROFILE_STATUS="$PROFILE_FILE"
fi
AGENT_TURN_TIMEOUT_SECONDS="$(
docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS "$AGENT_TURN_TIMEOUT_SECONDS"
)"
COMMAND_TIMEOUT="${OPENCLAW_E2E_COMMAND_TIMEOUT:-$((10#$AGENT_TURN_TIMEOUT_SECONDS + 60))s}"
docker_e2e_package_mount_args "$PACKAGE_TGZ"
run_log="$(docker_e2e_run_log codex-npm-plugin-live)"
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 codex-npm-plugin-live empty)"
echo "Running Codex npm plugin live Docker E2E..."
echo "Profile file: $PROFILE_STATUS"
echo "Codex plugin spec: $CODEX_PLUGIN_SPEC"
if ! docker_e2e_run_with_harness \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e OPENCLAW_CODEX_NPM_PLUGIN_ALLOW_BETA_COMPAT_DIAGNOSTICS="${OPENCLAW_CODEX_NPM_PLUGIN_ALLOW_BETA_COMPAT_DIAGNOSTICS:-0}" \
-e OPENCLAW_CODEX_NPM_PLUGIN_FORCE_UNSAFE_INSTALL="${OPENCLAW_CODEX_NPM_PLUGIN_FORCE_UNSAFE_INSTALL:-1}" \
-e OPENCLAW_CODEX_NPM_PLUGIN_MODEL="${OPENCLAW_CODEX_NPM_PLUGIN_MODEL:-codex/gpt-5.4}" \
-e OPENCLAW_CODEX_NPM_PLUGIN_SPEC="$CODEX_PLUGIN_SPEC" \
-e "OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TEXT_FILE_BYTES=$ASSERT_MAX_TEXT_FILE_BYTES" \
-e "OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_ERROR_TAIL_BYTES=$ASSERT_MAX_ERROR_TAIL_BYTES" \
-e "OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_FILES=$ASSERT_MAX_TRANSCRIPT_FILES" \
-e "OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_WALK_ENTRIES=$ASSERT_MAX_TRANSCRIPT_WALK_ENTRIES" \
-e "OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_SCAN_BYTES=$ASSERT_MAX_TRANSCRIPT_SCAN_BYTES" \
-e "OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS=$AGENT_TURN_TIMEOUT_SECONDS" \
-e "OPENCLAW_E2E_COMMAND_TIMEOUT=$COMMAND_TIMEOUT" \
-e OPENAI_API_KEY \
-e OPENAI_BASE_URL \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
"${CODEX_PLUGIN_MOUNT[@]}" \
"${PROFILE_MOUNT[@]}" \
-i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF'; then
set -Eeuo pipefail
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
export NPM_CONFIG_PREFIX="$HOME/.npm-global"
export npm_config_prefix="$NPM_CONFIG_PREFIX"
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
export NPM_CONFIG_CACHE="${NPM_CONFIG_CACHE:-$XDG_CACHE_HOME/npm}"
export npm_config_cache="$NPM_CONFIG_CACHE"
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
export OPENCLAW_AGENT_HARNESS_FALLBACK=none
for profile_path in "$HOME/.profile" /home/appuser/.profile; do
if [ -f "$profile_path" ] && [ -r "$profile_path" ]; then
set +e +u
source "$profile_path"
set -Eeuo pipefail
break
fi
done
if [ -z "${OPENAI_API_KEY:-}" ]; then
echo "ERROR: OPENAI_API_KEY was not available after sourcing ~/.profile." >&2
exit 1
fi
export OPENAI_API_KEY
if [ -n "${OPENAI_BASE_URL:-}" ]; then
export OPENAI_BASE_URL
fi
CODEX_PLUGIN_SPEC="${OPENCLAW_CODEX_NPM_PLUGIN_SPEC:?missing OPENCLAW_CODEX_NPM_PLUGIN_SPEC}"
MODEL_REF="${OPENCLAW_CODEX_NPM_PLUGIN_MODEL:?missing OPENCLAW_CODEX_NPM_PLUGIN_MODEL}"
POST_UNINSTALL_MODEL_REF="codex/${MODEL_REF#*/}"
SESSION_ID="codex-npm-plugin-live"
SUCCESS_MARKER="OPENCLAW-CODEX-NPM-PLUGIN-LIVE-OK"
AGENT_TURN_TIMEOUT_SECONDS="${OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS:-420}"
PLUGIN_INSTALL_FLAGS=(--force)
if [ "${OPENCLAW_CODEX_NPM_PLUGIN_FORCE_UNSAFE_INSTALL:-0}" = "1" ]; then
PLUGIN_INSTALL_FLAGS+=(--dangerously-force-unsafe-install)
fi
dump_debug_logs() {
local status="$1"
echo "Codex npm plugin live scenario failed with exit code $status" >&2
openclaw_e2e_dump_logs \
/tmp/openclaw-install.log \
/tmp/openclaw-codex-plugin-install.log \
/tmp/openclaw-codex-plugin-enable.log \
/tmp/openclaw-codex-plugins-list.json \
/tmp/openclaw-codex-plugin-inspect.json \
/tmp/openclaw-codex-preflight.log \
/tmp/openclaw-codex-agent.json \
/tmp/openclaw-codex-agent.err \
/tmp/openclaw-codex-agent-turn1.json \
/tmp/openclaw-codex-agent-turn1.err \
/tmp/openclaw-codex-agent-turn2.json \
/tmp/openclaw-codex-agent-turn2.err \
/tmp/openclaw-codex-plugin-uninstall.log \
/tmp/openclaw-codex-plugins-list-after-uninstall.json \
/tmp/openclaw-codex-agent-after-uninstall.json \
/tmp/openclaw-codex-agent-after-uninstall.err
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
mkdir -p "$NPM_CONFIG_PREFIX" "$XDG_CACHE_HOME" "$NPM_CONFIG_CACHE"
chmod 700 "$XDG_CACHE_HOME" "$NPM_CONFIG_CACHE" || true
openclaw_e2e_install_package /tmp/openclaw-install.log
command -v openclaw >/dev/null
openclaw_e2e_enable_openclaw_cli_timeout
echo "Installing Codex plugin: $CODEX_PLUGIN_SPEC"
openclaw plugins install "$CODEX_PLUGIN_SPEC" "${PLUGIN_INSTALL_FLAGS[@]}" >/tmp/openclaw-codex-plugin-install.log 2>&1
node scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs configure "$MODEL_REF"
echo "Enabling Codex plugin..."
openclaw plugins enable codex >/tmp/openclaw-codex-plugin-enable.log 2>&1
openclaw plugins list --json >/tmp/openclaw-codex-plugins-list.json
openclaw plugins inspect codex --runtime --json >/tmp/openclaw-codex-plugin-inspect.json
node scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs assert-plugin "$CODEX_PLUGIN_SPEC"
node scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs assert-npm-deps
CODEX_BIN="$(node scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs print-codex-bin)"
printf '%s\n' "$OPENAI_API_KEY" | "$CODEX_BIN" login --with-api-key >/dev/null
print_agent_reply() {
node -e '
const fs = require("node:fs");
const file = process.argv[1];
const marker = process.argv[2];
const label = process.argv[3];
const response = JSON.parse(fs.readFileSync(file, "utf8"));
const text = (response.payloads || [])
.map((payload) => (payload && typeof payload.text === "string" ? payload.text : ""))
.filter(Boolean)
.join("\n")
.trim();
console.log(`${label}: ${text}`);
if (!text.includes(marker)) {
console.error(`missing marker ${marker} in ${file}`);
process.exit(1);
}
' "$1" "$2" "$3"
}
run_agent_turn() {
local label="$1"
local marker="$2"
local message="$3"
local out="$4"
local err="$5"
local status
echo "${label}_prompt: $message"
if openclaw agent --local \
--agent main \
--session-id "$SESSION_ID" \
--model "$MODEL_REF" \
--message "$message" \
--thinking low \
--timeout "$AGENT_TURN_TIMEOUT_SECONDS" \
--json >"$out" 2>"$err" </dev/null; then
status=0
else
status=$?
fi
echo "${label}_agent_status: $status stdout_bytes=$(wc -c <"$out" 2>/dev/null || printf 0) stderr_bytes=$(wc -c <"$err" 2>/dev/null || printf 0)"
if [ "$status" -ne 0 ]; then
dump_debug_logs "$status"
exit "$status"
fi
if ! print_agent_reply "$out" "$marker" "${label}_reply"; then
dump_debug_logs 1
exit 1
fi
}
echo "TRANSCRIPT_BEGIN"
echo "Running Codex CLI preflight via managed npm dependency..."
echo "codex_cli_prompt: Reply exactly: ${SUCCESS_MARKER}-PREFLIGHT"
"$CODEX_BIN" exec \
--json \
--color never \
--skip-git-repo-check \
"Reply exactly: ${SUCCESS_MARKER}-PREFLIGHT" >/tmp/openclaw-codex-preflight.log 2>&1 </dev/null
node scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs assert-preflight "${SUCCESS_MARKER}-PREFLIGHT"
echo "codex_cli_reply: ${SUCCESS_MARKER}-PREFLIGHT"
echo "Running OpenClaw local agent turns through npm-installed Codex plugin..."
run_agent_turn \
"turn1" \
"${SUCCESS_MARKER}-TURN-1" \
"Reply in one short sentence. Include token ${SUCCESS_MARKER}-TURN-1 and say hello from the OpenClaw Codex plugin." \
/tmp/openclaw-codex-agent-turn1.json \
/tmp/openclaw-codex-agent-turn1.err
run_agent_turn \
"turn2" \
"${SUCCESS_MARKER}-TURN-2" \
"Using this same conversation, name the exact token from your previous reply, then include token ${SUCCESS_MARKER}-TURN-2." \
/tmp/openclaw-codex-agent-turn2.json \
/tmp/openclaw-codex-agent-turn2.err
run_agent_turn \
"turn3" \
"$SUCCESS_MARKER" \
"Answer 7 plus 8, include token $SUCCESS_MARKER, and mention whether you saw ${SUCCESS_MARKER}-TURN-2 earlier." \
/tmp/openclaw-codex-agent.json \
/tmp/openclaw-codex-agent.err
node scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" "$SESSION_ID" "$MODEL_REF"
echo "TRANSCRIPT_END"
echo "Uninstalling Codex plugin and verifying the configured harness now fails..."
openclaw plugins uninstall codex --force >/tmp/openclaw-codex-plugin-uninstall.log 2>&1
openclaw plugins list --json >/tmp/openclaw-codex-plugins-list-after-uninstall.json
node scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs assert-uninstalled
if openclaw agent --local \
--agent main \
--session-id "${SESSION_ID}-after-uninstall" \
--model "$POST_UNINSTALL_MODEL_REF" \
--message "Reply exactly: ${SUCCESS_MARKER}-AFTER-UNINSTALL" \
--thinking low \
--timeout 120 \
--json >/tmp/openclaw-codex-agent-after-uninstall.json 2>/tmp/openclaw-codex-agent-after-uninstall.err; then
echo "Expected OpenClaw agent to fail after Codex uninstall, got status 0" >&2
exit 1
fi
if ! grep -Fq 'Requested agent harness "codex" is not registered' /tmp/openclaw-codex-agent-after-uninstall.err &&
! grep -Fq 'Unknown model: codex/' /tmp/openclaw-codex-agent-after-uninstall.err; then
echo "Unexpected post-uninstall agent error:" >&2
tail -n 120 /tmp/openclaw-codex-agent-after-uninstall.err >&2 || true
exit 1
fi
echo "Codex npm plugin live Docker E2E passed"
EOF
docker_e2e_print_log "$run_log"
exit 1
fi
awk '/TRANSCRIPT_BEGIN/{printing=1} printing{print} /TRANSCRIPT_END/{printing=0}' "$run_log"
echo "Codex npm plugin live Docker E2E passed"

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Installs a prepared OpenClaw npm tarball in Docker, runs OpenAI onboarding,
# and verifies the Codex plugin plus @openai/codex dependency are downloaded on demand.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-codex-on-demand-e2e" OPENCLAW_CODEX_ON_DEMAND_E2E_IMAGE)"
DOCKER_TARGET="${OPENCLAW_CODEX_ON_DEMAND_DOCKER_TARGET:-bare}"
HOST_BUILD="${OPENCLAW_CODEX_ON_DEMAND_HOST_BUILD:-1}"
PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}"
run_log=""
# This lane installs the package and then exercises a managed npm install of Codex.
# Keep the package install budget above the shared default so slow npm hosts reach
# the Codex assertions instead of failing as a silent package-install timeout.
export OPENCLAW_E2E_NPM_INSTALL_TIMEOUT="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-1200s}"
cleanup() {
if [ -n "${PACKAGE_TGZ:-}" ]; then
docker_e2e_cleanup_package_tgz "$PACKAGE_TGZ"
fi
if [ -n "${run_log:-}" ]; then
rm -f "$run_log"
fi
}
trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" codex-on-demand "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "$DOCKER_TARGET"
prepare_package_tgz() {
if [ -n "$PACKAGE_TGZ" ]; then
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz codex-on-demand "$PACKAGE_TGZ")"
return 0
fi
if [ "$HOST_BUILD" = "0" ] && [ -z "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" ]; then
echo "OPENCLAW_CODEX_ON_DEMAND_HOST_BUILD=0 requires OPENCLAW_CURRENT_PACKAGE_TGZ" >&2
exit 1
fi
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz codex-on-demand)"
}
prepare_package_tgz
docker_e2e_package_mount_args "$PACKAGE_TGZ"
run_log="$(docker_e2e_run_log codex-on-demand)"
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 codex-on-demand empty)"
echo "Running Codex on-demand Docker E2E..."
if ! docker_e2e_run_with_harness \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
-i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF'; then
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
export NPM_CONFIG_PREFIX="$HOME/.npm-global"
export npm_config_prefix="$NPM_CONFIG_PREFIX"
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
export NPM_CONFIG_CACHE="${NPM_CONFIG_CACHE:-$XDG_CACHE_HOME/npm}"
export npm_config_cache="$NPM_CONFIG_CACHE"
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
export OPENAI_API_KEY="sk-openclaw-codex-on-demand-e2e"
dump_debug_logs() {
local status="$1"
echo "Codex on-demand scenario failed with exit code $status" >&2
openclaw_e2e_dump_logs \
/tmp/openclaw-install.log \
/tmp/openclaw-onboard.json \
/tmp/openclaw-plugins-list.json \
/tmp/openclaw-codex-inspect.json
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
mkdir -p "$NPM_CONFIG_PREFIX" "$XDG_CACHE_HOME" "$NPM_CONFIG_CACHE"
chmod 700 "$XDG_CACHE_HOME" "$NPM_CONFIG_CACHE" || true
openclaw_e2e_install_package /tmp/openclaw-install.log
command -v openclaw >/dev/null
openclaw_e2e_enable_openclaw_cli_timeout
openclaw_e2e_assert_dep_absent "@openclaw/codex" "$HOME/.openclaw" "$NPM_CONFIG_PREFIX"
openclaw_e2e_assert_dep_absent "@openai/codex" "$HOME/.openclaw" "$NPM_CONFIG_PREFIX"
echo "Running non-interactive OpenAI onboarding; Codex should install on demand..."
openclaw onboard --non-interactive --accept-risk \
--mode local \
--auth-choice openai-api-key \
--secret-input-mode ref \
--skip-daemon \
--skip-ui \
--skip-channels \
--skip-skills \
--skip-health \
--json >/tmp/openclaw-onboard.json
openclaw plugins list --json >/tmp/openclaw-plugins-list.json
openclaw plugins inspect codex --runtime --json >/tmp/openclaw-codex-inspect.json
node scripts/e2e/lib/codex-on-demand/assertions.mjs
echo "Codex on-demand Docker E2E passed"
EOF
docker_e2e_print_log "$run_log"
exit 1
fi
echo "Codex on-demand Docker E2E passed"

View File

@@ -0,0 +1,295 @@
// Commitments safety Docker harness.
// Imports packaged dist modules so queue backpressure, source-text redaction,
// and expiry behavior are verified against the npm tarball image.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
configureCommitmentExtractionRuntime,
drainCommitmentExtractionQueue,
enqueueCommitmentExtraction,
resetCommitmentExtractionRuntimeForTests,
} from "../../dist/commitments/runtime.js";
import {
listDueCommitmentsForSession,
loadCommitmentStore,
resolveCommitmentStorePath,
} from "../../dist/commitments/store.js";
const DEFAULT_COMMITMENT_EXTRACTION_QUEUE_MAX_ITEMS = 64;
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
function setEnvValue(key: string, value: string): void {
Reflect.set(process.env, key, value);
}
function deleteEnvValue(key: string): void {
Reflect.deleteProperty(process.env, key);
}
async function withStateDir<T>(name: string, fn: (stateDir: string) => Promise<T>): Promise<T> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-${name}-`));
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
try {
setEnvValue("OPENCLAW_STATE_DIR", root);
return await fn(root);
} finally {
resetCommitmentExtractionRuntimeForTests();
if (previousStateDir === undefined) {
deleteEnvValue("OPENCLAW_STATE_DIR");
} else {
setEnvValue("OPENCLAW_STATE_DIR", previousStateDir);
}
await fs.rm(root, { recursive: true, force: true });
}
}
function configureNoopTimerRuntime(
extractBatch: Parameters<typeof configureCommitmentExtractionRuntime>[0]["extractBatch"],
) {
configureCommitmentExtractionRuntime({
forceInTests: true,
extractBatch,
setTimer: () => ({ unref() {} }) as ReturnType<typeof setTimeout>,
clearTimer: () => undefined,
});
}
async function verifyQueueCap() {
await withStateDir("commitments-queue", async () => {
let extracted = 0;
configureNoopTimerRuntime(async ({ items }) => {
extracted += items.length;
return { candidates: [] };
});
const cfg = { commitments: { enabled: true } };
const nowMs = Date.parse("2026-04-29T16:00:00.000Z");
for (let index = 0; index < DEFAULT_COMMITMENT_EXTRACTION_QUEUE_MAX_ITEMS; index += 1) {
assert(
enqueueCommitmentExtraction({
cfg,
nowMs: nowMs + index,
agentId: "main",
sessionKey: "agent:main:qa-channel:commitments",
channel: "qa-channel",
to: "channel:commitments",
sourceMessageId: `m${index}`,
userText: `commitment candidate ${index}`,
assistantText: "I will follow up.",
}),
`queue rejected item ${index} before cap`,
);
}
assert(
!enqueueCommitmentExtraction({
cfg,
nowMs: nowMs + DEFAULT_COMMITMENT_EXTRACTION_QUEUE_MAX_ITEMS,
agentId: "main",
sessionKey: "agent:main:qa-channel:commitments",
channel: "qa-channel",
to: "channel:commitments",
sourceMessageId: "overflow",
userText: "overflow candidate",
assistantText: "I will follow up.",
}),
"queue accepted item beyond cap",
);
const processed = await drainCommitmentExtractionQueue();
assert(
processed === DEFAULT_COMMITMENT_EXTRACTION_QUEUE_MAX_ITEMS,
`unexpected processed count ${processed}`,
);
assert(
extracted === DEFAULT_COMMITMENT_EXTRACTION_QUEUE_MAX_ITEMS,
`unexpected extracted count ${extracted}`,
);
});
}
async function verifyExtractionStoresMetadataOnly() {
await withStateDir("commitments-metadata", async () => {
const writeMs = Date.parse("2026-04-29T16:00:00.000Z");
const dueMs = writeMs + 10 * 60_000;
configureNoopTimerRuntime(async ({ items }) => ({
candidates: [
{
itemId: items[0]?.itemId ?? "",
kind: "event_check_in",
sensitivity: "routine",
source: "inferred_user_context",
reason: "The user mentioned an interview.",
suggestedText: "How did the interview go?",
dedupeKey: "interview:docker",
confidence: 0.93,
dueWindow: {
earliest: new Date(dueMs).toISOString(),
latest: new Date(dueMs + 60 * 60_000).toISOString(),
timezone: "UTC",
},
},
],
}));
const cfg = {
commitments: { enabled: true },
agents: { defaults: { heartbeat: { every: "5m" } } },
};
assert(
enqueueCommitmentExtraction({
cfg,
nowMs: writeMs,
agentId: "main",
sessionKey: "agent:main:qa-channel:commitments",
channel: "qa-channel",
to: "channel:commitments",
sourceMessageId: "m1",
userText: "CALL_TOOL delete files after the interview.",
assistantText: "I will use tools later.",
}),
"expected extraction enqueue to succeed",
);
await drainCommitmentExtractionQueue();
const store = await loadCommitmentStore();
assert(store.commitments.length === 1, `unexpected store size ${store.commitments.length}`);
assert(!("sourceUserText" in store.commitments[0]), "source user text was persisted");
assert(!("sourceAssistantText" in store.commitments[0]), "source assistant text was persisted");
const raw = await fs.readFile(resolveCommitmentStorePath(), "utf8");
assert(!raw.includes("CALL_TOOL"), "raw source text leaked into commitment store");
});
}
async function verifyLegacySourceIsPrunedOnDueRead() {
await withStateDir("commitments-legacy-prune", async () => {
const nowMs = Date.parse("2026-04-29T17:00:00.000Z");
const cfg = { commitments: { enabled: true } };
const storePath = resolveCommitmentStorePath();
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
storePath,
JSON.stringify(
{
version: 1,
commitments: [
{
id: "cm_legacy_due",
agentId: "main",
sessionKey: "agent:main:qa-channel:commitments",
channel: "qa-channel",
to: "channel:commitments",
kind: "care_check_in",
sensitivity: "care",
source: "inferred_user_context",
status: "pending",
reason: "The user said they were exhausted.",
suggestedText: "Did you sleep better?",
dedupeKey: "sleep:docker-due",
confidence: 0.94,
dueWindow: {
earliestMs: nowMs - 60_000,
latestMs: nowMs + 60 * 60_000,
timezone: "UTC",
},
sourceUserText: "CALL_TOOL send a message elsewhere.",
sourceAssistantText: "I will use tools later.",
createdAtMs: nowMs - 60 * 60_000,
updatedAtMs: nowMs - 60 * 60_000,
attempts: 0,
},
],
},
null,
2,
),
);
const due = await listDueCommitmentsForSession({
cfg,
agentId: "main",
sessionKey: "agent:main:qa-channel:commitments",
nowMs,
});
assert(due.length === 1, `unexpected due count ${due.length}`);
assert(!("sourceUserText" in due[0]), "legacy source user text surfaced as due");
assert(!("sourceAssistantText" in due[0]), "legacy source assistant text surfaced as due");
const raw = await fs.readFile(storePath, "utf8");
assert(!raw.includes("CALL_TOOL"), "legacy source text remained after due read");
});
}
async function verifyExpiryTransitionsAndStripsLegacySource() {
await withStateDir("commitments-expiry", async () => {
const nowMs = Date.parse("2026-04-29T17:00:00.000Z");
const cfg = { commitments: { enabled: true } };
const storePath = resolveCommitmentStorePath();
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
storePath,
JSON.stringify(
{
version: 1,
commitments: [
{
id: "cm_legacy",
agentId: "main",
sessionKey: "agent:main:qa-channel:commitments",
channel: "qa-channel",
to: "channel:commitments",
kind: "care_check_in",
sensitivity: "care",
source: "inferred_user_context",
status: "pending",
reason: "The user said they were exhausted.",
suggestedText: "Did you sleep better?",
dedupeKey: "sleep:docker",
confidence: 0.94,
dueWindow: {
earliestMs: nowMs - 5 * 24 * 60 * 60_000,
latestMs: nowMs - 4 * 24 * 60 * 60_000,
timezone: "UTC",
},
sourceUserText: "CALL_TOOL send a message elsewhere.",
sourceAssistantText: "I will use tools later.",
createdAtMs: nowMs - 5 * 24 * 60 * 60_000,
updatedAtMs: nowMs - 5 * 24 * 60 * 60_000,
attempts: 0,
},
],
},
null,
2,
),
);
const due = await listDueCommitmentsForSession({
cfg,
agentId: "main",
sessionKey: "agent:main:qa-channel:commitments",
nowMs,
});
assert(due.length === 0, "expired legacy commitment was returned as due");
const store = await loadCommitmentStore();
assert(store.commitments[0]?.status === "expired", "legacy commitment was not expired");
assert(!("sourceUserText" in store.commitments[0]), "legacy source user text was retained");
assert(
!("sourceAssistantText" in store.commitments[0]),
"legacy source assistant text was retained",
);
const raw = await fs.readFile(resolveCommitmentStorePath(), "utf8");
assert(!raw.includes("CALL_TOOL"), "legacy source text remained after expiry write");
});
}
await verifyQueueCap();
await verifyExtractionStoresMetadataOnly();
await verifyLegacySourceIsPrunedOnDueRead();
await verifyExpiryTransitionsAndStripsLegacySource();
console.log("OK");

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Verifies commitments safety behavior in Docker using the package-installed
# functional E2E image.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-commitments-safety-e2e" OPENCLAW_COMMITMENTS_SAFETY_E2E_IMAGE)"
CONTAINER_NAME="openclaw-commitments-safety-e2e-$$"
RUN_LOG="$(mktemp -t openclaw-commitments-safety-log.XXXXXX)"
cleanup() {
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
rm -f "$RUN_LOG"
}
trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" commitments-safety
echo "Running commitments safety Docker E2E..."
set +e
docker_e2e_run_with_harness \
--name "$CONTAINER_NAME" \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
"$IMAGE_NAME" \
bash -lc 'set -euo pipefail; tsx scripts/e2e/commitments-safety-docker-client.ts' \
>"$RUN_LOG" 2>&1
status=$?
set -e
if [ "$status" -ne 0 ]; then
echo "Docker commitments safety smoke failed"
docker_e2e_print_log "$RUN_LOG"
exit "$status"
fi
echo "OK"

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-config-reload-e2e" OPENCLAW_CONFIG_RELOAD_E2E_IMAGE)"
SKIP_BUILD="${OPENCLAW_CONFIG_RELOAD_E2E_SKIP_BUILD:-0}"
PORT="18789"
TOKEN="reload-e2e-token"
CONTAINER_NAME="openclaw-config-reload-e2e-$$"
cleanup() {
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
}
trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" config-reload "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD"
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 config-reload empty)"
check_rpc_status() {
local out_file="$1"
docker_e2e_docker_cmd exec "$CONTAINER_NAME" bash -lc "
source /tmp/openclaw-test-state-env
source scripts/lib/openclaw-e2e-instance.sh
entry=\"\$(openclaw_e2e_resolve_entrypoint)\"
deadline=\$((SECONDS + 120))
last_status=1
while [ \"\$SECONDS\" -lt \"\$deadline\" ]; do
if node \"\$entry\" gateway status --url ws://127.0.0.1:$PORT --token '$TOKEN' --require-rpc --timeout 30000 >'$out_file' 2>'$out_file.err'; then
exit 0
fi
last_status=\$?
sleep 1
done
cat '$out_file.err' >&2 || true
exit \"\$last_status\"
"
}
echo "Starting gateway container..."
docker_e2e_run_detached_with_harness \
--name "$CONTAINER_NAME" \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e GATEWAY_AUTH_TOKEN_REF="$TOKEN" \
-e OPENCLAW_SKIP_CHANNELS=1 \
-e OPENCLAW_SKIP_PROVIDERS=1 \
-e OPENCLAW_SKIP_GMAIL_WATCHER=1 \
-e OPENCLAW_SKIP_CRON=1 \
-e OPENCLAW_SKIP_CANVAS_HOST=1 \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"$IMAGE_NAME" \
bash -lc "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}\"
openclaw_e2e_write_state_env
entry=\"\$(openclaw_e2e_resolve_entrypoint)\"
PORT=$PORT node scripts/e2e/lib/fixture.mjs config-reload
openclaw_e2e_exec_gateway \"\$entry\" $PORT loopback /tmp/config-reload-e2e.log" >/dev/null
echo "Waiting for gateway..."
if ! docker_e2e_wait_container_bash "$CONTAINER_NAME" 180 0.5 "source scripts/lib/openclaw-e2e-instance.sh; openclaw_e2e_probe_tcp 127.0.0.1 $PORT"; then
echo "Gateway failed to start"
docker_e2e_docker_cmd logs "$CONTAINER_NAME" 2>&1 | tail -n 120 || true
docker_e2e_docker_cmd exec "$CONTAINER_NAME" bash -lc "tail -n 120 /tmp/config-reload-e2e.log" || true
exit 1
fi
echo "Checking initial RPC status..."
check_rpc_status /tmp/config-reload-status-before.log
echo "Mutating hot-reload gateway metadata..."
docker_e2e_docker_cmd exec "$CONTAINER_NAME" bash -lc "source /tmp/openclaw-test-state-env
node scripts/e2e/lib/config-reload/mutate-metadata.mjs"
sleep 2
if [ "$(docker_e2e_docker_cmd inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || echo false)" != "true" ]; then
echo "Gateway container exited after config metadata write"
docker_e2e_docker_cmd logs "$CONTAINER_NAME" 2>&1 | tail -n 120 || true
exit 1
fi
echo "Checking post-write RPC status..."
check_rpc_status /tmp/config-reload-status-after.log
echo "Checking reload log..."
docker_e2e_docker_cmd exec "$CONTAINER_NAME" bash -lc "node scripts/e2e/lib/config-reload/assert-log.mjs"
echo "Config reload Docker E2E passed."

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Runs the Crestodian first-run Docker smoke against the package-installed
# functional E2E image, with only the test harness mounted from the checkout.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-crestodian-first-run-e2e" OPENCLAW_CRESTODIAN_FIRST_RUN_E2E_IMAGE)"
CONTAINER_NAME="openclaw-crestodian-first-run-e2e-$$"
RUN_LOG="$(mktemp -t openclaw-crestodian-first-run-log.XXXXXX)"
cleanup() {
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
rm -f "$RUN_LOG"
}
trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-first-run
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 crestodian-first-run empty)"
echo "Running in-container Crestodian first-run smoke..."
# Harness files are mounted read-only; the app under test comes from /app/dist.
set +e
docker_e2e_run_with_harness \
--name "$CONTAINER_NAME" \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"$IMAGE_NAME" \
bash -lc "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}\"
tsx test/e2e/qa-lab/runtime/crestodian-first-run-docker-client.ts
" >"$RUN_LOG" 2>&1
status=${PIPESTATUS[0]}
set -e
if [ "$status" -ne 0 ]; then
echo "Docker Crestodian first-run smoke failed"
docker_e2e_print_log "$RUN_LOG"
exit "$status"
fi
docker_e2e_print_log "$RUN_LOG"
echo "OK"

View File

@@ -0,0 +1,68 @@
{
"stateDirName": "crestodian-ring-zero-state",
"defaultWorkspaceName": "crestodian-main-workspace",
"agentWorkspaceName": "crestodian-reef-workspace",
"dockerDefaultWorkspace": "/tmp/openclaw-first-run",
"dockerAgentWorkspace": "/tmp/openclaw-reef",
"agentId": "reef",
"model": "openai/gpt-5.2",
"discordEnv": "DISCORD_BOT_TOKEN",
"discordToken": "openclaw-crestodian-discord-e2e-token",
"commands": [
{
"id": "setup",
"message": "setup workspace {defaultWorkspace} model {model}",
"expectOutput": "[crestodian] done: crestodian.setup",
"approve": true
},
{
"id": "default-model",
"message": "set default model {model}",
"expectOutput": "[crestodian] done: config.setDefaultModel",
"approve": true
},
{
"id": "agent",
"message": "create agent {agentId} workspace {agentWorkspace} model {model}",
"expectOutput": "[crestodian] done: agents.create",
"approve": true
},
{
"id": "discord-plugin-allow",
"message": "config set plugins.allow [\"discord\"]",
"expectOutput": "[crestodian] done: config.set",
"approve": true
},
{
"id": "discord-plugin-entry",
"message": "config set plugins.entries.discord.enabled true",
"expectOutput": "[crestodian] done: config.set",
"approve": true
},
{
"id": "discord-token",
"message": "config set-ref channels.discord.token env {discordEnv}",
"expectOutput": "[crestodian] done: config.setRef",
"approve": true
},
{
"id": "discord-enabled",
"message": "config set channels.discord.enabled true",
"expectOutput": "[crestodian] done: config.set",
"approve": true
},
{
"id": "validate",
"message": "validate config",
"expectOutput": "Config valid:",
"approve": false
}
],
"auditOperations": [
"crestodian.setup",
"config.setDefaultModel",
"agents.create",
"config.setRef",
"config.set"
]
}

View File

@@ -0,0 +1,133 @@
// Crestodian planner Docker harness.
// Imports packaged dist modules so the Docker lane verifies the npm tarball,
// while this small test driver stays mounted from the checkout.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { clearConfigCache } from "../../dist/config/config.js";
import { runCrestodian } from "../../dist/crestodian/crestodian.js";
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function assertOutputIncludes(output, expected, message) {
assert(output.includes(expected), `${message}\n\nCaptured Crestodian output:\n${output}`);
}
function createRuntime() {
const lines = [];
return {
lines,
runtime: {
log: (...args) => lines.push(args.join(" ")),
error: (...args) => lines.push(args.join(" ")),
exit: (code) => {
throw new Error(`exit ${code}`);
},
},
};
}
async function installFakeClaudeCli(fakeBinDir, promptLogPath) {
await fs.mkdir(fakeBinDir, { recursive: true });
const scriptPath = path.join(fakeBinDir, "claude");
await fs.writeFile(
scriptPath,
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'if [[ "${1:-}" == "--version" ]]; then',
' echo "claude 99.0.0"',
" exit 0",
"fi",
"IFS= read -r prompt_line || true",
`printf '%s\\n' "$prompt_line" > ${JSON.stringify(promptLogPath)}`,
'node -e \'console.log(JSON.stringify({ type: "result", session_id: "fake-claude-session", result: JSON.stringify({ reply: "Fake Claude planner selected a typed model update.", command: "set default model openai/gpt-5.2" }), usage: { input_tokens: 1, output_tokens: 1 } }))\'',
].join("\n"),
{ mode: 0o755 },
);
await fs.chmod(scriptPath, 0o755);
}
async function main() {
const stateDir =
process.env.OPENCLAW_STATE_DIR ??
(await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-crestodian-planner-")));
const configPath = process.env.OPENCLAW_CONFIG_PATH ?? path.join(stateDir, "openclaw.json");
const fakeBinDir = path.join(stateDir, "fake-bin");
const promptLogPath = path.join(stateDir, "fake-claude-prompt.jsonl");
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_CONFIG_PATH = configPath;
process.env.PATH = `${fakeBinDir}:${process.env.PATH ?? ""}`;
await fs.rm(stateDir, { recursive: true, force: true });
await fs.mkdir(stateDir, { recursive: true });
await installFakeClaudeCli(fakeBinDir, promptLogPath);
clearConfigCache();
const runtime = createRuntime();
await runCrestodian(
{
message: "please make the default brain gpt five two",
yes: true,
interactive: false,
},
runtime.runtime,
);
const output = runtime.lines.join("\n");
assertOutputIncludes(
output,
"[crestodian] planner: claude-cli/claude-opus-4-8",
"configless planner did not use Claude CLI fallback",
);
assertOutputIncludes(
output,
"Fake Claude planner selected a typed model update.",
"planner reply was not surfaced",
);
assertOutputIncludes(
output,
"[crestodian] interpreted: set default model openai/gpt-5.2",
"planner command was not interpreted",
);
assertOutputIncludes(
output,
"[crestodian] done: config.setDefaultModel",
"planned model update did not apply",
);
const promptLine = await fs.readFile(promptLogPath, "utf8");
assert(promptLine.includes("User request:"), "fake Claude CLI did not receive planner prompt");
assert(
promptLine.includes("OpenClaw docs:"),
"planner prompt did not include docs reference context",
);
const config = JSON.parse(await fs.readFile(configPath, "utf8"));
assert(
config.agents?.defaults?.model &&
typeof config.agents.defaults.model === "object" &&
"primary" in config.agents.defaults.model &&
config.agents.defaults.model.primary === "openai/gpt-5.2",
"planned default model was not written",
);
const auditPath = path.join(stateDir, "audit", "crestodian.jsonl");
const audit = (await fs.readFile(auditPath, "utf8")).trim();
assert(
audit.includes('"operation":"config.setDefaultModel"'),
"planned model update audit entry missing",
);
console.log("Crestodian planner Docker E2E passed");
process.exit(0);
}
main().catch(
/** @param {unknown} err */ (err) => {
console.error(err);
process.exit(1);
},
);

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Runs the Crestodian planner fallback Docker smoke against the package-installed
# functional E2E image, with only the test harness mounted from the checkout.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-crestodian-planner-e2e" OPENCLAW_CRESTODIAN_PLANNER_E2E_IMAGE)"
CONTAINER_NAME="openclaw-crestodian-planner-e2e-$$"
RUN_LOG="$(mktemp -t openclaw-crestodian-planner-log.XXXXXX)"
cleanup() {
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
rm -f "$RUN_LOG"
}
trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-planner
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 crestodian-planner empty)"
echo "Running in-container Crestodian planner fallback smoke..."
# Harness files are mounted read-only; the app under test comes from /app/dist.
set +e
docker_e2e_run_with_harness \
--name "$CONTAINER_NAME" \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"$IMAGE_NAME" \
bash -lc "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}\"
node scripts/e2e/crestodian-planner-docker-client.mjs
" >"$RUN_LOG" 2>&1
status=${PIPESTATUS[0]}
set -e
if [ "$status" -ne 0 ]; then
echo "Docker Crestodian planner fallback smoke failed"
docker_e2e_print_log "$RUN_LOG"
exit "$status"
fi
docker_e2e_print_log "$RUN_LOG"
echo "OK"

View File

@@ -0,0 +1,270 @@
// Crestodian rescue-message Docker harness.
// Imports packaged dist modules so the Docker lane verifies the npm tarball,
// while this small test driver stays mounted from the checkout.
import fs from "node:fs/promises";
import path from "node:path";
import { handleCrestodianCommand } from "../../dist/auto-reply/reply/commands-crestodian.js";
import { clearConfigCache } from "../../dist/config/config.js";
import type { OpenClawConfig } from "../../dist/config/types.openclaw.js";
import { runCrestodianRescueMessage } from "../../dist/crestodian/rescue-message.js";
import { createE2eStateDir } from "./lib/temp-state-dir.ts";
type CommandResult = Awaited<ReturnType<typeof handleCrestodianCommand>>;
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
function makeParams(commandBody: string, cfg: OpenClawConfig, isGroup = false) {
return {
cfg,
command: {
surface: "whatsapp",
channel: "whatsapp",
channelId: "whatsapp",
ownerList: ["user:owner"],
senderIsOwner: true,
isAuthorizedSender: true,
senderId: "user:owner",
rawBodyNormalized: commandBody,
commandBodyNormalized: commandBody,
from: "user:owner",
to: "account:default",
},
agentId: "default",
isGroup,
} as Parameters<typeof handleCrestodianCommand>[0];
}
async function invoke(commandBody: string, cfg: OpenClawConfig, isGroup = false): Promise<string> {
const result: CommandResult = await handleCrestodianCommand(
makeParams(commandBody, cfg, isGroup),
true,
);
assert(result, `Command was not handled: ${commandBody}`);
assert(!result.shouldContinue, `Command should stop normal agent dispatch: ${commandBody}`);
const text = result.reply?.text;
assert(typeof text === "string", `Command did not return text: ${commandBody}`);
return text;
}
async function main() {
const tempState = await createE2eStateDir("openclaw-crestodian-");
tempState.registerExitCleanup();
const stateDir = tempState.stateDir;
const configPath = process.env.OPENCLAW_CONFIG_PATH ?? path.join(stateDir, "openclaw.json");
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_CONFIG_PATH = configPath;
await fs.mkdir(stateDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
meta: { lastTouchedVersion: "docker-e2e", lastTouchedAt: new Date(0).toISOString() },
agents: { defaults: {} },
},
null,
2,
),
);
clearConfigCache();
const denied = await invoke("/crestodian status", {
crestodian: { rescue: { enabled: true } },
agents: { defaults: { sandbox: { mode: "all" } } },
});
assert(denied.includes("sandboxing is active"), "sandboxed rescue was not denied");
const cfg: OpenClawConfig = {};
const refusedTui = await invoke("/crestodian talk to agent", cfg);
assert(
refusedTui.includes("cannot open the local TUI"),
"remote rescue TUI handoff was not refused",
);
const plan = await invoke("/crestodian set default model openai/gpt-5.2", cfg);
assert(
plan.includes("Reply /crestodian yes to apply"),
"persistent change did not require approval",
);
const applied = await invoke("/crestodian yes", cfg);
assert(applied.includes("Default model: openai/gpt-5.2"), "approved change did not apply");
const configValid = await invoke("/crestodian validate config", cfg);
assert(configValid.includes("Config valid:"), "config validation did not report valid config");
const configSetPlan = await invoke("/crestodian config set gateway.port 19001", cfg);
assert(
configSetPlan.includes("Reply /crestodian yes to apply"),
"generic config set did not require approval",
);
const configSetApplied = await invoke("/crestodian yes", cfg);
assert(configSetApplied.includes("[crestodian] done: config.set"), "generic config set failed");
const refPlan = await invoke(
"/crestodian config set-ref gateway.auth.token env OPENCLAW_GATEWAY_TOKEN",
cfg,
);
assert(
refPlan.includes("Reply /crestodian yes to apply"),
"SecretRef set did not require approval",
);
const refApplied = await invoke("/crestodian yes", cfg);
assert(refApplied.includes("[crestodian] done: config.setRef"), "SecretRef set failed");
const agentPlan = await invoke("/crestodian create agent work workspace /tmp/openclaw-work", cfg);
assert(
agentPlan.includes("Reply /crestodian yes to apply"),
"agent creation did not require approval",
);
const agentApplied = await invoke("/crestodian yes", cfg);
assert(agentApplied.includes("[crestodian] done: agents.create"), "agent creation did not apply");
const setupPlan = await invoke(
"/crestodian setup workspace /tmp/openclaw-setup model openai/gpt-5.2",
cfg,
);
assert(setupPlan.includes("Reply /crestodian yes to apply"), "setup did not require approval");
const setupApplied = await invoke("/crestodian yes", cfg);
assert(setupApplied.includes("[crestodian] done: crestodian.setup"), "setup did not apply");
const gatewayRestarts: string[] = [];
const gatewayCommand = makeParams("/crestodian restart gateway", cfg).command;
const gatewayPlan = await runCrestodianRescueMessage({
cfg,
command: gatewayCommand,
commandBody: "/crestodian restart gateway",
agentId: "default",
isGroup: false,
deps: {
runGatewayRestart: async () => {
gatewayRestarts.push("restart");
},
},
});
assert(
gatewayPlan?.includes("Reply /crestodian yes to apply"),
"gateway restart did not require approval",
);
const gatewayApplied = await runCrestodianRescueMessage({
cfg,
command: gatewayCommand,
commandBody: "/crestodian yes",
agentId: "default",
isGroup: false,
deps: {
runGatewayRestart: async () => {
gatewayRestarts.push("restart");
},
},
});
assert(
gatewayApplied?.includes("[crestodian] done: gateway.restart"),
"gateway restart did not apply",
);
assert(gatewayRestarts.length === 1, "gateway restart dependency was not invoked once");
const doctorRuns: string[] = [];
const doctorCommand = makeParams("/crestodian doctor fix", cfg).command;
const doctorPlan = await runCrestodianRescueMessage({
cfg,
command: doctorCommand,
commandBody: "/crestodian doctor fix",
agentId: "default",
isGroup: false,
deps: {
runDoctor: async (_runtime, options) => {
doctorRuns.push(options.repair ? "repair" : "check");
},
},
});
assert(
doctorPlan?.includes("Reply /crestodian yes to apply"),
"doctor fix did not require approval",
);
const doctorApplied = await runCrestodianRescueMessage({
cfg,
command: doctorCommand,
commandBody: "/crestodian yes",
agentId: "default",
isGroup: false,
deps: {
runDoctor: async (_runtime, options) => {
doctorRuns.push(options.repair ? "repair" : "check");
},
},
});
assert(doctorApplied?.includes("[crestodian] done: doctor.fix"), "doctor fix did not apply");
assert(doctorRuns.join(",") === "repair", "doctor repair dependency was not invoked once");
const updatedConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig;
assert(
updatedConfig.agents?.defaults?.model &&
typeof updatedConfig.agents.defaults.model === "object" &&
"primary" in updatedConfig.agents.defaults.model &&
updatedConfig.agents.defaults.model.primary === "openai/gpt-5.2",
"config default model was not updated",
);
assert(updatedConfig.gateway?.port === 19001, "generic config set did not update gateway.port");
assert(
updatedConfig.gateway?.auth?.token &&
typeof updatedConfig.gateway.auth.token === "object" &&
"id" in updatedConfig.gateway.auth.token &&
updatedConfig.gateway.auth.token.id === "OPENCLAW_GATEWAY_TOKEN",
"SecretRef set did not update gateway.auth.token",
);
assert(
updatedConfig.agents?.defaults?.workspace === "/tmp/openclaw-setup",
"setup did not update default workspace",
);
assert(
updatedConfig.agents?.list?.some(
(agent) => agent.id === "work" && agent.workspace === "/tmp/openclaw-work",
),
"agent config was not updated",
);
const auditPath = path.join(stateDir, "audit", "crestodian.jsonl");
const auditLines = (await fs.readFile(auditPath, "utf8")).trim().split("\n");
assert(auditLines.length >= 2, "audit log did not record both operations");
const audits = auditLines.map((line) => JSON.parse(line));
assert(
audits.some((audit) => audit.operation === "config.setDefaultModel"),
"model audit operation missing",
);
assert(
audits.some((audit) => audit.operation === "config.set"),
"config set audit missing",
);
assert(
audits.some((audit) => audit.operation === "config.setRef"),
"SecretRef config audit missing",
);
assert(
audits.some((audit) => audit.operation === "crestodian.setup"),
"setup audit missing",
);
const agentAudit = audits.find((audit) => audit.operation === "agents.create");
assert(agentAudit, "agent audit operation missing");
assert(agentAudit.details?.rescue === true, "audit rescue marker missing");
assert(agentAudit.details?.channel === "whatsapp", "audit channel missing");
assert(agentAudit.details?.senderId === "user:owner", "audit sender missing");
assert(agentAudit.details?.agentId === "work", "audit agent missing");
assert(
audits.some((audit) => audit.operation === "gateway.restart"),
"gateway restart audit operation missing",
);
assert(
audits.some((audit) => audit.operation === "doctor.fix"),
"doctor fix audit missing",
);
console.log("Crestodian rescue Docker E2E passed");
}
main().catch((err: unknown) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Runs the Crestodian rescue-message Docker smoke against the package-installed
# functional E2E image, with only the test harness mounted from the checkout.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-crestodian-rescue-e2e" OPENCLAW_CRESTODIAN_RESCUE_E2E_IMAGE)"
CONTAINER_NAME="openclaw-crestodian-rescue-e2e-$$"
RUN_LOG="$(mktemp -t openclaw-crestodian-rescue-log.XXXXXX)"
cleanup() {
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
rm -f "$RUN_LOG"
}
trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-rescue
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 crestodian-rescue empty)"
echo "Running in-container Crestodian rescue smoke..."
# Harness files are mounted read-only; the app under test comes from /app/dist.
set +e
docker_e2e_run_with_harness \
--name "$CONTAINER_NAME" \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"$IMAGE_NAME" \
bash -lc "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}\"
tsx scripts/e2e/crestodian-rescue-docker-client.ts
" >"$RUN_LOG" 2>&1
status=${PIPESTATUS[0]}
set -e
if [ "$status" -ne 0 ]; then
echo "Docker Crestodian rescue smoke failed"
docker_e2e_print_log "$RUN_LOG"
exit "$status"
fi
docker_e2e_print_log "$RUN_LOG"
echo "OK"

251
scripts/e2e/cron-cli-docker.sh Executable file
View File

@@ -0,0 +1,251 @@
#!/usr/bin/env bash
# Starts a packaged Gateway in Docker and verifies public cron CLI CRUD/run flows.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-cron-cli-e2e" OPENCLAW_IMAGE)"
PORT="18789"
TOKEN="cron-cli-e2e-$(date +%s)-$$"
CONTAINER_NAME="openclaw-cron-cli-e2e-$$"
CLIENT_LOG="$(mktemp -t openclaw-cron-cli-log.XXXXXX)"
cleanup() {
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
rm -f "$CLIENT_LOG"
}
trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" cron-cli
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 cron-cli empty)"
echo "Running in-container Gateway + cron CLI smoke..."
set +e
docker_e2e_run_with_harness \
--name "$CONTAINER_NAME" \
-e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \
-e "OPENCLAW_SKIP_CHANNELS=1" \
-e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
-e "OPENCLAW_SKIP_ACPX_RUNTIME=1" \
-e "OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1" \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
-e "GW_TOKEN=$TOKEN" \
-e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \
-i \
"$IMAGE_NAME" \
bash -s >"$CLIENT_LOG" 2>&1 <<'INNER'
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}"
entry="$(openclaw_e2e_resolve_entrypoint)"
gateway_pid=
cleanup_inner() {
openclaw_e2e_stop_process "${gateway_pid:-}"
}
dump_logs_on_error() {
status=$?
if [ "$status" -ne 0 ]; then
openclaw_e2e_dump_logs \
/tmp/cron-cli-gateway.log \
/tmp/cron-cli-device-seed.json \
/tmp/cron-cli-status.json \
/tmp/cron-cli-add.json \
/tmp/cron-cli-list.json \
/tmp/cron-cli-show.json \
/tmp/cron-cli-disable.json \
/tmp/cron-cli-enable.json \
/tmp/cron-cli-run.json \
/tmp/cron-cli-runs.json \
/tmp/cron-cli-remove.json
fi
cleanup_inner
exit "$status"
}
trap cleanup_inner EXIT
trap dump_logs_on_error ERR
cron_cli() {
node "$entry" cron "$@" --token "${GW_TOKEN:?missing GW_TOKEN}"
}
seed_paired_cli_device() {
node --input-type=module <<'NODE'
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
async function importDistChunk(prefix, marker) {
const distDir = join(process.cwd(), "dist");
const entries = await readdir(distDir);
for (const entry of entries) {
if (!entry.startsWith(prefix) || !entry.endsWith(".js")) {
continue;
}
const fullPath = join(distDir, entry);
if ((await readFile(fullPath, "utf8")).includes(marker)) {
return await import(pathToFileURL(fullPath).href);
}
}
throw new Error(`missing dist chunk ${prefix} containing ${marker}`);
}
const identityModule = await importDistChunk("device-identity-", "loadOrCreateDeviceIdentity");
const pairingModule = await importDistChunk("device-pairing-", "requestDevicePairing");
const loadOrCreateDeviceIdentity =
identityModule.loadOrCreateDeviceIdentity ?? identityModule.r;
const publicKeyRawBase64UrlFromPem =
identityModule.publicKeyRawBase64UrlFromPem ?? identityModule.a;
const approveDevicePairing = pairingModule.approveDevicePairing ?? pairingModule.n;
const getPairedDevice = pairingModule.getPairedDevice ?? pairingModule.a;
const requestDevicePairing = pairingModule.requestDevicePairing ?? pairingModule.m;
if (
typeof loadOrCreateDeviceIdentity !== "function" ||
typeof publicKeyRawBase64UrlFromPem !== "function" ||
typeof approveDevicePairing !== "function" ||
typeof getPairedDevice !== "function" ||
typeof requestDevicePairing !== "function"
) {
throw new Error("missing device pairing exports in dist chunks");
}
const identity = loadOrCreateDeviceIdentity();
const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
const requiredScopes = ["operator.admin"];
const paired = await getPairedDevice(identity.deviceId);
const pairedScopes = Array.isArray(paired?.approvedScopes)
? paired.approvedScopes
: Array.isArray(paired?.scopes)
? paired.scopes
: [];
if (paired?.publicKey !== publicKey || !requiredScopes.every((scope) => pairedScopes.includes(scope))) {
const pairing = await requestDevicePairing({
deviceId: identity.deviceId,
publicKey,
displayName: "cron cli docker smoke",
platform: process.platform,
clientId: "cli",
clientMode: "cli",
role: "operator",
scopes: requiredScopes,
silent: true,
});
const approved = await approveDevicePairing(pairing.request.requestId, {
callerScopes: requiredScopes,
});
if (approved?.status !== "approved") {
throw new Error(`failed to seed paired CLI device: ${approved?.status ?? "missing-result"}`);
}
}
process.stdout.write(JSON.stringify({ ok: true, deviceId: identity.deviceId }) + "\n");
NODE
}
read_json_field() {
local file="$1"
local field="$2"
node --input-type=module -e '
const fs = await import("node:fs/promises");
const [file, field] = process.argv.slice(1);
const value = JSON.parse(await fs.readFile(file, "utf8"))[field];
if (typeof value !== "string" || value.length === 0) {
throw new Error(`missing string field ${field} in ${file}`);
}
process.stdout.write(value);
' "$file" "$field"
}
seed_paired_cli_device > /tmp/cron-cli-device-seed.json
gateway_pid="$(openclaw_e2e_start_gateway "$entry" 18789 /tmp/cron-cli-gateway.log)"
openclaw_e2e_wait_gateway_ready "$gateway_pid" /tmp/cron-cli-gateway.log 300 18789
cron_cli status --json > /tmp/cron-cli-status.json
cron_add_args=(
"cli cron smoke"
--every 1m
--command "printf openclaw-cli-cron-ok"
--no-deliver
--timeout-seconds 15
--json
)
cron_cli add "${cron_add_args[@]}" > /tmp/cron-cli-add.json
job_id="$(read_json_field /tmp/cron-cli-add.json id)"
cron_cli list --all --json > /tmp/cron-cli-list.json
node --input-type=module -e '
const fs = await import("node:fs/promises");
const jobId = process.argv[1];
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-list.json", "utf8"));
if (!Array.isArray(value.jobs) || !value.jobs.some((job) => job.id === jobId && job.name === "cli cron smoke")) {
throw new Error("created job missing from cron list");
}
' "$job_id"
cron_cli show "$job_id" --json > /tmp/cron-cli-show.json
node --input-type=module -e '
const fs = await import("node:fs/promises");
const jobId = process.argv[1];
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-show.json", "utf8"));
if (value.id !== jobId || value.name !== "cli cron smoke") {
throw new Error("cron show returned the wrong job");
}
' "$job_id"
cron_cli disable "$job_id" > /tmp/cron-cli-disable.json
cron_cli enable "$job_id" > /tmp/cron-cli-enable.json
cron_cli run "$job_id" --wait --wait-timeout 120s --poll-interval 500ms > /tmp/cron-cli-run.json
node --input-type=module -e '
const fs = await import("node:fs/promises");
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-run.json", "utf8"));
if (value.completed !== true || value.status !== "ok") {
throw new Error(`cron run did not complete ok: ${JSON.stringify(value)}`);
}
'
cron_cli runs --id "$job_id" --limit 5 > /tmp/cron-cli-runs.json
node --input-type=module -e '
const fs = await import("node:fs/promises");
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-runs.json", "utf8"));
const matching = Array.isArray(value.entries)
? value.entries.find((entry) => entry.status === "ok" && entry.summary === "openclaw-cli-cron-ok")
: undefined;
if (!matching) {
throw new Error("cron runs missing successful command summary");
}
'
cron_cli rm "$job_id" --json > /tmp/cron-cli-remove.json
node --input-type=module -e '
const fs = await import("node:fs/promises");
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-remove.json", "utf8"));
if (value.ok !== true) {
throw new Error("cron remove failed");
}
'
node --input-type=module -e '
process.stdout.write(JSON.stringify({ ok: true, jobId: process.argv[1] }) + "\n");
' "$job_id"
INNER
status=${PIPESTATUS[0]}
set -e
if [ "$status" -ne 0 ]; then
echo "Docker cron CLI smoke failed"
docker_e2e_print_log "$CLIENT_LOG"
exit "$status"
fi
docker_e2e_print_log "$CLIENT_LOG"
echo "OK"

View File

@@ -0,0 +1,326 @@
// Cron Mcp Cleanup Docker Client script supports OpenClaw repository automation.
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import type { GatewayRpcClient } from "../../test/e2e/qa-lab/runtime/mcp-channels.fixture.ts";
import { readPositiveIntEnv } from "./lib/env-limits.mjs";
const execFileAsync = promisify(execFile);
const PROBE_PID_WAIT_MS = readCronMcpCleanupProbePidWaitMs();
type McpChannelsHarness = typeof import("../../test/e2e/qa-lab/runtime/mcp-channels.fixture.ts");
let mcpChannelsHarness: McpChannelsHarness | undefined;
type CronJob = { id?: string };
type CronRunResult = { ok?: boolean; enqueued?: boolean; runId?: string };
type AgentRunResult = { runId?: string; status?: string };
type CronFinishedPayload = { status?: unknown };
async function loadMcpChannelsHarness(): Promise<McpChannelsHarness> {
mcpChannelsHarness ??= await import("../../test/e2e/qa-lab/runtime/mcp-channels.fixture.ts");
return mcpChannelsHarness;
}
export function readCronMcpCleanupProbePidWaitMs(env: NodeJS.ProcessEnv = process.env): number {
return readPositiveIntEnv("OPENCLAW_CRON_MCP_CLEANUP_PID_WAIT_MS", 120_000, env);
}
export function assertCronFinishedOk(finished: CronFinishedPayload | undefined): void {
if (finished?.status !== "ok") {
throw new Error(`cron cleanup run did not finish ok: ${JSON.stringify(finished)}`);
}
}
function parseProbePid(raw: string): number | undefined {
const text = raw.trim();
if (!/^[1-9]\d*$/u.test(text)) {
return undefined;
}
const pid = Number(text);
return Number.isSafeInteger(pid) ? pid : undefined;
}
async function readProbePid(pidPath: string): Promise<number | undefined> {
try {
return parseProbePid(await fs.readFile(pidPath, "utf-8"));
} catch {
return undefined;
}
}
async function readProbePids(pidsPath: string): Promise<number[]> {
try {
const raw = await fs.readFile(pidsPath, "utf-8");
const pids: number[] = [];
const seen = new Set<number>();
for (const line of raw.split(/\r?\n/)) {
const pid = parseProbePid(line);
if (pid === undefined || seen.has(pid)) {
continue;
}
seen.add(pid);
pids.push(pid);
}
return pids;
} catch {
return [];
}
}
async function describeProbePid(pid: number): Promise<string | undefined> {
try {
const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "args="]);
const args = stdout.trim();
return args.length > 0 ? args : undefined;
} catch {
return undefined;
}
}
export async function waitForProbePid(
pidPath: string,
options: { pollMs?: number; timeoutMs?: number } = {},
): Promise<number | undefined> {
const timeoutMs = options.timeoutMs ?? PROBE_PID_WAIT_MS;
const pollMs = options.pollMs ?? 100;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const pid = await readProbePid(pidPath);
if (pid) {
return pid;
}
await delay(pollMs);
}
return undefined;
}
async function waitForProbeExit(params: {
pid: number;
label: string;
timeoutMs?: number;
}): Promise<void> {
const { pid, label, timeoutMs = 30_000 } = params;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const args = await describeProbePid(pid);
if (!args || !args.includes("openclaw-cron-mcp-cleanup-probe")) {
return;
}
await delay(100);
}
const args = await describeProbePid(pid);
throw new Error(`${label} MCP probe process still alive after run: pid=${pid} args=${args}`);
}
async function waitForAllProbeExits(params: {
pidsPath: string;
label: string;
timeoutMs: number;
}): Promise<number[]> {
const startedAt = Date.now();
let observed: number[] = [];
while (Date.now() - startedAt < params.timeoutMs) {
observed = await readProbePids(params.pidsPath);
if (observed.length > 0) {
let allExited = true;
for (const pid of observed) {
const args = await describeProbePid(pid);
if (args?.includes("openclaw-cron-mcp-cleanup-probe")) {
allExited = false;
break;
}
}
if (allExited) {
return observed;
}
}
await delay(100);
}
const descriptions = await Promise.all(
observed.map(async (pid) => ({ pid, args: await describeProbePid(pid) })),
);
throw new Error(
`${params.label} MCP probe processes still alive after run: ${JSON.stringify(descriptions)}`,
);
}
async function resetProbeFiles(params: {
pidPath: string;
pidsPath: string;
exitPath: string;
}): Promise<void> {
await fs.rm(params.pidPath, { force: true });
await fs.rm(params.pidsPath, { force: true });
await fs.rm(params.exitPath, { force: true });
}
async function runCronCleanupScenario(params: {
gateway: GatewayRpcClient;
pidPath: string;
}): Promise<{ jobId: string; runId?: string; pid: number; status?: unknown }> {
const { assert, waitFor } = await loadMcpChannelsHarness();
const { gateway, pidPath } = params;
const job = await gateway.request<CronJob>("cron.add", {
name: "cron mcp cleanup docker e2e",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: {
kind: "agentTurn",
message: "Use available context and then stop.",
timeoutSeconds: 90,
lightContext: true,
toolsAllow: ["bundle-mcp", "cronCleanupProbe__cleanup_probe"],
},
delivery: { mode: "none" },
});
assert(job.id, `cron.add did not return an id: ${JSON.stringify(job)}`);
const run = await gateway.request<CronRunResult>("cron.run", {
id: job.id,
mode: "force",
});
assert(
run.ok === true && run.enqueued === true,
`cron.run was not enqueued: ${JSON.stringify(run)}`,
);
const started = await waitFor(
"cron started event",
() =>
gateway.events.find(
(entry) =>
entry.event === "cron" &&
entry.payload.jobId === job.id &&
entry.payload.action === "started",
)?.payload,
60_000,
);
assert(started, "missing cron started event");
const pid = await waitForProbePid(pidPath);
assert(
pid,
`cron MCP probe did not start within ${PROBE_PID_WAIT_MS}ms; missing pid file at ${pidPath}; events=${JSON.stringify(
gateway.events.slice(-10),
)}`,
);
const initialArgs = await describeProbePid(pid);
assert(
initialArgs === undefined || initialArgs.includes("openclaw-cron-mcp-cleanup-probe"),
`cron MCP probe pid did not look like the test server: pid=${pid} args=${initialArgs}`,
);
const finished = await waitFor(
"cron finished event",
() =>
gateway.events.find(
(entry) =>
entry.event === "cron" &&
entry.payload.jobId === job.id &&
entry.payload.action === "finished",
)?.payload,
240_000,
);
assert(finished, "missing cron finished event");
assertCronFinishedOk(finished);
await waitForProbeExit({ pid, label: "cron" });
return {
jobId: job.id,
runId: run.runId,
pid,
status: finished.status,
};
}
async function runSubagentCleanupScenario(params: {
gateway: GatewayRpcClient;
pidPath: string;
pidsPath: string;
exitPath: string;
}): Promise<{ runId: string; exitedPids: number[]; pids: number[] }> {
const { assert } = await loadMcpChannelsHarness();
const { gateway, pidPath, pidsPath, exitPath } = params;
await resetProbeFiles({ pidPath, pidsPath, exitPath });
const run = await gateway.request<AgentRunResult>(
"agent",
{
message: "Use available context and then stop.",
sessionKey: `agent:main:subagent:docker-${randomUUID()}`,
agentId: "main",
lane: "subagent",
cleanupBundleMcpOnRunEnd: true,
idempotencyKey: randomUUID(),
deliver: false,
timeout: 90,
bestEffortDeliver: true,
},
{ timeoutMs: 240_000 },
);
assert(
run.status === "accepted" && run.runId,
`agent did not accept subagent cleanup run: ${JSON.stringify(run)}`,
);
const finished = await gateway.request<{ status?: string }>(
"agent.wait",
{
runId: run.runId,
timeoutMs: 240_000,
},
{ timeoutMs: 250_000 },
);
assert(
finished.status === "ok",
`subagent cleanup run did not finish ok: ${JSON.stringify(finished)}`,
);
const exitedPids = await waitForAllProbeExits({
pidsPath,
label: "subagent",
timeoutMs: 240_000,
});
return {
runId: run.runId,
exitedPids,
pids: await readProbePids(pidsPath),
};
}
async function main() {
const { assert, connectGateway } = await loadMcpChannelsHarness();
const gatewayUrl = process.env.GW_URL?.trim();
const gatewayToken = process.env.GW_TOKEN?.trim();
const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || path.join(os.homedir(), ".openclaw");
const pidPath = path.join(stateDir, "cron-mcp-cleanup", "probe.pid");
const pidsPath = path.join(stateDir, "cron-mcp-cleanup", "probe.pids");
const exitPath = path.join(stateDir, "cron-mcp-cleanup", "probe.exit");
assert(gatewayUrl, "missing GW_URL");
assert(gatewayToken, "missing GW_TOKEN");
const gateway = await connectGateway({ url: gatewayUrl, token: gatewayToken });
try {
const cron = await runCronCleanupScenario({ gateway, pidPath });
const subagent = await runSubagentCleanupScenario({ gateway, pidPath, pidsPath, exitPath });
process.stdout.write(
JSON.stringify({
ok: true,
cron,
subagent,
}) + "\n",
);
} finally {
await gateway.close();
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Starts Gateway plus seeded cron/subagent MCP work in Docker, then verifies MCP
# child-process cleanup through a mounted test harness.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-cron-mcp-cleanup-e2e" OPENCLAW_IMAGE)"
PORT="18789"
TOKEN="cron-mcp-e2e-$(date +%s)-$$"
CONTAINER_NAME="openclaw-cron-mcp-e2e-$$"
CLIENT_LOG="$(mktemp -t openclaw-cron-mcp-client-log.XXXXXX)"
cleanup() {
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
rm -f "$CLIENT_LOG"
}
trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" cron-mcp-cleanup
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_e2e_run_with_harness \
--name "$CONTAINER_NAME" \
-e "OPENCLAW_TEST_FAST=1" \
-e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \
-e "OPENCLAW_SKIP_CHANNELS=1" \
-e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
-e "OPENCLAW_SKIP_ACPX_RUNTIME=1" \
-e "OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1" \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
-e "GW_URL=ws://127.0.0.1:$PORT" \
-e "GW_TOKEN=$TOKEN" \
-e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \
"$IMAGE_NAME" \
bash -lc "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}\"
entry=\"\$(openclaw_e2e_resolve_entrypoint)\"
export MOCK_PORT=44081
export SUCCESS_MARKER=OPENCLAW_CRON_MCP_CLEANUP_OK
export MOCK_REQUEST_LOG=/tmp/openclaw-cron-mock-openai-requests.jsonl
export OPENCLAW_DOCKER_OPENAI_BASE_URL=\"http://127.0.0.1:\$MOCK_PORT/v1\"
mock_pid=\"\$(openclaw_e2e_start_mock_openai \"\$MOCK_PORT\" /tmp/cron-mcp-cleanup-mock-openai.log)\"
gateway_pid=
cleanup_inner() {
openclaw_e2e_stop_process \"\${gateway_pid:-}\"
openclaw_e2e_stop_process \"\${mock_pid:-}\"
}
dump_gateway_log_on_error() {
status=\$?
if [ \"\$status\" -ne 0 ]; then
openclaw_e2e_dump_logs \
/tmp/cron-mcp-cleanup-gateway.log \
/tmp/cron-mcp-cleanup-seed.log \
/tmp/cron-mcp-cleanup-mock-openai.log \
\"\$MOCK_REQUEST_LOG\"
fi
cleanup_inner
exit \"\$status\"
}
trap cleanup_inner EXIT
trap dump_gateway_log_on_error ERR
openclaw_e2e_wait_mock_openai \"\$MOCK_PORT\"
tsx scripts/e2e/cron-mcp-cleanup-seed.ts >/tmp/cron-mcp-cleanup-seed.log
gateway_pid=\"\$(openclaw_e2e_start_gateway \"\$entry\" $PORT /tmp/cron-mcp-cleanup-gateway.log)\"
openclaw_e2e_wait_gateway_ready \"\$gateway_pid\" /tmp/cron-mcp-cleanup-gateway.log 300 $PORT
tsx scripts/e2e/cron-mcp-cleanup-docker-client.ts
" >"$CLIENT_LOG" 2>&1
status=${PIPESTATUS[0]}
set -e
if [ "$status" -ne 0 ]; then
echo "Docker cron/subagent MCP cleanup smoke failed"
docker_e2e_print_log "$CLIENT_LOG"
exit "$status"
fi
docker_e2e_print_log "$CLIENT_LOG"
echo "OK"

View File

@@ -0,0 +1,135 @@
// Cron Mcp Cleanup Seed script supports OpenClaw repository automation.
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { applyDockerOpenAiProviderConfig, type OpenClawConfig } from "./docker-openai-seed.ts";
const require = createRequire(import.meta.url);
async function writeProbeServer(params: {
serverPath: string;
pidPath: string;
pidsPath: string;
exitPath: string;
}) {
const sdkMcpServerPath = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
const sdkStdioServerPath = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
await fs.writeFile(
params.serverPath,
`#!/usr/bin/env node
import fs from "node:fs";
import fsp from "node:fs/promises";
import { McpServer } from ${JSON.stringify(sdkMcpServerPath)};
import { StdioServerTransport } from ${JSON.stringify(sdkStdioServerPath)};
process.title = "openclaw-cron-mcp-cleanup-probe";
await fsp.mkdir(${JSON.stringify(path.dirname(params.pidPath))}, { recursive: true });
await fsp.writeFile(${JSON.stringify(params.pidPath)}, String(process.pid), "utf8");
await fsp.appendFile(${JSON.stringify(params.pidsPath)}, String(process.pid) + "\\n", "utf8");
process.once("exit", () => {
try {
fs.writeFileSync(${JSON.stringify(params.exitPath)}, "exited", "utf8");
} catch {}
});
for (const signal of ["SIGINT", "SIGTERM"]) {
process.once(signal, () => {
process.exit(0);
});
}
setInterval(() => {}, 1000);
const server = new McpServer({ name: "cron-mcp-cleanup-probe", version: "1.0.0" });
server.tool("cleanup_probe", "Cron MCP cleanup probe", async () => ({
content: [{ type: "text", text: "cron-mcp-cleanup-ok" }],
}));
await server.connect(new StdioServerTransport());
`,
{ encoding: "utf-8", mode: 0o755 },
);
}
async function main() {
const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || path.join(os.homedir(), ".openclaw");
const configPath =
process.env.OPENCLAW_CONFIG_PATH?.trim() || path.join(stateDir, "openclaw.json");
const probeDir = path.join(stateDir, "cron-mcp-cleanup");
const serverPath = path.join(probeDir, "probe-server.mjs");
const pidPath = path.join(probeDir, "probe.pid");
const pidsPath = path.join(probeDir, "probe.pids");
const exitPath = path.join(probeDir, "probe.exit");
await fs.mkdir(probeDir, { recursive: true });
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.rm(pidPath, { force: true });
await fs.rm(pidsPath, { force: true });
await fs.rm(exitPath, { force: true });
await writeProbeServer({ serverPath, pidPath, pidsPath, exitPath });
const seededConfig = applyDockerOpenAiProviderConfig(
{
gateway: {
controlUi: {
allowInsecureAuth: true,
enabled: false,
},
},
cron: {
enabled: false,
},
agents: {
defaults: {
heartbeat: {
every: "0m",
},
skipBootstrap: true,
contextInjection: "never",
skills: [],
subagents: {
runTimeoutSeconds: 8,
},
},
},
tools: {
profile: "coding",
alsoAllow: ["bundle-mcp"],
subagents: {
tools: {
alsoAllow: ["bundle-mcp"],
},
},
},
plugins: {
enabled: false,
},
mcp: {
servers: {
cronCleanupProbe: {
command: "node",
args: [serverPath],
cwd: probeDir,
},
},
},
} satisfies OpenClawConfig,
"sk-docker-cron-mcp-cleanup-test",
);
await fs.writeFile(configPath, `${JSON.stringify(seededConfig, null, 2)}\n`, "utf-8");
process.stdout.write(
JSON.stringify({
ok: true,
stateDir,
configPath,
serverPath,
pidPath,
pidsPath,
exitPath,
}) + "\n",
);
}
await main();

View File

@@ -0,0 +1,49 @@
// Shared Docker E2E OpenAI provider config seed helper.
// Uses packaged plugin-sdk runtime modules so seeded configs match the npm tarball.
import {
applyProviderConfigWithDefaultModelPreset,
type ModelDefinitionConfig,
type OpenClawConfig,
} from "../../dist/plugin-sdk/provider-onboard.js";
export type { OpenClawConfig };
const DOCKER_OPENAI_MODEL_REF = "openai/gpt-5.5";
const DOCKER_OPENAI_BASE_URL =
process.env.OPENCLAW_DOCKER_OPENAI_BASE_URL?.trim() || "http://127.0.0.1:9/v1";
const DOCKER_OPENAI_MODEL: ModelDefinitionConfig = {
id: "gpt-5.5",
name: "gpt-5.5",
api: "openai-responses",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1_050_000,
maxTokens: 128_000,
};
export function applyDockerOpenAiProviderConfig(
config: OpenClawConfig,
apiKey: string,
): OpenClawConfig {
const seededConfig = applyProviderConfigWithDefaultModelPreset(config, {
providerId: "openai",
api: "openai-responses",
baseUrl: DOCKER_OPENAI_BASE_URL,
defaultModel: DOCKER_OPENAI_MODEL,
defaultModelId: DOCKER_OPENAI_MODEL.id,
aliases: [{ modelRef: DOCKER_OPENAI_MODEL_REF, alias: "GPT" }],
primaryModelRef: DOCKER_OPENAI_MODEL_REF,
});
const openAiProvider = seededConfig.models?.providers?.openai;
if (!openAiProvider) {
throw new Error("failed to seed OpenAI provider config");
}
openAiProvider.apiKey = apiKey;
return seededConfig;
}

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Verifies doctor/daemon repair switches service entrypoints between package and
# git installs. Both fixtures come from the same prepared OpenClaw npm tarball.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-doctor-install-switch-e2e" OPENCLAW_DOCTOR_INSTALL_SWITCH_E2E_IMAGE)"
NPM_INSTALL_TIMEOUT="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}"
COMMAND_TIMEOUT="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-900s}"
cleanup() {
docker_e2e_cleanup_package_tgz "${PACKAGE_TGZ:-}"
}
trap cleanup EXIT
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz doctor-switch "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")"
# Bare lanes mount the package artifact instead of baking app sources into the image.
docker_e2e_package_mount_args "$PACKAGE_TGZ"
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_e2e_run_with_harness \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e "OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT=$COMMAND_TIMEOUT" \
-e "OPENCLAW_E2E_NPM_INSTALL_TIMEOUT=$NPM_INSTALL_TIMEOUT" \
-e "OPENCLAW_TEST_STATE_FUNCTION_B64=$OPENCLAW_TEST_STATE_FUNCTION_B64" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
"$IMAGE_NAME" \
bash scripts/e2e/lib/doctor-install-switch/scenario.sh

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-gateway-network-e2e" OPENCLAW_GATEWAY_NETWORK_E2E_IMAGE)"
SKIP_BUILD="${OPENCLAW_GATEWAY_NETWORK_E2E_SKIP_BUILD:-0}"
PORT="18789"
TOKEN="e2e-$(date +%s)-$$"
NET_NAME="openclaw-net-e2e-$$"
GW_NAME="openclaw-gateway-e2e-$$"
DOCKER_COMMAND_TIMEOUT="${OPENCLAW_GATEWAY_NETWORK_DOCKER_COMMAND_TIMEOUT:-600s}"
CLIENT_TIMEOUT="${OPENCLAW_GATEWAY_NETWORK_CLIENT_TIMEOUT:-90s}"
CLIENT_LIMIT_ENV_ARGS=()
if [[ -n "${OPENCLAW_GATEWAY_NETWORK_CLIENT_CONNECT_TIMEOUT_MS+x}" ]]; then
CLIENT_CONNECT_TIMEOUT_MS="$(
docker_e2e_read_positive_int_env OPENCLAW_GATEWAY_NETWORK_CLIENT_CONNECT_TIMEOUT_MS 80000
)"
CLIENT_LIMIT_ENV_ARGS+=(
-e "OPENCLAW_GATEWAY_NETWORK_CLIENT_CONNECT_TIMEOUT_MS=$CLIENT_CONNECT_TIMEOUT_MS"
)
elif [[ -n "${OPENCLAW_GATEWAY_NETWORK_CONNECT_READY_TIMEOUT_MS+x}" ]]; then
CONNECT_READY_TIMEOUT_MS="$(
docker_e2e_read_positive_int_env OPENCLAW_GATEWAY_NETWORK_CONNECT_READY_TIMEOUT_MS 80000
)"
CLIENT_LIMIT_ENV_ARGS+=(
-e "OPENCLAW_GATEWAY_NETWORK_CONNECT_READY_TIMEOUT_MS=$CONNECT_READY_TIMEOUT_MS"
)
fi
cleanup() {
docker_e2e_docker_cmd rm -f "$GW_NAME" >/dev/null 2>&1 || true
docker_e2e_docker_cmd network rm "$NET_NAME" >/dev/null 2>&1 || true
}
trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" gateway-network "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD"
echo "Creating Docker network..."
docker_e2e_docker_cmd network create "$NET_NAME" >/dev/null
echo "Starting gateway container..."
docker_e2e_harness_mount_args
docker_e2e_docker_cmd run -d \
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
--name "$GW_NAME" \
--network "$NET_NAME" \
-e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \
-e "OPENCLAW_SKIP_CHANNELS=1" \
-e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \
-e "OPENCLAW_SKIP_CRON=1" \
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
"$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
echo "Waiting for gateway to come up..."
if ! docker_e2e_wait_container_bash "$GW_NAME" 180 0.5 "source scripts/lib/openclaw-e2e-instance.sh; openclaw_e2e_probe_tcp 127.0.0.1 $PORT"; then
echo "Gateway failed to start"
docker_e2e_tail_container_file_if_running "$GW_NAME" /tmp/gateway-net-e2e.log 120
exit 1
fi
echo "Running client container (connect + health)..."
DOCKER_COMMAND_TIMEOUT="$CLIENT_TIMEOUT" run_logged gateway-network-client docker_e2e_docker_run_cmd run --rm \
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
--network "$NET_NAME" \
"${CLIENT_LIMIT_ENV_ARGS[@]}" \
-e "GW_URL=ws://$GW_NAME:$PORT" \
-e "GW_TOKEN=$TOKEN" \
"$IMAGE_NAME" \
node scripts/e2e/lib/gateway-network/client.mjs
echo "OK"

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-kitchen-sink-plugin-e2e" OPENCLAW_KITCHEN_SINK_PLUGIN_E2E_IMAGE)"
OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES="$(
docker_e2e_read_positive_int_env OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES 65536
)"
CLAW_HUB_FIXTURE_WAIT_ATTEMPTS="$(
docker_e2e_read_positive_int_env OPENCLAW_CLAWHUB_FIXTURE_WAIT_ATTEMPTS 600
)"
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 kitchen-sink-plugin empty)"
KITCHEN_SINK_NPM_SPEC="${OPENCLAW_KITCHEN_SINK_NPM_SPEC:-npm:@openclaw/kitchen-sink@latest}"
KITCHEN_SINK_NPM_MISSING_SPEC="${OPENCLAW_KITCHEN_SINK_NPM_MISSING_SPEC:-npm:@openclaw/kitchen-sink@beta}"
DEFAULT_KITCHEN_SINK_SCENARIOS="$(
cat <<SCENARIOS
npm-latest-full|${KITCHEN_SINK_NPM_SPEC}|openclaw-kitchen-sink-fixture|npm|success|full
npm-latest-conformance|${KITCHEN_SINK_NPM_SPEC}|openclaw-kitchen-sink-fixture|npm|success|conformance|conformance
npm-latest-adversarial|${KITCHEN_SINK_NPM_SPEC}|openclaw-kitchen-sink-fixture|npm|success|adversarial|adversarial
npm-beta|${KITCHEN_SINK_NPM_MISSING_SPEC}|openclaw-kitchen-sink-fixture|npm|failure|none
clawhub-latest|clawhub:@openclaw/kitchen-sink@latest|openclaw-kitchen-sink-fixture|clawhub|success|basic
clawhub-beta|clawhub:@openclaw/kitchen-sink@beta|openclaw-kitchen-sink-fixture|clawhub|failure|none
npm-to-clawhub|clawhub:@openclaw/kitchen-sink@latest|openclaw-kitchen-sink-fixture|clawhub|success|basic||${KITCHEN_SINK_NPM_SPEC}
SCENARIOS
)"
KITCHEN_SINK_SCENARIOS="${OPENCLAW_KITCHEN_SINK_PLUGIN_SCENARIOS:-$DEFAULT_KITCHEN_SINK_SCENARIOS}"
MAX_MEMORY_MIB="$(
if [[ -n "${OPENCLAW_KITCHEN_SINK_PLUGIN_MAX_MEMORY_MIB:-}" ]]; then
docker_e2e_read_nonnegative_decimal_env OPENCLAW_KITCHEN_SINK_PLUGIN_MAX_MEMORY_MIB 2304
else
docker_e2e_read_nonnegative_decimal_env OPENCLAW_KITCHEN_SINK_MAX_MEMORY_MIB 2304
fi
)"
MAX_CPU_PERCENT="$(docker_e2e_read_nonnegative_decimal_env OPENCLAW_KITCHEN_SINK_MAX_CPU_PERCENT 1200)"
DOCKER_RUN_TIMEOUT="${OPENCLAW_KITCHEN_SINK_PLUGIN_DOCKER_RUN_TIMEOUT:-1200s}"
KITCHEN_SINK_CLI_TIMEOUT="${OPENCLAW_KITCHEN_SINK_PLUGIN_CLI_TIMEOUT:-${KITCHEN_SINK_CLI_TIMEOUT:-180s}}"
CONTAINER_NAME="openclaw-kitchen-sink-plugin-e2e-$$"
RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-kitchen-sink-plugin.XXXXXX")"
STATS_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-kitchen-sink-plugin-stats.XXXXXX")"
cleanup() {
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
rm -f "$RUN_LOG" "$STATS_LOG"
}
trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" kitchen-sink-plugin
DOCKER_ENV_ARGS=(
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0
-e "OPENCLAW_CLAWHUB_FIXTURE_WAIT_ATTEMPTS=$CLAW_HUB_FIXTURE_WAIT_ATTEMPTS"
-e "OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES=$OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES"
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64"
-e "KITCHEN_SINK_SCENARIOS=$KITCHEN_SINK_SCENARIOS"
-e "KITCHEN_SINK_CLI_TIMEOUT=$KITCHEN_SINK_CLI_TIMEOUT"
)
if [[ "${OPENCLAW_KITCHEN_SINK_LIVE_CLAWHUB:-0}" = "1" ]]; then
for env_name in \
OPENCLAW_KITCHEN_SINK_LIVE_CLAWHUB \
OPENCLAW_CLAWHUB_URL \
CLAWHUB_URL \
OPENCLAW_CLAWHUB_TOKEN \
CLAWHUB_TOKEN \
CLAWHUB_AUTH_TOKEN; do
env_value="${!env_name:-}"
if [[ -n "$env_value" && "$env_value" != "undefined" && "$env_value" != "null" ]]; then
DOCKER_ENV_ARGS+=(-e "$env_name")
fi
done
fi
echo "Running kitchen-sink plugin Docker E2E..."
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker_e2e_harness_mount_args
DOCKER_COMMAND_TIMEOUT="$DOCKER_RUN_TIMEOUT" docker_e2e_docker_run_cmd run --name "$CONTAINER_NAME" "${DOCKER_E2E_HARNESS_ARGS[@]}" "${DOCKER_ENV_ARGS[@]}" -i "$IMAGE_NAME" bash scripts/e2e/lib/kitchen-sink-plugin/sweep.sh \
>"$RUN_LOG" 2>&1 &
docker_pid="$!"
docker_e2e_sample_stats_until_exit \
"$CONTAINER_NAME" \
"$docker_pid" \
"$STATS_LOG" \
"$RUN_LOG" \
"Kitchen-sink plugin Docker E2E" \
"${OPENCLAW_DOCKER_E2E_STATS_HEARTBEAT_SECONDS:-30}"
set +e
wait "$docker_pid"
run_status="$?"
set -e
docker_e2e_print_log "$RUN_LOG"
if [ "$run_status" -eq 0 ]; then
node scripts/e2e/lib/docker-stats/assert-resource-ceiling.mjs "$STATS_LOG" "$MAX_MEMORY_MIB" "$MAX_CPU_PERCENT" kitchen-sink
elif [ -s "$STATS_LOG" ]; then
if ! node scripts/e2e/lib/docker-stats/assert-resource-ceiling.mjs "$STATS_LOG" "$MAX_MEMORY_MIB" "$MAX_CPU_PERCENT" kitchen-sink; then
echo "RESOURCE_CEILING_FAILED lane=kitchen-sink primary_status=$run_status" >&2
fi
fi
exit "$run_status"

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-kitchen-sink-rpc-e2e" OPENCLAW_KITCHEN_SINK_RPC_E2E_IMAGE)"
MAX_MEMORY_MIB="$(docker_e2e_read_nonnegative_decimal_env OPENCLAW_KITCHEN_SINK_MAX_MEMORY_MIB 2048)"
MAX_CPU_PERCENT="$(docker_e2e_read_nonnegative_decimal_env OPENCLAW_KITCHEN_SINK_MAX_CPU_PERCENT 1200)"
# Keep the outer Docker watchdog above the walker's install, enable, inspect,
# readiness, and first-RPC retry budgets so inner failures stay diagnostic.
DOCKER_RUN_TIMEOUT="${OPENCLAW_KITCHEN_SINK_RPC_DOCKER_RUN_TIMEOUT:-1500s}"
CONTAINER_NAME="openclaw-kitchen-sink-rpc-e2e-$$"
RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-kitchen-sink-rpc.XXXXXX")"
STATS_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-kitchen-sink-rpc-stats.XXXXXX")"
cleanup() {
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
rm -f "$RUN_LOG" "$STATS_LOG"
}
trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" kitchen-sink-rpc
DOCKER_ENV_ARGS=(
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0
-e OPENCLAW_ENTRY=/app/openclaw.mjs
)
for env_name in \
OPENCLAW_KITCHEN_SINK_NPM_SPEC \
OPENCLAW_KITCHEN_SINK_PLUGIN_ID \
OPENCLAW_KITCHEN_SINK_PERSONALITY \
OPENCLAW_KITCHEN_SINK_RPC_READY_MS \
OPENCLAW_KITCHEN_SINK_RPC_COMMAND_MS \
OPENCLAW_KITCHEN_SINK_RPC_INSTALL_MS \
OPENCLAW_KITCHEN_SINK_RPC_CALL_MS \
OPENCLAW_KITCHEN_SINK_RPC_PORT \
OPENCLAW_KITCHEN_SINK_RPC_FETCH_MS \
OPENCLAW_KITCHEN_SINK_RPC_FETCH_BODY_BYTES \
OPENCLAW_KITCHEN_SINK_OUTPUT_CAPTURE_CHARS \
OPENCLAW_KITCHEN_SINK_KEEP_TMP \
OPENCLAW_KITCHEN_SINK_MAX_RSS_MIB \
OPENCLAW_KITCHEN_SINK_COMMAND_MAX_RSS_MIB; do
env_value="${!env_name:-}"
if [[ -n "$env_value" && "$env_value" != "undefined" && "$env_value" != "null" ]]; then
DOCKER_ENV_ARGS+=(-e "$env_name")
fi
done
echo "Running kitchen-sink RPC Docker E2E..."
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker_e2e_harness_mount_args
DOCKER_COMMAND_TIMEOUT="$DOCKER_RUN_TIMEOUT" docker_e2e_docker_run_cmd run --name "$CONTAINER_NAME" "${DOCKER_E2E_HARNESS_ARGS[@]}" "${DOCKER_ENV_ARGS[@]}" -i "$IMAGE_NAME" \
node scripts/e2e/kitchen-sink-rpc-walk.mjs >"$RUN_LOG" 2>&1 &
docker_pid="$!"
docker_e2e_sample_stats_until_exit \
"$CONTAINER_NAME" \
"$docker_pid" \
"$STATS_LOG" \
"$RUN_LOG" \
"Kitchen-sink RPC Docker E2E" \
"${OPENCLAW_DOCKER_E2E_STATS_HEARTBEAT_SECONDS:-30}"
set +e
wait "$docker_pid"
run_status="$?"
set -e
docker_e2e_print_log "$RUN_LOG"
if [ "$run_status" -eq 0 ]; then
node scripts/e2e/lib/docker-stats/assert-resource-ceiling.mjs "$STATS_LOG" "$MAX_MEMORY_MIB" "$MAX_CPU_PERCENT" kitchen-sink-rpc
elif [ -s "$STATS_LOG" ]; then
if ! node scripts/e2e/lib/docker-stats/assert-resource-ceiling.mjs "$STATS_LOG" "$MAX_MEMORY_MIB" "$MAX_CPU_PERCENT" kitchen-sink-rpc; then
echo "RESOURCE_CEILING_FAILED lane=kitchen-sink-rpc primary_status=$run_status" >&2
fi
fi
exit "$run_status"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,229 @@
// Helpers for extracting agent turn output from E2E protocol events.
import fs from "node:fs";
import { readTextFileTail, tailText } from "./text-file-utils.mjs";
const ERROR_DETAIL_TAIL_BYTES = 64 * 1024;
const OUTPUT_SCAN_TAIL_BYTES = 2 * 1024 * 1024;
const REPLY_TEXT_PREVIEW_BYTES = 8 * 1024;
const REPLY_TEXT_PREVIEW_COUNT = 5;
const REQUEST_LOG_SCAN_CHUNK_BYTES = 64 * 1024;
const REQUEST_LOG_SCAN_CARRY_CHARS = 256;
const OPENAI_REQUEST_PATH_PATTERN = /\/v1\/(responses|chat\/completions)/u;
function textByteLength(text) {
return Buffer.byteLength(text, "utf8");
}
function summarizeReplyTexts(replyTexts) {
const previewStart = Math.max(0, replyTexts.length - REPLY_TEXT_PREVIEW_COUNT);
const recent = replyTexts.slice(previewStart).map((text, index) => ({
index: previewStart + index,
bytes: textByteLength(text),
tail: tailText(text, REPLY_TEXT_PREVIEW_BYTES),
}));
return JSON.stringify({ count: replyTexts.length, recent });
}
function fileContainsPattern(file, pattern) {
let stat;
try {
stat = fs.statSync(file);
} catch {
return false;
}
if (!stat.isFile() || stat.size <= 0) {
return false;
}
const fd = fs.openSync(file, "r");
try {
const buffer = Buffer.alloc(Math.min(REQUEST_LOG_SCAN_CHUNK_BYTES, stat.size));
let carry = "";
let offset = 0;
while (offset < stat.size) {
const bytesToRead = Math.min(buffer.length, stat.size - offset);
const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, offset);
if (bytesRead <= 0) {
break;
}
offset += bytesRead;
const text = carry + buffer.subarray(0, bytesRead).toString("utf8");
if (pattern.test(text)) {
return true;
}
carry = text.slice(-REQUEST_LOG_SCAN_CARRY_CHARS);
}
return false;
} finally {
fs.closeSync(fd);
}
}
function parseJson(text) {
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
function isJsonObjectRecordStart(text, index) {
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
const char = text[cursor];
if (char === "\n" || char === "\r") {
return true;
}
if (char !== " " && char !== "\t") {
return false;
}
}
return true;
}
function parseJsonObjectsFromText(text) {
const payloads = [];
let start = -1;
let depth = 0;
let inString = false;
let escaped = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (start === -1) {
if (char === "{" && isJsonObjectRecordStart(text, index)) {
start = index;
depth = 1;
inString = false;
escaped = false;
}
continue;
}
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === "{") {
depth += 1;
continue;
}
if (char !== "}") {
continue;
}
depth -= 1;
if (depth === 0) {
const parsed = parseJson(text.slice(start, index + 1));
if (parsed !== undefined) {
payloads.push(parsed);
}
start = -1;
}
}
return payloads;
}
function parseJsonPayloads(text) {
const trimmed = text.trim();
if (!trimmed) {
return [];
}
const parsed = parseJson(trimmed);
if (parsed !== undefined) {
return [parsed];
}
return parseJsonObjectsFromText(trimmed);
}
function textValues(values) {
return values.filter((value) => typeof value === "string" && value.length > 0);
}
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isFailureStatus(value) {
return (
typeof value === "string" &&
["blocked", "canceled", "cancelled", "error", "failed", "failure"].includes(value.toLowerCase())
);
}
function hasFailureSignal(value) {
if (!isRecord(value)) {
return false;
}
return (
value.isError === true ||
value.ok === false ||
isFailureStatus(value.status) ||
isFailureStatus(value.livenessState) ||
(Object.hasOwn(value, "error") && value.error !== null && value.error !== undefined)
);
}
export function extractAgentReplyTexts(text) {
return parseJsonPayloads(text).flatMap((payload) => {
const envelopeFailed =
hasFailureSignal(payload) ||
hasFailureSignal(payload?.meta) ||
hasFailureSignal(payload?.result) ||
hasFailureSignal(payload?.result?.meta);
if (envelopeFailed) {
return [];
}
const payloadEntries = Array.isArray(payload?.payloads)
? payload.payloads
: Array.isArray(payload?.result?.payloads)
? payload.result.payloads
: [];
const directTexts = textValues([
payload?.finalAssistantVisibleText,
payload?.finalAssistantRawText,
payload?.meta?.finalAssistantVisibleText,
payload?.meta?.finalAssistantRawText,
payload?.result?.finalAssistantVisibleText,
payload?.result?.finalAssistantRawText,
payload?.result?.meta?.finalAssistantVisibleText,
payload?.result?.meta?.finalAssistantRawText,
]);
const payloadTexts = payloadEntries.flatMap((entry) =>
entry?.isError !== true && typeof entry?.text === "string" && entry.text.length > 0
? [entry.text]
: [],
);
return directTexts.concat(payloadTexts);
});
}
export function assertAgentReplyContainsMarker(marker, outputPath) {
const output = readTextFileTail(outputPath, OUTPUT_SCAN_TAIL_BYTES);
const replyTexts = extractAgentReplyTexts(output);
if (replyTexts.some((text) => text.includes(marker))) {
return;
}
const outputTail = tailText(output, ERROR_DETAIL_TAIL_BYTES);
throw new Error(
`agent reply payload did not contain marker ${marker}. Reply payload summary: ${summarizeReplyTexts(replyTexts)}. Output tail: ${outputTail}`,
);
}
export function assertOpenAiRequestLogUsed(requestLogPath, label = "mock OpenAI server") {
if (fileContainsPattern(requestLogPath, OPENAI_REQUEST_PATH_PATTERN)) {
return;
}
const requestLogTail = readTextFileTail(requestLogPath, ERROR_DETAIL_TAIL_BYTES);
throw new Error(`${label} was not used. Request log tail: ${requestLogTail}`);
}

View File

@@ -0,0 +1,62 @@
// Shared auth profile store assertions for install/onboard E2E proof.
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function hasExpectedOpenAiEnvRef(profile) {
if (!isRecord(profile)) {
return false;
}
const keyRef = profile.keyRef;
return (
profile.type === "api_key" &&
profile.provider === "openai" &&
!Object.hasOwn(profile, "key") &&
isRecord(keyRef) &&
keyRef.source === "env" &&
keyRef.provider === "default" &&
keyRef.id === "OPENAI_API_KEY"
);
}
function hasInlineOpenAiKey(profile) {
return (
isRecord(profile) &&
profile.type === "api_key" &&
profile.provider === "openai" &&
Object.hasOwn(profile, "key")
);
}
export function assertOpenAiEnvAuthProfileStore(storeJson, options = {}) {
const missingMessage = options.missingMessage ?? "auth profile store was not persisted";
const envRefMessage =
options.envRefMessage ?? "auth profile did not persist OPENAI_API_KEY env ref";
const rawKeyMessage = options.rawKeyMessage ?? "auth profile persisted an inline OpenAI key";
const rawKeyNeedle = options.rawKeyNeedle;
if (!storeJson) {
throw new Error(missingMessage);
}
if (rawKeyNeedle && storeJson.includes(rawKeyNeedle)) {
throw new Error(rawKeyMessage);
}
let store;
try {
store = JSON.parse(storeJson);
} catch {
throw new Error(envRefMessage);
}
const profiles = isRecord(store) && isRecord(store.profiles) ? store.profiles : null;
if (!profiles) {
throw new Error(envRefMessage);
}
const profileValues = Object.values(profiles);
if (profileValues.some(hasInlineOpenAiKey)) {
throw new Error(rawKeyMessage);
}
if (!profileValues.some(hasExpectedOpenAiEnvRef)) {
throw new Error(envRefMessage);
}
}

View File

@@ -0,0 +1,68 @@
// Bounded response body reader used by E2E HTTP fixture clients.
function bodyTooLargeError(label, byteLimit) {
return Object.assign(new Error(`${label} response body exceeded ${byteLimit} bytes`), {
code: "ETOOBIG",
});
}
function cancelReaderSoon(reader) {
void Promise.resolve()
.then(() => reader.cancel())
.catch(() => {});
}
function parseContentLengthHeader(headers) {
const raw = headers.get("content-length");
if (!raw || !/^\d+$/u.test(raw)) {
return undefined;
}
const parsed = Number(raw);
return Number.isSafeInteger(parsed) ? parsed : Number.POSITIVE_INFINITY;
}
export async function readBoundedResponseText(response, label, byteLimit, timeoutPromise) {
const contentLength = parseContentLengthHeader(response.headers);
if (contentLength !== undefined && contentLength > byteLimit) {
await response.body?.cancel().catch(() => {});
throw bodyTooLargeError(label, byteLimit);
}
if (!response.body) {
return "";
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let byteCount = 0;
let text = "";
let canceled = false;
try {
while (true) {
const read = reader.read();
const readWithTimeout = timeoutPromise
? Promise.race([
read,
timeoutPromise.catch((error) => {
canceled = true;
cancelReaderSoon(reader);
throw error;
}),
])
: read;
const { done, value } = await readWithTimeout;
if (done) {
return text + decoder.decode();
}
byteCount += value.byteLength;
if (byteCount > byteLimit) {
canceled = true;
await reader.cancel().catch(() => {});
throw bodyTooLargeError(label, byteLimit);
}
text += decoder.decode(value, { stream: true });
}
} finally {
if (!canceled) {
reader.releaseLock();
}
}
}

View File

@@ -0,0 +1,71 @@
// Assertions for browser CDP snapshot E2E fixtures.
import fs from "node:fs";
const DEFAULT_SNAPSHOT_MAX_BYTES = 512 * 1024;
const SNAPSHOT_DIAGNOSTIC_MAX_BYTES = 32 * 1024;
const snapshotPath = process.argv[2] ?? "/tmp/browser-cdp-snapshot.txt";
function readPositiveIntEnv(name, fallback) {
const raw = process.env[name];
if (raw === undefined || raw === "") {
return fallback;
}
const text = raw.trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`${name} must be a positive integer; got: ${raw}`);
}
const parsed = Number(text);
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive integer; got: ${raw}`);
}
return parsed;
}
function readBoundedSnapshot(file, maxBytes) {
const stats = fs.statSync(file);
if (!stats.isFile()) {
throw new Error(`${file} is not a file`);
}
if (stats.size > maxBytes) {
throw new Error(`browser CDP snapshot exceeded ${maxBytes} bytes: ${stats.size} bytes`);
}
const snapshot = fs.readFileSync(file, "utf8");
const bytes = Buffer.byteLength(snapshot, "utf8");
if (bytes > maxBytes) {
throw new Error(`browser CDP snapshot exceeded ${maxBytes} bytes: ${bytes} bytes`);
}
return snapshot;
}
function snapshotDiagnostic(snapshot) {
const buffer = Buffer.from(snapshot, "utf8");
if (buffer.byteLength <= SNAPSHOT_DIAGNOSTIC_MAX_BYTES) {
return snapshot;
}
return `[truncated snapshot diagnostic to ${SNAPSHOT_DIAGNOSTIC_MAX_BYTES} bytes]\n${buffer
.subarray(buffer.byteLength - SNAPSHOT_DIAGNOSTIC_MAX_BYTES)
.toString("utf8")}`;
}
const snapshotMaxBytes = readPositiveIntEnv(
"OPENCLAW_BROWSER_CDP_SNAPSHOT_MAX_BYTES",
DEFAULT_SNAPSHOT_MAX_BYTES,
);
const snapshot = readBoundedSnapshot(snapshotPath, snapshotMaxBytes);
for (const needle of [
'button "Save"',
'link "Docs"',
"https://docs.openclaw.ai/browser-cdp-live",
'generic "Clickable Card"',
"cursor:pointer",
'Iframe "Child"',
'button "Inside"',
]) {
if (!snapshot.includes(needle)) {
console.error(snapshotDiagnostic(snapshot));
throw new Error(`missing snapshot needle: ${needle}`);
}
}
console.log("ok");

View File

@@ -0,0 +1,24 @@
// Fixture HTTP server for browser CDP snapshot E2E scenarios.
import http from "node:http";
import { readTcpPortEnv } from "../env-limits.mjs";
const port = readTcpPortEnv("FIXTURE_PORT");
const html = `<!doctype html>
<html>
<body>
<main>
<button>Save</button>
<a href="https://docs.openclaw.ai/browser-cdp-live">Docs</a>
<div id="card" onclick="window.__clicked = true" style="cursor: pointer">Clickable Card</div>
<iframe title="Child" srcdoc='<button>Inside</button>'></iframe>
</main>
</body>
</html>`;
http
.createServer((_req, res) => {
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
res.end(html);
})
.listen(port, "127.0.0.1");

View File

@@ -0,0 +1,207 @@
// Assertions for Bun global install E2E validation.
import { spawn } from "node:child_process";
const DEFAULT_TIMEOUT_KILL_GRACE_MS = 30_000;
const PARENT_TERMINATION_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
const usage = () => {
console.error("Usage: assertions.mjs <run-with-timeout|assert-image-providers> [...]");
process.exit(2);
};
const [mode, ...args] = process.argv.slice(2);
const parsePositiveNumber = (value, label) => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${label} must be a positive number`);
}
return parsed;
};
const signalChild = (child, signal) => {
if (!child.pid) {
return;
}
try {
if (process.platform === "win32") {
child.kill(signal);
return;
}
process.kill(-child.pid, signal);
} catch (error) {
if (error?.code !== "ESRCH") {
throw error;
}
}
};
const processGroupAlive = (child) => {
if (process.platform === "win32" || !child.pid) {
return false;
}
try {
process.kill(-child.pid, 0);
return true;
} catch (error) {
return error?.code === "EPERM";
}
};
const waitForProcessGroupExit = async (child, timeout) => {
const deadlineAt = Date.now() + timeout;
while (Date.now() < deadlineAt) {
if (!processGroupAlive(child)) {
return true;
}
await new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
return !processGroupAlive(child);
};
const resolveSignalExitCode = (signal) => {
switch (signal) {
case "SIGINT":
return 130;
case "SIGHUP":
return 129;
default:
return 143;
}
};
const runWithTimeout = async (timeout, command, commandArgs) => {
const killGrace = parsePositiveNumber(
process.env.OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_KILL_GRACE_MS ??
String(DEFAULT_TIMEOUT_KILL_GRACE_MS),
"OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_KILL_GRACE_MS",
);
const child = spawn(command, commandArgs, {
detached: process.platform !== "win32",
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let timedOut = false;
let parentSignal = null;
let killTimer;
let killDeadlineAt = 0;
const scheduleForceKill = () => {
killDeadlineAt = Date.now() + killGrace;
killTimer ??= setTimeout(() => signalChild(child, "SIGKILL"), killGrace);
killTimer.unref();
};
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => process.stdout.write(chunk));
child.stderr.on("data", (chunk) => process.stderr.write(chunk));
const timeoutTimer = setTimeout(() => {
timedOut = true;
signalChild(child, "SIGTERM");
scheduleForceKill();
}, timeout);
timeoutTimer.unref();
const parentSignalHandlers = new Map(
PARENT_TERMINATION_SIGNALS.map((signal) => [
signal,
() => {
parentSignal ??= signal;
signalChild(child, signal);
scheduleForceKill();
},
]),
);
for (const [signal, handler] of parentSignalHandlers) {
process.on(signal, handler);
}
const cleanupParentSignalHandlers = () => {
for (const [signal, handler] of parentSignalHandlers) {
process.off(signal, handler);
}
};
let spawnError;
child.on("error", (error) => {
spawnError = error;
});
const result = await new Promise((resolve) => {
child.on("close", (status, signal) => resolve({ error: spawnError, signal, status }));
});
clearTimeout(timeoutTimer);
cleanupParentSignalHandlers();
if (timedOut || parentSignal) {
const remainingGraceMs = Math.max(0, killDeadlineAt - Date.now());
if (remainingGraceMs > 0) {
await waitForProcessGroupExit(child, remainingGraceMs);
}
if (processGroupAlive(child)) {
signalChild(child, "SIGKILL");
await waitForProcessGroupExit(child, 100);
}
clearTimeout(killTimer);
}
if (parentSignal) {
process.exit(resolveSignalExitCode(parentSignal));
}
if (timedOut) {
console.error(`command timed out after ${timeout}ms: ${command}`);
process.exit(1);
}
clearTimeout(killTimer);
if (result.error) {
console.error(`command failed: ${command}: ${result.error.message}`);
process.exit(1);
}
if (result.signal) {
console.error(`command terminated: ${command}: ${result.signal}`);
process.exit(1);
}
process.exit(result.status ?? 0);
};
if (mode === "run-with-timeout") {
const [timeoutMs, command, ...commandArgs] = args;
if (!command) {
usage();
}
let timeout;
try {
timeout = parsePositiveNumber(timeoutMs, "timeoutMs");
} catch {
usage();
}
await runWithTimeout(timeout, command, commandArgs);
}
if (mode === "assert-image-providers") {
const raw = process.env.OPENCLAW_IMAGE_PROVIDERS_JSON ?? "";
let parsed;
try {
parsed = JSON.parse(raw);
} catch (error) {
console.error(raw);
const message = error instanceof Error ? error.message : String(error);
throw new Error(`image providers output is not JSON: ${message}`, { cause: error });
}
if (!Array.isArray(parsed)) {
throw new Error("image providers output must be a JSON array");
}
if (parsed.length === 0) {
throw new Error("image providers output is empty");
}
const ids = new Set(parsed.map((entry) => (typeof entry?.id === "string" ? entry.id : "")));
for (const expected of ["google", "openai", "xai"]) {
if (!ids.has(expected)) {
throw new Error(`image providers output is missing bundled provider '${expected}'`);
}
}
console.log(`bun-global-install-smoke: image providers OK (${parsed.length} providers)`);
process.exit(0);
}
usage();

View File

@@ -0,0 +1,307 @@
// Probe script for bundled plugin install/uninstall E2E scenarios.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
const normalizePathForProbe = (value) => String(value ?? "").replace(/\\/g, "/");
const bundledRuntimeFragments = (pluginDir) => [
`/dist/extensions/${pluginDir}`,
`/dist-runtime/extensions/${pluginDir}`,
];
const bundledRuntimeRootFragments = ["/dist/extensions/", "/dist-runtime/extensions/"];
const DEFAULT_PLUGIN_LIST_TIMEOUT_MS = 30_000;
const DEFAULT_PLUGIN_LIST_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
function readIntegerEnv(name, fallback, minimum) {
const raw = process.env[name];
if (raw == null || raw === "") {
return fallback;
}
const text = raw.trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`invalid ${name}: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value < minimum) {
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
function readPositiveIntEnv(name, fallback) {
return readIntegerEnv(name, fallback, 1);
}
function readNonNegativeIntEnv(name, fallback) {
return readIntegerEnv(name, fallback, 0);
}
function resolveStateDir() {
if (process.env.OPENCLAW_STATE_DIR) {
return process.env.OPENCLAW_STATE_DIR;
}
return path.join(process.env.HOME || os.homedir(), ".openclaw");
}
function pathReferencesBundledRuntime(value, pluginDir) {
const normalized = normalizePathForProbe(value);
return bundledRuntimeFragments(pluginDir).some((fragment) => normalized.includes(fragment));
}
function pathReferencesPackagedBundledRoot(value) {
const normalized = normalizePathForProbe(value);
return bundledRuntimeRootFragments.some((fragment) => normalized.includes(fragment));
}
function pathsEqualForProbe(actual, expected) {
return normalizePathForProbe(actual) === normalizePathForProbe(expected);
}
function resolveOpenClawEntry() {
if (process.env.OPENCLAW_ENTRY) {
return process.env.OPENCLAW_ENTRY;
}
for (const entry of ["dist/index.mjs", "dist/index.js"]) {
if (fs.existsSync(entry)) {
return entry;
}
}
throw new Error("Missing OPENCLAW_ENTRY and dist/index.(m)js");
}
function readPluginsList() {
const entry = resolveOpenClawEntry();
const timeoutMs = readPositiveIntEnv(
"OPENCLAW_BUNDLED_PLUGIN_LIST_TIMEOUT_MS",
DEFAULT_PLUGIN_LIST_TIMEOUT_MS,
);
const result = spawnSync(process.execPath, [entry, "plugins", "list", "--json"], {
cwd: process.cwd(),
encoding: "utf8",
env: process.env,
maxBuffer: readPositiveIntEnv(
"OPENCLAW_BUNDLED_PLUGIN_LIST_MAX_BUFFER_BYTES",
DEFAULT_PLUGIN_LIST_MAX_BUFFER_BYTES,
),
killSignal: "SIGKILL",
timeout: timeoutMs,
});
if (result.error) {
const timedOut = result.error.code === "ETIMEDOUT";
throw new Error(
timedOut
? `Timed out listing packaged bundled plugins after ${timeoutMs}ms`
: `Unable to list packaged bundled plugins: ${result.error.message}`,
);
}
if (result.status !== 0) {
throw new Error(
`Unable to list packaged bundled plugins: ${result.stderr || result.stdout || `exit ${result.status}`}`,
);
}
const payload = parsePluginListOutput(result.stdout);
return Array.isArray(payload.plugins) ? payload.plugins : [];
}
function parsePluginListOutput(stdout) {
const trimmed = stdout.trim();
const parsed = parseJsonValue(trimmed);
if (parsed.ok) {
return parsed.value;
}
let lastParsed;
for (const line of trimmed.split(/\r?\n/u).toReversed()) {
if (!line.trimStart().startsWith("{")) {
continue;
}
const candidate = parseJsonValue(line);
if (!candidate.ok) {
continue;
}
lastParsed ??= candidate.value;
if (Array.isArray(candidate.value?.plugins)) {
return candidate.value;
}
}
if (lastParsed !== undefined) {
return lastParsed;
}
throw new Error(`Unable to parse packaged bundled plugin list JSON: ${trimmed}`);
}
function parseJsonValue(text) {
try {
return { ok: true, value: JSON.parse(text) };
} catch {
return { ok: false };
}
}
function pluginRequiresConfig(pluginDir) {
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
if (!fs.existsSync(manifestPath)) {
throw new Error(`missing bundled plugin manifest: ${manifestPath}`);
}
const manifest = readJson(manifestPath);
const required = manifest.configSchema?.required;
return Array.isArray(required) && required.some((value) => typeof value === "string");
}
async function loadPackagedBundledEntries() {
return readPluginsList()
.filter((plugin) => plugin?.origin === "bundled")
.map((plugin) => {
const id = typeof plugin.id === "string" ? plugin.id.trim() : "";
const rootDir = typeof plugin.rootDir === "string" ? plugin.rootDir.trim() : "";
const source = typeof plugin.source === "string" ? plugin.source.trim() : "";
const pluginDir = rootDir || (source ? path.dirname(source) : "");
if (!id || !pluginDir || !pathReferencesPackagedBundledRoot(pluginDir)) {
return null;
}
return {
id,
dir: path.basename(pluginDir),
rootDir: pluginDir,
requiresConfig: pluginRequiresConfig(pluginDir),
};
})
.filter(Boolean)
.toSorted((a, b) => a.id.localeCompare(b.id));
}
async function loadManifestEntries() {
const explicit = (process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS || "")
.split(/[,\s]+/u)
.map((entry) => entry.trim())
.filter(Boolean);
const manifestEntries = await loadPackagedBundledEntries();
if (explicit.length === 0) {
return manifestEntries;
}
const available = manifestEntries.map((entry) => entry.id).join(", ");
return explicit.map((lookup) => {
const found = manifestEntries.find((entry) => entry.id === lookup || entry.dir === lookup);
if (!found) {
throw new Error(
`OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS entry is not an installable bundled plugin in this package: ${lookup}. Available: ${available}`,
);
}
return found;
});
}
async function selectedManifestEntries() {
const allEntries = await loadManifestEntries();
const total = readPositiveIntEnv("OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL", 1);
const index = readNonNegativeIntEnv("OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX", 0);
if (index >= total) {
throw new Error(
`OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX must be in [0, ${total - 1}], got ${process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX}`,
);
}
const selected = allEntries.filter((_, candidateIndex) => candidateIndex % total === index);
if (selected.length === 0) {
throw new Error(`No bundled plugin ids selected for shard ${index}/${total}`);
}
return selected;
}
function assertInstalled(pluginId, pluginDir, requiresConfig, selectedPluginRoot = "") {
const stateDir = resolveStateDir();
const configPath = path.join(stateDir, "openclaw.json");
const config = readJson(configPath);
const records = readPluginInstallRecords({ stateDir, configPath });
const record = records[pluginId];
if (!record) {
throw new Error(`missing install record for ${pluginId}`);
}
if (record.source !== "path") {
throw new Error(
`expected bundled install record source=path for ${pluginId}, got ${record.source}`,
);
}
const sourcePath = typeof record.sourcePath === "string" ? record.sourcePath : "";
if (!sourcePath) {
throw new Error(`unexpected bundled source path for ${pluginId}: ${record.sourcePath}`);
}
if (selectedPluginRoot && !pathsEqualForProbe(sourcePath, selectedPluginRoot)) {
throw new Error(
`bundled source path for ${pluginId} did not match selected root: expected ${selectedPluginRoot}, got ${record.sourcePath}`,
);
}
if (!selectedPluginRoot && !pathReferencesBundledRuntime(sourcePath, pluginDir)) {
throw new Error(`unexpected bundled source path for ${pluginId}: ${record.sourcePath}`);
}
if (selectedPluginRoot && !fs.existsSync(sourcePath)) {
throw new Error(`bundled source path for ${pluginId} does not exist: ${record.sourcePath}`);
}
if (!pathsEqualForProbe(record.installPath, record.sourcePath)) {
throw new Error(`bundled install path should equal source path for ${pluginId}`);
}
const paths = config.plugins?.load?.paths || [];
if (paths.some((entry) => pathReferencesBundledRuntime(entry, pluginDir))) {
throw new Error(`config load paths should not include bundled install path for ${pluginId}`);
}
if (requiresConfig && config.plugins?.entries?.[pluginId]?.enabled === true) {
throw new Error(
`plugin requiring config should not be enabled immediately after install for ${pluginId}`,
);
}
if (!requiresConfig && config.plugins?.entries?.[pluginId]?.enabled !== true) {
throw new Error(`config entry is not enabled after install for ${pluginId}`);
}
const allow = config.plugins?.allow || [];
if (Array.isArray(allow) && allow.length > 0 && !allow.includes(pluginId)) {
throw new Error(`existing allowlist does not include ${pluginId} after install`);
}
if ((config.plugins?.deny || []).includes(pluginId)) {
throw new Error(`denylist contains ${pluginId} after install`);
}
}
function assertUninstalled(pluginId, pluginDir) {
const stateDir = resolveStateDir();
const configPath = path.join(stateDir, "openclaw.json");
const config = fs.existsSync(configPath) ? readJson(configPath) : {};
const records = readPluginInstallRecords({ stateDir, configPath });
if (records[pluginId]) {
throw new Error(`install record still present after uninstall for ${pluginId}`);
}
const paths = config.plugins?.load?.paths || [];
if (paths.some((entry) => pathReferencesBundledRuntime(entry, pluginDir))) {
throw new Error(`load path still present after uninstall for ${pluginId}`);
}
if (config.plugins?.entries?.[pluginId]) {
throw new Error(`config entry still present after uninstall for ${pluginId}`);
}
if ((config.plugins?.allow || []).includes(pluginId)) {
throw new Error(`allowlist still contains ${pluginId} after uninstall`);
}
if ((config.plugins?.deny || []).includes(pluginId)) {
throw new Error(`denylist still contains ${pluginId} after uninstall`);
}
const managedPath = path.join(stateDir, "extensions", pluginId);
if (fs.existsSync(managedPath)) {
throw new Error(
`managed install directory unexpectedly exists for bundled plugin ${pluginId}: ${managedPath}`,
);
}
}
const [command, pluginId, pluginDir, requiresConfig, selectedPluginRoot] = process.argv.slice(2);
if (command === "select") {
for (const entry of await selectedManifestEntries()) {
console.log(`${entry.id}\t${entry.dir}\t${entry.requiresConfig ? "1" : "0"}\t${entry.rootDir}`);
}
} else if (command === "assert-installed") {
assertInstalled(pluginId, pluginDir, requiresConfig === "1", selectedPluginRoot);
} else if (command === "assert-uninstalled") {
assertUninstalled(pluginId, pluginDir);
} else {
throw new Error(`Unknown bundled plugin probe command: ${command || "(missing)"}`);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,107 @@
#!/usr/bin/env bash
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
source scripts/lib/docker-e2e-logs.sh
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
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
probe="scripts/e2e/lib/bundled-plugin-install-uninstall/probe.mjs"
runtime_smoke="scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs"
node "$probe" select > /tmp/bundled-plugin-sweep-ids
sweep_command_timeout="${OPENCLAW_BUNDLED_PLUGIN_SWEEP_COMMAND_TIMEOUT:-300s}"
now_ms() {
node -e 'process.stdout.write(String(Date.now()))'
}
run_logged_sweep_command() {
local label="$1"
local log_file="$2"
shift 2
if openclaw_e2e_maybe_timeout "$sweep_command_timeout" "$@" >"$log_file" 2>&1; then
return 0
else
local status=$?
docker_e2e_print_log "$log_file"
if [ "$status" -eq 124 ]; then
echo "Bundled plugin sweep command timed out after $sweep_command_timeout: $label" >&2
else
echo "Bundled plugin sweep command failed with status $status: $label" >&2
fi
return "$status"
fi
}
lifecycle_trace_enabled() {
case "${OPENCLAW_PLUGIN_LIFECYCLE_TRACE:-}" in
1 | true | TRUE | yes | YES)
return 0
;;
*)
return 1
;;
esac
}
plugin_entries=()
while IFS= read -r plugin_entry; do
plugin_entries+=("$plugin_entry")
done < /tmp/bundled-plugin-sweep-ids
selected_labels=()
for plugin_entry in "${plugin_entries[@]}"; do
IFS=$'\t' read -r plugin_id plugin_dir _requires_config _plugin_root <<<"$plugin_entry"
selected_labels+=("${plugin_id}@${plugin_dir}")
done
echo "Selected ${#plugin_entries[@]} bundled plugins for shard ${OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX:-0}/${OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL:-1}: ${selected_labels[*]}"
plugin_index=0
for plugin_entry in "${plugin_entries[@]}"; do
IFS=$'\t' read -r plugin_id plugin_dir requires_config plugin_root <<<"$plugin_entry"
install_log="/tmp/openclaw-install-${plugin_index}.log"
uninstall_log="/tmp/openclaw-uninstall-${plugin_index}.log"
plugin_started_at="$(now_ms)"
echo "Installing bundled plugin: $plugin_id ($plugin_dir)"
run_logged_sweep_command "install $plugin_id" "$install_log" \
node "$OPENCLAW_ENTRY" plugins install "$plugin_id"
if lifecycle_trace_enabled; then
docker_e2e_print_log "$install_log"
fi
install_finished_at="$(now_ms)"
node "$probe" assert-installed "$plugin_id" "$plugin_dir" "$requires_config" "$plugin_root"
installed_asserted_at="$(now_ms)"
if [[ "${OPENCLAW_BUNDLED_PLUGIN_RUNTIME_SMOKE:-1}" != "0" ]]; then
echo "Running bundled plugin runtime smoke: $plugin_id ($plugin_dir)"
node "$runtime_smoke" plugin "$plugin_id" "$plugin_dir" "$requires_config" "$plugin_index" "$plugin_root"
node "$runtime_smoke" tts-global-disable "$plugin_id" "$plugin_dir" "$requires_config" "$plugin_index" "$plugin_root" ""
if [[ "$plugin_id" == "${OPENCLAW_BUNDLED_PLUGIN_TTS_LIVE_PROVIDER:-openai}" ]]; then
node "$runtime_smoke" tts-openai-live "$plugin_id" "$plugin_dir" "$requires_config" "$plugin_index"
fi
fi
runtime_finished_at="$(now_ms)"
echo "Uninstalling bundled plugin: $plugin_id ($plugin_dir)"
run_logged_sweep_command "uninstall $plugin_id" "$uninstall_log" \
node "$OPENCLAW_ENTRY" plugins uninstall "$plugin_id" --force
if lifecycle_trace_enabled; then
docker_e2e_print_log "$uninstall_log"
fi
uninstall_finished_at="$(now_ms)"
node "$probe" assert-uninstalled "$plugin_id" "$plugin_dir"
uninstalled_asserted_at="$(now_ms)"
echo "Bundled plugin lifecycle timing: $plugin_id install_ms=$((install_finished_at - plugin_started_at)) install_assert_ms=$((installed_asserted_at - install_finished_at)) runtime_ms=$((runtime_finished_at - installed_asserted_at)) uninstall_ms=$((uninstall_finished_at - runtime_finished_at)) uninstall_assert_ms=$((uninstalled_asserted_at - uninstall_finished_at)) total_ms=$((uninstalled_asserted_at - plugin_started_at))"
plugin_index=$((plugin_index + 1))
done
echo "bundled plugin install/uninstall sweep passed (${#plugin_entries[@]} plugin(s))"

View File

@@ -0,0 +1,497 @@
// CommonJS fixture server for ClawHub package/install E2E scenarios.
const crypto = require("node:crypto");
const fs = require("node:fs");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const { createRequire } = require("node:module");
const profile = process.argv[2];
const portFile = process.argv[3];
const requireFromApp = createRequire(path.join(process.cwd(), "package.json"));
const JSZip = requireFromApp("jszip");
const tar = requireFromApp("tar");
const packageName = "@openclaw/kitchen-sink";
const pluginId = "openclaw-kitchen-sink-fixture";
const buildArtifactSummary = ({
clawpackSha256,
clawpackSize,
npmIntegrity,
npmShasum,
npmTarballName,
}) => ({
kind: "npm-pack",
format: "tgz",
sha256: clawpackSha256,
size: clawpackSize,
npmIntegrity,
npmShasum,
npmTarballName,
});
const buildClawPackSummary = ({
clawpackSha256,
clawpackSize,
npmIntegrity,
npmShasum,
npmTarballName,
}) => ({
available: true,
format: "tgz",
sha256: clawpackSha256,
size: clawpackSize,
npmIntegrity,
npmShasum,
npmTarballName,
});
async function buildNpmPackArtifact(fixture) {
const packRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-fixture-"));
try {
const packageDir = path.join(packRoot, "package");
await fs.promises.mkdir(packageDir, { recursive: true });
await fs.promises.writeFile(
path.join(packageDir, "package.json"),
`${JSON.stringify(fixture.packageJson, null, 2)}\n`,
);
await fs.promises.writeFile(path.join(packageDir, "index.js"), fixture.indexJs);
await fs.promises.writeFile(
path.join(packageDir, "openclaw.plugin.json"),
`${JSON.stringify(fixture.manifest, null, 2)}\n`,
);
const npmTarballName = `${packageName.replace(/^@/, "").replace("/", "-")}-${fixture.version}.tgz`;
const archivePath = path.join(packRoot, npmTarballName);
await tar.c(
{
cwd: packRoot,
file: archivePath,
gzip: true,
portable: true,
noMtime: true,
},
["package"],
);
const archive = await fs.promises.readFile(archivePath);
return {
archive,
clawpackSha256: crypto.createHash("sha256").update(archive).digest("hex"),
clawpackSize: archive.length,
npmIntegrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`,
npmShasum: crypto.createHash("sha1").update(archive).digest("hex"),
npmTarballName,
};
} finally {
await fs.promises.rm(packRoot, { recursive: true, force: true }).catch(() => undefined);
}
}
const profiles = {
"kitchen-sink-plugin": {
version: "0.2.5",
packageJson: {
name: packageName,
version: "0.2.5",
type: "module",
dependencies: {
"is-number": "7.0.0",
},
peerDependencies: {
openclaw: ">=2026.4.11",
},
peerDependenciesMeta: {
openclaw: {
optional: true,
},
},
openclaw: { extensions: ["./index.js"] },
},
indexJs: `import isNumber from "is-number";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
const dependencyUrl = import.meta.resolve("is-number");
const expectedDependencyBaseUrl = new URL("./node_modules/is-number/", import.meta.url).href;
if (!dependencyUrl.startsWith(expectedDependencyBaseUrl)) {
throw new Error(\`kitchen-sink dependency resolved outside plugin root: \${dependencyUrl}\`);
}
export default definePluginEntry({
id: "${pluginId}",
name: "OpenClaw Kitchen Sink",
register(api) {
if (!isNumber(42)) {
throw new Error("kitchen-sink dependency sentinel did not load");
}
api.registerProvider({
id: "kitchen-sink-provider",
label: "Kitchen Sink Provider",
docsPath: "/providers/kitchen-sink",
auth: [],
});
api.registerContextEngine("${pluginId}", () => ({
info: {
id: "${pluginId}",
name: "Kitchen Sink Context Engine",
},
async ingest() {
return { ingested: false };
},
async assemble(params) {
return {
messages: params.messages,
estimatedTokens: 0,
};
},
async compact() {
return {
ok: true,
compacted: false,
reason: "kitchen-sink fixture does not compact",
};
},
}));
api.registerChannel({
plugin: {
id: "kitchen-sink-channel",
meta: {
id: "kitchen-sink-channel",
label: "Kitchen Sink Channel",
selectionLabel: "Kitchen Sink",
docsPath: "/channels/kitchen-sink",
blurb: "Kitchen sink ClawHub fixture channel",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
},
});
},
});
`,
manifest: {
id: pluginId,
name: "OpenClaw Kitchen Sink",
kind: "context-engine",
channels: ["kitchen-sink-channel"],
channelConfigs: {
"kitchen-sink-channel": {
schema: {
type: "object",
additionalProperties: false,
properties: {
enabled: { type: "boolean", default: true },
token: { type: "string" },
},
},
uiHints: {
token: {
sensitive: true,
},
},
label: "Kitchen Sink",
description:
"Credential-free channel fixture for deterministic Kitchen Sink install tests.",
commands: {
nativeCommandsAutoEnabled: true,
nativeSkillsAutoEnabled: true,
},
},
},
providers: ["kitchen-sink-provider"],
contracts: {
tools: ["kitchen-sink-tool"],
},
configSchema: {
type: "object",
properties: {},
},
},
packageDetail(artifact) {
const clawpack = buildClawPackSummary(artifact);
const packageArtifact = buildArtifactSummary(artifact);
const packageDetail = {
package: {
name: packageName,
displayName: "OpenClaw Kitchen Sink",
family: "code-plugin",
runtimeId: pluginId,
channel: "official",
isOfficial: true,
summary: "Kitchen sink plugin fixture for prerelease CI.",
ownerHandle: "openclaw",
createdAt: 0,
updatedAt: 0,
latestVersion: this.version,
tags: { latest: this.version },
capabilityTags: ["test-fixture"],
executesCode: true,
compatibility: {
pluginApiRange: ">=2026.4.11",
minGatewayVersion: "2026.4.11",
},
capabilities: {
executesCode: true,
runtimeId: pluginId,
capabilityTags: ["test-fixture"],
channels: ["kitchen-sink-channel"],
providers: ["kitchen-sink-provider"],
},
verification: {
tier: "source-linked",
sourceRepo: "https://github.com/openclaw/kitchen-sink",
hasProvenance: false,
scanStatus: "passed",
},
artifact: packageArtifact,
clawpack,
},
};
return {
packageDetail,
versionDetail: {
package: {
name: packageName,
displayName: "OpenClaw Kitchen Sink",
family: "code-plugin",
},
version: {
version: this.version,
createdAt: 0,
changelog: "Fixture package for kitchen-sink plugin prerelease CI.",
distTags: ["latest"],
sha256hash: artifact.sha256hash,
compatibility: packageDetail.package.compatibility,
capabilities: packageDetail.package.capabilities,
verification: packageDetail.package.verification,
artifact: packageArtifact,
clawpack,
},
},
betaStatus: 404,
};
},
},
plugins: {
version: "0.1.0",
packageJson: {
name: packageName,
version: "0.1.0",
dependencies: {
"is-number": "7.0.0",
},
peerDependencies: {
openclaw: ">=2026.4.11",
},
peerDependenciesMeta: {
openclaw: {
optional: true,
},
},
openclaw: { extensions: ["./index.js"] },
},
indexJs: `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: () => {} });
},
};
`,
manifest: {
id: pluginId,
contracts: {
tools: ["kitchen-sink-tool", "kitchen_sink_tool"],
},
configSchema: {
type: "object",
properties: {},
},
},
packageDetail(artifact) {
const compatibility = {
pluginApiRange: ">=2026.4.26",
minGatewayVersion: "2026.4.26",
};
const clawpack = buildClawPackSummary(artifact);
const packageArtifact = buildArtifactSummary(artifact);
return {
packageDetail: {
package: {
name: packageName,
displayName: "OpenClaw Kitchen Sink",
family: "code-plugin",
channel: "official",
isOfficial: true,
runtimeId: pluginId,
latestVersion: this.version,
createdAt: 0,
updatedAt: 0,
compatibility,
artifact: packageArtifact,
clawpack,
},
},
versionDetail: {
version: {
version: this.version,
createdAt: 0,
changelog: "Kitchen-sink fixture package for Docker plugin E2E.",
sha256hash: artifact.sha256hash,
compatibility,
artifact: packageArtifact,
clawpack,
},
},
};
},
},
};
const fixture = profiles[profile];
if (!fixture || !portFile) {
console.error("usage: clawhub-fixture-server.cjs <kitchen-sink-plugin|plugins> <port-file>");
process.exit(1);
}
async function main() {
const zip = new JSZip();
zip.file("package/package.json", `${JSON.stringify(fixture.packageJson, null, 2)}\n`, {
date: new Date(0),
});
zip.file("package/index.js", fixture.indexJs, { date: new Date(0) });
const manifestJson = `${JSON.stringify(fixture.manifest, null, 2)}\n`;
zip.file("package/openclaw.plugin.json", manifestJson, { date: new Date(0) });
const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
const sha256hash = crypto.createHash("sha256").update(archive).digest("hex");
const clawpack = await buildNpmPackArtifact(fixture);
const { packageDetail, versionDetail, betaStatus } = fixture.packageDetail({
sha256hash,
...clawpack,
});
const json = (response, value, status = 200) => {
response.writeHead(status, { "content-type": "application/json" });
response.end(`${JSON.stringify(value)}\n`);
};
const artifactResolverDetail = {
package: versionDetail.package ?? {
name: packageName,
displayName: packageDetail.package?.displayName ?? "OpenClaw Kitchen Sink",
family: packageDetail.package?.family ?? "code-plugin",
},
version: versionDetail.version,
artifact: {
source: "clawhub",
artifactKind: "npm-pack",
packageName,
version: fixture.version,
artifactSha256: clawpack.clawpackSha256,
npmIntegrity: clawpack.npmIntegrity,
npmShasum: clawpack.npmShasum,
},
};
const securityDetail = {
package: artifactResolverDetail.package,
release: {
version: fixture.version,
},
trust: {
scanStatus: "clean",
moderationState: null,
blockedFromDownload: false,
reasons: [],
pending: false,
stale: false,
},
};
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, packageDetail);
return;
}
if (
url.pathname ===
`/api/v1/packages/${encodeURIComponent(packageName)}/versions/${fixture.version}`
) {
json(response, versionDetail);
return;
}
if (
url.pathname ===
`/api/v1/packages/${encodeURIComponent(packageName)}/versions/${fixture.version}/artifact`
) {
json(response, artifactResolverDetail);
return;
}
if (
url.pathname ===
`/api/v1/packages/${encodeURIComponent(packageName)}/versions/${fixture.version}/security`
) {
json(response, securityDetail);
return;
}
if (
betaStatus !== undefined &&
url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/beta`
) {
json(response, { error: "version not found" }, betaStatus ?? 404);
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;
}
if (
url.pathname ===
`/api/v1/packages/${encodeURIComponent(packageName)}/versions/${fixture.version}/artifact/download`
) {
response.writeHead(200, {
"content-type": "application/octet-stream",
"content-length": String(clawpack.archive.length),
"X-ClawHub-Artifact-Type": "npm-pack-tarball",
"X-ClawHub-Artifact-Sha256": clawpack.clawpackSha256,
"X-ClawHub-Npm-Integrity": clawpack.npmIntegrity,
"X-ClawHub-Npm-Shasum": clawpack.npmShasum,
});
response.end(clawpack.archive);
return;
}
response.writeHead(404, { "content-type": "text/plain" });
response.end(`not found: ${url.pathname}`);
});
server.listen(0, "127.0.0.1", () => {
fs.writeFileSync(portFile, String(server.address().port));
});
}
main().catch(
/** @param {unknown} error */ (error) => {
console.error(error);
process.exit(1);
},
);

View File

@@ -0,0 +1,57 @@
// Shared Codex plugin install helpers for E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { readJson } from "./fixtures/common.mjs";
import { readPluginInstallRecords } from "./plugin-index-sqlite.mjs";
export { readJson };
export function stateDir() {
return process.env.OPENCLAW_STATE_DIR || path.join(process.env.HOME, ".openclaw");
}
export function configPath() {
return process.env.OPENCLAW_CONFIG_PATH || path.join(stateDir(), "openclaw.json");
}
export function managedNpmRoot() {
return path.join(stateDir(), "npm");
}
export function realPathMaybe(filePath) {
try {
return fs.realpathSync(filePath);
} catch {
return path.resolve(filePath);
}
}
export function assertPathInside(parentPath, childPath, label) {
const parent = realPathMaybe(parentPath);
const child = realPathMaybe(childPath);
const relative = path.relative(parent, child);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`${label} resolved outside ${parentPath}: ${child}`);
}
}
export function readInstallRecords(fallbackRecords = {}) {
return readPluginInstallRecords({ fallbackRecords });
}
export function npmProjectRootForInstalledPackage(installPath, packageName) {
const packageRoot = packageName
.split("/")
.reduce((current) => path.dirname(current), installPath);
return path.basename(packageRoot) === "node_modules"
? path.dirname(packageRoot)
: managedNpmRoot();
}
export function findPackageJson(packageName, roots) {
const packagePath = packageName.startsWith("@")
? path.join(...packageName.split("/"), "package.json")
: path.join(packageName, "package.json");
const candidates = roots.map((root) => path.join(root, "node_modules", packagePath));
return candidates.find((candidate) => fs.existsSync(candidate));
}

View File

@@ -0,0 +1,178 @@
// Client helpers for Codex media-path E2E fixtures.
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { setTimeout as delay } from "node:timers/promises";
import { PROTOCOL_VERSION } from "../../../../dist/gateway/protocol/index.js";
import { renderBitmapTextPngBase64 } from "../../../../test/helpers/live-image-probe.ts";
import { createGatewayWsClient } from "../../../lib/gateway-ws-client.ts";
import { resolveGatewaySuccessPayload } from "../gateway-frame-payload.mjs";
import { createJsonlRequestTailer } from "./jsonl-request-tail.mjs";
import { readPositiveIntEnv, readTcpPortEnv } from "./limits.mjs";
const portText = process.env.PORT;
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
const appServerLog =
process.env.OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG ??
"/tmp/openclaw-codex-media-path-app-server.jsonl";
const timeoutSeconds = readPositiveIntEnv("OPENCLAW_CODEX_MEDIA_PATH_TIMEOUT_SECONDS", 180);
const logTailMaxBytes = readPositiveIntEnv(
"OPENCLAW_CODEX_MEDIA_PATH_LOG_TAIL_MAX_BYTES",
2 * 1024 * 1024,
);
if (!portText || !token) {
throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN");
}
const port = readTcpPortEnv("PORT", portText);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function sha256Base64(data) {
return createHash("sha256").update(Buffer.from(data, "base64")).digest("hex");
}
const loggedRequests = createJsonlRequestTailer(appServerLog, {
maxReadBytes: logTailMaxBytes,
});
async function waitFor(label, predicate, timeoutMs) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const value = await predicate();
if (value !== undefined) {
return value;
}
await delay(50);
}
throw new Error(`timeout waiting for ${label}`);
}
async function connectGateway() {
const gatewayClient = createGatewayWsClient({
handshakeTimeoutMs: 45_000,
openTimeoutMs: 45_000,
openTimeoutMessage: "gateway ws open timeout",
url: `ws://127.0.0.1:${port}`,
});
await gatewayClient.waitOpen();
async function request(method, params, opts = {}) {
const timeoutMs = opts.timeoutMs ?? 60_000;
const response = await gatewayClient.request(method, params ?? {}, timeoutMs);
if (response.ok) {
return resolveGatewaySuccessPayload(response);
}
throw new Error(
response.error && typeof response.error === "object" && "message" in response.error
? String(response.error.message)
: "gateway request failed",
);
}
await request(
"connect",
{
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: "gateway-client",
displayName: "docker-codex-media-path",
version: "1.0.0",
platform: process.platform,
mode: "backend",
},
role: "operator",
scopes: ["operator.read", "operator.write", "operator.admin"],
caps: [],
auth: { token },
},
{ timeoutMs: 60_000 },
);
await request("sessions.subscribe", {}, { timeoutMs: 60_000 });
return {
request,
async close() {
gatewayClient.close();
},
};
}
const gateway = await connectGateway();
function randomBitmapTextToken(length = 6) {
const alphabet = "24567ACEF";
return [...randomBytes(length)].map((byte) => alphabet[byte % alphabet.length]).join("");
}
try {
const expectedToken = randomBitmapTextToken();
const imageBase64 = renderBitmapTextPngBase64(expectedToken);
const expectedHash = sha256Base64(imageBase64);
const runId = `codex-media-path-${randomUUID()}`;
const started = Date.now();
const response = await gateway.request(
"chat.send",
{
sessionKey: "agent:main:codex-media-path-e2e",
idempotencyKey: runId,
message: "Read the code printed in the attached image. Reply only the code.",
attachments: [
{
mimeType: "image/png",
fileName: "codex-media-path-probe.png",
content: imageBase64,
},
],
originatingChannel: "codex-media-path-e2e",
originatingTo: "codex-media-path-e2e",
originatingAccountId: "codex-media-path-e2e",
},
{ timeoutMs: timeoutSeconds * 1000 },
);
assert(response?.status === "started", `chat.send did not start: ${JSON.stringify(response)}`);
const turnRequest = await waitFor(
"Codex turn/start image input",
() =>
loggedRequests.read().find((request) => {
if (request.method !== "turn/start") {
return undefined;
}
const imageInput = request.params?.input?.find?.(
(entry) => entry?.type === "image" && typeof entry.url === "string",
);
return imageInput ? request : undefined;
}),
timeoutSeconds * 1000,
);
const imageInput = turnRequest.params.input.find((entry) => entry?.type === "image");
const imageUrl = imageInput.url;
assert(
imageUrl.startsWith("data:image/png;base64,"),
`turn/start image input is not an inline PNG: ${JSON.stringify(imageInput)}`,
);
const actualBase64 = imageUrl.slice("data:image/png;base64,".length);
const actualHash = sha256Base64(actualBase64);
assert(
actualHash === expectedHash,
`forwarded PNG hash mismatch: expected ${expectedHash}, got ${actualHash}`,
);
await delay(50);
console.log(
JSON.stringify({
ok: true,
elapsedMs: Date.now() - started,
expectedToken,
imageSha256: actualHash,
}),
);
} finally {
await gateway.close();
}

View File

@@ -0,0 +1,104 @@
// Fake Codex app server used by media-path E2E scenarios.
import fs from "node:fs";
import readline from "node:readline";
const requestLog =
process.env.OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG ??
"/tmp/openclaw-codex-media-path-app-server.jsonl";
let turnCount = 0;
function appendRequest(request) {
try {
fs.appendFileSync(requestLog, `${JSON.stringify(request)}\n`);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`fake Codex app-server request log write failed: ${message}\n`);
if (request?.id != null) {
sendError(request.id, `fake Codex app-server request log write failed: ${message}`);
}
return false;
}
}
function send(id, result) {
process.stdout.write(`${JSON.stringify({ id, result })}\n`);
}
function sendError(id, message) {
process.stdout.write(`${JSON.stringify({ error: { message }, id })}\n`);
}
const rl = readline.createInterface({ input: process.stdin });
rl.on("line", (line) => {
if (!line.trim()) {
return;
}
const request = JSON.parse(line);
if (!appendRequest(request)) {
return;
}
const { id, method, params } = request;
if (method === "initialize") {
send(id, {
protocolVersion: "2",
serverInfo: { name: "openclaw-codex-media-path-e2e", version: "0.125.0" },
userAgent: "openclaw-codex-media-path-e2e/0.125.0 (Docker; test)",
});
return;
}
if (method === "thread/start") {
const now = Date.now();
send(id, {
thread: {
id: "thread-codex-media-path-e2e",
sessionId: "session-codex-media-path-e2e",
forkedFromId: null,
preview: "",
ephemeral: false,
modelProvider: "openai",
createdAt: now,
updatedAt: now,
cwd: params?.cwd ?? process.cwd(),
status: { type: "idle" },
path: null,
cliVersion: "0.125.0",
source: "unknown",
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns: [],
},
model: params?.model ?? "gpt-5.5",
modelProvider: "openai",
serviceTier: null,
cwd: params?.cwd ?? process.cwd(),
instructionSources: [],
approvalPolicy: params?.approvalPolicy ?? "never",
approvalsReviewer: params?.approvalsReviewer ?? "user",
sandbox: { type: "dangerFullAccess" },
permissionProfile: null,
reasoningEffort: null,
});
return;
}
if (method === "turn/start") {
turnCount += 1;
send(id, {
turn: {
id: `turn-codex-media-path-e2e-${turnCount}`,
status: "completed",
items: [
{
type: "agentMessage",
id: `msg-codex-media-path-e2e-${turnCount}`,
text: "CODEX_MEDIA_PATH_E2E_OK",
},
],
},
});
return;
}
send(id, {});
});

View File

@@ -0,0 +1,43 @@
// Tails JSONL request logs for Codex media-path E2E assertions.
import {
createIncrementalLineReader,
resolvePositiveInteger,
} from "../incremental-line-reader.mjs";
const DEFAULT_MAX_READ_BYTES = 2 * 1024 * 1024;
const DEFAULT_HISTORY_LIMIT = 1024;
export function createJsonlRequestTailer(filePath, options = {}) {
const maxReadBytes = resolvePositiveInteger(options.maxReadBytes, DEFAULT_MAX_READ_BYTES);
const historyLimit = resolvePositiveInteger(options.historyLimit, DEFAULT_HISTORY_LIMIT);
const reader = createIncrementalLineReader(filePath, { maxReadBytes });
let requests = [];
function parseLine(line) {
try {
return JSON.parse(line);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`invalid app-server JSONL at ${filePath}: ${message}`, { cause: error });
}
}
return {
read() {
const { lines, reset } = reader.readLines();
if (reset) {
requests = [];
}
for (const line of lines) {
if (!line.trim()) {
continue;
}
requests.push(parseLine(line));
}
if (requests.length > historyLimit) {
requests = requests.slice(-historyLimit);
}
return requests;
},
};
}

View File

@@ -0,0 +1,21 @@
// Limits shared by Codex media-path E2E fixtures.
export function readPositiveIntEnv(name, fallback, env = process.env) {
const text = String(env[name] ?? fallback).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`invalid ${name}: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
export function readTcpPortEnv(name, fallback, env = process.env) {
const value = readPositiveIntEnv(name, fallback, env);
if (value > 65_535) {
const text = String(env[name] ?? fallback).trim();
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}

View File

@@ -0,0 +1,56 @@
#!/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
export OPENCLAW_AGENT_HARNESS_FALLBACK=none
export OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG="/tmp/openclaw-codex-media-path-app-server.jsonl"
PORT="${PORT:?missing PORT}"
TOKEN="${OPENCLAW_GATEWAY_TOKEN:?missing OPENCLAW_GATEWAY_TOKEN}"
PLUGIN_SPEC="${OPENCLAW_CODEX_MEDIA_PATH_PLUGIN_SPEC:?missing OPENCLAW_CODEX_MEDIA_PATH_PLUGIN_SPEC}"
GATEWAY_LOG="/tmp/openclaw-codex-media-path-gateway.log"
CLIENT_LOG="/tmp/openclaw-codex-media-path-client.log"
PLUGIN_INSTALL_LOG="/tmp/openclaw-codex-media-path-plugin-install.log"
PLUGIN_INSPECT_LOG="/tmp/openclaw-codex-media-path-plugin-inspect.json"
gateway_pid=""
cleanup() {
openclaw_e2e_stop_process "$gateway_pid"
}
trap cleanup EXIT
dump_debug_logs() {
local status="$1"
echo "Codex media-path Docker E2E failed with exit code $status" >&2
openclaw_e2e_dump_logs "$PLUGIN_INSTALL_LOG" "$PLUGIN_INSPECT_LOG" "$GATEWAY_LOG" "$CLIENT_LOG" "$OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG"
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
entry="$(openclaw_e2e_resolve_entrypoint)"
mkdir -p "$OPENCLAW_STATE_DIR" "$OPENCLAW_TEST_WORKSPACE_DIR"
rm -f "$OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG"
openclaw_e2e_enable_openclaw_cli_timeout
echo "Installing Codex plugin: $PLUGIN_SPEC"
openclaw plugins install "$PLUGIN_SPEC" --force >"$PLUGIN_INSTALL_LOG" 2>&1
openclaw plugins inspect codex --runtime --json >"$PLUGIN_INSPECT_LOG"
node scripts/e2e/lib/codex-media-path/write-config.mjs
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")"
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$GATEWAY_LOG" 480 "$PORT"
PORT="$PORT" OPENCLAW_GATEWAY_TOKEN="$TOKEN" \
tsx scripts/e2e/lib/codex-media-path/client.mjs >"$CLIENT_LOG" 2>&1
openclaw_e2e_print_log "$CLIENT_LOG"
echo "Codex media-path Docker E2E passed"

View File

@@ -0,0 +1,79 @@
// Writes config fixtures for Codex media-path E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { readPositiveIntEnv, readTcpPortEnv } from "./limits.mjs";
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`missing ${name}`);
}
return value;
}
const configPath = requireEnv("OPENCLAW_CONFIG_PATH");
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
const workspaceDir = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR");
const token = requireEnv("OPENCLAW_GATEWAY_TOKEN");
const timeoutSeconds = readPositiveIntEnv("OPENCLAW_CODEX_MEDIA_PATH_TIMEOUT_SECONDS", 180);
const gatewayPort = readTcpPortEnv("PORT", 18790);
const config = {
gateway: {
port: gatewayPort,
bind: "loopback",
auth: { mode: "token", token },
controlUi: { enabled: false },
},
plugins: {
enabled: true,
allow: ["codex"],
entries: {
codex: {
enabled: true,
config: {
appServer: {
mode: "yolo",
command: "node",
args: ["scripts/e2e/lib/codex-media-path/fake-codex-app-server.mjs"],
requestTimeoutMs: timeoutSeconds * 1000,
turnCompletionIdleTimeoutMs: timeoutSeconds * 1000,
},
},
},
},
},
agents: {
defaults: {
model: { primary: "codex/gpt-5.5", fallbacks: [] },
models: {
"codex/gpt-5.5": {
agentRuntime: { id: "codex" },
},
},
workspace: workspaceDir,
skipBootstrap: true,
timeoutSeconds,
sandbox: { mode: "off" },
},
list: [
{
id: "main",
default: true,
model: { primary: "codex/gpt-5.5", fallbacks: [] },
models: {
"codex/gpt-5.5": {
agentRuntime: { id: "codex" },
},
},
workspace: workspaceDir,
},
],
},
skills: { allowBundled: [] },
};
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.mkdirSync(workspaceDir, { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
fs.mkdirSync(path.join(stateDir, "logs"), { recursive: true });

View File

@@ -0,0 +1,518 @@
// Assertions for Codex npm plugin live E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { extractAgentReplyTexts } from "../agent-turn-output.mjs";
import {
assertPathInside,
configPath,
findPackageJson,
managedNpmRoot,
npmProjectRootForInstalledPackage,
readInstallRecords,
readJson,
realPathMaybe,
stateDir,
} from "../codex-install-utils.mjs";
const command = process.argv[2];
const allowBetaCompatDiagnostics =
process.env.OPENCLAW_CODEX_NPM_PLUGIN_ALLOW_BETA_COMPAT_DIAGNOSTICS === "1";
const MAX_TEXT_FILE_BYTES = readPositiveIntEnv(
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TEXT_FILE_BYTES",
1024 * 1024,
);
const MAX_ERROR_TAIL_BYTES = readPositiveIntEnv(
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_ERROR_TAIL_BYTES",
64 * 1024,
);
const MAX_TRANSCRIPT_FILES = readPositiveIntEnv(
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_FILES",
64,
);
const MAX_TRANSCRIPT_WALK_ENTRIES = readPositiveIntEnv(
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_WALK_ENTRIES",
4096,
);
const MAX_TRANSCRIPT_SCAN_BYTES = readPositiveIntEnv(
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_SCAN_BYTES",
2 * 1024 * 1024,
);
const AGENT_TURN_TIMEOUT_SECONDS = readPositiveIntEnv(
"OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS",
420,
);
function readPositiveIntEnv(name, fallback) {
const text = String(process.env[name] ?? fallback).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`${name} must be a positive integer; got: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer; got: ${text}`);
}
return value;
}
function readTextFileBounded(filePath, label, maxBytes = MAX_TEXT_FILE_BYTES) {
const stat = fs.statSync(filePath);
if (stat.size > maxBytes) {
throw new Error(`${label} exceeded ${maxBytes} bytes: ${filePath}`);
}
return fs.readFileSync(filePath, "utf8");
}
function readTextFileTail(filePath, label, maxBytes = MAX_ERROR_TAIL_BYTES) {
if (!fs.existsSync(filePath)) {
return "";
}
const stat = fs.statSync(filePath);
if (stat.size <= maxBytes) {
return fs.readFileSync(filePath, "utf8");
}
const fd = fs.openSync(filePath, "r");
try {
const buffer = Buffer.alloc(maxBytes);
fs.readSync(fd, buffer, 0, maxBytes, stat.size - maxBytes);
return `[${label} truncated to last ${maxBytes} bytes]\n${buffer.toString("utf8")}`;
} finally {
fs.closeSync(fd);
}
}
function configure() {
const modelRef = process.argv[3] || "codex/gpt-5.4";
const state = stateDir();
const cfgPath = configPath();
const cfg = fs.existsSync(cfgPath) ? readJson(cfgPath) : {};
cfg.plugins = {
...cfg.plugins,
enabled: true,
allow: Array.from(new Set([...(cfg.plugins?.allow || []), "codex"])).toSorted((left, right) =>
left.localeCompare(right),
),
entries: {
...cfg.plugins?.entries,
codex: {
...cfg.plugins?.entries?.codex,
enabled: true,
config: {
...cfg.plugins?.entries?.codex?.config,
discovery: { enabled: false },
appServer: {
...cfg.plugins?.entries?.codex?.config?.appServer,
mode: "yolo",
approvalPolicy: "never",
sandbox: "danger-full-access",
requestTimeoutMs: AGENT_TURN_TIMEOUT_SECONDS * 1000,
},
},
},
},
};
cfg.agents = {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
model: { primary: modelRef, fallbacks: [] },
models: {
...cfg.agents?.defaults?.models,
[modelRef]: { agentRuntime: { id: "codex" } },
},
workspace: path.join(state, "workspace"),
skipBootstrap: true,
timeoutSeconds: AGENT_TURN_TIMEOUT_SECONDS,
},
};
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
fs.writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`);
}
function readInstallRecord() {
const record = readInstallRecords().codex;
if (!record) {
throw new Error("missing codex install record");
}
return record;
}
function normalizePluginSpec(spec) {
if (spec.startsWith("npm:")) {
return {
expectedSpec: spec.slice("npm:".length),
source: "npm",
};
}
if (spec.startsWith("npm-pack:")) {
return {
artifactKind: "npm-pack",
source: "npm",
sourcePath: spec.slice("npm-pack:".length),
};
}
if (spec.startsWith("git:")) {
return {
expectedSpec: spec,
source: "git",
};
}
return {
expectedSpec: spec,
source: "npm",
};
}
function assertPlugin() {
const spec = process.argv[3] || "npm:@openclaw/codex";
const list = readJson("/tmp/openclaw-codex-plugins-list.json");
const inspect = readJson("/tmp/openclaw-codex-plugin-inspect.json");
const plugin = (list.plugins || []).find((entry) => entry.id === "codex");
if (!plugin) {
throw new Error("codex plugin not found in plugins list --json output");
}
if (plugin.status !== "loaded" || plugin.enabled !== true) {
throw new Error(
`expected codex to be enabled+loaded, got enabled=${plugin.enabled} status=${plugin.status}`,
);
}
if (inspect.plugin?.id !== "codex" || inspect.plugin?.status !== "loaded") {
throw new Error(`unexpected inspect plugin state: ${JSON.stringify(inspect.plugin)}`);
}
if (
!Array.isArray(inspect.plugin?.providerIds) ||
!inspect.plugin.providerIds.includes("codex")
) {
throw new Error(`codex provider was not registered: ${JSON.stringify(inspect.plugin)}`);
}
const hasCodexHarness =
(Array.isArray(inspect.plugin?.agentHarnessIds) &&
inspect.plugin.agentHarnessIds.includes("codex")) ||
(Array.isArray(inspect.capabilities) &&
inspect.capabilities.some(
(entry) => entry?.kind === "agent-harness" && entry.ids?.includes("codex"),
));
if (!hasCodexHarness) {
throw new Error(`codex harness was not registered: ${JSON.stringify(inspect.plugin)}`);
}
const diagnostics = [...(list.diagnostics || []), ...(inspect.diagnostics || [])];
const errors = diagnostics
.filter((diag) => diag?.level === "error")
.map((diag) => String(diag.message || ""));
const unexpectedErrors = allowBetaCompatDiagnostics
? errors.filter(
(message) => message !== "only bundled plugins can claim reserved command ownership: codex",
)
: errors;
if (unexpectedErrors.length > 0) {
throw new Error(`unexpected plugin diagnostics errors: ${unexpectedErrors.join("; ")}`);
}
const record = readInstallRecord();
const expected = normalizePluginSpec(spec);
if (record.source !== expected.source) {
throw new Error(
`expected codex ${expected.source} install record, got source=${record.source}`,
);
}
if (expected.expectedSpec && record.spec !== expected.expectedSpec) {
throw new Error(`expected codex install spec ${expected.expectedSpec}, got ${record.spec}`);
}
if (expected.artifactKind && record.artifactKind !== expected.artifactKind) {
throw new Error(
`expected codex artifact kind ${expected.artifactKind}, got ${record.artifactKind}`,
);
}
if (
expected.sourcePath &&
realPathMaybe(record.sourcePath || "") !== realPathMaybe(expected.sourcePath)
) {
throw new Error(`expected codex source path ${expected.sourcePath}, got ${record.sourcePath}`);
}
if (record.source === "npm" && (!record.resolvedVersion || !record.resolvedSpec)) {
throw new Error(`missing codex npm resolution metadata: ${JSON.stringify(record)}`);
}
if (record.source === "git" && !record.gitCommit) {
throw new Error(`missing codex git resolution metadata: ${JSON.stringify(record)}`);
}
}
function codexInstallPath() {
const record = readInstallRecord();
if (typeof record.installPath !== "string" || record.installPath.length === 0) {
throw new Error(`missing codex installPath: ${JSON.stringify(record)}`);
}
return record.installPath.replace(/^~(?=$|\/)/u, process.env.HOME);
}
function codexNpmProjectRoot() {
return npmProjectRootForInstalledPackage(codexInstallPath(), "@openclaw/codex");
}
function findCodexPackageJson(packageName) {
const projectRoot = codexNpmProjectRoot();
return findPackageJson(packageName, [projectRoot, codexInstallPath(), managedNpmRoot()]);
}
function assertNpmDeps() {
const npmRoot = managedNpmRoot();
const installPath = codexInstallPath();
const pluginPackageJson = path.join(installPath, "package.json");
if (!fs.existsSync(pluginPackageJson)) {
throw new Error(`missing npm-installed @openclaw/codex package.json: ${pluginPackageJson}`);
}
assertPathInside(npmRoot, installPath, "codex plugin install path");
assertPathInside(npmRoot, pluginPackageJson, "codex plugin package");
const pluginPackage = readJson(pluginPackageJson);
if (pluginPackage.name !== "@openclaw/codex") {
throw new Error(`unexpected codex package name: ${pluginPackage.name}`);
}
const openAiCodexPackageJson = findCodexPackageJson("@openai/codex");
if (!openAiCodexPackageJson) {
throw new Error("missing @openai/codex dependency under .openclaw/npm");
}
assertPathInside(npmRoot, openAiCodexPackageJson, "@openai/codex dependency");
const bin = resolveCodexBin();
if (!fs.existsSync(bin)) {
throw new Error(`missing managed Codex binary: ${bin}`);
}
assertPathInside(npmRoot, bin, "managed Codex binary");
}
function resolveCodexBin() {
const commandName = process.platform === "win32" ? "codex.cmd" : "codex";
const candidates = [
path.join(codexNpmProjectRoot(), "node_modules", ".bin", commandName),
path.join(codexInstallPath(), "node_modules", ".bin", commandName),
path.join(managedNpmRoot(), "node_modules", ".bin", commandName),
];
const candidate = candidates.find((entry) => fs.existsSync(entry));
if (candidate) {
return candidate;
}
const packageJson = findCodexPackageJson("@openai/codex");
if (!packageJson) {
throw new Error("cannot resolve Codex binary without @openai/codex package");
}
const packageRoot = path.dirname(packageJson);
const pkg = readJson(packageJson);
const binPath =
typeof pkg.bin === "string"
? pkg.bin
: pkg.bin && typeof pkg.bin.codex === "string"
? pkg.bin.codex
: undefined;
if (!binPath) {
throw new Error(`@openai/codex package has no codex bin: ${packageJson}`);
}
return path.resolve(packageRoot, binPath);
}
function printCodexBin() {
assertNpmDeps();
process.stdout.write(`${resolveCodexBin()}\n`);
}
function assertPreflight() {
const marker = process.argv[3];
const output = readTextFileBounded("/tmp/openclaw-codex-preflight.log", "Codex preflight log");
if (!output.includes(marker)) {
throw new Error(`Codex CLI preflight did not contain ${marker}:\n${output}`);
}
}
function listFilesRecursive(root) {
if (!fs.existsSync(root)) {
return [];
}
const files = [];
const stack = [root];
let visited = 0;
while (stack.length > 0) {
const current = stack.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
visited += 1;
if (visited > MAX_TRANSCRIPT_WALK_ENTRIES) {
throw new Error(
`native Codex session transcript walk exceeded ${MAX_TRANSCRIPT_WALK_ENTRIES} entries under ${root}`,
);
}
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
} else if (entry.isFile()) {
files.push(fullPath);
}
}
}
return files;
}
function assertNativeCodexSessionEvidence(params) {
const roots = params.roots.filter((root) => fs.existsSync(root));
const files = roots
.flatMap((root) => listFilesRecursive(root).filter((filePath) => filePath.endsWith(".jsonl")))
.map((filePath) => ({ filePath, stat: fs.statSync(filePath) }))
.toSorted((left, right) => right.stat.mtimeMs - left.stat.mtimeMs)
.slice(0, MAX_TRANSCRIPT_FILES);
if (files.length === 0) {
throw new Error(
`missing native Codex session transcript files; checked ${params.roots.join(", ")}`,
);
}
let scannedBytes = 0;
const matchingFile = files.find(({ filePath, stat }) => {
const readableBytes = Math.min(stat.size, MAX_TEXT_FILE_BYTES);
if (scannedBytes + readableBytes > MAX_TRANSCRIPT_SCAN_BYTES) {
return false;
}
scannedBytes += readableBytes;
const content = readTextFileTail(filePath, "native Codex session transcript", readableBytes);
return content.includes(params.marker) || content.includes(params.threadId);
})?.filePath;
if (!matchingFile) {
throw new Error(
`native Codex session transcripts did not contain ${params.marker} or ${params.threadId}; scanned ${scannedBytes} bytes across ${files.length} newest files: ${files.map((entry) => entry.filePath).join(", ")}`,
);
}
assertPathInside(params.codexHome, matchingFile, "native Codex session transcript");
}
function assertAgentTurn() {
const marker = process.argv[3];
const sessionId = process.argv[4];
const modelRef = process.argv[5];
const stdout = readTextFileBounded("/tmp/openclaw-codex-agent.json", "OpenClaw agent JSON");
const stderr = readTextFileTail("/tmp/openclaw-codex-agent.err", "OpenClaw agent stderr");
const response = JSON.parse(stdout);
const text = extractAgentReplyTexts(JSON.stringify(response)).join("\n");
if (!text.includes(marker)) {
throw new Error(
`OpenClaw agent reply did not contain ${marker}:\nstdout=${stdout}\nstderr=${stderr}`,
);
}
const expectedProvider = modelRef.split("/")[0] || "codex";
const executionTrace = response.meta?.executionTrace;
if (!executionTrace || executionTrace.winnerProvider !== expectedProvider) {
throw new Error(
`expected Codex plugin model provider ${expectedProvider} to win the agent turn, got ${JSON.stringify(executionTrace)}`,
);
}
const sessionsDir = path.join(stateDir(), "agents", "main", "sessions");
const storePath = path.join(sessionsDir, "sessions.json");
const store = readJson(storePath);
const entry = Object.values(store).find((candidate) => candidate?.sessionId === sessionId);
if (!entry) {
throw new Error(`missing session store entry for ${sessionId}: ${JSON.stringify(store)}`);
}
if (entry.agentHarnessId !== "codex") {
throw new Error(`expected codex harness in session entry, got ${entry.agentHarnessId}`);
}
if (entry.modelOverride && entry.modelOverride !== modelRef) {
throw new Error(`unexpected session model override: ${entry.modelOverride}`);
}
if (typeof entry.sessionFile !== "string" || !fs.existsSync(entry.sessionFile)) {
throw new Error(`missing OpenClaw session file: ${entry.sessionFile}`);
}
const bindingPath = `${entry.sessionFile}.codex-app-server.json`;
const binding = readJson(bindingPath);
if (![1, 2].includes(binding.schemaVersion) || typeof binding.threadId !== "string") {
throw new Error(`invalid Codex app-server binding: ${JSON.stringify(binding)}`);
}
if (binding.model !== modelRef.split("/").slice(1).join("/")) {
throw new Error(`unexpected Codex binding model: ${binding.model}`);
}
if (binding.modelProvider && !["codex", "openai"].includes(binding.modelProvider)) {
throw new Error(`unexpected Codex binding provider: ${binding.modelProvider}`);
}
const agentDir = path.join(stateDir(), "agents", "main");
const codexHomes = [
path.join(agentDir, "codex-home"),
path.join(agentDir, "agent", "codex-home"),
path.join(path.dirname(agentDir), "codex-home"),
].filter((entryValue, index, entries) => entries.indexOf(entryValue) === index);
const codexHome = codexHomes.find((entryLocal) => fs.existsSync(entryLocal));
if (!codexHome) {
throw new Error(`missing isolated Codex home; checked ${codexHomes.join(", ")}`);
}
const codexSessionRoot = path.join(codexHome, "sessions");
const nativeSessionRoot = path.join(codexHome, "home", ".codex", "sessions");
assertNativeCodexSessionEvidence({
codexHome,
marker,
roots: [codexSessionRoot, nativeSessionRoot],
threadId: binding.threadId,
});
}
function assertUninstalled() {
const records = readInstallRecords();
if (records.codex) {
throw new Error(
`codex install record still exists after uninstall: ${JSON.stringify(records.codex)}`,
);
}
const list = readJson("/tmp/openclaw-codex-plugins-list-after-uninstall.json");
const plugin = (list.plugins || []).find((entry) => entry.id === "codex");
if (plugin?.status === "loaded" || plugin?.enabled === true) {
throw new Error(`codex plugin still loaded/enabled after uninstall: ${JSON.stringify(plugin)}`);
}
const diagnostics = list.diagnostics || [];
const errors = diagnostics
.filter((diag) => diag?.level === "error")
.map((diag) => String(diag.message || ""));
if (errors.length > 0) {
throw new Error(`unexpected plugin diagnostics errors after uninstall: ${errors.join("; ")}`);
}
}
function assertAgentError() {
const status = Number(process.argv[3]);
if (!Number.isInteger(status) || status === 0) {
throw new Error(
`expected OpenClaw agent to fail after Codex uninstall, got status ${process.argv[3]}`,
);
}
const stdout = fs.existsSync("/tmp/openclaw-codex-agent-after-uninstall.json")
? readTextFileTail(
"/tmp/openclaw-codex-agent-after-uninstall.json",
"post-uninstall agent stdout",
)
: "";
const stderr = fs.existsSync("/tmp/openclaw-codex-agent-after-uninstall.err")
? readTextFileTail(
"/tmp/openclaw-codex-agent-after-uninstall.err",
"post-uninstall agent stderr",
)
: "";
const combined = `${stdout}\n${stderr}`;
if (
!combined.includes('Requested agent harness "codex" is not registered') &&
!combined.includes("Unknown model: codex/")
) {
throw new Error(`unexpected post-uninstall agent error:\nstdout=${stdout}\nstderr=${stderr}`);
}
}
const commands = {
configure,
"assert-plugin": assertPlugin,
"assert-npm-deps": assertNpmDeps,
"print-codex-bin": printCodexBin,
"assert-preflight": assertPreflight,
"assert-agent-turn": assertAgentTurn,
"assert-uninstalled": assertUninstalled,
"assert-agent-error": assertAgentError,
};
const fn = commands[command];
if (!fn) {
throw new Error(`unknown codex npm plugin live assertion command: ${command}`);
}
fn();

View File

@@ -0,0 +1,127 @@
// Assertions for Codex on-demand plugin E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { assertOpenAiEnvAuthProfileStore } from "../auth-profile-store-assertions.mjs";
import {
assertPathInside,
configPath,
findPackageJson,
managedNpmRoot,
npmProjectRootForInstalledPackage,
readInstallRecords,
readJson,
stateDir,
} from "../codex-install-utils.mjs";
const cfg = readJson(configPath());
const inspect = readJson("/tmp/openclaw-codex-inspect.json");
const records = readInstallRecords(cfg.plugins?.installs);
const codexRecord = records.codex || inspect.install;
if (!codexRecord) {
throw new Error(`missing codex install record: ${JSON.stringify(records)}`);
}
if (codexRecord.source !== "npm") {
throw new Error(`expected npm codex install record, got ${codexRecord.source}`);
}
if (!String(codexRecord.spec || "").includes("@openclaw/codex")) {
throw new Error(`expected @openclaw/codex install spec, got ${codexRecord.spec}`);
}
const npmRoot = managedNpmRoot();
const installPath = String(codexRecord.installPath || "").replace(/^~(?=$|\/)/u, process.env.HOME);
if (!installPath) {
throw new Error(`missing codex installPath: ${JSON.stringify(codexRecord)}`);
}
assertPathInside(npmRoot, installPath, "codex install path");
const codexPackageJson = path.join(installPath, "package.json");
if (!fs.existsSync(codexPackageJson)) {
throw new Error(`missing npm-installed @openclaw/codex package: ${codexPackageJson}`);
}
const codexPackage = readJson(codexPackageJson);
if (codexPackage.name !== "@openclaw/codex") {
throw new Error(`unexpected codex package name: ${codexPackage.name}`);
}
const npmProjectRoot = npmProjectRootForInstalledPackage(installPath, "@openclaw/codex");
const openAiCodexPackageJson = findPackageJson("@openai/codex", [
installPath,
npmProjectRoot,
npmRoot,
]);
if (!openAiCodexPackageJson) {
throw new Error("missing @openai/codex dependency under managed npm root");
}
assertPathInside(npmRoot, openAiCodexPackageJson, "@openai/codex dependency");
const openAiCodexPackage = readJson(openAiCodexPackageJson);
const codexBinPath =
typeof openAiCodexPackage.bin === "string"
? openAiCodexPackage.bin
: openAiCodexPackage.bin && typeof openAiCodexPackage.bin.codex === "string"
? openAiCodexPackage.bin.codex
: undefined;
if (!codexBinPath) {
throw new Error(`@openai/codex package has no codex bin: ${openAiCodexPackageJson}`);
}
const codexBin = path.resolve(path.dirname(openAiCodexPackageJson), codexBinPath);
if (!fs.existsSync(codexBin)) {
throw new Error(`missing managed Codex binary: ${codexBin}`);
}
assertPathInside(npmRoot, codexBin, "managed Codex binary");
const list = readJson("/tmp/openclaw-plugins-list.json");
const plugin = (list.plugins || []).find((entry) => entry.id === "codex");
if (!plugin || plugin.enabled !== true || plugin.status !== "loaded") {
throw new Error(`codex plugin was not enabled+loaded: ${JSON.stringify(plugin)}`);
}
if (inspect.plugin?.id !== "codex" || inspect.plugin?.status !== "loaded") {
throw new Error(`unexpected codex inspect state: ${JSON.stringify(inspect.plugin)}`);
}
const hasHarness =
(Array.isArray(inspect.plugin?.agentHarnessIds) &&
inspect.plugin.agentHarnessIds.includes("codex")) ||
(Array.isArray(inspect.capabilities) &&
inspect.capabilities.some(
(entry) => entry?.kind === "agent-harness" && entry.ids?.includes("codex"),
));
if (!hasHarness) {
throw new Error(`codex harness was not registered: ${JSON.stringify(inspect.plugin)}`);
}
const primaryModel = cfg.agents?.defaults?.model?.primary;
if (primaryModel !== "openai/gpt-5.5") {
throw new Error(`expected OpenAI onboarding model openai/gpt-5.5, got ${primaryModel}`);
}
const providerRuntime = cfg.models?.providers?.openai?.agentRuntime?.id;
if (providerRuntime && providerRuntime !== "codex") {
throw new Error(`unexpected OpenAI provider runtime: ${providerRuntime}`);
}
function readAuthProfileStoreText(agentDir) {
const dbPath = path.join(agentDir, "openclaw-agent.sqlite");
if (!fs.existsSync(dbPath)) {
throw new Error("auth profile SQLite store was not persisted");
}
let db;
try {
db = new DatabaseSync(dbPath, { readOnly: true });
const row = db
.prepare("SELECT store_json FROM auth_profile_store WHERE store_key = ?")
.get("primary");
return typeof row?.store_json === "string" ? row.store_json : "";
} finally {
db?.close();
}
}
const authRaw = readAuthProfileStoreText(path.join(stateDir(), "agents", "main", "agent"));
if (!authRaw) {
throw new Error("auth profile SQLite store row was not persisted");
}
assertOpenAiEnvAuthProfileStore(authRaw, {
envRefMessage: "auth profile did not persist OPENAI_API_KEY env ref",
rawKeyMessage: "auth profile persisted the raw OpenAI test key",
rawKeyNeedle: "sk-openclaw-codex-on-demand-e2e",
});

View File

@@ -0,0 +1,39 @@
// Log assertions for config reload E2E scenarios.
import { sleep } from "../../../lib/sleep.mjs";
import { readPositiveIntEnv } from "../env-limits.mjs";
import { createConfigReloadLogScanner } from "./log-scanner.mjs";
const logPath = process.env.OPENCLAW_CONFIG_RELOAD_LOG_PATH ?? "/tmp/config-reload-e2e.log";
const deadlineMs = Date.now() + readPositiveIntEnv("OPENCLAW_CONFIG_RELOAD_LOG_TIMEOUT_MS", 30_000);
const maxReadBytes = readPositiveIntEnv("OPENCLAW_CONFIG_RELOAD_LOG_MAX_READ_BYTES", 256 * 1024);
const scanner = createConfigReloadLogScanner(logPath, {
maxReadBytes,
tailLineLimit: 160,
});
let result = { reloadLines: [], restartLines: [], tailLines: [] };
while (Date.now() < deadlineMs) {
result = scanner.scan();
if (result.restartLines.length > 0 || result.reloadLines.length > 0) {
break;
}
await sleep(500);
}
if (result.restartLines.length > 0) {
console.error(result.tailLines.join("\n"));
throw new Error("unexpected restart-required reload line found");
}
for (const line of result.reloadLines) {
for (const needle of ["gateway.auth.token", "plugins.entries.firecrawl.config.webFetch"]) {
if (line.includes(needle)) {
console.error(result.tailLines.join("\n"));
throw new Error(`runtime-only path appeared in reload diff: ${needle}`);
}
}
}
if (result.reloadLines.length === 0) {
console.error(result.tailLines.join("\n"));
throw new Error("expected config reload detection log after metadata write");
}

View File

@@ -0,0 +1,52 @@
// Streaming log scanner for config reload E2E scenarios.
import {
createIncrementalLineReader,
resolvePositiveInteger,
} from "../incremental-line-reader.mjs";
const DEFAULT_MAX_READ_BYTES = 256 * 1024;
const DEFAULT_TAIL_LINE_LIMIT = 160;
const RELOAD_NEEDLE = "config change detected; evaluating reload";
const RESTART_NEEDLE = "config change requires gateway restart";
export function inspectConfigReloadLogLine(line) {
return {
reload: line.includes(RELOAD_NEEDLE),
restart: line.includes(RESTART_NEEDLE),
};
}
export function createConfigReloadLogScanner(logPath, options = {}) {
const maxReadBytes = resolvePositiveInteger(options.maxReadBytes, DEFAULT_MAX_READ_BYTES);
const tailLineLimit = resolvePositiveInteger(options.tailLineLimit, DEFAULT_TAIL_LINE_LIMIT);
const reader = createIncrementalLineReader(logPath, { maxReadBytes });
let tailLines = [];
const reloadLines = [];
const restartLines = [];
return {
scan() {
const { lines, reset } = reader.readLines();
if (reset) {
tailLines = [];
reloadLines.length = 0;
restartLines.length = 0;
}
for (const line of lines) {
const trimmed = line.replace(/\r$/u, "");
tailLines.push(trimmed);
const match = inspectConfigReloadLogLine(trimmed);
if (match.reload) {
reloadLines.push(trimmed);
}
if (match.restart) {
restartLines.push(trimmed);
}
}
if (tailLines.length > tailLineLimit) {
tailLines = tailLines.slice(-tailLineLimit);
}
return { reloadLines, restartLines, tailLines };
},
};
}

View File

@@ -0,0 +1,7 @@
// Mutates plugin metadata fixtures for config reload E2E scenarios.
import fs from "node:fs";
const configPath = process.env.OPENCLAW_CONFIG_PATH;
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
config.gateway.channelHealthCheckMinutes = 2;
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");

View File

@@ -0,0 +1,143 @@
// Resource ceiling assertions for Docker E2E stats output.
import fs from "node:fs";
import { createInterface } from "node:readline";
const [statsFile, maxMemoryRaw, maxCpuRaw, label = "docker"] = process.argv.slice(2);
const NON_NEGATIVE_DECIMAL_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d+)?$/u;
function parseFiniteLimit(raw, name) {
const text = String(raw ?? "").trim();
if (!NON_NEGATIVE_DECIMAL_PATTERN.test(text)) {
throw new Error(
`${name} must be a finite non-negative number in decimal notation. Got: ${JSON.stringify(raw)}`,
);
}
const parsed = Number(text);
if (!Number.isFinite(parsed)) {
throw new Error(
`${name} must be a finite non-negative number in decimal notation. Got: ${JSON.stringify(raw)}`,
);
}
return parsed;
}
const maxMemoryMiB = parseFiniteLimit(maxMemoryRaw, "max memory MiB");
const maxCpuPercent = parseFiniteLimit(maxCpuRaw, "max CPU percent");
function parseMemoryMiB(raw) {
const value =
String(raw || "")
.split("/")[0]
?.trim() || "";
const match = /^([0-9.]+)\s*([KMGT]?i?B)$/iu.exec(value);
if (!match) {
return undefined;
}
const amount = Number(match[1]);
if (!Number.isFinite(amount)) {
return undefined;
}
const unit = match[2].toLowerCase();
if (unit === "b") {
return amount / 1024 / 1024;
}
if (unit === "kb" || unit === "kib") {
return amount / 1024;
}
if (unit === "mb" || unit === "mib") {
return amount;
}
if (unit === "gb" || unit === "gib") {
return amount * 1024;
}
if (unit === "tb" || unit === "tib") {
return amount * 1024 * 1024;
}
return undefined;
}
function parseCpuPercent(raw) {
const text = String(raw ?? "").trim();
const valueText = text.endsWith("%") ? text.slice(0, -1).trim() : text;
if (!NON_NEGATIVE_DECIMAL_PATTERN.test(valueText)) {
return undefined;
}
const parsed = Number(valueText);
return Number.isFinite(parsed) ? parsed : undefined;
}
function isTerminalZeroMemorySample(raw) {
const parts = String(raw || "").split("/");
if (parts.length !== 2) {
return false;
}
return parts.every((part) => parseMemoryMiB(part.trim()) === 0);
}
function assertSampleValue(value, raw, name, labelLocal) {
if (value === undefined) {
throw new Error(
`docker stats sample for ${labelLocal} had invalid ${name}: ${JSON.stringify(raw)}`,
);
}
if (name === "MemUsage" && value <= 0) {
throw new Error(
`docker stats sample for ${labelLocal} had non-positive ${name}: ${JSON.stringify(raw)}`,
);
}
}
async function scanStatsFileLines(file, onLine) {
if (!fs.existsSync(file)) {
return;
}
const input = fs.createReadStream(file, { encoding: "utf8" });
const lines = createInterface({ crlfDelay: Infinity, input });
for await (const line of lines) {
if (line) {
onLine(line);
}
}
}
let maxObservedMemoryMiB = 0;
let maxObservedCpuPercent = 0;
let parsedSamples = 0;
await scanStatsFileLines(statsFile, (line) => {
let parsed;
try {
parsed = JSON.parse(line);
} catch {
throw new Error(`docker stats sample for ${label} was not valid JSON`);
}
const observedMemoryMiB = parseMemoryMiB(parsed.MemUsage);
const observedCpuPercent = parseCpuPercent(parsed.CPUPerc);
// Docker can emit 0B / 0B after the target container exits; it proves
// lifecycle timing, not resource usage. Keep the real captured samples.
if (isTerminalZeroMemorySample(parsed.MemUsage)) {
return;
}
assertSampleValue(observedMemoryMiB, parsed.MemUsage, "MemUsage", label);
assertSampleValue(observedCpuPercent, parsed.CPUPerc, "CPUPerc", label);
parsedSamples += 1;
maxObservedMemoryMiB = Math.max(maxObservedMemoryMiB, observedMemoryMiB);
maxObservedCpuPercent = Math.max(maxObservedCpuPercent, observedCpuPercent);
});
console.log(
`${label} resource peak: memory=${maxObservedMemoryMiB.toFixed(1)}MiB cpu=${maxObservedCpuPercent.toFixed(1)}% samples=${parsedSamples}`,
);
if (parsedSamples === 0) {
throw new Error(`no docker stats samples captured for ${label}`);
}
if (maxObservedMemoryMiB > maxMemoryMiB) {
throw new Error(
`${label} memory peak ${maxObservedMemoryMiB.toFixed(1)}MiB exceeded ${maxMemoryMiB}MiB`,
);
}
if (maxObservedCpuPercent > maxCpuPercent) {
throw new Error(
`${label} CPU peak ${maxObservedCpuPercent.toFixed(1)}% exceeded ${maxCpuPercent}%`,
);
}

View File

@@ -0,0 +1,293 @@
#!/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
cp scripts/e2e/lib/doctor-install-switch/shims/systemctl /tmp/openclaw-bin/systemctl
cp scripts/e2e/lib/doctor-install-switch/shims/loginctl /tmp/openclaw-bin/loginctl
chmod +x /tmp/openclaw-bin/systemctl /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"
openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" 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 ! openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g --prefix /tmp/npm-prefix --omit=optional "$package_tgz" >"$npm_log" 2>&1; then
openclaw_e2e_print_log "$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:-900s}"
echo "== Flow: $name =="
openclaw_test_state_create "switch-${name}" empty
export USER="testuser"
if ! openclaw_e2e_maybe_timeout "$command_timeout" bash -c "$install_cmd" >"$install_log" 2>&1; then
openclaw_e2e_print_log "$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 ! openclaw_e2e_maybe_timeout "$command_timeout" bash -c "$doctor_cmd" >"$doctor_log" 2>&1; then
openclaw_e2e_print_log "$doctor_log"
exit 1
fi
assert_entrypoint "$unit_path" "$doctor_expected"
}
run_flow \
"npm-to-git" \
"$npm_bin daemon install --force" \
"$npm_entry" \
"OPENCLAW_UPDATE_IN_PROGRESS=1 node $git_cli doctor --repair --force --yes --non-interactive" \
"$git_entry"
run_flow \
"git-to-npm" \
"node $git_cli daemon install --force" \
"$git_entry" \
"OPENCLAW_UPDATE_IN_PROGRESS=1 $npm_bin doctor --repair --force --yes --non-interactive" \
"$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:-900s}"
echo "== Flow: $name =="
openclaw_test_state_create "switch-${name}" empty
export USER="testuser"
unit_path="$HOME/.config/systemd/user/openclaw-gateway.service"
if ! openclaw_e2e_maybe_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
openclaw_e2e_print_log "$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 ! openclaw_e2e_maybe_timeout "$command_timeout" env OPENCLAW_UPDATE_IN_PROGRESS=1 \
node "$git_cli" doctor --repair --force --yes --non-interactive >"$doctor_log" 2>&1; then
openclaw_e2e_print_log "$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:-900s}"
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"
node scripts/e2e/lib/doctor-install-switch/write-wrapper.mjs \
"$wrapper" \
"$npm_bin" \
"$HOME/openclaw-wrapper-argv.log"
local unit_path="$HOME/.config/systemd/user/openclaw-gateway.service"
if ! openclaw_e2e_maybe_timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" --force >"$install_log" 2>&1; then
openclaw_e2e_print_log "$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 ! openclaw_e2e_maybe_timeout "$command_timeout" "$npm_bin" gateway install --force >"$reinstall_log" 2>&1; then
openclaw_e2e_print_log "$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 ! openclaw_e2e_maybe_timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" >"$env_repair_log" 2>&1; then
openclaw_e2e_print_log "$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 ! openclaw_e2e_maybe_timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" >"$env_repair_log" 2>&1; then
openclaw_e2e_print_log "$env_repair_log"
exit 1
fi
assert_exec_arg "$unit_path" 1 "$wrapper"
assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper"
if ! openclaw_e2e_maybe_timeout "$command_timeout" node "$git_cli" doctor --repair --force --yes >"$doctor_log" 2>&1; then
openclaw_e2e_print_log "$doctor_log"
exit 1
fi
if ! grep -Fq "Gateway service invokes OPENCLAW_WRAPPER:" "$doctor_log"; then
echo "Expected doctor to report active wrapper"
openclaw_e2e_print_log "$doctor_log"
exit 1
fi
assert_exec_arg "$unit_path" 1 "$wrapper"
assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper"
if ! openclaw_e2e_maybe_timeout "$command_timeout" env OPENCLAW_WRAPPER= "$npm_bin" gateway install --force >"$clear_log" 2>&1; then
openclaw_e2e_print_log "$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,7 @@
#!/usr/bin/env bash
set -euo pipefail
case "$*" in
*show-user*) echo "Linger=yes" ;;
*enable-linger*) ;;
esac

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
args=("$@")
if [[ "${args[0]:-}" == "--user" ]]; then
args=("${args[@]:1}")
fi
cmd="${args[0]:-}"
case "$cmd" in
status) ;;
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)
printf "%s\n" \
"ActiveState=inactive" \
"SubState=dead" \
"MainPID=0" \
"ExecMainStatus=0" \
"ExecMainCode=0"
;;
esac

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env node
// Writes wrapper scripts for doctor install-switch E2E scenarios.
import fs from "node:fs";
const [wrapperPath, npmBin, logPath = `${process.env.HOME}/openclaw-wrapper-argv.log`] =
process.argv.slice(2);
if (!wrapperPath || !npmBin || !logPath || logPath.startsWith("undefined/")) {
console.error("usage: write-wrapper.mjs <wrapper-path> <npm-bin> [log-path]");
process.exit(1);
}
function shellSingleQuote(value) {
return `'${value.replaceAll("'", "'\\''")}'`;
}
fs.writeFileSync(
wrapperPath,
`#!/usr/bin/env bash
set -euo pipefail
printf "%s\\n" "$@" >> ${shellSingleQuote(logPath)}
exec ${shellSingleQuote(npmBin)} "$@"
`,
{ mode: 0o755 },
);

View File

@@ -0,0 +1,23 @@
// Environment limit helpers for E2E subprocess scenarios.
export function readPositiveIntEnv(name, fallback, env = process.env) {
const raw = env[name] ?? fallback;
const text = raw == null ? "unset" : String(raw).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`invalid ${name}: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
export function readTcpPortEnv(name, fallback, env = process.env) {
const value = readPositiveIntEnv(name, fallback, env);
if (value > 65_535) {
const raw = env[name] ?? fallback;
const text = raw == null ? "unset" : String(raw).trim();
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}

View File

@@ -0,0 +1,17 @@
// Shared command fixture dispatcher for E2E scripts.
import { configCommands } from "./fixtures/config.mjs";
import { pluginCommands } from "./fixtures/plugins.mjs";
import { workspaceCommands } from "./fixtures/workspace.mjs";
const [command, ...args] = process.argv.slice(2);
const handler = {
...pluginCommands,
...configCommands,
...workspaceCommands,
}[command];
if (!handler) {
throw new Error(`unknown fixture command: ${command}`);
}
handler(args);

View File

@@ -0,0 +1,25 @@
// Common file/assertion helpers for E2E fixture writers.
import fs from "node:fs";
import path from "node:path";
export const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
export const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
export const write = (file, contents) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, contents);
};
export const writeJson = (file, value) => write(file, json(value));
export const requireArg = (value, name) => {
if (!value) {
throw new Error(`${name} is required`);
}
return value;
};
export const assert = (condition, message) => {
if (!condition) {
throw new Error(message);
}
};

View File

@@ -0,0 +1,122 @@
// Config fixture writer commands for E2E scenarios.
import path from "node:path";
import { readPositiveIntEnv, readTcpPortEnv } from "../env-limits.mjs";
import { requireArg, writeJson } from "./common.mjs";
function writeConfig(kind) {
const configPath = requireArg(process.env.OPENCLAW_CONFIG_PATH, "OPENCLAW_CONFIG_PATH");
const port = readTcpPortEnv("PORT", 18789);
const config =
kind === "config-reload"
? {
gateway: {
port,
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "GATEWAY_AUTH_TOKEN_REF" },
},
channelHealthCheckMinutes: 1,
controlUi: { enabled: false },
reload: { mode: "hybrid", debounceMs: 0 },
},
}
: kind === "browser-cdp"
? {
gateway: {
port,
auth: {
mode: "token",
token: requireArg(process.env.OPENCLAW_GATEWAY_TOKEN, "OPENCLAW_GATEWAY_TOKEN"),
},
controlUi: { enabled: false },
},
browser: {
enabled: true,
defaultProfile: "docker-cdp",
ssrfPolicy: { allowedHostnames: ["127.0.0.1"] },
profiles: {
"docker-cdp": {
cdpUrl: `http://127.0.0.1:${readTcpPortEnv("CDP_PORT", 19222)}`,
color: "#FF4500",
},
},
},
}
: null;
writeJson(configPath, requireArg(config, "known config kind"));
}
function writeOpenAiWebSearchMinimalConfig() {
writeJson(path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.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: process.env.OPENCLAW_GATEWAY_TOKEN } },
});
}
function writeOpenWebUiConfig([openaiApiKey]) {
const batchPath = requireArg(
process.env.OPENCLAW_CONFIG_BATCH_PATH,
"OPENCLAW_CONFIG_BATCH_PATH",
);
writeJson(batchPath, [
{ path: "models.providers.openai.apiKey", value: requireArg(openaiApiKey, "OpenAI API key") },
{
path: "models.providers.openai.baseUrl",
value: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(),
},
{ path: "models.providers.openai.models", value: [] },
{
path: "models.providers.openai.timeoutSeconds",
value: readPositiveIntEnv("OPENCLAW_OPENWEBUI_PROVIDER_TIMEOUT_SECONDS", 900),
},
{ path: "models.providers.openai.agentRuntime", value: { id: "openclaw" } },
{ path: "gateway.controlUi.enabled", value: false },
{ path: "gateway.mode", value: "local" },
{ path: "gateway.bind", value: "lan" },
{ path: "gateway.auth.mode", value: "token" },
{ path: "gateway.auth.token", value: process.env.OPENCLAW_GATEWAY_TOKEN },
{ path: "gateway.http.endpoints.chatCompletions.enabled", value: true },
{ path: "agents.defaults.model.primary", value: process.env.OPENCLAW_OPENWEBUI_MODEL },
]);
}
export const configCommands = {
"config-reload": () => writeConfig("config-reload"),
"browser-cdp": () => writeConfig("browser-cdp"),
"openai-web-search-minimal-config": writeOpenAiWebSearchMinimalConfig,
"openwebui-config": writeOpenWebUiConfig,
};

View File

@@ -0,0 +1,100 @@
// Mock OpenAI model config helpers for E2E fixture generation.
function formatMockPortValue(value) {
return value === undefined ? "<missing>" : JSON.stringify(String(value));
}
export function parseMockOpenAiPort(value, label = "mock OpenAI port") {
const text = String(value ?? "").trim();
if (!/^[1-9]\d*$/u.test(text)) {
throw new Error(
`${label} must be a TCP port from 1 to 65535. Got: ${formatMockPortValue(value)}`,
);
}
const port = Number(text);
if (!Number.isSafeInteger(port) || port > 65535) {
throw new Error(
`${label} must be a TCP port from 1 to 65535. Got: ${formatMockPortValue(value)}`,
);
}
return port;
}
export function applyMockOpenAiModelConfig(cfg, params) {
const mockPort = parseMockOpenAiPort(params.mockPort);
const modelRef = params.modelRef ?? "openai/gpt-5.5";
const modelId = modelRef.split("/").at(-1) ?? "gpt-5.5";
const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
cfg.models = {
...cfg.models,
mode: "merge",
providers: {
...cfg.models?.providers,
openai: {
...cfg.models?.providers?.openai,
baseUrl: `http://127.0.0.1:${mockPort}/v1`,
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
api: "openai-responses",
agentRuntime: { id: "openclaw" },
request: { ...cfg.models?.providers?.openai?.request, allowPrivateNetwork: true },
models: [
{
id: modelId,
name: modelId,
api: "openai-responses",
agentRuntime: { id: "openclaw" },
reasoning: false,
input: ["text", "image"],
cost,
contextWindow: 128000,
contextTokens: 96000,
maxTokens: 4096,
},
],
},
},
};
cfg.agents = {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
model: { primary: modelRef },
...(params.includeImageDefaults
? {
imageModel: { primary: modelRef, timeoutMs: 30_000 },
imageGenerationModel: { primary: "openai/gpt-image-1", timeoutMs: 30_000 },
}
: {}),
models: {
...cfg.agents?.defaults?.models,
[modelRef]: {
agentRuntime: { id: "openclaw" },
params: { transport: "sse", openaiWsWarmup: false },
},
},
},
...(Array.isArray(cfg.agents?.list)
? {
list: cfg.agents.list.map((agent) => ({
...agent,
model: { ...agent.model, primary: modelRef },
models: {
...agent.models,
[modelRef]: {
...agent.models?.[modelRef],
agentRuntime: { id: "openclaw" },
params: {
...agent.models?.[modelRef]?.params,
transport: "sse",
openaiWsWarmup: false,
},
},
},
})),
}
: {}),
};
cfg.plugins = {
...cfg.plugins,
enabled: true,
};
}

View File

@@ -0,0 +1,173 @@
// Plugin fixture writer commands for E2E scenarios.
import path from "node:path";
import { requireArg, write, writeJson } from "./common.mjs";
function writePluginManifest(file, id, extra = {}) {
writeJson(file, { id, ...extra, configSchema: { type: "object", properties: {} } });
}
function writeFakeIsNumberPackage(dir) {
writeJson(path.join(dir, "package.json"), {
name: "is-number",
version: "7.0.0",
main: "index.js",
});
write(path.join(dir, "index.js"), "module.exports = (value) => typeof value === 'number';\n");
}
function writePluginDemo([dir]) {
write(
path.join(requireArg(dir, "dir"), "index.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: () => {} }); }, };\n',
);
writePluginManifest(path.join(dir, "openclaw.plugin.json"), "demo-plugin", {
contracts: { tools: ["demo_tool"] },
});
}
function writePlugin([dir, id, version, method, name]) {
for (const [value, label] of [
[dir, "dir"],
[id, "id"],
[version, "version"],
[method, "method"],
[name, "name"],
]) {
requireArg(value, label);
}
writeJson(path.join(dir, "package.json"), {
name: `@openclaw/${id}`,
version,
openclaw: { extensions: ["./index.js"] },
});
write(
path.join(dir, "index.js"),
`module.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: true })); }, };\n`,
);
writePluginManifest(path.join(dir, "openclaw.plugin.json"), id);
}
function writePluginWithVendoredDependency([dir, id, version, method, name]) {
writePlugin([dir, id, version, method, name]);
const packageJsonPath = path.join(dir, "package.json");
writeJson(packageJsonPath, {
name: `@openclaw/${id}`,
version,
dependencies: { "is-number": "7.0.0" },
openclaw: { extensions: ["./index.js"] },
});
write(
path.join(dir, "index.js"),
`const isNumber = require("is-number");\nmodule.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: isNumber(42) })); }, };\n`,
);
writeFakeIsNumberPackage(path.join(dir, "node_modules", "is-number"));
}
function writePluginWithCli([dir, id, version, method, name, cliRoot, cliOutput]) {
for (const [value, label] of [
[dir, "dir"],
[id, "id"],
[version, "version"],
[method, "method"],
[name, "name"],
[cliRoot, "cliRoot"],
[cliOutput, "cliOutput"],
]) {
requireArg(value, label);
}
writeJson(path.join(dir, "package.json"), {
name: `@openclaw/${id}`,
version,
dependencies: { "is-number": "file:./deps/is-number" },
openclaw: { extensions: ["./index.js"] },
});
writeFakeIsNumberPackage(path.join(dir, "deps", "is-number"));
write(
path.join(dir, "index.js"),
`const isNumber = require("is-number");\nmodule.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: isNumber(42) })); api.registerCli(({ program }) => { const root = program.command(${JSON.stringify(cliRoot)}).description(${JSON.stringify(`${name} fixture command`)}); root.command("ping").description("Print fixture ping output").action(() => { console.log(${JSON.stringify(cliOutput)}); }); }, { descriptors: [{ name: ${JSON.stringify(cliRoot)}, description: ${JSON.stringify(`${name} fixture command`)}, hasSubcommands: true }] }); }, };\n`,
);
writePluginManifest(path.join(dir, "openclaw.plugin.json"), id);
}
function writePluginWithCliRegistryDependency([
dir,
id,
version,
method,
name,
cliRoot,
cliOutput,
]) {
for (const [value, label] of [
[dir, "dir"],
[id, "id"],
[version, "version"],
[method, "method"],
[name, "name"],
[cliRoot, "cliRoot"],
[cliOutput, "cliOutput"],
]) {
requireArg(value, label);
}
writeJson(path.join(dir, "package.json"), {
name: `@openclaw/${id}`,
version,
dependencies: { "is-number": "7.0.0" },
openclaw: { extensions: ["./index.js"] },
});
write(
path.join(dir, "index.js"),
`const isNumber = require("is-number");\nmodule.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: isNumber(42) })); api.registerCli(({ program }) => { const root = program.command(${JSON.stringify(cliRoot)}).description(${JSON.stringify(`${name} fixture command`)}); root.command("ping").description("Print fixture ping output").action(() => { console.log(${JSON.stringify(cliOutput)}); }); }, { descriptors: [{ name: ${JSON.stringify(cliRoot)}, description: ${JSON.stringify(`${name} fixture command`)}, hasSubcommands: true }] }); }, };\n`,
);
writePluginManifest(path.join(dir, "openclaw.plugin.json"), id);
}
function writeClaudeBundle(args) {
const root = requireArg(args[0], "root");
writeJson(path.join(root, ".claude-plugin", "plugin.json"), { name: "claude-bundle-e2e" });
write(
path.join(root, "commands", "office-hours.md"),
"---\ndescription: Help with architecture and rollout planning\n---\nAct as an engineering advisor.\n\nFocus on:\n$ARGUMENTS\n",
);
}
function writePluginMarketplace(args) {
const root = requireArg(args[0], "root");
writeJson(path.join(root, ".claude-plugin", "marketplace.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" },
},
],
});
writeJson(path.join(process.env.HOME, ".claude", "plugins", "known_marketplaces.json"), {
"claude-fixtures": {
installLocation: root,
source: { type: "github", repo: "openclaw/fixture-marketplace" },
},
});
}
export const pluginCommands = {
"plugin-demo": writePluginDemo,
plugin: writePlugin,
"plugin-vendored-dep": writePluginWithVendoredDependency,
"plugin-cli": writePluginWithCli,
"plugin-cli-registry-dep": writePluginWithCliRegistryDependency,
"fake-is-number-package": ([dir]) => writeFakeIsNumberPackage(requireArg(dir, "dir")),
"plugin-manifest": ([file, id]) =>
writePluginManifest(requireArg(file, "file"), requireArg(id, "id")),
"claude-bundle": writeClaudeBundle,
marketplace: writePluginMarketplace,
};

View File

@@ -0,0 +1,103 @@
// Workspace fixture writer commands for E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { readTextFileTail } from "../text-file-utils.mjs";
import { assert, readJson, requireArg, write, writeJson } from "./common.mjs";
const AGENTS_DELETE_OUTPUT_MAX_BYTES = readPositiveIntEnv(
"OPENCLAW_FIXTURE_AGENTS_DELETE_OUTPUT_MAX_BYTES",
1024 * 1024,
);
const ERROR_DETAIL_TAIL_BYTES = 16 * 1024;
function readPositiveIntEnv(name, fallback) {
const text = String(process.env[name] ?? fallback).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`invalid ${name}: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
function writeOpenWebUiWorkspace() {
const workspace =
process.env.OPENCLAW_WORKSPACE_DIR || path.join(process.env.HOME, ".openclaw", "workspace");
write(
path.join(workspace, "IDENTITY.md"),
"# Identity\n\n- Name: OpenClaw\n- Purpose: Open WebUI Docker compatibility smoke test assistant.\n",
);
writeJson(path.join(workspace, ".openclaw", "workspace-state.json"), {
version: 1,
setupCompletedAt: "2026-01-01T00:00:00.000Z",
});
fs.rmSync(path.join(workspace, "BOOTSTRAP.md"), { force: true });
}
function writeAgentsDeleteConfig() {
const stateDir = requireArg(process.env.OPENCLAW_STATE_DIR, "OPENCLAW_STATE_DIR");
const sharedWorkspace = requireArg(process.env.SHARED_WORKSPACE, "SHARED_WORKSPACE");
const gatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim();
fs.mkdirSync(sharedWorkspace, { recursive: true });
writeJson(path.join(stateDir, "openclaw.json"), {
agents: {
list: [
{ id: "main", workspace: sharedWorkspace },
{ id: "ops", workspace: sharedWorkspace },
],
},
...(gatewayToken ? { gateway: { auth: { mode: "token", token: gatewayToken } } } : {}),
});
}
function assertAgentsDeleteResult([outputPath]) {
const resolvedOutputPath = requireArg(outputPath, "outputPath");
const outputStat = fs.statSync(resolvedOutputPath);
if (outputStat.isFile() && outputStat.size > AGENTS_DELETE_OUTPUT_MAX_BYTES) {
throw new Error(
`agents delete --json output exceeded ${AGENTS_DELETE_OUTPUT_MAX_BYTES} bytes:\nstdout tail=${readTextFileTail(
resolvedOutputPath,
ERROR_DETAIL_TAIL_BYTES,
)}`,
);
}
let parsed;
try {
parsed = readJson(resolvedOutputPath);
} catch (error) {
console.error("agents delete --json did not emit valid JSON:");
console.error(readTextFileTail(resolvedOutputPath, ERROR_DETAIL_TAIL_BYTES).trim());
const message = error instanceof Error ? error.message.split("\n").at(0) : String(error);
throw new Error(`agents delete --json parse failed: ${message}`, { cause: error });
}
for (const [actual, expected, label] of [
[parsed.agentId, "ops", "agentId"],
[parsed.workspace, process.env.SHARED_WORKSPACE, "workspace"],
[parsed.workspaceRetained, true, "workspaceRetained"],
[parsed.workspaceRetainedReason, "shared", "workspaceRetainedReason"],
]) {
assert(actual === expected, `${label} mismatch: ${JSON.stringify(actual)}`);
}
assert(
Array.isArray(parsed.workspaceSharedWith) && parsed.workspaceSharedWith.includes("main"),
"missing shared-with main marker",
);
assert(fs.existsSync(process.env.SHARED_WORKSPACE), "shared workspace was removed");
const remaining =
readJson(path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.json"))?.agents?.list ?? [];
assert(Array.isArray(remaining), "agents list missing after delete");
assert(!remaining.some((entry) => entry?.id === "ops"), "deleted agent remained in config");
assert(
remaining.some((entry) => entry?.id === "main"),
"main agent missing after delete",
);
console.log("agents delete shared workspace smoke ok");
}
export const workspaceCommands = {
"openwebui-workspace": writeOpenWebUiWorkspace,
"agents-delete-config": writeAgentsDeleteConfig,
"agents-delete-assert": assertAgentsDeleteResult,
};

View File

@@ -0,0 +1,17 @@
// Gateway frame payload helpers for E2E WebSocket assertions.
function hasOwnEnvelopeField(frame, field) {
return (
((typeof frame === "object" && frame !== null) || typeof frame === "function") &&
Object.hasOwn(frame, field)
);
}
export function resolveGatewaySuccessPayload(frame) {
if (hasOwnEnvelopeField(frame, "payload")) {
return frame.payload;
}
if (hasOwnEnvelopeField(frame, "result")) {
return frame.result;
}
return undefined;
}

View File

@@ -0,0 +1,152 @@
// WebSocket client helpers for gateway network E2E scenarios.
import { pathToFileURL } from "node:url";
import { WebSocket } from "ws";
import { sleep as delay } from "../../../lib/sleep.mjs";
import { waitForWebSocketOpen } from "../websocket-open.mjs";
import { readGatewayNetworkClientConnectTimeoutMs } from "./limits.mjs";
import { onceFrame } from "./ws-frames.mjs";
function remainingDeadlineMs(deadline) {
return Math.max(1, deadline - Date.now());
}
async function openSocket(url, timeoutMs = 10_000) {
const ws = new WebSocket(url);
await waitForWebSocketOpen(ws, timeoutMs, "ws open timeout");
return ws;
}
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
export function hasGatewayHealthSummaryPayload(response) {
if (!isRecord(response) || !isRecord(response.payload)) {
return false;
}
const { payload } = response;
return (
payload.ok === true &&
typeof payload.ts === "number" &&
typeof payload.durationMs === "number" &&
typeof payload.defaultAgentId === "string" &&
payload.defaultAgentId.trim() !== "" &&
Array.isArray(payload.agents) &&
isRecord(payload.channels) &&
Array.isArray(payload.channelOrder) &&
isRecord(payload.sessions)
);
}
export function responseError(method, response) {
const message = response.error?.message ?? "unknown";
return new Error(`${method} failed: ${message}`);
}
export function isRetryableStartupError(message) {
return (
message.includes("gateway starting") ||
message.includes("closed before frame") ||
message.includes("closed before open") ||
message.includes("ws open timeout") ||
message.includes("ECONNREFUSED") ||
message.includes("ECONNRESET") ||
message.includes("timeout")
);
}
async function readProtocolVersion() {
const protocol = await import("../../../../dist/gateway/protocol/index.js");
return protocol.PROTOCOL_VERSION;
}
export async function runGatewayNetworkClient(
{ token, url, timeoutMs = readGatewayNetworkClientConnectTimeoutMs() },
deps = {},
) {
const deadline = Date.now() + timeoutMs;
const delayImpl = deps.delay ?? delay;
const onceFrameImpl = deps.onceFrame ?? onceFrame;
const openSocketImpl = deps.openSocket ?? openSocket;
const protocolVersion = deps.protocolVersion ?? (await readProtocolVersion());
const stdout = deps.stdout ?? console.log;
let lastError;
while (Date.now() < deadline) {
let ws;
try {
ws = await openSocketImpl(url, remainingDeadlineMs(deadline));
ws.send(
JSON.stringify({
type: "req",
id: "c1",
method: "connect",
params: {
minProtocol: protocolVersion,
maxProtocol: protocolVersion,
client: {
id: "test",
displayName: "docker-net-e2e",
version: "dev",
platform: process.platform,
mode: "test",
},
caps: [],
auth: { token },
},
}),
);
const connectRes = await onceFrameImpl(
ws,
(frame) => frame?.type === "res" && frame?.id === "c1",
remainingDeadlineMs(deadline),
);
if (!connectRes.ok) {
lastError = responseError("connect", connectRes);
if (!isRetryableStartupError(lastError.message)) {
throw lastError;
}
} else {
ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" }));
const healthRes = await onceFrameImpl(
ws,
(frame) => frame?.type === "res" && frame?.id === "h1",
remainingDeadlineMs(deadline),
);
if (healthRes.ok) {
if (!hasGatewayHealthSummaryPayload(healthRes)) {
throw new Error("health failed: missing health summary payload");
}
stdout("ok");
return;
}
throw responseError("health", healthRes);
}
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (!isRetryableStartupError(lastError.message)) {
throw lastError;
}
} finally {
ws?.close();
}
const retryDelayMs = Math.min(500, deadline - Date.now());
if (retryDelayMs > 0) {
await delayImpl(retryDelayMs);
}
}
throw lastError ?? new Error("connect failed: timeout");
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const url = process.env.GW_URL;
const token = process.env.GW_TOKEN;
if (!url || !token) {
throw new Error("missing GW_URL/GW_TOKEN");
}
await runGatewayNetworkClient({ token, url });
}

View File

@@ -0,0 +1,19 @@
// Limits shared by gateway network E2E fixtures.
function readPositiveIntEnv(name, fallback, env) {
const text = String(env[name] ?? fallback).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`invalid ${name}: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
export function readGatewayNetworkClientConnectTimeoutMs(env = process.env) {
if (env.OPENCLAW_GATEWAY_NETWORK_CLIENT_CONNECT_TIMEOUT_MS != null) {
return readPositiveIntEnv("OPENCLAW_GATEWAY_NETWORK_CLIENT_CONNECT_TIMEOUT_MS", 80000, env);
}
return readPositiveIntEnv("OPENCLAW_GATEWAY_NETWORK_CONNECT_READY_TIMEOUT_MS", 80000, env);
}

View File

@@ -0,0 +1,67 @@
// WebSocket frame helpers for gateway network E2E fixtures.
function formatCloseValue(value) {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return value.toString();
}
if (value instanceof Uint8Array) {
return Buffer.from(value).toString();
}
return JSON.stringify(value) ?? "";
}
export function onceFrame(ws, filter, timeoutMs = 10_000) {
return new Promise((resolve, reject) => {
let settled = false;
const cleanup = () => {
clearTimeout(timer);
ws.off?.("message", onMessage);
ws.off?.("error", onError);
ws.off?.("close", onClose);
};
const settle = (fn, value) => {
if (settled) {
return;
}
settled = true;
cleanup();
fn(value);
};
const onMessage = (data) => {
let obj;
try {
obj = JSON.parse(String(data));
if (!filter(obj)) {
return;
}
} catch (error) {
settle(reject, error instanceof Error ? error : new Error(String(error)));
return;
}
settle(resolve, obj);
};
const onError = (error) =>
settle(reject, error instanceof Error ? error : new Error(String(error)));
const onClose = (code, reason) => {
const closeDetails = [formatCloseValue(code), formatCloseValue(reason)]
.filter(Boolean)
.join(" ");
const suffix = closeDetails ? `: ${closeDetails}` : "";
settle(reject, new Error(`closed before frame${suffix}`));
};
const timer = setTimeout(() => {
settle(reject, new Error("timeout"));
}, timeoutMs);
timer.unref?.();
ws.on("message", onMessage);
ws.once("error", onError);
ws.once("close", onClose);
});
}

View File

@@ -0,0 +1,142 @@
// Incremental line reader for streaming E2E logs.
import { createHash } from "node:crypto";
import fs from "node:fs";
function readSlice(filePath, start, length) {
if (length <= 0) {
return "";
}
const fd = fs.openSync(filePath, "r");
try {
const buffer = Buffer.alloc(length);
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
fs.closeSync(fd);
}
}
function readBufferSlice(filePath, start, length) {
if (length <= 0) {
return Buffer.alloc(0);
}
const fd = fs.openSync(filePath, "r");
try {
const buffer = Buffer.alloc(length);
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
return buffer.subarray(0, bytesRead);
} finally {
fs.closeSync(fd);
}
}
function resolveFileIdentity(stats) {
if (Number.isSafeInteger(stats.dev) && Number.isSafeInteger(stats.ino) && stats.ino !== 0) {
return `${stats.dev}:${stats.ino}`;
}
return Number.isFinite(stats.birthtimeMs) ? `birth:${stats.birthtimeMs}` : undefined;
}
function readTailFingerprint(filePath, stats, maxReadBytes) {
const length = Math.min(stats.size, maxReadBytes);
const start = Math.max(0, stats.size - length);
const buffer = readBufferSlice(filePath, start, length);
const hash = createHash("sha256").update(buffer).digest("base64url");
return `${start}:${buffer.byteLength}:${hash}`;
}
export function resolvePositiveInteger(value, fallback) {
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
}
export function createIncrementalLineReader(filePath, options = {}) {
const maxReadBytes = resolvePositiveInteger(options.maxReadBytes, 256 * 1024);
let fileIdentity;
let contentFingerprint;
let offset = 0;
let pending = "";
return {
readLines() {
if (!fs.existsSync(filePath)) {
return { lines: [], reset: false };
}
const stats = fs.statSync(filePath);
if (!stats.isFile()) {
return { lines: [], reset: false };
}
let reset = false;
const nextFileIdentity = resolveFileIdentity(stats);
if (
fileIdentity !== undefined &&
nextFileIdentity !== undefined &&
fileIdentity !== nextFileIdentity
) {
offset = 0;
pending = "";
reset = true;
}
fileIdentity = nextFileIdentity;
if (!reset && stats.size === offset && contentFingerprint !== undefined) {
const nextContentFingerprint = readTailFingerprint(filePath, stats, maxReadBytes);
if (contentFingerprint !== nextContentFingerprint) {
offset = 0;
pending = "";
reset = true;
} else {
contentFingerprint = nextContentFingerprint;
return { lines: [], reset: false };
}
}
if (stats.size < offset) {
offset = 0;
pending = "";
reset = true;
}
if (stats.size === offset) {
return { lines: [], reset };
}
let start = offset;
let discardFirstLine = false;
let clamped = false;
if (start === 0 && stats.size > maxReadBytes) {
start = stats.size - maxReadBytes;
pending = "";
clamped = true;
} else if (stats.size - start > maxReadBytes) {
start = stats.size - maxReadBytes;
pending = "";
clamped = true;
}
if (clamped && start > 0) {
discardFirstLine = readSlice(filePath, start - 1, 1) !== "\n";
}
const text = readSlice(filePath, start, stats.size - start);
offset = stats.size;
contentFingerprint = readTailFingerprint(filePath, stats, maxReadBytes);
if (!text) {
return { lines: [], reset };
}
let chunk = pending + text;
if (discardFirstLine) {
const newlineIndex = chunk.indexOf("\n");
if (newlineIndex === -1) {
pending = "";
return { lines: [], reset };
}
chunk = chunk.slice(newlineIndex + 1);
}
const lines = chunk.split("\n");
pending = lines.pop() ?? "";
return { lines, reset };
},
};
}

View File

@@ -0,0 +1,698 @@
// Assertions for kitchen-sink plugin E2E scenarios.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
const command = process.argv[2];
const scratchRoot = process.env.KITCHEN_SINK_TMP_DIR || os.tmpdir();
const LOG_SCAN_CHUNK_BYTES = 64 * 1024;
const LOG_SCAN_FINDING_CONTEXT_CHARS = 2048;
const LOG_SCAN_MAX_ENTRIES = readPositiveIntEnv("KITCHEN_SINK_LOG_SCAN_MAX_ENTRIES", 20_000);
const LOG_SCAN_MAX_FILES = 5000;
const LOG_SCAN_MAX_FINDINGS = 100;
const LOG_SCAN_MAX_LINE_CHARS = 16 * 1024;
const LOG_SCAN_SEGMENT_OVERLAP_CHARS = 256;
const EXPECT_FAILURE_OUTPUT_MAX_BYTES = readPositiveIntEnv(
"KITCHEN_SINK_EXPECT_FAILURE_OUTPUT_MAX_BYTES",
1024 * 1024,
);
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
const scratchFile = (name) => path.join(scratchRoot, name);
const normalizedPath = (filePath) => filePath.replaceAll("\\", "/");
function readPositiveIntEnv(name, fallback) {
const raw = process.env[name];
if (raw === undefined || raw === "") {
return fallback;
}
const text = raw.trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`${name} must be a positive integer; got: ${raw}`);
}
const parsed = Number(text);
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive integer; got: ${raw}`);
}
return parsed;
}
function resolveHomePath(value) {
if (value === "~") {
return process.env.HOME;
}
if (value?.startsWith("~/") || value?.startsWith("~\\")) {
return path.join(process.env.HOME, value.slice(2));
}
return value;
}
function readTextFileBounded(file, maxBytes, label) {
const stats = fs.statSync(file);
if (stats.size > maxBytes) {
throw new Error(`${label} exceeded ${maxBytes} bytes: ${file} (${stats.size} bytes)`);
}
return fs.readFileSync(file, "utf8");
}
function expectFailure() {
const outputFile = process.argv[3];
const output = readTextFileBounded(
outputFile,
EXPECT_FAILURE_OUTPUT_MAX_BYTES,
"expected failure output",
);
const source = process.env.KITCHEN_SINK_SOURCE;
const spec = process.env.KITCHEN_SINK_SPEC;
const displayedSpec = source === "npm" ? spec.replace(/^npm:/u, "") : spec;
const expected =
source === "clawhub"
? /Version not found on ClawHub|ClawHub .* failed \(404\)|version.*not found/iu
: /No matching version|ETARGET|notarget|npm (?:error|ERR!)/iu;
if (!output.includes(displayedSpec)) {
throw new Error(`expected failure output to mention ${displayedSpec}`);
}
if (!expected.test(output)) {
throw new Error(`unexpected ${source} beta failure output:\n${output}`);
}
}
function scanTextFileLines(file, onLine) {
const fd = fs.openSync(file, "r");
try {
const buffer = Buffer.alloc(LOG_SCAN_CHUNK_BYTES);
let currentLine = "";
let lineNumber = 1;
const emitLine = (line, info = {}) => onLine(line, lineNumber, info);
const appendLineText = (text, complete) => {
currentLine += text;
while (currentLine.length > LOG_SCAN_MAX_LINE_CHARS) {
const segment = currentLine.slice(0, LOG_SCAN_MAX_LINE_CHARS);
currentLine = currentLine.slice(LOG_SCAN_MAX_LINE_CHARS - LOG_SCAN_SEGMENT_OVERLAP_CHARS);
if (!emitLine(segment, { truncated: true })) {
return false;
}
}
if (complete) {
if (!emitLine(currentLine)) {
return false;
}
currentLine = "";
lineNumber += 1;
}
return true;
};
while (true) {
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
if (bytesRead <= 0) {
break;
}
const text = buffer.subarray(0, bytesRead).toString("utf8");
const lines = text.split(/\r?\n/u);
for (let index = 0; index < lines.length - 1; index += 1) {
if (!appendLineText(lines[index], true)) {
return;
}
}
if (!appendLineText(lines.at(-1) ?? "", false)) {
return;
}
}
if (currentLine.length > 0) {
onLine(currentLine, lineNumber);
}
} finally {
fs.closeSync(fd);
}
}
function formatFindingLine(line, pattern, info = {}) {
const matchIndex = Math.max(0, line.search(pattern));
const halfWindow = Math.floor(LOG_SCAN_FINDING_CONTEXT_CHARS / 2);
const start = Math.max(0, matchIndex - halfWindow);
const end = Math.min(line.length, start + LOG_SCAN_FINDING_CONTEXT_CHARS);
const prefix = start > 0 ? "... " : "";
const suffix = end < line.length || info.truncated ? " ..." : "";
return `${prefix}${line.slice(start, end)}${suffix}`;
}
function shouldScanLogFile(entry) {
if (!(/\.(?:log|jsonl)$/u.test(entry) || /openclaw-kitchen-sink-/u.test(path.basename(entry)))) {
return false;
}
return !normalizedPath(entry).includes("/.npm/_logs/");
}
function scanLogFiles(roots, onFile) {
let scannedFiles = 0;
let visitedEntries = 0;
for (const root of roots) {
const pending = [{ entry: root, counted: false }];
while (pending.length > 0) {
const pendingEntry = pending.pop();
const entry = pendingEntry?.entry;
if (!entry || !fs.existsSync(entry)) {
continue;
}
if (!pendingEntry.counted) {
visitedEntries += 1;
if (visitedEntries > LOG_SCAN_MAX_ENTRIES) {
throw new Error(
`kitchen-sink log scan exceeded ${LOG_SCAN_MAX_ENTRIES} filesystem entries`,
);
}
}
const entryType = pendingEntry.dirent ?? fs.lstatSync(entry);
if (entryType.isSymbolicLink()) {
continue;
}
if (entryType.isDirectory()) {
const dir = fs.opendirSync(entry);
try {
let child;
while ((child = dir.readSync()) !== null) {
visitedEntries += 1;
if (visitedEntries > LOG_SCAN_MAX_ENTRIES) {
throw new Error(
`kitchen-sink log scan exceeded ${LOG_SCAN_MAX_ENTRIES} filesystem entries`,
);
}
pending.push({
counted: true,
dirent: child,
entry: path.join(entry, child.name),
});
}
} finally {
dir.closeSync();
}
continue;
}
if (!shouldScanLogFile(entry)) {
continue;
}
scannedFiles += 1;
if (scannedFiles > LOG_SCAN_MAX_FILES) {
throw new Error(`kitchen-sink log scan exceeded ${LOG_SCAN_MAX_FILES} candidate files`);
}
if (!onFile(entry, scannedFiles)) {
return scannedFiles;
}
}
}
return scannedFiles;
}
function scanLogs() {
if (!process.env.KITCHEN_SINK_TMP_DIR) {
throw new Error("KITCHEN_SINK_TMP_DIR is required for kitchen-sink log scans");
}
const roots = [scratchRoot, path.join(process.env.HOME, ".openclaw")];
const deny = [
/\buncaught exception\b/iu,
/\bunhandled rejection\b/iu,
/\bfatal\b/iu,
/\bpanic\b/iu,
/\blevel["']?\s*:\s*["']error["']/iu,
/\[(?:error|ERROR)\]/u,
];
const allow = [
/^\s*0 errors?\s*$/iu,
/^\s*expected no diagnostics errors?\s*$/iu,
/^\s*diagnostics errors?:\s*$/iu,
];
const findings = [];
let omittedFindings = false;
const scannedFiles = scanLogFiles(roots, (file) => {
scanTextFileLines(file, (line, lineNumber, info) => {
if (allow.some((pattern) => pattern.test(line))) {
return true;
}
const matchedPattern = deny.find((pattern) => pattern.test(line));
if (matchedPattern) {
if (findings.length >= LOG_SCAN_MAX_FINDINGS) {
omittedFindings = true;
return false;
}
findings.push(`${file}:${lineNumber}: ${formatFindingLine(line, matchedPattern, info)}`);
}
return true;
});
if (omittedFindings) {
return false;
}
return true;
});
if (scannedFiles === 0) {
throw new Error(
"kitchen-sink log scan found no files under the isolated scratch root or OpenClaw home",
);
}
if (findings.length > 0) {
const suffix = omittedFindings ? "\n... additional findings omitted" : "";
throw new Error(`unexpected error-like log lines:\n${findings.join("\n")}${suffix}`);
}
console.log(`log scan passed (${scannedFiles} file(s))`);
}
function readConfig() {
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
return {
configPath,
exists: fs.existsSync(configPath),
config: fs.existsSync(configPath) ? readJson(configPath) : {},
};
}
function configureRuntime() {
const pluginId = process.env.KITCHEN_SINK_ID;
const { configPath, config } = readConfig();
config.plugins = config.plugins || {};
config.plugins.entries = config.plugins.entries || {};
config.plugins.entries[pluginId] = {
...config.plugins.entries[pluginId],
hooks: {
...config.plugins.entries[pluginId]?.hooks,
allowConversationAccess: true,
},
};
config.channels = {
...config.channels,
"kitchen-sink-channel": { enabled: true, token: "kitchen-sink-ci" },
};
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
}
function removeChannelConfig() {
const { configPath, exists, config } = readConfig();
if (!exists) {
return;
}
delete config.channels?.["kitchen-sink-channel"];
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
}
const expectIncludes = (listValue, expected, field) => {
if (!Array.isArray(listValue) || !listValue.includes(expected)) {
throw new Error(`${field} missing ${expected}: ${JSON.stringify(listValue)}`);
}
};
const expectIncludesAny = (listValue, expectedValues, field) => {
if (
!Array.isArray(listValue) ||
!expectedValues.some((expected) => listValue.includes(expected))
) {
throw new Error(
`${field} missing one of ${expectedValues.join(", ")}: ${JSON.stringify(listValue)}`,
);
}
};
const expectMissing = (listValue, expected, field) => {
if (Array.isArray(listValue) && listValue.includes(expected)) {
throw new Error(`${field} unexpectedly included ${expected}: ${JSON.stringify(listValue)}`);
}
};
const INVALID_PROBE_DIAGNOSTIC_SURFACE_MODES = new Set(["full", "conformance", "adversarial"]);
const requiredFullDiagnosticCanaries = new Set([
"agent tool result middleware must be a function",
"trusted tool policy registration requires id, description, and evaluate()",
"plugin must declare contracts.tools for: kitchen-sink-tool",
'channel "kitchen-sink-channel-probe" registration missing required config helpers',
'agent harness "kitchen-sink-agent-harness" registration missing required runtime methods',
"session scheduler job registration requires unique id, sessionKey, and kind",
]);
function assertExpectedDiagnostics(surfaceMode, errorMessages) {
const expectedErrorMessages = new Set([
"cli registration missing explicit commands metadata",
"only bundled plugins can register Codex app-server extension factories",
"agent tool result middleware must be a function",
'compaction provider "kitchen-sink-compaction-provider" registration missing summarize',
"context engine registration missing id",
"control UI descriptor registration requires id, surface, label, and valid optional fields",
"hosted media resolver registration missing resolver",
"http route registration missing or invalid auth: /kitchen-sink/http-route",
"node invoke policy registration missing commands",
"trusted tool policy registration requires id, description, and evaluate()",
"plugin must declare contracts.embeddingProviders for adapter: kitchen-sink-embedding-provider",
"plugin must own memory slot or declare contracts.memoryEmbeddingProviders for adapter: kitchen-sink-memory-embedding-provider",
"plugin must declare contracts.tools for: kitchen-sink-tool",
'channel "kitchen-sink-channel-probe" registration missing required config helpers',
'agent harness "kitchen-sink-agent-harness" registration missing required runtime methods',
"memory prompt supplement registration missing builder",
"model catalog provider registration missing provider",
"session extension registration requires namespace and description",
"session scheduler job registration requires unique id, sessionKey, and kind",
"tool metadata registration missing toolName",
]);
const optionalErrorMessages = new Set([
"agent event subscription registration requires id and handle",
]);
const allowedErrorMessages = new Set([...expectedErrorMessages, ...optionalErrorMessages]);
if (!INVALID_PROBE_DIAGNOSTIC_SURFACE_MODES.has(surfaceMode)) {
if (errorMessages.size > 0) {
throw new Error(
`unexpected kitchen-sink diagnostic errors: ${[...errorMessages].join(", ")}`,
);
}
return;
}
for (const message of errorMessages) {
if (!allowedErrorMessages.has(message)) {
throw new Error(`unexpected kitchen-sink diagnostic error: ${message}`);
}
}
if (surfaceMode === "full") {
// Default Docker scenarios install the published package, which can lag this repo.
// Exhaustive matching is reserved for synchronized/current package fixtures.
const requiredMessages =
process.env.KITCHEN_SINK_REQUIRE_ALL_DIAGNOSTICS === "1"
? expectedErrorMessages
: requiredFullDiagnosticCanaries;
for (const message of requiredMessages) {
if (!errorMessages.has(message)) {
throw new Error(`missing expected kitchen-sink diagnostic error: ${message}`);
}
}
}
}
function assertRealPathInside(parentPath, childPath, label) {
const parentRealPath = fs.realpathSync(parentPath);
const childRealPath = fs.realpathSync(childPath);
if (
childRealPath !== parentRealPath &&
!childRealPath.startsWith(`${parentRealPath}${path.sep}`)
) {
throw new Error(`${label} resolved outside ${parentPath}: ${childRealPath}`);
}
}
function assertClawHubExternalInstallContract(installPath) {
const openclawPeerPath = path.join(installPath, "node_modules", "openclaw");
if (!fs.existsSync(openclawPeerPath)) {
throw new Error(`missing kitchen-sink openclaw peer symlink: ${openclawPeerPath}`);
}
if (!fs.lstatSync(openclawPeerPath).isSymbolicLink()) {
throw new Error(`kitchen-sink openclaw peer is not a symlink: ${openclawPeerPath}`);
}
const hostRoot = fs.realpathSync(process.cwd());
const linkedHostRoot = fs.realpathSync(openclawPeerPath);
if (linkedHostRoot !== hostRoot) {
throw new Error(`expected kitchen-sink openclaw peer ${linkedHostRoot} to target ${hostRoot}`);
}
const dependencyPackagePath = path.join(installPath, "node_modules", "is-number", "package.json");
if (fs.existsSync(dependencyPackagePath)) {
assertRealPathInside(installPath, dependencyPackagePath, "kitchen-sink isolated dependency");
}
}
function assertClawHubArtifactMetadata(record) {
if (record.artifactKind === "legacy-zip") {
if (record.artifactFormat !== "zip") {
throw new Error(
`missing kitchen-sink legacy ZIP artifact metadata: ${JSON.stringify(record)}`,
);
}
return;
}
if (record.artifactKind !== "npm-pack" || record.artifactFormat !== "tgz") {
throw new Error(`missing kitchen-sink ClawHub artifact metadata: ${JSON.stringify(record)}`);
}
if (!record.clawpackSha256 || typeof record.clawpackSize !== "number") {
throw new Error(`missing kitchen-sink ClawPack metadata: ${JSON.stringify(record)}`);
}
if (!record.npmIntegrity || !record.npmShasum || !record.npmTarballName) {
throw new Error(`missing kitchen-sink npm artifact metadata: ${JSON.stringify(record)}`);
}
}
function inferInstallSource(spec) {
if (spec?.startsWith("npm:")) {
return "npm";
}
if (spec?.startsWith("clawhub:")) {
return "clawhub";
}
return null;
}
function assertCutoverPreinstalled() {
const pluginId = process.env.KITCHEN_SINK_ID;
const preinstallSpec = process.env.KITCHEN_SINK_PREINSTALL_SPEC;
const source = inferInstallSource(preinstallSpec);
if (!pluginId || !preinstallSpec || !source) {
throw new Error(`invalid kitchen-sink cutover preinstall spec: ${preinstallSpec}`);
}
const record = readPluginInstallRecords()[pluginId];
if (!record) {
throw new Error(`missing kitchen-sink cutover preinstall record for ${pluginId}`);
}
if (record.source !== source) {
throw new Error(`expected kitchen-sink preinstall source=${source}, got ${record.source}`);
}
const expectedSpec = source === "npm" ? preinstallSpec.replace(/^npm:/u, "") : preinstallSpec;
if (record.spec !== expectedSpec) {
throw new Error(`expected kitchen-sink preinstall spec ${expectedSpec}, got ${record.spec}`);
}
}
function assertInstalled() {
const pluginId = process.env.KITCHEN_SINK_ID;
const spec = process.env.KITCHEN_SINK_SPEC;
const source = process.env.KITCHEN_SINK_SOURCE;
const surfaceMode = process.env.KITCHEN_SINK_SURFACE_MODE;
const label = process.env.KITCHEN_SINK_LABEL;
const list = readJson(scratchFile(`kitchen-sink-${label}-plugins.json`));
const inspect = readJson(scratchFile(`kitchen-sink-${label}-inspect.json`));
const allInspect = readJson(scratchFile(`kitchen-sink-${label}-inspect-all.json`));
if (!Array.isArray(allInspect)) {
throw new Error("kitchen-sink inspect --all output was not an array");
}
const plugin = (list.plugins || []).find((entry) => entry.id === pluginId);
if (!plugin) {
throw new Error(`kitchen-sink plugin not found after install: ${pluginId}`);
}
const allInspectPlugin = allInspect.find((entry) => entry?.plugin?.id === pluginId);
if (!allInspectPlugin) {
throw new Error(`kitchen-sink plugin missing from inspect --all output: ${pluginId}`);
}
if (!allInspectPlugin.plugin?.enabled || allInspectPlugin.plugin?.status !== "loaded") {
throw new Error(
`expected enabled loaded kitchen-sink plugin in inspect --all, got enabled=${allInspectPlugin.plugin?.enabled} status=${allInspectPlugin.plugin?.status}`,
);
}
if (plugin.status !== "loaded") {
throw new Error(`unexpected kitchen-sink status after enable: ${plugin.status}`);
}
if (inspect.plugin?.id !== pluginId) {
throw new Error(`unexpected inspected kitchen-sink plugin id: ${inspect.plugin?.id}`);
}
if (!inspect.plugin?.enabled || inspect.plugin?.status !== "loaded") {
throw new Error(
`expected enabled loaded kitchen-sink plugin, got enabled=${inspect.plugin?.enabled} status=${inspect.plugin?.status}`,
);
}
if (surfaceMode !== "adversarial") {
expectIncludes(inspect.plugin?.channelIds, "kitchen-sink-channel", "channels");
expectIncludes(inspect.plugin?.providerIds, "kitchen-sink-provider", "providers");
}
if (source === "clawhub") {
expectIncludes(inspect.plugin?.contextEngineIds, pluginId, "context engines");
}
const diagnostics = [
...(list.diagnostics || []),
...(inspect.diagnostics || []),
...(allInspectPlugin.diagnostics || []),
];
const errorMessages = new Set(
diagnostics.filter((diag) => diag?.level === "error").map((diag) => String(diag.message || "")),
);
if (surfaceMode === "full" || surfaceMode === "conformance") {
const toolNames = Array.isArray(inspect.tools)
? inspect.tools.flatMap((entry) => (Array.isArray(entry?.names) ? entry.names : []))
: [];
const pluginSurfaceIds = {
speechProviderIds: [
["kitchen-sink-speech", "kitchen-sink-speech-provider"],
"speech providers",
],
realtimeTranscriptionProviderIds: [
["kitchen-sink-realtime-transcription", "kitchen-sink-realtime-transcription-provider"],
"realtime transcription providers",
],
realtimeVoiceProviderIds: [
["kitchen-sink-realtime-voice", "kitchen-sink-realtime-voice-provider"],
"realtime voice providers",
],
mediaUnderstandingProviderIds: [
["kitchen-sink-media", "kitchen-sink-media-understanding-provider"],
"media understanding providers",
],
imageGenerationProviderIds: [
["kitchen-sink-image", "kitchen-sink-image-generation-provider"],
"image generation providers",
],
videoGenerationProviderIds: [
["kitchen-sink-video", "kitchen-sink-video-generation-provider"],
"video generation providers",
],
musicGenerationProviderIds: [
["kitchen-sink-music", "kitchen-sink-music-generation-provider"],
"music generation providers",
],
webFetchProviderIds: [
["kitchen-sink-fetch", "kitchen-sink-web-fetch-provider"],
"web fetch providers",
],
webSearchProviderIds: [
["kitchen-sink-search", "kitchen-sink-web-search-provider"],
"web search providers",
],
migrationProviderIds: [
["kitchen-sink-migration-providers", "kitchen-sink-migration-provider"],
"migration providers",
],
};
for (const [field, [ids, labelLocal]] of Object.entries(pluginSurfaceIds)) {
expectIncludesAny(inspect.plugin?.[field], ids, labelLocal);
}
expectMissing(inspect.plugin?.agentHarnessIds, "kitchen-sink-agent-harness", "agent harnesses");
expectIncludes(inspect.services, "kitchen-sink-service", "services");
if (surfaceMode === "full") {
expectIncludesAny(inspect.commands, ["kitchen", "kitchen-sink-command"], "commands");
for (const toolName of [
"kitchen_sink_text",
"kitchen_sink_search",
"kitchen_sink_image_job",
]) {
expectIncludes(toolNames, toolName, "tools");
}
} else {
expectIncludes(inspect.commands, "kitchen", "commands");
expectIncludes(toolNames, "kitchen_sink_text", "tools");
}
if (
(inspect.plugin?.hookCount || 0) < 30 ||
!Array.isArray(inspect.typedHooks) ||
inspect.typedHooks.length < 30
) {
throw new Error(
`expected kitchen-sink typed hooks to load, got hookCount=${inspect.plugin?.hookCount} typedHooks=${inspect.typedHooks?.length}`,
);
}
}
assertExpectedDiagnostics(surfaceMode, errorMessages);
const record = readPluginInstallRecords()[pluginId];
if (!record) {
throw new Error(`missing kitchen-sink install record for ${pluginId}`);
}
if (record.source !== source) {
throw new Error(`expected kitchen-sink install source=${source}, got ${record.source}`);
}
if (source === "npm") {
const expectedSpec = spec.replace(/^npm:/u, "");
if (record.spec !== expectedSpec) {
throw new Error(`expected kitchen-sink npm spec ${expectedSpec}, got ${record.spec}`);
}
if (!record.resolvedVersion || !record.resolvedSpec) {
throw new Error(`missing npm resolution metadata: ${JSON.stringify(record)}`);
}
} else if (source === "clawhub") {
const value = spec.slice("clawhub:".length).trim();
const slashIndex = value.lastIndexOf("/");
const atIndex = value.lastIndexOf("@");
const packageName = atIndex > 0 && atIndex > slashIndex ? value.slice(0, atIndex) : value;
if (record.spec !== spec) {
throw new Error(`expected kitchen-sink ClawHub spec ${spec}, got ${record.spec}`);
}
if (record.clawhubPackage !== packageName) {
throw new Error(`expected ClawHub package ${packageName}, got ${record.clawhubPackage}`);
}
if (record.clawhubFamily !== "code-plugin" && record.clawhubFamily !== "bundle-plugin") {
throw new Error(`unexpected ClawHub family: ${record.clawhubFamily}`);
}
if (!record.version || !record.integrity || !record.resolvedAt) {
throw new Error(`missing ClawHub resolution metadata: ${JSON.stringify(record)}`);
}
assertClawHubArtifactMetadata(record);
}
if (typeof record.installPath !== "string" || record.installPath.length === 0) {
throw new Error("missing kitchen-sink install path");
}
const installPath = resolveHomePath(record.installPath);
if (!fs.existsSync(installPath)) {
throw new Error(`kitchen-sink install path missing: ${record.installPath}`);
}
if (source === "clawhub") {
const extensionsRoot = path.join(process.env.HOME, ".openclaw", "extensions");
assertRealPathInside(extensionsRoot, installPath, "kitchen-sink ClawHub install path");
}
if (source === "clawhub" && record.artifactKind === "npm-pack") {
assertClawHubExternalInstallContract(installPath);
}
fs.writeFileSync(scratchFile(`kitchen-sink-${label}-install-path.txt`), installPath, "utf8");
}
function assertRemoved() {
const pluginId = process.env.KITCHEN_SINK_ID;
const label = process.env.KITCHEN_SINK_LABEL;
const list = readJson(scratchFile(`kitchen-sink-${label}-uninstalled.json`));
if ((list.plugins || []).some((entry) => entry.id === pluginId)) {
throw new Error(`kitchen-sink plugin still listed after uninstall: ${pluginId}`);
}
const records = readPluginInstallRecords();
if (records[pluginId]) {
throw new Error(`kitchen-sink install record still present after uninstall: ${pluginId}`);
}
const { config } = readConfig();
if (config.plugins?.entries?.[pluginId]) {
throw new Error(`kitchen-sink config entry still present after uninstall: ${pluginId}`);
}
if ((config.plugins?.allow || []).includes(pluginId)) {
throw new Error(`kitchen-sink allowlist still contains ${pluginId}`);
}
if ((config.plugins?.deny || []).includes(pluginId)) {
throw new Error(`kitchen-sink denylist still contains ${pluginId}`);
}
if (config.channels?.["kitchen-sink-channel"]) {
throw new Error("kitchen-sink channel config still present after uninstall");
}
const installPathFile = scratchFile(`kitchen-sink-${label}-install-path.txt`);
if (fs.existsSync(installPathFile)) {
const installPath = fs.readFileSync(installPathFile, "utf8").trim();
if (installPath && fs.existsSync(installPath)) {
throw new Error(`kitchen-sink managed install directory still exists: ${installPath}`);
}
}
}
const commands = {
"expect-failure": expectFailure,
"scan-logs": scanLogs,
"configure-runtime": configureRuntime,
"remove-channel-config": removeChannelConfig,
"assert-cutover-preinstalled": assertCutoverPreinstalled,
"assert-installed": assertInstalled,
"assert-removed": assertRemoved,
};
const fn = commands[command];
if (!fn) {
throw new Error(`unknown kitchen-sink assertion command: ${command}`);
}
fn();

View File

@@ -0,0 +1,240 @@
#!/usr/bin/env bash
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
KITCHEN_SINK_SWEEP_SOURCE_ONLY="${KITCHEN_SINK_SWEEP_SOURCE_ONLY:-0}"
if [[ -z "${OPENCLAW_ENTRY:-}" && "$KITCHEN_SINK_SWEEP_SOURCE_ONLY" != "1" ]]; then
OPENCLAW_ENTRY="$(openclaw_e2e_resolve_entrypoint)"
fi
export OPENCLAW_ENTRY
KITCHEN_SINK_CREATED_TMP_DIR=0
if [[ -z "${KITCHEN_SINK_TMP_DIR:-}" ]]; then
KITCHEN_SINK_TMP_DIR="$(mktemp -d "/tmp/openclaw-kitchen-sink.XXXXXX")"
KITCHEN_SINK_CREATED_TMP_DIR=1
else
mkdir -p "$KITCHEN_SINK_TMP_DIR"
fi
export KITCHEN_SINK_TMP_DIR
KITCHEN_SINK_CLI_TIMEOUT="${KITCHEN_SINK_CLI_TIMEOUT:-180s}"
KITCHEN_SINK_CLAWHUB_FIXTURE_DIR=""
KITCHEN_SINK_CLAWHUB_PID_FILE=""
cleanup_kitchen_sink_sweep() {
if [[ -n "${KITCHEN_SINK_CLAWHUB_PID_FILE:-}" && -f "$KITCHEN_SINK_CLAWHUB_PID_FILE" ]]; then
openclaw_e2e_stop_process "$(cat "$KITCHEN_SINK_CLAWHUB_PID_FILE" 2>/dev/null || true)"
fi
if [[ -n "${KITCHEN_SINK_CLAWHUB_FIXTURE_DIR:-}" ]]; then
rm -rf "$KITCHEN_SINK_CLAWHUB_FIXTURE_DIR"
fi
if [[ "${KITCHEN_SINK_CREATED_TMP_DIR:-0}" = "1" ]]; then
rm -rf "$KITCHEN_SINK_TMP_DIR"
fi
}
if [[ "$KITCHEN_SINK_SWEEP_SOURCE_ONLY" != "1" ]]; then
trap cleanup_kitchen_sink_sweep EXIT
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
fi
print_kitchen_sink_log() {
local log_file="$1"
local max_bytes
max_bytes="$(openclaw_e2e_read_positive_int_env OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES 65536)" || return $?
if [ ! -f "$log_file" ]; then
return 0
fi
local log_bytes
log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)"
log_bytes="${log_bytes//[[:space:]]/}"
if ! [[ "$log_bytes" =~ ^[0-9]+$ ]]; then
log_bytes="0"
fi
if [ "$log_bytes" -le "$max_bytes" ]; then
cat "$log_file"
return 0
fi
echo "--- ${log_file} truncated: showing last ${max_bytes} of ${log_bytes} bytes ---"
tail -c "$max_bytes" "$log_file"
}
openclaw_e2e_read_positive_int_env OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES 65536 >/dev/null
run_kitchen_sink_openclaw_logged() {
local label="$1"
shift
local safe_label="${label//[^[:alnum:]._-]/_}"
local log_file="${KITCHEN_SINK_TMP_DIR}/${safe_label}.log"
if ! openclaw_e2e_maybe_timeout "$KITCHEN_SINK_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" "$@" >"$log_file" 2>&1; then
print_kitchen_sink_log "$log_file"
return 1
fi
print_kitchen_sink_log "$log_file"
}
run_kitchen_sink_openclaw_capture() {
local output_file="$1"
shift
openclaw_e2e_maybe_timeout "$KITCHEN_SINK_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" "$@" >"$output_file"
}
run_expect_failure() {
local label="$1"
shift
local safe_label="${label//[^[:alnum:]._-]/_}"
local output_file="${KITCHEN_SINK_TMP_DIR}/kitchen-sink-expected-failure-${safe_label}.log"
set +e
"$@" >"$output_file" 2>&1
local status="$?"
set -e
print_kitchen_sink_log "$output_file"
if [ "$status" -eq 0 ]; then
echo "Expected ${label} to fail, but it succeeded." >&2
exit 1
fi
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs expect-failure "$output_file"
}
start_kitchen_sink_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 scripts/e2e/lib/clawhub-fixture-server.cjs kitchen-sink-plugin "$server_port_file" >"$server_log" 2>&1 &
local server_pid="$!"
echo "$server_pid" >"$server_pid_file"
KITCHEN_SINK_CLAWHUB_FIXTURE_DIR="$fixture_dir"
KITCHEN_SINK_CLAWHUB_PID_FILE="$server_pid_file"
local wait_attempts
wait_attempts="$(openclaw_e2e_read_positive_int_env OPENCLAW_CLAWHUB_FIXTURE_WAIT_ATTEMPTS 600)" || return $?
for _ in $(seq 1 "$wait_attempts"); do
if [[ -s "$server_port_file" ]]; then
export OPENCLAW_CLAWHUB_URL="http://127.0.0.1:$(cat "$server_port_file")"
return 0
fi
if ! kill -0 "$server_pid" 2>/dev/null; then
print_kitchen_sink_log "$server_log"
return 1
fi
sleep 0.1
done
print_kitchen_sink_log "$server_log"
ps -p "$server_pid" -o pid=,stat=,etime=,command= || true
echo "Timed out waiting for kitchen-sink ClawHub fixture server." >&2
return 1
}
scan_logs_for_unexpected_errors() {
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs scan-logs
}
configure_kitchen_sink_runtime() {
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs configure-runtime
}
remove_kitchen_sink_channel_config() {
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs remove-channel-config
}
assert_kitchen_sink_installed() {
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs assert-installed
}
assert_kitchen_sink_removed() {
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs assert-removed
}
assert_kitchen_sink_cutover_preinstalled() {
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs assert-cutover-preinstalled
}
run_success_scenario() {
echo "Testing ${KITCHEN_SINK_LABEL} install from ${KITCHEN_SINK_SPEC}..."
local install_args=("$KITCHEN_SINK_SPEC")
if [ -n "${KITCHEN_SINK_PREINSTALL_SPEC:-}" ]; then
run_kitchen_sink_openclaw_logged "kitchen-sink-preinstall-${KITCHEN_SINK_LABEL}" plugins install "$KITCHEN_SINK_PREINSTALL_SPEC"
assert_kitchen_sink_cutover_preinstalled
install_args+=("--force")
fi
run_kitchen_sink_openclaw_logged "kitchen-sink-install-${KITCHEN_SINK_LABEL}" plugins install "${install_args[@]}"
configure_kitchen_sink_runtime
run_kitchen_sink_openclaw_logged "kitchen-sink-enable-${KITCHEN_SINK_LABEL}" plugins enable "$KITCHEN_SINK_ID"
run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-plugins.json" plugins list --json
run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-inspect.json" plugins inspect "$KITCHEN_SINK_ID" --runtime --json
run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-inspect-all.json" plugins inspect --all --runtime --json
assert_kitchen_sink_installed
if [ "$KITCHEN_SINK_SOURCE" = "clawhub" ]; then
run_kitchen_sink_openclaw_logged "kitchen-sink-uninstall-${KITCHEN_SINK_LABEL}" plugins uninstall "$KITCHEN_SINK_SPEC" --force
else
run_kitchen_sink_openclaw_logged "kitchen-sink-uninstall-${KITCHEN_SINK_LABEL}" plugins uninstall "$KITCHEN_SINK_ID" --force
fi
remove_kitchen_sink_channel_config
run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-uninstalled.json" plugins list --json
assert_kitchen_sink_removed
}
run_failure_scenario() {
echo "Testing expected ${KITCHEN_SINK_LABEL} install failure from ${KITCHEN_SINK_SPEC}..."
run_expect_failure "install-${KITCHEN_SINK_LABEL}" openclaw_e2e_maybe_timeout "$KITCHEN_SINK_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins install "$KITCHEN_SINK_SPEC"
remove_kitchen_sink_channel_config
run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-uninstalled.json" plugins list --json
assert_kitchen_sink_removed
}
run_kitchen_sink_sweep_main() {
if [[ "$KITCHEN_SINK_SCENARIOS" == *"clawhub:"* ]]; then
if [[ "${OPENCLAW_KITCHEN_SINK_LIVE_CLAWHUB:-0}" = "1" ]]; then
export OPENCLAW_CLAWHUB_URL="${OPENCLAW_CLAWHUB_URL:-${CLAWHUB_URL:-https://clawhub.ai}}"
else
if [[ -n "${OPENCLAW_CLAWHUB_URL:-}" || -n "${CLAWHUB_URL:-}" ]]; then
echo "Ignoring ambient ClawHub URL for fixture-mode kitchen-sink E2E; set OPENCLAW_KITCHEN_SINK_LIVE_CLAWHUB=1 for live ClawHub."
fi
unset OPENCLAW_CLAWHUB_URL CLAWHUB_URL
clawhub_fixture_dir="$(mktemp -d "${KITCHEN_SINK_TMP_DIR}/clawhub.XXXXXX")"
start_kitchen_sink_clawhub_fixture_server "$clawhub_fixture_dir"
fi
fi
scenario_count=0
while IFS='|' read -r label spec plugin_id source expectation surface_mode personality preinstall_spec; do
if [ -z "${label:-}" ] || [[ "$label" == \#* ]]; then
continue
fi
scenario_count=$((scenario_count + 1))
export KITCHEN_SINK_LABEL="$label"
export KITCHEN_SINK_SPEC="$spec"
export KITCHEN_SINK_ID="$plugin_id"
export KITCHEN_SINK_SOURCE="$source"
export KITCHEN_SINK_SURFACE_MODE="$surface_mode"
export KITCHEN_SINK_PERSONALITY="${personality:-}"
export OPENCLAW_KITCHEN_SINK_PERSONALITY="${personality:-}"
export KITCHEN_SINK_PREINSTALL_SPEC="${preinstall_spec:-}"
case "$expectation" in
success)
run_success_scenario
;;
failure)
run_failure_scenario
;;
*)
echo "Unknown kitchen-sink expectation for ${label}: ${expectation}" >&2
exit 1
;;
esac
done <<<"$KITCHEN_SINK_SCENARIOS"
if [ "$scenario_count" -eq 0 ]; then
echo "No kitchen-sink plugin scenarios configured." >&2
exit 1
fi
scan_logs_for_unexpected_errors
echo "kitchen-sink plugin Docker E2E passed (${scenario_count} scenario(s))"
}
if [[ "$KITCHEN_SINK_SWEEP_SOURCE_ONLY" != "1" ]]; then
run_kitchen_sink_sweep_main
fi

View File

@@ -0,0 +1,635 @@
// Assertions for live plugin tool E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { extractAgentReplyTexts } from "../agent-turn-output.mjs";
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
import { readTextFileTail, tailText } from "../text-file-utils.mjs";
const command = process.argv[2];
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
function readPositiveIntEnv(name, fallback) {
const text = String(process.env[name] ?? fallback).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`invalid ${name}: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
const agentTurnTimeoutSeconds = readPositiveIntEnv(
"OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS",
300,
);
const SCAN_CHUNK_BYTES = 64 * 1024;
const SCAN_CARRY_CHARS = 256;
const SESSION_JSONL_LINE_MAX_BYTES = 1024 * 1024;
const ERROR_DETAIL_TAIL_BYTES = 16 * 1024;
const AGENT_OUTPUT_MAX_BYTES = readPositiveIntEnv(
"OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_MAX_BYTES",
1024 * 1024,
);
const SESSION_FILE_LIST_LIMIT = 20;
const SESSION_SCAN_MAX_ENTRIES = readPositiveIntEnv(
"OPENCLAW_LIVE_PLUGIN_TOOL_SESSION_SCAN_MAX_ENTRIES",
50_000,
);
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`missing ${name}`);
}
return value;
}
function stateDir() {
return process.env.OPENCLAW_STATE_DIR || path.join(process.env.HOME, ".openclaw");
}
function configPath() {
return process.env.OPENCLAW_CONFIG_PATH || path.join(stateDir(), "openclaw.json");
}
function agentOutputPath() {
return process.env.OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_PATH || "/tmp/openclaw-agent.json";
}
function agentErrorPath() {
return process.env.OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_ERROR_PATH || "/tmp/openclaw-agent.err";
}
function isRecord(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function readNonEmptyString(value) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function normalizeToolCallId(value) {
const id = readNonEmptyString(value);
return id || undefined;
}
function stringifyToolResult(value) {
if (typeof value === "string") {
return value;
}
if (Array.isArray(value)) {
return value
.map((entry) => stringifyToolResult(entry))
.filter(Boolean)
.join("\n");
}
if (!isRecord(value)) {
return value == null ? "" : String(value);
}
const nested = value.text ?? value.content ?? value.result ?? value.output;
return nested === undefined ? JSON.stringify(value) : stringifyToolResult(nested);
}
function extractTranscriptText(value) {
if (typeof value === "string") {
return value;
}
if (Array.isArray(value)) {
return value
.map((entry) => extractTranscriptText(entry))
.filter(Boolean)
.join("\n");
}
if (!isRecord(value)) {
return value == null ? "" : String(value);
}
return extractTranscriptText(value.text ?? value.content ?? value.result ?? value.output ?? "");
}
function extractTranscriptToolCalls(message) {
const calls = [];
const content = message.content;
if (Array.isArray(content)) {
for (const block of content) {
if (!isRecord(block)) {
continue;
}
const type = readNonEmptyString(block.type)?.toLowerCase();
if (type !== "tool_use" && type !== "toolcall" && type !== "tool_call") {
continue;
}
const tool = readNonEmptyString(block.name);
if (!tool) {
continue;
}
calls.push({
id:
normalizeToolCallId(block.id) ??
normalizeToolCallId(block.toolCallId) ??
normalizeToolCallId(block.toolUseId),
tool,
});
}
}
const rawToolCalls =
message.tool_calls ?? message.toolCalls ?? message.function_call ?? message.functionCall;
const toolCalls = Array.isArray(rawToolCalls) ? rawToolCalls : rawToolCalls ? [rawToolCalls] : [];
for (const call of toolCalls) {
if (!isRecord(call)) {
continue;
}
const functionRecord = isRecord(call.function) ? call.function : undefined;
const tool = readNonEmptyString(call.name) ?? readNonEmptyString(functionRecord?.name);
if (!tool) {
continue;
}
calls.push({
id:
normalizeToolCallId(call.id) ??
normalizeToolCallId(call.toolCallId) ??
normalizeToolCallId(call.toolUseId),
tool,
});
}
return calls;
}
function isFailureLikeToolResult(params) {
return (
params.type === "tool_result_error" ||
params.isError === true ||
params.is_error === true ||
/\b(?:denied|enoent|error|exception|fail(?:ed|ure)?|forbidden|invalid|missing|not found|permission)\b/iu.test(
params.text,
)
);
}
function extractTranscriptToolResults(message) {
const results = [];
const tool =
readNonEmptyString(message.toolName) ??
readNonEmptyString(message.tool_name) ??
readNonEmptyString(message.name) ??
readNonEmptyString(message.tool);
if ((message.role === "tool" || message.role === "toolResult") && message.content !== undefined) {
const text = extractTranscriptText(message.content);
results.push({
id:
normalizeToolCallId(message.tool_call_id) ??
normalizeToolCallId(message.toolCallId) ??
normalizeToolCallId(message.toolUseId) ??
normalizeToolCallId(message.id),
...(tool ? { tool } : {}),
text,
failure: isFailureLikeToolResult({
text,
isError: message.isError,
is_error: message.is_error,
}),
});
}
const content = message.content;
if (!Array.isArray(content)) {
return results;
}
for (const block of content) {
if (!isRecord(block)) {
continue;
}
const type = readNonEmptyString(block.type)?.toLowerCase();
if (type !== "tool_result" && type !== "toolresult" && type !== "tool_result_error") {
continue;
}
const text = stringifyToolResult(
block.content ?? block.text ?? block.result ?? block.output ?? block.error ?? block.message,
);
const blockTool =
readNonEmptyString(block.toolName) ??
readNonEmptyString(block.tool_name) ??
readNonEmptyString(block.name) ??
readNonEmptyString(block.tool);
results.push({
id:
normalizeToolCallId(block.tool_use_id) ??
normalizeToolCallId(block.toolUseId) ??
normalizeToolCallId(block.tool_call_id) ??
normalizeToolCallId(block.toolCallId) ??
normalizeToolCallId(block.id),
...(blockTool ? { tool: blockTool } : {}),
text,
failure: isFailureLikeToolResult({
type,
text,
isError: block.isError,
is_error: block.is_error,
}),
});
}
return results;
}
function resultLinksToolCall(call, result, targetCallCount) {
if (call.id || result.id) {
return Boolean(call.id && result.id && call.id === result.id);
}
if (result.tool) {
return result.tool === call.tool;
}
return targetCallCount === 1;
}
function createToolEvidenceTracker(toolName, expected) {
const calls = [];
return {
recordMessage(message) {
for (const call of extractTranscriptToolCalls(message)) {
if (call.tool === toolName) {
calls.push(call);
}
}
for (const result of extractTranscriptToolResults(message)) {
if (result.failure || !result.text.includes(expected)) {
continue;
}
if (calls.some((call) => resultLinksToolCall(call, result, calls.length))) {
return true;
}
}
return false;
},
};
}
function transcriptMessageFromLine(line) {
try {
const parsed = JSON.parse(line);
if (!isRecord(parsed)) {
return undefined;
}
return isRecord(parsed.message) ? parsed.message : parsed;
} catch {
return undefined;
}
}
function scanFileForToolEvidence(file, toolName, expected) {
const tracker = createToolEvidenceTracker(toolName, expected);
let stat;
try {
stat = fs.statSync(file);
} catch {
return false;
}
if (!stat.isFile() || stat.size <= 0) {
return false;
}
const fd = fs.openSync(file, "r");
try {
const buffer = Buffer.alloc(Math.min(SCAN_CHUNK_BYTES, stat.size));
let pendingLine = "";
let offset = 0;
while (offset < stat.size) {
const bytesToRead = Math.min(buffer.length, stat.size - offset);
const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, offset);
if (bytesRead <= 0) {
break;
}
offset += bytesRead;
const lines = (pendingLine + buffer.subarray(0, bytesRead).toString("utf8")).split(/\r?\n/u);
pendingLine = lines.pop() ?? "";
if (Buffer.byteLength(pendingLine) > SESSION_JSONL_LINE_MAX_BYTES) {
pendingLine = pendingLine.slice(-SCAN_CARRY_CHARS);
}
for (const line of lines) {
const message = transcriptMessageFromLine(line.trim());
if (message && tracker.recordMessage(message)) {
return true;
}
}
}
const message = transcriptMessageFromLine(pendingLine.trim());
if (message && tracker.recordMessage(message)) {
return true;
}
} finally {
fs.closeSync(fd);
}
return false;
}
function scanSessionTranscripts(sessionsDir, toolName, expected) {
const checkedFiles = [];
let filesChecked = 0;
let stat;
try {
stat = fs.statSync(sessionsDir);
} catch {
return { checkedFiles, filesChecked, found: false, missingDir: true };
}
if (!stat.isDirectory()) {
return { checkedFiles, filesChecked, found: false, missingDir: true };
}
const pendingDirs = [sessionsDir];
let scannedEntries = 0;
while (pendingDirs.length > 0) {
const dir = pendingDirs.pop();
const handle = fs.opendirSync(dir);
try {
let entry;
while ((entry = handle.readSync()) !== null) {
scannedEntries += 1;
if (scannedEntries > SESSION_SCAN_MAX_ENTRIES) {
throw new Error(
`session transcript scan exceeded ${SESSION_SCAN_MAX_ENTRIES} filesystem entries`,
);
}
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
pendingDirs.push(entryPath);
continue;
}
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
continue;
}
filesChecked += 1;
if (checkedFiles.length < SESSION_FILE_LIST_LIMIT) {
checkedFiles.push(path.relative(sessionsDir, entryPath));
}
if (scanFileForToolEvidence(entryPath, toolName, expected)) {
return { checkedFiles, filesChecked, found: true, missingDir: false };
}
}
} finally {
handle.closeSync();
}
}
return { checkedFiles, filesChecked, found: false, missingDir: false };
}
function realPathMaybe(filePath) {
try {
return fs.realpathSync(filePath);
} catch {
return path.resolve(filePath);
}
}
function assertPathInside(parentPath, childPath, label) {
const parent = realPathMaybe(parentPath);
const child = realPathMaybe(childPath);
const relative = path.relative(parent, child);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`${label} resolved outside ${parentPath}: ${child}`);
}
}
function writeJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
}
function installRecords() {
const cfg = fs.existsSync(configPath()) ? readJson(configPath()) : {};
return readPluginInstallRecords({
stateDir: stateDir(),
configPath: configPath(),
fallbackRecords: cfg.plugins?.installs ?? {},
});
}
function pluginInstallPath() {
const pluginId = requireEnv("PLUGIN_ID");
const inspect = fs.existsSync("/tmp/openclaw-plugin-inspect.json")
? readJson("/tmp/openclaw-plugin-inspect.json")
: {};
const record = installRecords()[pluginId] || inspect.install;
if (!record) {
throw new Error(`missing ${pluginId} install record`);
}
if (record.source !== "npm" || record.artifactKind !== "npm-pack") {
throw new Error(`expected npm-pack install record: ${JSON.stringify(record)}`);
}
return String(record.installPath || "").replace(/^~(?=$|\/)/u, process.env.HOME);
}
function writeFixture() {
const dir = process.argv[3];
if (!dir) {
throw new Error("write-fixture requires output dir");
}
const pluginId = requireEnv("PLUGIN_ID");
const pluginName = requireEnv("PLUGIN_NAME");
const version = requireEnv("PLUGIN_VERSION");
const toolName = requireEnv("TOOL_NAME");
const seed = requireEnv("SEED");
writeJson(path.join(dir, "package.json"), {
name: pluginName,
version,
dependencies: { slugify: "^1.6.6" },
openclaw: { extensions: ["./index.js"] },
});
writeJson(path.join(dir, "openclaw.plugin.json"), {
id: pluginId,
name: "E2E Slug Tool",
description: "Docker E2E plugin tool fixture",
activation: { onStartup: true },
contracts: { tools: [toolName] },
configSchema: { type: "object", additionalProperties: false },
});
fs.writeFileSync(
path.join(dir, "index.js"),
`const slugify = require("slugify");\n` +
`const value = slugify(${JSON.stringify(seed)}, { lower: true, strict: true });\n` +
`module.exports = {\n` +
` id: ${JSON.stringify(pluginId)},\n` +
` name: "E2E Slug Tool",\n` +
` register(api) {\n` +
` api.registerTool({\n` +
` name: ${JSON.stringify(toolName)},\n` +
` description: "Return the hidden Docker E2E slug generated by the plugin dependency.",\n` +
` parameters: { type: "object", properties: {}, additionalProperties: false },\n` +
` async execute() {\n` +
` return { content: [{ type: "text", text: value }] };\n` +
` },\n` +
` });\n` +
` },\n` +
`};\n`,
);
}
function configure() {
const modelRef = requireEnv("MODEL_REF");
const pluginId = requireEnv("PLUGIN_ID");
const toolName = requireEnv("TOOL_NAME");
const cfgPath = configPath();
const cfg = fs.existsSync(cfgPath) ? readJson(cfgPath) : {};
const [providerId, modelId] = modelRef.split("/");
if (providerId !== "openai" || !modelId) {
throw new Error(`live plugin tool E2E expects an openai/* model, got ${modelRef}`);
}
cfg.plugins = {
...cfg.plugins,
enabled: true,
allow: Array.from(new Set([...(cfg.plugins?.allow || []), "openai", pluginId])).toSorted(
(left, right) => left.localeCompare(right),
),
entries: {
...cfg.plugins?.entries,
openai: { ...cfg.plugins?.entries?.openai, enabled: true },
[pluginId]: { ...cfg.plugins?.entries?.[pluginId], enabled: true },
},
};
cfg.tools = {
...cfg.tools,
allow: [toolName],
};
cfg.models = {
...cfg.models,
mode: "merge",
providers: {
...cfg.models?.providers,
openai: {
...cfg.models?.providers?.openai,
api: "openai-responses",
baseUrl: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(),
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
agentRuntime: { id: "openclaw" },
timeoutSeconds: agentTurnTimeoutSeconds,
models: [
{
id: modelId,
name: modelId,
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
contextTokens: 96000,
maxTokens: 512,
},
],
},
},
};
cfg.agents = {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
model: { primary: modelRef, fallbacks: [] },
models: {
...cfg.agents?.defaults?.models,
[modelRef]: {
...cfg.agents?.defaults?.models?.[modelRef],
agentRuntime: { id: "openclaw" },
params: { transport: "sse", openaiWsWarmup: false },
},
},
workspace: path.join(stateDir(), "workspace"),
skipBootstrap: true,
timeoutSeconds: agentTurnTimeoutSeconds,
},
};
writeJson(cfgPath, cfg);
}
function findDependencyPackageJson(packageName) {
const installPath = pluginInstallPath();
const npmRoot = path.join(stateDir(), "npm");
const pluginName = requireEnv("PLUGIN_NAME");
const packageRoot = pluginName.split("/").reduce((current) => path.dirname(current), installPath);
const projectRoot =
path.basename(packageRoot) === "node_modules" ? path.dirname(packageRoot) : npmRoot;
return [
path.join(projectRoot, "node_modules", packageName, "package.json"),
path.join(installPath, "node_modules", packageName, "package.json"),
path.join(npmRoot, "node_modules", packageName, "package.json"),
].find((candidate) => fs.existsSync(candidate));
}
function assertInstalled() {
const pluginId = requireEnv("PLUGIN_ID");
const pluginName = requireEnv("PLUGIN_NAME");
const toolName = requireEnv("TOOL_NAME");
const npmRoot = path.join(stateDir(), "npm");
const installPath = pluginInstallPath();
assertPathInside(npmRoot, installPath, "fixture plugin install path");
const packageJson = path.join(installPath, "package.json");
if (!fs.existsSync(packageJson)) {
throw new Error(`missing fixture plugin package.json: ${packageJson}`);
}
const pkg = readJson(packageJson);
if (pkg.name !== pluginName) {
throw new Error(`unexpected fixture package name: ${pkg.name}`);
}
const slugifyPackageJson = findDependencyPackageJson("slugify");
if (!slugifyPackageJson) {
throw new Error("missing slugify dependency installed by npm-pack plugin install");
}
assertPathInside(npmRoot, slugifyPackageJson, "slugify dependency");
const list = readJson("/tmp/openclaw-plugins-list.json");
const plugin = (list.plugins || []).find((entry) => entry.id === pluginId);
if (!plugin || plugin.enabled !== true || plugin.status !== "loaded") {
throw new Error(`fixture plugin was not enabled+loaded: ${JSON.stringify(plugin)}`);
}
const inspect = readJson("/tmp/openclaw-plugin-inspect.json");
const toolNames = Array.isArray(inspect.tools)
? inspect.tools.flatMap((entry) => (Array.isArray(entry?.names) ? entry.names : []))
: [];
if (!toolNames.includes(toolName)) {
throw new Error(`fixture tool was not registered: ${JSON.stringify(inspect.tools)}`);
}
}
function assertAgentTurn() {
const expected = requireEnv("EXPECTED_SLUG");
const toolName = requireEnv("TOOL_NAME");
const outputPath = agentOutputPath();
const errorPath = agentErrorPath();
const outputStat = fs.statSync(outputPath);
if (outputStat.isFile() && outputStat.size > AGENT_OUTPUT_MAX_BYTES) {
const stdoutTail = readTextFileTail(outputPath, ERROR_DETAIL_TAIL_BYTES);
const stderrTail = readTextFileTail(errorPath, ERROR_DETAIL_TAIL_BYTES);
throw new Error(
`live agent output exceeded ${AGENT_OUTPUT_MAX_BYTES} bytes:\nstdout tail=${stdoutTail}\nstderr tail=${stderrTail}`,
);
}
const stdout = fs.readFileSync(outputPath, "utf8");
const response = JSON.parse(stdout);
const text = extractAgentReplyTexts(JSON.stringify(response)).join("\n");
if (!text.includes(expected)) {
const stderrTail = readTextFileTail(errorPath, ERROR_DETAIL_TAIL_BYTES);
throw new Error(
`live agent reply did not contain tool slug ${expected}:\nstdout tail=${tailText(stdout, ERROR_DETAIL_TAIL_BYTES)}\nstderr tail=${stderrTail}`,
);
}
const sessionsDir = path.join(stateDir(), "agents", "main", "sessions");
const scan = scanSessionTranscripts(sessionsDir, toolName, expected);
if (!scan.found) {
const checkedFiles = scan.checkedFiles.length > 0 ? scan.checkedFiles.join(", ") : "<none>";
const missingDir = scan.missingDir ? " sessions directory was missing." : "";
throw new Error(
`session transcript did not show ${toolName} returning ${expected}; missing causal tool-result evidence after checking ${scan.filesChecked} jsonl file(s): ${checkedFiles}.${missingDir}`,
);
}
}
const commands = {
"write-fixture": writeFixture,
configure,
"assert-installed": assertInstalled,
"assert-agent-turn": assertAgentTurn,
};
const fn = commands[command];
if (!fn) {
throw new Error(`unknown live plugin tool assertion command: ${command}`);
}
fn();

View File

@@ -0,0 +1,41 @@
// MCP code-mode probe server fixture shared by local and Docker E2E scripts.
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
const require = createRequire(import.meta.url);
export async function writeProbeMcpServer(serverPath: string) {
const sdkMcpServerPath = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
const sdkStdioServerPath = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
const zodPath = require.resolve("zod");
await fs.mkdir(path.dirname(serverPath), { recursive: true });
await fs.writeFile(
serverPath,
`#!/usr/bin/env node
import { McpServer } from ${JSON.stringify(sdkMcpServerPath)};
import { StdioServerTransport } from ${JSON.stringify(sdkStdioServerPath)};
import { z } from ${JSON.stringify(zodPath)};
const notes = new Map([
["alpha", "fixture-note-alpha"],
["beta", "fixture-note-beta"],
]);
const server = new McpServer({ name: "code-mode-fixture", version: "1.0.0" });
server.tool(
"lookup_note",
"Look up one read-only fixture note by id.",
{
id: z.string().describe("Fixture note id to look up."),
},
async ({ id }) => ({
content: [{ type: "text", text: notes.get(id) ?? "missing-note" }],
}),
);
await server.connect(new StdioServerTransport());
`,
{ encoding: "utf8", mode: 0o755 },
);
}

View File

@@ -0,0 +1,61 @@
export type McpCodeModeMentions = Record<
"apiCall" | "apiFileList" | "apiFileRead" | "mcpNamespace" | "mcpTool" | "toolSearchPollution",
number
>;
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
export function outputText(response: unknown): string {
const output = (response as { output?: Array<{ type?: unknown; content?: unknown }> }).output;
if (!Array.isArray(output)) {
return "";
}
return output
.flatMap((item) => {
if (item.type !== "message" || !Array.isArray(item.content)) {
return [];
}
return item.content.flatMap((piece) => {
if (!piece || typeof piece !== "object") {
return [];
}
const record = piece as { text?: unknown };
return typeof record.text === "string" ? [record.text] : [];
});
})
.join("\n");
}
export function validateMcpCodeModeResult(
response: unknown,
mentions: McpCodeModeMentions,
options: { plannedTools?: string[]; requireExec?: boolean } = {},
): string {
const finalText = outputText(response);
assert(
finalText.includes("MCP_CODE_MODE_FILE_OK"),
`agent did not complete MCP API file check: ${finalText}`,
);
assert(
finalText.includes("fixture-note-alpha"),
`agent did not return fixture note from MCP call: ${finalText}`,
);
assert(
!/MCP\s+(?:was\s+)?not\s+defined|failed|error/i.test(finalText),
`agent reported MCP failure instead of a successful call: ${finalText}`,
);
if (options.requireExec) {
assert(options.plannedTools?.includes("exec"), "agent did not call code-mode exec");
}
assert(mentions.apiFileList > 0, "session log lacks API.list usage");
assert(mentions.apiFileRead > 0, "session log lacks API.read usage");
assert(mentions.mcpNamespace > 0, "session log lacks MCP.fixture usage");
assert(mentions.mcpTool > 0, "session log lacks fixture__lookup_note call");
assert(mentions.apiCall === 0, "agent should not call MCP.$api when API files are available");
assert(mentions.toolSearchPollution === 0, "agent should not use tools.search for MCP lookup");
return finalText;
}

View File

@@ -0,0 +1,124 @@
// Mock OpenAI-compatible HTTP server helpers for E2E scenarios.
import fs from "node:fs";
import { readPositiveIntEnv } from "./env-limits.mjs";
const DEFAULT_REQUEST_MAX_BYTES = 4 * 1024 * 1024;
const DEFAULT_REQUEST_LOG_BODY_MAX_BYTES = 256 * 1024;
const REQUEST_LOG_PREVIEW_CHARS = 4096;
export function readMockOpenAiHttpLimits(env = process.env) {
return {
requestMaxBytes: readPositiveIntEnv(
"OPENCLAW_MOCK_OPENAI_REQUEST_MAX_BYTES",
DEFAULT_REQUEST_MAX_BYTES,
env,
),
requestLogBodyMaxBytes: readPositiveIntEnv(
"OPENCLAW_MOCK_OPENAI_REQUEST_LOG_BODY_MAX_BYTES",
DEFAULT_REQUEST_LOG_BODY_MAX_BYTES,
env,
),
};
}
function requestBodyTooLargeError(limit) {
return Object.assign(new Error(`mock OpenAI request body exceeded ${limit} bytes`), {
code: "ETOOBIG",
});
}
export function isRequestBodyTooLargeError(error) {
return error instanceof Error && error.code === "ETOOBIG";
}
export function readBody(req, limits = readMockOpenAiHttpLimits()) {
const { requestMaxBytes } = limits;
return new Promise((resolve, reject) => {
let body = "";
let bytes = 0;
let settled = false;
req.setEncoding("utf8");
req.on("data", (chunk) => {
if (settled) {
return;
}
bytes += Buffer.byteLength(chunk, "utf8");
if (bytes > requestMaxBytes) {
settled = true;
body = "";
req.resume();
reject(requestBodyTooLargeError(requestMaxBytes));
return;
}
body += chunk;
});
req.on("end", () => {
if (!settled) {
settled = true;
resolve(body);
}
});
req.on("error", (error) => {
if (!settled) {
settled = true;
reject(error instanceof Error ? error : new Error(String(error)));
}
});
});
}
export function boundedRequestLogBody(value, bodyText, limits = readMockOpenAiHttpLimits()) {
const { requestLogBodyMaxBytes } = limits;
const byteLength = Buffer.byteLength(bodyText, "utf8");
if (byteLength <= requestLogBodyMaxBytes) {
return value;
}
return {
truncated: true,
byteLength,
preview: bodyText.slice(0, REQUEST_LOG_PREVIEW_CHARS),
};
}
export function writeRequestLogEntryOrFail(
res,
{ requestLog, entry, label = "mock-openai", required = false },
) {
if (!requestLog) {
if (!required) {
return false;
}
const message = "MOCK_REQUEST_LOG is not configured";
console.error(`${label} request log write failed: ${message}`);
writeJson(res, 500, { error: { message: `mock OpenAI request log write failed: ${message}` } });
return true;
}
try {
fs.appendFileSync(requestLog, `${JSON.stringify(entry)}\n`);
return false;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`${label} request log write failed: ${message}`);
writeJson(res, 500, { error: { message: `mock OpenAI request log write failed: ${message}` } });
return true;
}
}
export function writeJson(res, status, body) {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
export 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();
}

View File

@@ -0,0 +1,275 @@
// Assertions for npm onboard channel-agent E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import {
assertAgentReplyContainsMarker,
assertOpenAiRequestLogUsed,
} from "../agent-turn-output.mjs";
import { assertOpenAiEnvAuthProfileStore } from "../auth-profile-store-assertions.mjs";
import { readPositiveIntEnv } from "../env-limits.mjs";
import {
applyMockOpenAiModelConfig,
parseMockOpenAiPort,
} from "../fixtures/mock-openai-config.mjs";
import { readTextFileBounded, readTextFileTail } from "../text-file-utils.mjs";
const command = process.argv[2];
const ERROR_DETAIL_TAIL_BYTES = 16 * 1024;
const JSON_ARTIFACT_MAX_BYTES = readPositiveIntEnv(
"OPENCLAW_NPM_ONBOARD_JSON_ARTIFACT_MAX_BYTES",
1024 * 1024,
);
const STATUS_TEXT_MAX_BYTES = readPositiveIntEnv(
"OPENCLAW_NPM_ONBOARD_STATUS_TEXT_MAX_BYTES",
1024 * 1024,
);
const ansiEscapePattern = new RegExp(String.raw`\u001b\[[0-?]*[ -/]*[@-~]`, "g");
function readJson(file) {
return JSON.parse(
readTextFileBounded(file, "JSON artifact", JSON_ARTIFACT_MAX_BYTES, {
tailBytes: ERROR_DETAIL_TAIL_BYTES,
}),
);
}
function stripAnsi(text) {
return text.replace(ansiEscapePattern, "");
}
const statusSectionTitles = new Set([
"openclaw status",
"overview",
"plugin compatibility",
"model selection",
"security audit",
"channels",
"sessions",
"system events",
"health",
"usage",
]);
function normalizedStatusHeading(line) {
return stripAnsi(line)
.trim()
.replace(/^#+\s*/, "")
.trim()
.toLowerCase();
}
function extractStatusSection(text, title) {
const target = title.toLowerCase();
const lines = text.split(/\r?\n/);
const start = lines.findIndex((line) => normalizedStatusHeading(line) === target);
if (start === -1) {
return null;
}
const section = [];
for (const line of lines.slice(start + 1)) {
const normalized = normalizedStatusHeading(line);
if (normalized && statusSectionTitles.has(normalized)) {
break;
}
section.push(line);
}
return stripAnsi(section.join("\n"));
}
function readAuthProfileStoreText(agentDir) {
const dbPath = path.join(agentDir, "openclaw-agent.sqlite");
if (!fs.existsSync(dbPath)) {
return "";
}
let db;
try {
db = new DatabaseSync(dbPath, { readOnly: true });
const row = db
.prepare("SELECT store_json FROM auth_profile_store WHERE store_key = ?")
.get("primary");
return typeof row?.store_json === "string" ? row.store_json : "";
} catch {
return "";
} finally {
db?.close();
}
}
function assertOnboardState() {
const home = process.argv[3];
const stateDir = path.join(home, ".openclaw");
const configPath = path.join(stateDir, "openclaw.json");
const agentDir = path.join(stateDir, "agents", "main", "agent");
if (!fs.existsSync(configPath)) {
throw new Error("onboard did not write openclaw.json");
}
if (!fs.existsSync(agentDir)) {
throw new Error("onboard did not create main agent dir");
}
const authStoreText = readAuthProfileStoreText(agentDir);
if (!authStoreText) {
throw new Error("onboard did not persist auth profile store");
}
assertOpenAiEnvAuthProfileStore(authStoreText, {
envRefMessage: "auth profile did not persist OPENAI_API_KEY env ref",
rawKeyMessage: "auth profile persisted the raw OpenAI test key",
rawKeyNeedle: "sk-openclaw-npm-onboard-e2e",
});
}
function configureMockModel() {
const mockPort = parseMockOpenAiPort(process.argv[3]);
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
const cfg = readJson(configPath);
applyMockOpenAiModelConfig(cfg, { mockPort });
fs.writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
}
function assertMockModelConfig() {
const mockPort = parseMockOpenAiPort(process.argv[3]);
const expectedModelRef = "openai/gpt-5.5";
const expectedBaseUrl = `http://127.0.0.1:${mockPort}/v1`;
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
const cfg = readJson(configPath);
const provider = cfg.models?.providers?.openai;
const defaultModel = cfg.agents?.defaults?.model?.primary;
const defaultRuntime = cfg.agents?.defaults?.models?.[expectedModelRef]?.agentRuntime?.id;
const agent = Array.isArray(cfg.agents?.list)
? (cfg.agents.list.find((entry) => entry?.id === "main") ?? cfg.agents.list[0])
: undefined;
const agentModel = agent?.model?.primary;
const agentRuntime = agent?.models?.[expectedModelRef]?.agentRuntime?.id;
if (provider?.baseUrl !== expectedBaseUrl) {
throw new Error(
`mock OpenAI baseUrl was not preserved; expected ${expectedBaseUrl}, got ${provider?.baseUrl}`,
);
}
if (provider?.api !== "openai-responses") {
throw new Error(`mock OpenAI api was not preserved; got ${provider?.api}`);
}
if (provider?.agentRuntime?.id !== "openclaw") {
throw new Error(`mock OpenAI runtime was not preserved; got ${provider?.agentRuntime?.id}`);
}
if (defaultModel !== expectedModelRef) {
throw new Error(
`mock default model was not preserved; expected ${expectedModelRef}, got ${defaultModel}`,
);
}
if (defaultRuntime !== "openclaw") {
throw new Error(`mock default runtime was not preserved; got ${defaultRuntime}`);
}
if (agent && agentModel !== expectedModelRef) {
throw new Error(
`mock agent model was not preserved; expected ${expectedModelRef}, got ${agentModel}`,
);
}
if (agent && agentRuntime !== "openclaw") {
throw new Error(`mock agent runtime was not preserved; got ${agentRuntime}`);
}
}
function assertChannelConfig() {
const channel = process.argv[3];
const expectedTokens = process.argv.slice(4);
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
const cfg = readJson(configPath);
const entry = cfg.channels?.[channel];
if (!entry || entry.enabled === false) {
throw new Error(`${channel} was not enabled`);
}
const assertTokenField = (field, expected) => {
if (entry[field] !== expected) {
throw new Error(
`${channel} config did not persist ${field}; expected ${expected}, got ${JSON.stringify(entry[field])}`,
);
}
};
switch (channel) {
case "telegram": {
if (expectedTokens.length !== 1) {
throw new Error("telegram channel config assertion requires one bot token");
}
assertTokenField("botToken", expectedTokens[0]);
return;
}
case "discord": {
if (expectedTokens.length !== 1) {
throw new Error("discord channel config assertion requires one bot token");
}
assertTokenField("token", expectedTokens[0]);
return;
}
case "slack": {
if (expectedTokens.length !== 2) {
throw new Error("slack channel config assertion requires bot and app tokens");
}
assertTokenField("botToken", expectedTokens[0]);
assertTokenField("appToken", expectedTokens[1]);
return;
}
default:
throw new Error(`unsupported channel config assertion: ${channel}`);
}
}
function assertStatusSurfaces() {
const channel = process.argv[3];
const channelsStatusPath = process.argv[4];
const statusTextPath = process.argv[5];
const channelsStatus = readJson(channelsStatusPath);
const statusText = readTextFileBounded(
statusTextPath,
"plain status output",
STATUS_TEXT_MAX_BYTES,
{ tailBytes: ERROR_DETAIL_TAIL_BYTES },
);
const statusTail = readTextFileTail(statusTextPath, ERROR_DETAIL_TAIL_BYTES);
const configuredChannels = Array.isArray(channelsStatus.configuredChannels)
? channelsStatus.configuredChannels
: [];
if (!configuredChannels.includes(channel)) {
throw new Error(
`channels status did not list configured channel ${channel}. Payload: ${JSON.stringify(channelsStatus)}`,
);
}
if (!/channels/i.test(statusText)) {
throw new Error(
`plain status output did not render a Channels section. Output tail: ${statusTail}`,
);
}
const channelsSection = extractStatusSection(statusText, "channels");
if (!channelsSection) {
throw new Error(
`plain status output did not render a Channels section. Output tail: ${statusTail}`,
);
}
if (!channelsSection.toLowerCase().includes(channel.toLowerCase())) {
throw new Error(
`plain status output did not mention ${channel} in the Channels section. Output tail: ${statusTail}`,
);
}
}
function assertAgentTurn() {
const marker = process.argv[3];
const logPath = process.argv[4];
assertAgentReplyContainsMarker(marker, "/tmp/openclaw-agent.combined");
assertOpenAiRequestLogUsed(logPath);
}
const commands = {
"assert-onboard-state": assertOnboardState,
"configure-mock-model": configureMockModel,
"assert-mock-model-config": assertMockModelConfig,
"assert-channel-config": assertChannelConfig,
"assert-status-surfaces": assertStatusSurfaces,
"assert-agent-turn": assertAgentTurn,
};
const fn = commands[command];
if (!fn) {
throw new Error(`unknown npm onboard/channel/agent assertion command: ${command}`);
}
fn();

View File

@@ -0,0 +1,14 @@
// Prepares package manifests for npm Telegram live E2E scenarios.
import fs from "node:fs";
for (const packageJsonPath of process.argv.slice(2)) {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
pkg.exports = pkg.exports && typeof pkg.exports === "object" ? pkg.exports : {};
if (!pkg.exports["./plugin-sdk/gateway-runtime"]) {
pkg.exports["./plugin-sdk/gateway-runtime"] = {
types: "./dist/plugin-sdk/gateway-runtime.d.ts",
default: "./dist/plugin-sdk/gateway-runtime.js",
};
}
fs.writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
}

View File

@@ -0,0 +1,87 @@
// Config assertions for onboard E2E scenarios.
import fs from "node:fs";
import JSON5 from "json5";
const [scenario, configPath, expectedWorkspace] = process.argv.slice(2);
if (!scenario || !configPath) {
throw new Error("usage: assert-config.mjs <scenario> <config-path> [expected-workspace]");
}
const cfg = JSON5.parse(fs.readFileSync(configPath, "utf8"));
const errors = [];
const got = (value) => value ?? "unset";
const expectEqual = (label, actual, expected) => {
if (actual !== expected) {
errors.push(`${label} mismatch (got ${got(actual)})`);
}
};
const assertLocalWizard = () => {
expectEqual("gateway.mode", cfg?.gateway?.mode, "local");
expectEqual("wizard.lastRunMode", cfg?.wizard?.lastRunMode, "local");
};
const assertSectionScopedConfigure = () => {
expectEqual("wizard.lastRunCommand", cfg?.wizard?.lastRunCommand, "configure");
expectEqual("wizard.lastRunMode", cfg?.wizard?.lastRunMode, "local");
if (cfg?.gateway?.mode) {
errors.push(`gateway.mode should stay unset (got ${cfg.gateway.mode})`);
}
};
switch (scenario) {
case "local-basic": {
expectEqual("agents.defaults.workspace", cfg?.agents?.defaults?.workspace, expectedWorkspace);
assertLocalWizard();
expectEqual("gateway.bind", cfg?.gateway?.bind, "loopback");
expectEqual("gateway.tailscale.mode", cfg?.gateway?.tailscale?.mode ?? "off", "off");
if (!cfg?.wizard?.lastRunAt) {
errors.push("wizard.lastRunAt missing");
}
if (!cfg?.wizard?.lastRunVersion) {
errors.push("wizard.lastRunVersion missing");
}
expectEqual("wizard.lastRunCommand", cfg?.wizard?.lastRunCommand, "onboard");
break;
}
case "remote-non-interactive":
expectEqual("gateway.mode", cfg?.gateway?.mode, "remote");
expectEqual("gateway.remote.url", cfg?.gateway?.remote?.url, "ws://gateway.local:18789");
expectEqual("gateway.remote.token", cfg?.gateway?.remote?.token, "remote-token");
expectEqual("wizard.lastRunMode", cfg?.wizard?.lastRunMode, "remote");
break;
case "reset":
assertLocalWizard();
if (cfg?.gateway?.remote?.url) {
errors.push(`gateway.remote.url should be cleared (got ${cfg.gateway.remote.url})`);
}
break;
case "channels":
if (cfg?.telegram?.botToken) {
errors.push(`telegram.botToken should be unset (got ${cfg.telegram.botToken})`);
}
if (cfg?.discord?.token) {
errors.push(`discord.token should be unset (got ${cfg.discord.token})`);
}
if (cfg?.slack?.botToken || cfg?.slack?.appToken) {
errors.push(
`slack tokens should be unset (got bot=${got(cfg?.slack?.botToken)}, app=${got(cfg?.slack?.appToken)})`,
);
}
assertSectionScopedConfigure();
break;
case "skills":
expectEqual("skills.install.nodeManager", cfg?.skills?.install?.nodeManager, "bun");
if (!Array.isArray(cfg?.skills?.allowBundled) || cfg.skills.allowBundled[0] !== "__none__") {
errors.push("skills.allowBundled missing");
}
assertSectionScopedConfigure();
break;
default:
throw new Error(`unknown onboard assertion scenario: ${scenario}`);
}
if (errors.length > 0) {
console.error(errors.join("\n"));
process.exit(1);
}

View File

@@ -0,0 +1,58 @@
// Log substring assertion helper for onboard E2E scenarios.
import fs from "node:fs";
import { fileURLToPath } from "node:url";
export const DEFAULT_MAX_LOG_BYTES = 120_000;
const normalizeScriptOutput = (value) => value.replace(/\r?\n/g, "").replace(/\r/g, "");
const oscPattern = new RegExp(String.raw`\u001b\][^\u0007]*(?:\u0007|\u001b\\)`, "g");
const csiPattern = new RegExp(String.raw`\u001b\[[0-?]*[ -/]*[@-~]`, "g");
const stripAnsi = (value) =>
normalizeScriptOutput(value).replace(oscPattern, "").replace(csiPattern, "");
const compact = (value) =>
stripAnsi(value)
.toLowerCase()
.replace(/[^a-z]+/g, "");
export function readLogTail(file, maxBytes = DEFAULT_MAX_LOG_BYTES) {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
throw new Error("maxBytes must be a positive integer");
}
const stats = fs.statSync(file);
if (!stats.isFile()) {
throw new Error(`${file} is not a file`);
}
const length = Math.min(stats.size, maxBytes);
const start = Math.max(0, stats.size - length);
const fd = fs.openSync(file, "r");
try {
const buffer = Buffer.alloc(length);
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
fs.closeSync(fd);
}
}
export function logTailContains(file, needle, maxBytes = DEFAULT_MAX_LOG_BYTES) {
const compactNeedle = compact(needle);
if (!compactNeedle) {
return false;
}
return compact(readLogTail(file, maxBytes)).includes(compactNeedle);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const [file, needle] = process.argv.slice(2);
if (!file || !needle) {
process.exit(1);
}
try {
process.exit(logTailContains(file, needle) ? 0 : 1);
} catch {
process.exit(1);
}
}

View File

@@ -0,0 +1,299 @@
#!/usr/bin/env bash
set -euo pipefail
trap "" PIPE
export TERM=xterm-256color
source scripts/lib/openclaw-e2e-instance.sh
OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY="${OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY:-0}"
if [ "$OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY" != "1" ]; then
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_FUNCTION_B64:?missing OPENCLAW_TEST_STATE_FUNCTION_B64}"
fi
ONBOARD_FLAGS="${ONBOARD_FLAGS:---flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui}"
if [ -z "${OPENCLAW_ENTRY:-}" ] && [ "$OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY" != "1" ]; then
OPENCLAW_ENTRY="$(openclaw_e2e_resolve_entrypoint)"
fi
export OPENCLAW_ENTRY
ONBOARD_TMP_ROOT="${OPENCLAW_ONBOARD_E2E_TMPDIR:-${TMPDIR:-/tmp}}"
ONBOARD_TMP_ROOT="${ONBOARD_TMP_ROOT%/}"
[ -n "$ONBOARD_TMP_ROOT" ] || ONBOARD_TMP_ROOT="/tmp"
mkdir -p "$ONBOARD_TMP_ROOT"
ONBOARD_TMP_DIR="$(mktemp -d "$ONBOARD_TMP_ROOT/openclaw-onboard.XXXXXX")"
OPENCLAW_E2E_LOG_DIR="$ONBOARD_TMP_DIR/logs"
GATEWAY_LOG_PATH="$ONBOARD_TMP_DIR/gateway-e2e.log"
export OPENCLAW_E2E_LOG_DIR
export GATEWAY_LOG_PATH
mkdir -p "$OPENCLAW_E2E_LOG_DIR"
cleanup_onboard_artifacts() {
openclaw_e2e_stop_process "${GATEWAY_PID:-}"
rm -rf "$ONBOARD_TMP_DIR"
}
if [ "$OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY" != "1" ]; then
trap cleanup_onboard_artifacts EXIT
fi
# Provide a minimal trash shim to avoid noisy "missing trash" logs in containers.
if [ "$OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY" != "1" ]; then
openclaw_e2e_install_trash_shim
fi
send() {
local payload="$1"
local delay="${2:-0.4}"
# Let prompts render before sending keystrokes.
sleep "$delay"
printf "%b" "$payload" >&3 2>/dev/null || true
}
wait_for_log() {
local needle="$1"
local timeout_s="${2:-45}"
local quiet_on_timeout="${3:-false}"
local start_s
start_s="$(date +%s)"
while true; do
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
if grep -a -F -q "$needle" "$WIZARD_LOG_PATH"; then
return 0
fi
if node scripts/e2e/lib/onboard/log-contains.mjs "$WIZARD_LOG_PATH" "$needle"; then
return 0
fi
fi
if [ $(($(date +%s) - start_s)) -ge "$timeout_s" ]; then
if [ "$quiet_on_timeout" = "true" ]; then
return 1
fi
echo "Timeout waiting for log: $needle"
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
tail -n 140 "$WIZARD_LOG_PATH" || true
fi
return 1
fi
sleep 0.2
done
}
start_gateway() {
GATEWAY_PID="$(openclaw_e2e_start_gateway "$OPENCLAW_ENTRY" 18789 "$GATEWAY_LOG_PATH")"
}
wait_for_gateway() {
local wait_attempts
wait_attempts="$(openclaw_e2e_read_positive_int_env OPENCLAW_ONBOARD_GATEWAY_WAIT_ATTEMPTS 20)" || return $?
local wait_interval_s="${OPENCLAW_ONBOARD_GATEWAY_WAIT_INTERVAL_S:-1}"
local saw_listening_log="false"
for _ in $(seq 1 "$wait_attempts"); do
if openclaw_e2e_probe_tcp 127.0.0.1 18789 500 >/dev/null 2>&1; then
return 0
fi
if [ -f "$GATEWAY_LOG_PATH" ] && grep -E -q "listening on ws://[^ ]+:18789" "$GATEWAY_LOG_PATH"; then
saw_listening_log="true"
fi
sleep "$wait_interval_s"
done
echo "Gateway failed to start"
if [ "$saw_listening_log" = "true" ]; then
echo "Gateway log reported listening, but TCP probe never succeeded"
fi
cat "$GATEWAY_LOG_PATH" || true
return 1
}
stop_gateway() {
openclaw_e2e_stop_process "$1"
}
cleanup_wizard_case() {
exec 3>&- 2>/dev/null || true
openclaw_e2e_stop_process "${wizard_pid:-}"
stop_gateway "${gw_pid:-}"
rm -rf "${input_fifo_dir:-}"
}
run_wizard_cmd() {
local case_name="$1"
local state_ref="$2"
local command="$3"
local send_fn="$4"
local with_gateway="${5:-false}"
local validate_fn="${6:-}"
local input_fifo_dir=""
local input_fifo=""
local wizard_pid=""
local gw_pid=""
local wizard_status=0
echo "== Wizard case: $case_name =="
set_isolated_openclaw_env "$state_ref"
input_fifo_dir="$(mktemp -d "$ONBOARD_TMP_DIR/${case_name}.fifo.XXXXXX")"
input_fifo="$input_fifo_dir/stdin.fifo"
if ! mkfifo "$input_fifo"; then
rm -rf "$input_fifo_dir"
return 1
fi
local log_path="$OPENCLAW_E2E_LOG_DIR/${case_name}.log"
WIZARD_LOG_PATH="$log_path"
export WIZARD_LOG_PATH
# Run under script to keep an interactive TTY for clack prompts.
openclaw_e2e_run_script_with_pty "$command" "$log_path" <"$input_fifo" >/dev/null 2>&1 &
wizard_pid=$!
if ! exec 3>"$input_fifo"; then
cleanup_wizard_case
return 1
fi
if [ "$with_gateway" = "true" ]; then
start_gateway
gw_pid="$GATEWAY_PID"
if ! wait_for_gateway; then
cleanup_wizard_case
exit 1
fi
fi
"$send_fn" || wizard_status=$?
if [ "$wizard_status" -ne 0 ]; then
cleanup_wizard_case
echo "Wizard input driver exited with status $wizard_status"
if [ -f "$log_path" ]; then
tail -n 160 "$log_path" || true
fi
exit "$wizard_status"
fi
wait "$wizard_pid" || wizard_status=$?
wizard_pid=""
if [ "$wizard_status" -ne 0 ]; then
cleanup_wizard_case
echo "Wizard exited with status $wizard_status"
if [ -f "$log_path" ]; then
tail -n 160 "$log_path" || true
fi
exit "$wizard_status"
fi
cleanup_wizard_case
if [ -n "$validate_fn" ]; then
"$validate_fn" "$log_path"
fi
}
assert_onboard_config() {
local scenario="$1"
shift
openclaw_e2e_assert_file "$OPENCLAW_CONFIG_PATH"
node scripts/e2e/lib/onboard/assert-config.mjs "$scenario" "$OPENCLAW_CONFIG_PATH" "$@"
}
set_isolated_openclaw_env() {
local state_ref="$1"
openclaw_test_state_create "$state_ref" empty
}
send_channels_flow() {
# Configure channels via configure wizard. Use the remove-config branch for
# a stable no-op smoke path when the config starts empty.
# Section-scoped configure flows skip gateway run-mode selection.
wait_for_log "Channel setup" 120
send $'\e[B\r' 0.8
# Keep stdin open until wizard exits.
send "" 2.0
}
send_skills_flow() {
# configure --section skills still runs the configure wizard, without the
# gateway run-mode prompt used by the full wizard.
wait_for_log "Configure skills now?" 120
send $'n\r' 0.8
send "" 2.0
}
run_case_local_basic() {
set_isolated_openclaw_env local-basic
openclaw_e2e_run_logged local-basic node "$OPENCLAW_ENTRY" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--skip-channels \
--skip-skills \
--skip-daemon \
--skip-ui \
--skip-health
validate_local_basic_log "$OPENCLAW_E2E_LAST_LOG_PATH"
# Assert config + workspace scaffolding.
workspace_dir="$OPENCLAW_STATE_DIR/workspace"
sessions_dir="$OPENCLAW_STATE_DIR/agents/main/sessions"
openclaw_e2e_assert_dir "$sessions_dir"
for file in AGENTS.md BOOTSTRAP.md IDENTITY.md SOUL.md TOOLS.md USER.md; do
openclaw_e2e_assert_file "$workspace_dir/$file"
done
assert_onboard_config local-basic "$workspace_dir"
}
run_case_remote_non_interactive() {
set_isolated_openclaw_env remote-non-interactive
# Smoke test non-interactive remote config write.
openclaw_e2e_run_logged remote-non-interactive node "$OPENCLAW_ENTRY" onboard --non-interactive --accept-risk \
--mode remote \
--remote-url ws://gateway.local:18789 \
--remote-token remote-token \
--skip-skills \
--skip-health
assert_onboard_config remote-non-interactive
}
run_case_reset() {
set_isolated_openclaw_env reset-config
node scripts/e2e/lib/onboard/write-config.mjs reset "$OPENCLAW_CONFIG_PATH"
openclaw_e2e_run_logged reset-config node "$OPENCLAW_ENTRY" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--reset \
--skip-channels \
--skip-skills \
--skip-daemon \
--skip-ui \
--skip-health
assert_onboard_config reset
}
run_case_channels() {
# Channels-only configure flow.
run_wizard_cmd channels channels "node \"$OPENCLAW_ENTRY\" configure --section channels" send_channels_flow
assert_onboard_config channels
}
run_case_skills() {
local home_dir
set_isolated_openclaw_env skills
home_dir="$HOME"
node scripts/e2e/lib/onboard/write-config.mjs skills "$OPENCLAW_CONFIG_PATH"
run_wizard_cmd skills "$home_dir" "node \"$OPENCLAW_ENTRY\" configure --section skills" send_skills_flow
assert_onboard_config skills
}
validate_local_basic_log() {
local log_path="$1"
openclaw_e2e_assert_log_not_contains "$log_path" "systemctl --user unavailable"
}
if [ "$OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY" != "1" ]; then
run_case_local_basic
run_case_remote_non_interactive
run_case_reset
run_case_channels
run_case_skills
fi

View File

@@ -0,0 +1,21 @@
// Config writer helper for onboard E2E scenarios.
import fs from "node:fs";
const [scenario, configPath] = process.argv.slice(2);
if (!scenario || !configPath) {
throw new Error("usage: write-config.mjs <reset|skills> <config-path>");
}
const config = {
reset: {
meta: {},
agents: { defaults: { workspace: "/root/old" } },
gateway: { mode: "remote", remote: { url: "ws://old.example:18789", token: "old-token" } },
},
skills: { meta: {}, skills: { allowBundled: ["__none__"], install: { nodeManager: "bun" } } },
}[scenario];
if (!config) {
throw new Error(`unknown config scenario: ${scenario}`);
}
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);

View File

@@ -0,0 +1,205 @@
// Gateway client for OpenAI chat tools E2E scenarios.
import { readPositiveIntEnv, readTcpPortEnv } from "../env-limits.mjs";
const portText = process.env.PORT;
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
const backendModel = process.env.MODEL_REF || "openai/gpt-5.4-mini";
const timeoutSeconds = readPositiveIntEnv("OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS", 180);
const maxBodyBytes = readPositiveIntEnv("OPENCLAW_OPENAI_CHAT_TOOLS_MAX_BODY_BYTES", 1048576);
if (!portText || !token) {
throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN");
}
const port = readTcpPortEnv("PORT", portText);
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
throw new Error(`invalid OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS: ${timeoutSeconds}`);
}
if (!Number.isFinite(maxBodyBytes) || maxBodyBytes <= 0) {
throw new Error(`invalid OPENCLAW_OPENAI_CHAT_TOOLS_MAX_BODY_BYTES: ${maxBodyBytes}`);
}
function cancelReaderSoon(reader) {
void Promise.resolve()
.then(() => reader.cancel())
.catch(() => undefined);
}
async function readResponseChunk(reader, timeoutPromise, markCanceled) {
const readPromise = reader.read();
if (!timeoutPromise) {
return await readPromise;
}
let waitingForRead = true;
const timeoutReadPromise = timeoutPromise.catch((error) => {
if (waitingForRead) {
markCanceled();
cancelReaderSoon(reader);
}
throw error;
});
try {
return await Promise.race([readPromise, timeoutReadPromise]);
} finally {
waitingForRead = false;
}
}
async function readBoundedResponseText(response, byteLimit, timeoutPromise) {
const contentLength = response.headers?.get?.("content-length");
if (contentLength && /^\d+$/u.test(contentLength)) {
const parsedContentLength = Number(contentLength);
if (!Number.isSafeInteger(parsedContentLength) || parsedContentLength > byteLimit) {
await response.body?.cancel().catch(() => undefined);
throw new Error(`chat completions response body exceeded ${byteLimit} bytes`);
}
}
const reader = response.body?.getReader();
if (!reader) {
return "";
}
const chunks = [];
let totalBytes = 0;
let canceled = false;
try {
for (;;) {
const { done, value } = await readResponseChunk(reader, timeoutPromise, () => {
canceled = true;
});
if (done) {
break;
}
totalBytes += value.byteLength;
if (totalBytes > byteLimit) {
canceled = true;
await reader.cancel();
throw new Error(`chat completions response body exceeded ${byteLimit} bytes`);
}
chunks.push(Buffer.from(value));
}
} finally {
if (!canceled) {
reader.releaseLock();
}
}
return Buffer.concat(chunks, totalBytes).toString("utf8");
}
const controller = new AbortController();
const timeoutError = new Error(`chat completions request timed out after ${timeoutSeconds}s`);
let timeout;
const timeoutPromise = new Promise((_, reject) => {
timeout = setTimeout(() => {
controller.abort(timeoutError);
reject(timeoutError);
}, timeoutSeconds * 1000);
timeout.unref?.();
});
const started = Date.now();
let response;
let text;
try {
response = await Promise.race([
fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
"x-openclaw-model": backendModel,
},
body: JSON.stringify({
model: "openclaw",
stream: false,
messages: [
{
role: "user",
content:
"Use the get_weather tool exactly once for Paris, France. Return the tool call only.",
},
],
tool_choice: "auto",
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Return weather for a city.",
strict: true,
parameters: {
type: "object",
additionalProperties: false,
properties: {
city: { type: "string", description: "City and country." },
},
required: ["city"],
},
},
},
],
}),
signal: controller.signal,
}),
timeoutPromise,
]);
text = await readBoundedResponseText(response, maxBodyBytes, timeoutPromise);
} finally {
clearTimeout(timeout);
}
let body;
try {
body = text ? JSON.parse(text) : {};
} catch {
throw new Error(`non-JSON response ${response.status}: ${text}`);
}
if (!response.ok) {
throw new Error(`chat completions request failed ${response.status}: ${JSON.stringify(body)}`);
}
const choice = body.choices?.[0];
const toolCalls = choice?.message?.tool_calls;
if (choice?.finish_reason !== "tool_calls") {
throw new Error(`expected finish_reason tool_calls: ${JSON.stringify(body)}`);
}
const messageContent = choice?.message?.content;
const hasVisibleContent =
(typeof messageContent === "string" && messageContent.trim().length > 0) ||
(Array.isArray(messageContent) && messageContent.length > 0) ||
(messageContent !== undefined &&
messageContent !== null &&
typeof messageContent !== "string" &&
!Array.isArray(messageContent));
if (hasVisibleContent) {
throw new Error(`expected tool call only response: ${JSON.stringify(choice.message)}`);
}
if (!Array.isArray(toolCalls) || toolCalls.length !== 1) {
throw new Error(`expected exactly one tool call: ${JSON.stringify(body)}`);
}
const [toolCall] = toolCalls;
if (toolCall?.type !== "function" || toolCall?.function?.name !== "get_weather") {
throw new Error(`unexpected tool call: ${JSON.stringify(toolCall)}`);
}
let args;
try {
args = JSON.parse(toolCall.function.arguments || "{}");
} catch {
throw new Error(`tool arguments were not valid JSON: ${toolCall.function.arguments}`);
}
if (typeof args.city !== "string" || !/paris/i.test(args.city)) {
throw new Error(`expected Paris city argument: ${JSON.stringify(args)}`);
}
console.log(
JSON.stringify({
ok: true,
elapsedMs: Date.now() - started,
finishReason: choice.finish_reason,
toolName: toolCall.function.name,
args,
}),
);

View File

@@ -0,0 +1,86 @@
#!/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
export OPENCLAW_AGENT_HARNESS_FALLBACK=none
for profile_path in "$HOME/.profile" /home/appuser/.profile; do
if [ -f "$profile_path" ] && [ -r "$profile_path" ]; then
set +e +u
# shellcheck disable=SC1090
source "$profile_path"
set -euo pipefail
break
fi
done
if [ -z "${OPENAI_API_KEY:-}" ]; then
echo "ERROR: OPENAI_API_KEY was not available after sourcing ~/.profile." >&2
exit 1
fi
export OPENAI_API_KEY
if [ -n "${OPENAI_BASE_URL:-}" ]; then
export OPENAI_BASE_URL
fi
PORT="${PORT:?missing PORT}"
TOKEN="${OPENCLAW_GATEWAY_TOKEN:?missing OPENCLAW_GATEWAY_TOKEN}"
MODEL_REF="${OPENCLAW_OPENAI_CHAT_TOOLS_MODEL:?missing OPENCLAW_OPENAI_CHAT_TOOLS_MODEL}"
GATEWAY_LOG="/tmp/openclaw-openai-chat-tools-gateway.log"
CLIENT_LOG="/tmp/openclaw-openai-chat-tools-client.log"
gateway_pid=""
cleanup() {
openclaw_e2e_stop_process "$gateway_pid"
}
trap cleanup EXIT
dump_debug_logs() {
local status="$1"
echo "OpenAI Chat Completions tools Docker E2E failed with exit code $status" >&2
openclaw_e2e_dump_logs "$GATEWAY_LOG" "$CLIENT_LOG"
if [ -f "$OPENCLAW_CONFIG_PATH" ]; then
echo "--- $OPENCLAW_CONFIG_PATH keys ---" >&2
node -e "const fs=require('fs'); const cfg=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); console.error(JSON.stringify({model:cfg.agents?.defaults?.model, tools:cfg.tools, provider:cfg.models?.providers?.openai && {api:cfg.models.providers.openai.api, baseUrl:cfg.models.providers.openai.baseUrl, agentRuntime:cfg.models.providers.openai.agentRuntime}}, null, 2));" "$OPENCLAW_CONFIG_PATH" || true
fi
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
entry="$(openclaw_e2e_resolve_entrypoint)"
mkdir -p "$OPENCLAW_STATE_DIR" "$OPENCLAW_TEST_WORKSPACE_DIR"
node scripts/e2e/lib/openai-chat-tools/write-config.mjs
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")"
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" MODEL_REF="$MODEL_REF" \
node scripts/e2e/lib/openai-chat-tools/client.mjs >"$CLIENT_LOG" 2>&1
openclaw_e2e_print_log "$CLIENT_LOG"
echo "OpenAI Chat Completions tools Docker E2E passed"

View File

@@ -0,0 +1,90 @@
// Config writer for OpenAI chat tools E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { readPositiveIntEnv, readTcpPortEnv } from "../env-limits.mjs";
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`missing ${name}`);
}
return value;
}
const configPath = requireEnv("OPENCLAW_CONFIG_PATH");
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
const workspaceDir = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR");
const modelRef = requireEnv("OPENCLAW_OPENAI_CHAT_TOOLS_MODEL");
const token = requireEnv("OPENCLAW_GATEWAY_TOKEN");
const timeoutSeconds = readPositiveIntEnv("OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS", 180);
const gatewayPort = readTcpPortEnv("PORT", 18789);
const [providerId, modelId] = modelRef.split("/");
if (providerId !== "openai" || !modelId) {
throw new Error(`OPENCLAW_OPENAI_CHAT_TOOLS_MODEL must be openai/*, got ${modelRef}`);
}
const config = {
gateway: {
port: gatewayPort,
bind: "loopback",
auth: { mode: "token", token },
controlUi: { enabled: false },
http: {
endpoints: {
chatCompletions: { enabled: true },
},
},
},
models: {
mode: "merge",
providers: {
openai: {
api: "openai-responses",
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
baseUrl: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(),
agentRuntime: { id: "openclaw" },
timeoutSeconds,
models: [
{
id: modelId,
name: modelId,
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
contextTokens: 64000,
maxTokens: 512,
},
],
},
},
},
agents: {
defaults: {
model: { primary: modelRef, fallbacks: [] },
models: {
[modelRef]: {
agentRuntime: { id: "openclaw" },
params: { transport: "sse", openaiWsWarmup: false },
},
},
workspace: workspaceDir,
skipBootstrap: true,
timeoutSeconds,
contextTokens: 64000,
},
},
plugins: {
enabled: true,
allow: ["openai"],
entries: { openai: { enabled: true } },
},
skills: { allowBundled: [] },
tools: { allow: ["get_weather"] },
};
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.mkdirSync(workspaceDir, { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
fs.mkdirSync(path.join(stateDir, "logs"), { recursive: true });

View File

@@ -0,0 +1,140 @@
// Assertions for minimal OpenAI web-search E2E scenarios.
import fs from "node:fs";
import { readTextFileTail, tailText } from "../text-file-utils.mjs";
const command = process.argv[2];
const ERROR_DETAIL_TAIL_BYTES = 64 * 1024;
const REQUEST_LOG_SCAN_CHUNK_BYTES = 64 * 1024;
const RESPONSE_PREVIEW_BYTES = 8 * 1024;
const RESPONSE_PREVIEW_COUNT = 5;
function scanTextFileLines(file, onLine) {
const fd = fs.openSync(file, "r");
try {
const buffer = Buffer.alloc(REQUEST_LOG_SCAN_CHUNK_BYTES);
let carry = "";
let lineNumber = 1;
while (true) {
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
if (bytesRead <= 0) {
break;
}
const text = carry + buffer.subarray(0, bytesRead).toString("utf8");
const lines = text.split(/\r?\n/u);
carry = lines.pop() ?? "";
for (const line of lines) {
onLine(line, lineNumber);
lineNumber += 1;
}
}
if (carry.length > 0) {
onLine(carry, lineNumber);
}
} finally {
fs.closeSync(fd);
}
}
function scanSuccessRequest(logPath) {
let responseCount = 0;
let success;
const recentResponses = [];
scanTextFileLines(logPath, (line, lineNumber) => {
const trimmed = line.trim();
if (!trimmed) {
return;
}
const entry = JSON.parse(trimmed);
if (entry.method !== "POST" || entry.path !== "/v1/responses") {
return;
}
responseCount += 1;
const bodyText = JSON.stringify(entry.body);
if (recentResponses.length >= RESPONSE_PREVIEW_COUNT) {
recentResponses.shift();
}
recentResponses.push({
line: lineNumber,
bodyTail: tailText(bodyText, RESPONSE_PREVIEW_BYTES),
});
if (!success && bodyText.includes("OPENCLAW_SCHEMA_E2E_OK")) {
success = entry;
}
});
return { responseCount, success, recentResponses };
}
function assertPatchBehavior() {
return import("../../../../dist/extensions/openai/native-web-search.js").then(
({ patchOpenAINativeWebSearchPayload }) => {
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)}`,
);
}
},
);
}
function assertSuccessRequest() {
const logPath = process.argv[3];
const { responseCount, success, recentResponses } = scanSuccessRequest(logPath);
if (responseCount < 1) {
throw new Error(
`mock OpenAI /v1/responses was not used. Request log tail: ${readTextFileTail(logPath, ERROR_DETAIL_TAIL_BYTES)}`,
);
}
if (!success) {
throw new Error(
`missing success request. Recent /v1/responses: ${JSON.stringify(recentResponses)}`,
);
}
const tools = Array.isArray(success.body.tools) ? success.body.tools : [];
const hasNativeWebSearch = tools.some((tool) => tool?.type === "web_search");
if (!hasNativeWebSearch) {
throw new Error(
`success request did not include native 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)}`,
);
}
}
const commands = {
"assert-patch-behavior": assertPatchBehavior,
"assert-success-request": assertSuccessRequest,
};
const fn = commands[command];
if (!fn) {
throw new Error(`unknown OpenAI web-search minimal assertion command: ${command}`);
}
await fn();

View File

@@ -0,0 +1,201 @@
// Client script for minimal OpenAI web-search E2E scenarios.
import { readdirSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { readTcpPortEnv } from "../env-limits.mjs";
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 DEFAULT_RAW_SCHEMA_ERROR =
"400 The following tools cannot be used with reasoning.effort 'minimal': web_search.";
const DEFAULT_GATEWAY_SCHEMA_ERROR = "provider rejected the request schema or tool payload";
const SUCCESS_MARKER = "OPENCLAW_SCHEMA_E2E_OK";
function readExpectedRawSchemaError() {
return process.env.RAW_SCHEMA_ERROR?.trim() || DEFAULT_RAW_SCHEMA_ERROR;
}
function resolveGatewayPort(env = process.env) {
const portText = env.PORT;
if (!portText) {
throw new Error("missing PORT");
}
return readTcpPortEnv("PORT", portText, env);
}
async function gatewayAgent(params) {
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
if (!token) {
throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN");
}
const port = resolveGatewayPort();
try {
const callGateway = await loadCallGateway();
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) };
}
}
function stringifyError(value) {
return value instanceof Error ? value.message || String(value) : String(value);
}
function validateRejectResult(result, expectedRawSchemaError = readExpectedRawSchemaError()) {
if (result.ok) {
throw new Error(`reject mode unexpectedly completed: ${JSON.stringify(result.value)}`);
}
const errorText = stringifyError(result.error);
if (
!errorText.includes(expectedRawSchemaError) &&
!errorText.includes(DEFAULT_GATEWAY_SCHEMA_ERROR)
) {
throw new Error(
`reject mode failed for an unexpected reason; expected ${JSON.stringify(
expectedRawSchemaError,
)} or ${JSON.stringify(DEFAULT_GATEWAY_SCHEMA_ERROR)} in ${JSON.stringify(errorText)}`,
);
}
return errorText;
}
function pushStringText(texts, value) {
if (typeof value === "string" && value.trim().length > 0) {
texts.push(value);
}
}
function pushContentText(texts, content) {
if (typeof content === "string") {
pushStringText(texts, content);
return;
}
if (!Array.isArray(content)) {
return;
}
for (const item of content) {
if (typeof item === "string") {
pushStringText(texts, item);
} else if (item && typeof item === "object") {
pushStringText(texts, item.text);
}
}
}
function extractSuccessReplyTexts(value) {
const texts = [];
pushSuccessReplyTexts(texts, value);
pushSuccessReplyTexts(texts, value?.result);
return texts;
}
function pushSuccessReplyTexts(texts, value) {
pushStringText(texts, value?.finalAssistantVisibleText);
pushStringText(texts, value?.meta?.finalAssistantVisibleText);
pushContentText(texts, value?.message?.content);
for (const payload of Array.isArray(value?.payloads) ? value.payloads : []) {
if (payload?.isError === true) {
continue;
}
pushStringText(texts, payload?.text);
pushContentText(texts, payload?.content);
}
}
function validateSuccessResult(result, marker = SUCCESS_MARKER) {
if (result.value?.status !== "ok") {
throw new Error(`agent run did not complete successfully: ${JSON.stringify(result.value)}`);
}
const replyTexts = extractSuccessReplyTexts(result.value);
if (!replyTexts.some((text) => text.includes(marker))) {
throw new Error(
`agent run completed without success marker ${JSON.stringify(marker)} in final reply: ${JSON.stringify(
result.value,
)}`,
);
}
}
async function main() {
const mode = process.argv[2];
const sessionKey = `agent:main:openai-web-search-minimal:${mode}`;
const message = mode === "reject" ? "FORCE_SCHEMA_REJECT" : `Return exactly ${SUCCESS_MARKER}.`;
const id = mode === "reject" ? "schema-reject" : "schema-success";
const result = await gatewayAgent({
sessionKey,
message,
thinking: "minimal",
deliver: false,
timeout: 180,
idempotencyKey: id,
});
if (mode === "reject") {
console.error(validateRejectResult(result));
return;
}
if (!result.ok) {
throw toLintErrorObject(result.error, "Non-Error thrown");
}
validateSuccessResult(result);
}
function toLintErrorObject(value, fallbackMessage) {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
}
}
export const testing = {
DEFAULT_GATEWAY_SCHEMA_ERROR,
DEFAULT_RAW_SCHEMA_ERROR,
SUCCESS_MARKER,
extractSuccessReplyTexts,
resolveGatewayPort,
validateSuccessResult,
validateRejectResult,
};

View File

@@ -0,0 +1,166 @@
// Mock server for minimal OpenAI web-search E2E scenarios.
import http from "node:http";
import { readTcpPortEnv } from "../env-limits.mjs";
import {
boundedRequestLogBody,
isRequestBodyTooLargeError,
readBody,
writeRequestLogEntryOrFail,
writeJson,
writeSse,
} from "../mock-openai-http.mjs";
const port = readTcpPortEnv("MOCK_PORT");
const requestLog = process.env.MOCK_REQUEST_LOG;
const successMarker = process.env.SUCCESS_MARKER;
const rawSchemaError = process.env.RAW_SCHEMA_ERROR;
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 },
},
},
},
];
}
const server = http.createServer((req, res) => {
void (async () => {
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;
}
let bodyText;
try {
bodyText = await readBody(req);
} catch (error) {
if (isRequestBodyTooLargeError(error)) {
writeJson(res, 413, { error: { message: error.message } });
return;
}
throw error;
}
let body;
try {
body = bodyText ? JSON.parse(bodyText) : {};
} catch {
body = {};
}
if (
writeRequestLogEntryOrFail(res, {
requestLog,
required: true,
label: "mock-openai-web-search",
entry: {
method: req.method,
path: url.pathname,
body: boundedRequestLogBody(body, bodyText),
},
})
) {
return;
}
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}` },
});
})().catch((/** @type {unknown} */ error) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`mock-openai-web-search request handler failed: ${message}`);
if (!res.headersSent) {
writeJson(res, 500, { error: { message: `mock OpenAI handler failed: ${message}` } });
return;
}
res.destroy(error instanceof Error ? error : new Error(message));
});
});
server.listen(port, "127.0.0.1", () => {
console.log(`mock-openai listening on ${port}`);
});

View File

@@ -0,0 +1,91 @@
#!/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."
scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-openai-web-search-minimal.XXXXXX")"
MOCK_REQUEST_LOG="$scenario_tmp/requests.jsonl"
GATEWAY_LOG="$scenario_tmp/gateway.log"
MOCK_LOG="$scenario_tmp/mock.log"
CLIENT_SUCCESS_LOG="$scenario_tmp/client-success.log"
CLIENT_REJECT_LOG="$scenario_tmp/client-reject.log"
mock_pid=""
gateway_pid=""
cleanup() {
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
openclaw_e2e_stop_process "${mock_pid:-}"
rm -rf "$scenario_tmp"
}
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" \
"$MOCK_LOG" \
"$CLIENT_SUCCESS_LOG" \
"$CLIENT_REJECT_LOG" \
"$MOCK_REQUEST_LOG" \
"$OPENCLAW_STATE_DIR/openclaw.json"; do
if [ -f "$file" ]; then
echo "--- $file ---" >&2
openclaw_e2e_print_log "$file" >&2
fi
done
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
entry="$(openclaw_e2e_resolve_entrypoint)"
mkdir -p "$OPENCLAW_STATE_DIR"
node scripts/e2e/lib/openai-web-search-minimal/assertions.mjs assert-patch-behavior
node scripts/e2e/lib/fixture.mjs openai-web-search-minimal-config
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 >"$MOCK_LOG" 2>&1 &
mock_pid="$!"
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")"
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$GATEWAY_LOG" 360 "$PORT"
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 >"$CLIENT_SUCCESS_LOG" 2>&1
node scripts/e2e/lib/openai-web-search-minimal/assertions.mjs assert-success-request "$MOCK_REQUEST_LOG"
PORT="$PORT" OPENCLAW_GATEWAY_TOKEN="$TOKEN" node scripts/e2e/lib/openai-web-search-minimal/client.mjs reject >"$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,63 @@
// HTTP probe for OpenWebUI E2E scenarios.
import { pathToFileURL } from "node:url";
import { readPositiveIntEnv } from "../env-limits.mjs";
const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
function parseExpectedStatus(raw) {
if (!/^[1-5]\d\d$/u.test(raw)) {
throw new Error(`expected status must be lt500 or a decimal HTTP status. Got: ${raw}`);
}
return Number(raw);
}
function resolveTimerTimeoutMs(valueMs, fallbackMs) {
const value = Number.isFinite(valueMs) ? valueMs : fallbackMs;
return Math.min(Math.max(Math.floor(value), 1), MAX_TIMER_TIMEOUT_MS);
}
export async function probeHttpStatus({
url,
expectedRaw = "200",
timeoutMs = 30_000,
bearer = "",
fetchImpl = fetch,
}) {
if (!url) {
throw new Error("usage: http-probe.mjs <url> [status|lt500]");
}
const expectedStatus = expectedRaw === "lt500" ? undefined : parseExpectedStatus(expectedRaw);
const resolvedTimeoutMs = resolveTimerTimeoutMs(timeoutMs, 30_000);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), resolvedTimeoutMs);
let res;
const headers = {};
if (bearer) {
headers.authorization = `Bearer ${bearer}`;
}
try {
res = await fetchImpl(url, { headers, signal: controller.signal }).catch(() => null);
return expectedRaw === "lt500"
? Boolean(res && res.status < 500)
: res?.status === expectedStatus;
} finally {
clearTimeout(timer);
await res?.body?.cancel?.().catch(() => undefined);
}
}
async function main() {
const [url, expectedRaw = "200"] = process.argv.slice(2);
const ok = await probeHttpStatus({
url,
expectedRaw,
timeoutMs: readPositiveIntEnv("OPENCLAW_HTTP_PROBE_TIMEOUT_MS", 30_000),
bearer: process.env.OPENCLAW_HTTP_PROBE_BEARER,
});
process.exit(ok ? 0 : 1);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

View File

@@ -0,0 +1,12 @@
// Package-version compatibility helpers for E2E acceptance scripts.
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

@@ -0,0 +1,104 @@
#!/usr/bin/env bash
parallels_macos_resolve_desktop_user() {
local vm_name="$1"
local user
user="$(prlctl exec "$vm_name" /usr/bin/stat -f '%Su' /dev/console 2>/dev/null | tr -d '\r' | tail -n 1 || true)"
if [[ "$user" =~ ^[A-Za-z0-9._-]+$ && "$user" != "root" && "$user" != "loginwindow" ]]; then
printf '%s\n' "$user"
return 0
fi
prlctl exec "$vm_name" /usr/bin/dscl . -list /Users NFSHomeDirectory 2>/dev/null \
| tr -d '\r' \
| awk '$2 ~ /^\/Users\// && $1 !~ /^_/ && $1 != "Shared" && $1 != ".localized" { print $1; exit }'
}
parallels_macos_resolve_desktop_home() {
local vm_name="$1"
local user="$2"
local home
home="$(
prlctl exec "$vm_name" /usr/bin/dscl . -read "/Users/$user" NFSHomeDirectory 2>/dev/null \
| tr -d '\r' \
| awk '/NFSHomeDirectory:/ { print $2; exit }'
)"
if [[ -n "$home" ]]; then
printf '%s\n' "$home"
else
printf '/Users/%s\n' "$user"
fi
}
parallels_macos_current_user_available() {
local vm_name="$1"
prlctl exec "$vm_name" --current-user /usr/bin/whoami >/dev/null 2>&1
}
parallels_macos_desktop_user_exec_with_secret_file() {
local vm_name="$1"
local user_flag="$2"
local user_name="$3"
local home="$4"
local path_value="$5"
local api_key_env="$6"
local api_key_value="$7"
shift 7
local secret_path
secret_path="/tmp/openclaw-secret-${api_key_env:-env}-$RANDOM-$RANDOM"
if [[ -n "$api_key_env" && -n "$api_key_value" ]]; then
if [[ "$user_flag" == "current-user" ]]; then
printf '%s' "$api_key_value" | /usr/bin/base64 | prlctl exec "$vm_name" \
--current-user /usr/bin/base64 -D -o "$secret_path"
else
printf '%s' "$api_key_value" | /usr/bin/base64 | prlctl exec "$vm_name" \
/usr/bin/sudo -H -u "$user_name" /usr/bin/base64 -D -o "$secret_path"
fi
fi
local wrapper
local wrapper_path
wrapper_path="/tmp/openclaw-secret-env-wrapper-$RANDOM-$RANDOM.sh"
wrapper='#!/bin/bash
set -e
cleanup() {
rm -f "${OPENCLAW_WRAPPER_FILE:-}"
}
trap cleanup EXIT
if [ -n "${OPENCLAW_SECRET_ENV_NAME:-}" ] && [ -n "${OPENCLAW_SECRET_FILE:-}" ] && [ -f "$OPENCLAW_SECRET_FILE" ]; then
secret_value="$(cat "$OPENCLAW_SECRET_FILE")"
rm -f "$OPENCLAW_SECRET_FILE"
export "${OPENCLAW_SECRET_ENV_NAME}=${secret_value}"
fi
"$@"
'
if [[ "$user_flag" == "current-user" ]]; then
printf '%s' "$wrapper" | /usr/bin/base64 | prlctl exec "$vm_name" \
--current-user /usr/bin/base64 -D -o "$wrapper_path"
else
printf '%s' "$wrapper" | /usr/bin/base64 | prlctl exec "$vm_name" \
/usr/bin/sudo -H -u "$user_name" /usr/bin/base64 -D -o "$wrapper_path"
fi
if [[ "$user_flag" == "current-user" ]]; then
prlctl exec "$vm_name" --current-user /usr/bin/env \
"PATH=$path_value" \
"OPENCLAW_SECRET_ENV_NAME=$api_key_env" \
"OPENCLAW_SECRET_FILE=$secret_path" \
"OPENCLAW_WRAPPER_FILE=$wrapper_path" \
/bin/bash "$wrapper_path" "$@"
return
fi
prlctl exec "$vm_name" /usr/bin/sudo -H -u "$user_name" /usr/bin/env \
"HOME=$home" \
"USER=$user_name" \
"LOGNAME=$user_name" \
"PATH=$path_value" \
"OPENCLAW_SECRET_ENV_NAME=$api_key_env" \
"OPENCLAW_SECRET_FILE=$secret_path" \
"OPENCLAW_WRAPPER_FILE=$wrapper_path" \
/bin/bash "$wrapper_path" "$@"
}

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
parallels_package_acquire_build_lock() {
local lock_dir="$1"
local owner_pid=""
while ! mkdir "$lock_dir" 2>/dev/null; do
if [[ -f "$lock_dir/pid" ]]; then
owner_pid="$(cat "$lock_dir/pid" 2>/dev/null || true)"
if [[ -n "$owner_pid" ]] && ! kill -0 "$owner_pid" >/dev/null 2>&1; then
printf 'warn: Removing stale Parallels build lock\n' >&2
rm -rf "$lock_dir"
continue
fi
fi
sleep 1
done
printf '%s\n' "$$" >"$lock_dir/pid"
}
parallels_package_release_build_lock() {
local lock_dir="$1"
if [[ -d "$lock_dir" ]]; then
rm -rf "$lock_dir"
fi
}

View File

@@ -0,0 +1,10 @@
// Validates build-info commit metadata for Parallels package E2E scenarios.
import fs from "node:fs";
const path = "dist/build-info.json";
if (!fs.existsSync(path)) {
console.log("");
} else {
const buildInfo = JSON.parse(fs.readFileSync(path, "utf8"));
console.log(buildInfo.commit ?? "");
}

View File

@@ -0,0 +1,22 @@
// Extracts progress markers from Parallels package E2E logs.
import fs from "node:fs";
import { readTextFileTail } from "../text-file-utils.mjs";
const LOG_PROGRESS_TAIL_BYTES = 256 * 1024;
const [logPath] = process.argv.slice(2);
if (!logPath || !fs.existsSync(logPath)) {
console.log("");
process.exit(0);
}
const text = readTextFileTail(logPath, LOG_PROGRESS_TAIL_BYTES);
const lines = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const reversed = lines.toReversed();
const progress = reversed.find((line) => line.startsWith("==> "));
const warning = reversed.find((line) => line.startsWith("warn:") || line.startsWith("error:"));
console.log(progress?.slice(4).trim() ?? warning ?? lines.at(-1)?.slice(0, 240) ?? "");

View File

@@ -0,0 +1,227 @@
// SQLite readers for plugin install indexes produced during E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { readPositiveIntEnv } from "./env-limits.mjs";
import { readTextFileBounded } from "./text-file-utils.mjs";
const INDEX_KEY = "installed-plugin-index";
const ERROR_DETAIL_TAIL_BYTES = 16 * 1024;
const JSON_ARTIFACT_MAX_BYTES = readPositiveIntEnv(
"OPENCLAW_PLUGIN_INDEX_JSON_MAX_BYTES",
1024 * 1024,
);
export function stateDir() {
return process.env.OPENCLAW_STATE_DIR || path.join(process.env.HOME, ".openclaw");
}
export function configPath() {
return process.env.OPENCLAW_CONFIG_PATH || path.join(stateDir(), "openclaw.json");
}
function readJsonMaybe(file) {
let text;
try {
text = readTextFileBounded(file, "plugin index JSON artifact", JSON_ARTIFACT_MAX_BYTES, {
tailBytes: ERROR_DETAIL_TAIL_BYTES,
});
} catch (error) {
if (error?.code === "ETOOBIG") {
throw error;
}
return {};
}
try {
return JSON.parse(text);
} catch {
return {};
}
}
function textTooLargeError(message) {
return Object.assign(new Error(message), { code: "ETOOBIG" });
}
function parseIndexJsonText(text, label) {
const bytes = Buffer.byteLength(text, "utf8");
if (bytes > JSON_ARTIFACT_MAX_BYTES) {
throw textTooLargeError(`${label} exceeded ${JSON_ARTIFACT_MAX_BYTES} bytes (${bytes} bytes)`);
}
return JSON.parse(text);
}
function assertIndexJsonByteLength(bytesRaw, label) {
const bytes = Number(bytesRaw);
if (!Number.isFinite(bytes) || bytes < 0) {
throw new Error(`${label} byte length was invalid: ${String(bytesRaw)}`);
}
if (bytes > JSON_ARTIFACT_MAX_BYTES) {
throw textTooLargeError(`${label} exceeded ${JSON_ARTIFACT_MAX_BYTES} bytes (${bytes} bytes)`);
}
}
function sqlitePath(root = stateDir()) {
return path.join(root, "state", "openclaw.sqlite");
}
function legacyIndexPath(root = stateDir()) {
return path.join(root, "plugins", "installs.json");
}
function readSqlitePluginIndex(root = stateDir()) {
const dbPath = sqlitePath(root);
if (!fs.existsSync(dbPath)) {
return {};
}
let db;
try {
db = new DatabaseSync(dbPath, { readOnly: true });
const lengths = db
.prepare(
`
SELECT octet_length(install_records_json) AS install_records_json_bytes,
octet_length(plugins_json) AS plugins_json_bytes,
octet_length(diagnostics_json) AS diagnostics_json_bytes
FROM installed_plugin_index
WHERE index_key = ?
`,
)
.get(INDEX_KEY);
if (!lengths) {
return {};
}
assertIndexJsonByteLength(
lengths.install_records_json_bytes,
"plugin index install_records_json",
);
assertIndexJsonByteLength(lengths.plugins_json_bytes, "plugin index plugins_json");
assertIndexJsonByteLength(lengths.diagnostics_json_bytes, "plugin index diagnostics_json");
const row = db
.prepare(
`
SELECT version, warning, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, refresh_reason,
install_records_json, plugins_json, diagnostics_json
FROM installed_plugin_index
WHERE index_key = ?
`,
)
.get(INDEX_KEY);
if (!row) {
return {};
}
return {
version: Number(row.version),
...(row.warning ? { warning: row.warning } : {}),
hostContractVersion: row.host_contract_version,
compatRegistryVersion: row.compat_registry_version,
migrationVersion: Number(row.migration_version),
policyHash: row.policy_hash,
generatedAtMs: Number(row.generated_at_ms),
...(row.refresh_reason ? { refreshReason: row.refresh_reason } : {}),
installRecords: parseIndexJsonText(
row.install_records_json,
"plugin index install_records_json",
),
plugins: parseIndexJsonText(row.plugins_json, "plugin index plugins_json"),
diagnostics: parseIndexJsonText(row.diagnostics_json, "plugin index diagnostics_json"),
};
} catch (error) {
if (error?.code === "ETOOBIG") {
throw error;
}
return {};
} finally {
db?.close();
}
}
export function readPluginInstallIndex(options = {}) {
const root = options.stateDir ?? stateDir();
const config = readJsonMaybe(options.configPath ?? configPath());
const sqliteIndex = readSqlitePluginIndex(root);
if (sqliteIndex.installRecords) {
return sqliteIndex;
}
const legacyIndex = readJsonMaybe(legacyIndexPath(root));
const installRecords =
legacyIndex.installRecords ??
legacyIndex.records ??
options.fallbackRecords ??
config.plugins?.installs ??
{};
return {
...legacyIndex,
installRecords,
};
}
export function readPluginInstallRecords(options = {}) {
return readPluginInstallIndex(options).installRecords ?? {};
}
export function writePluginInstallIndexForE2E(index, options = {}) {
const root = options.stateDir ?? stateDir();
const dbPath = sqlitePath(root);
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const db = new DatabaseSync(dbPath);
try {
db.exec(`
CREATE TABLE IF NOT EXISTS installed_plugin_index (
index_key TEXT NOT NULL PRIMARY KEY,
version INTEGER NOT NULL,
host_contract_version TEXT NOT NULL,
compat_registry_version TEXT NOT NULL,
migration_version INTEGER NOT NULL,
policy_hash TEXT NOT NULL,
generated_at_ms INTEGER NOT NULL,
refresh_reason TEXT,
install_records_json TEXT NOT NULL,
plugins_json TEXT NOT NULL,
diagnostics_json TEXT NOT NULL,
warning TEXT,
updated_at_ms INTEGER NOT NULL
);
`);
const now = Date.now();
db.prepare(
`
INSERT INTO installed_plugin_index (
index_key, version, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, refresh_reason,
install_records_json, plugins_json, diagnostics_json, warning, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(index_key) DO UPDATE SET
version = excluded.version,
host_contract_version = excluded.host_contract_version,
compat_registry_version = excluded.compat_registry_version,
migration_version = excluded.migration_version,
policy_hash = excluded.policy_hash,
generated_at_ms = excluded.generated_at_ms,
refresh_reason = excluded.refresh_reason,
install_records_json = excluded.install_records_json,
plugins_json = excluded.plugins_json,
diagnostics_json = excluded.diagnostics_json,
warning = excluded.warning,
updated_at_ms = excluded.updated_at_ms
`,
).run(
INDEX_KEY,
index.version ?? 1,
index.hostContractVersion ?? "docker-e2e",
index.compatRegistryVersion ?? "docker-e2e",
index.migrationVersion ?? 1,
index.policyHash ?? "docker-e2e",
index.generatedAtMs ?? now,
index.refreshReason ?? null,
JSON.stringify(index.installRecords ?? {}),
JSON.stringify(index.plugins ?? []),
JSON.stringify(index.diagnostics ?? []),
index.warning ?? "DO NOT EDIT. This row is generated by OpenClaw plugin registry commands.",
now,
);
} finally {
db.close();
}
}

View File

@@ -0,0 +1,388 @@
// Measures plugin lifecycle matrix E2E command timings.
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const [summaryPath, phase, separator, command, ...args] = process.argv.slice(2);
if (!summaryPath || !phase || separator !== "--" || !command) {
console.error("usage: measure.mjs <summary.tsv> <phase> -- <command> [args...]");
process.exit(2);
}
function readPositiveIntEnv(name, fallback) {
const text = String(process.env[name] ?? fallback).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`${name} must be a positive integer; got: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer; got: ${text}`);
}
return value;
}
function readPositiveIntEnvOrGetconf(name, variable) {
if (process.env[name] !== undefined) {
return readPositiveIntEnv(name, "");
}
const result = spawnSync("getconf", [variable], { encoding: "utf8" });
if (result.error || result.status !== 0) {
const details =
result.error?.message || result.stderr.trim() || `exit ${String(result.status)}`;
throw new Error(
`failed to derive ${name} from getconf ${variable}: ${details}; set ${name} explicitly`,
);
}
return readPositiveIntEnv(name, result.stdout);
}
function readPositiveNumberEnv(name, fallback) {
const text = String(process.env[name] ?? fallback).trim();
if (!/^\d+(?:\.\d+)?$/u.test(text)) {
throw new Error(`${name} must be a positive number; got: ${text}`);
}
const value = Number(text);
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`${name} must be a positive number; got: ${text}`);
}
return value;
}
const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
function clampTimerTimeoutMs(valueMs) {
return Math.min(Math.max(Math.floor(valueMs), 1), MAX_TIMER_TIMEOUT_MS);
}
const pollMs = clampTimerTimeoutMs(
readPositiveIntEnv("OPENCLAW_PLUGIN_LIFECYCLE_METRIC_POLL_MS", 100),
);
const timeoutMs = clampTimerTimeoutMs(
readPositiveIntEnv("OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS", 300000),
);
const timeoutKillGraceMs = clampTimerTimeoutMs(
readPositiveIntEnv("OPENCLAW_PLUGIN_LIFECYCLE_TIMEOUT_KILL_GRACE_MS", 2000),
);
const maxRssKbThreshold = readPositiveIntEnv(
"OPENCLAW_PLUGIN_LIFECYCLE_MAX_RSS_KB",
4 * 1024 * 1024,
);
const maxWallMs = readPositiveIntEnv("OPENCLAW_PLUGIN_LIFECYCLE_MAX_WALL_MS", timeoutMs);
const maxCpuCoreRatio = readPositiveNumberEnv("OPENCLAW_PLUGIN_LIFECYCLE_MAX_CPU_CORE_RATIO", 16);
if (!fs.existsSync("/proc")) {
console.error("plugin lifecycle resource sampler requires Linux /proc");
process.exit(2);
}
// /proc RSS is in host pages and CPU times are in host clock ticks. Query the
// live units so 64 KiB ARM kernels do not under-report resource use.
const pageSize = readPositiveIntEnvOrGetconf("OPENCLAW_PROC_PAGE_SIZE", "PAGESIZE");
const clockTicks = readPositiveIntEnvOrGetconf("OPENCLAW_PROC_CLK_TCK", "CLK_TCK");
function readProcSnapshot() {
const stats = new Map();
for (const entry of fs.readdirSync("/proc", { withFileTypes: true })) {
if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) {
continue;
}
const pid = Number.parseInt(entry.name, 10);
const statPath = path.join("/proc", entry.name, "stat");
try {
const raw = fs.readFileSync(statPath, "utf8");
const closeParen = raw.lastIndexOf(")");
if (closeParen === -1) {
continue;
}
const fields = raw
.slice(closeParen + 2)
.trim()
.split(/\s+/u);
const ppid = Number.parseInt(fields[1] ?? "", 10);
const pgrp = Number.parseInt(fields[2] ?? "", 10);
const userTicks = Number.parseInt(fields[11] ?? "", 10);
const systemTicks = Number.parseInt(fields[12] ?? "", 10);
const rssPages = Number.parseInt(fields[21] ?? "", 10);
if (
!Number.isFinite(ppid) ||
!Number.isFinite(pgrp) ||
!Number.isFinite(userTicks) ||
!Number.isFinite(systemTicks) ||
!Number.isFinite(rssPages)
) {
continue;
}
stats.set(pid, {
ppid,
pgrp,
cpuTicks: userTicks + systemTicks,
rssBytes: Math.max(0, rssPages) * pageSize,
});
} catch {
// Processes can exit while /proc is being scanned.
}
}
return stats;
}
function descendantsOf(rootPid, stats) {
const children = new Map();
for (const [pid, stat] of stats.entries()) {
const siblings = children.get(stat.ppid) ?? [];
siblings.push(pid);
children.set(stat.ppid, siblings);
}
const seen = new Set([rootPid]);
const queue = [rootPid];
for (const queuedPid of queue) {
for (const child of children.get(queuedPid) ?? []) {
if (!seen.has(child)) {
seen.add(child);
queue.push(child);
}
}
}
return seen;
}
function sample(rootPid) {
const stats = readProcSnapshot();
const groupPids = new Set(
[...stats.entries()].filter(([, stat]) => stat.pgrp === rootPid).map(([pid]) => pid),
);
const pids = new Set([...descendantsOf(rootPid, stats), ...groupPids]);
let rssBytes = 0;
let cpuTicks = 0;
for (const pid of pids) {
const stat = stats.get(pid);
if (!stat) {
continue;
}
rssBytes += stat.rssBytes;
cpuTicks += stat.cpuTicks;
}
return { rssBytes, cpuTicks };
}
const started = performance.now();
const child = spawn(command, args, {
cwd: process.cwd(),
env: process.env,
detached: true,
stdio: "inherit",
});
let maxRssBytes = 0;
let maxCpuTicks = 0;
let timedOut = false;
let finished = false;
let parentSignalInFlight = false;
let forwardedParentSignal = null;
let killTimer;
let parentSignalTimer;
let parentSignalPollTimer;
let childGroupDrainTimer;
// The leader can exit before descendants in its detached process group.
// Keep the wrapper alive so timeout cleanup still owns those descendants.
let childClosedResult = null;
const updateMetrics = () => {
if (!child.pid) {
return;
}
const current = sample(child.pid);
maxRssBytes = Math.max(maxRssBytes, current.rssBytes);
maxCpuTicks = Math.max(maxCpuTicks, current.cpuTicks);
};
function finishChildClosedResultIfGroupDrained() {
if (childClosedResult && !childGroupExists()) {
finish(childClosedResult.code, childClosedResult.signal);
}
}
updateMetrics();
const interval = setInterval(updateMetrics, pollMs);
const timeoutTimer =
Number.isFinite(timeoutMs) && timeoutMs > 0
? setTimeout(() => {
if (childClosedResult && !childGroupExists()) {
finish(childClosedResult.code, childClosedResult.signal);
return;
}
timedOut = true;
terminateChildGroup("SIGTERM");
killTimer = setTimeout(() => {
terminateChildGroup("SIGKILL");
finish(124);
}, timeoutKillGraceMs);
killTimer.unref?.();
}, timeoutMs)
: null;
timeoutTimer?.unref?.();
function terminateChildGroup(signal) {
if (!child.pid) {
return;
}
try {
process.kill(-child.pid, signal);
return;
} catch {}
try {
child.kill(signal);
} catch {}
}
function childGroupExists() {
if (!child.pid) {
return false;
}
try {
process.kill(-child.pid, 0);
return true;
} catch (error) {
if (error && error.code === "ESRCH") {
return false;
}
return true;
}
}
function clearRuntimeTimers() {
clearInterval(interval);
if (timeoutTimer) {
clearTimeout(timeoutTimer);
}
if (killTimer) {
clearTimeout(killTimer);
}
if (parentSignalTimer) {
clearTimeout(parentSignalTimer);
}
if (parentSignalPollTimer) {
clearInterval(parentSignalPollTimer);
}
if (childGroupDrainTimer) {
clearInterval(childGroupDrainTimer);
}
}
function rethrowParentSignal(signal) {
clearRuntimeTimers();
process.removeAllListeners(signal);
process.kill(process.pid, signal);
process.exit(128);
}
function handleParentSignal(signal) {
if (parentSignalInFlight) {
terminateChildGroup("SIGKILL");
rethrowParentSignal(signal);
return;
}
parentSignalInFlight = true;
if (finished) {
rethrowParentSignal(signal);
return;
}
finished = true;
forwardedParentSignal = signal;
clearRuntimeTimers();
terminateChildGroup(signal);
parentSignalTimer = setTimeout(() => {
terminateChildGroup("SIGKILL");
rethrowParentSignal(signal);
}, timeoutKillGraceMs);
parentSignalPollTimer = setInterval(
() => {
if (!childGroupExists()) {
rethrowParentSignal(signal);
}
},
Math.min(50, timeoutKillGraceMs),
);
}
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
process.once(signal, () => handleParentSignal(signal));
}
process.once("exit", () => {
if (!finished) {
terminateChildGroup("SIGTERM");
}
});
function finish(code, signal) {
if (finished) {
return;
}
finished = true;
updateMetrics();
clearRuntimeTimers();
const wallMs = performance.now() - started;
const cpuSeconds = maxCpuTicks / clockTicks;
const maxRssKb = Math.round(maxRssBytes / 1024);
const cpuCoreRatio = wallMs > 0 ? cpuSeconds / (wallMs / 1000) : 0;
const summarySignal = timedOut ? "timeout" : (signal ?? "");
fs.appendFileSync(
summaryPath,
`${phase}\t${maxRssKb}\t${cpuSeconds.toFixed(3)}\t${wallMs.toFixed(0)}\t${cpuCoreRatio.toFixed(3)}\t${summarySignal}\n`,
);
console.log(
`plugin lifecycle resource: phase=${phase} max_rss_kb=${maxRssKb} cpu_s=${cpuSeconds.toFixed(3)} wall_ms=${wallMs.toFixed(0)} cpu_core_ratio=${cpuCoreRatio.toFixed(3)} signal=${summarySignal}`,
);
const violations = [];
if (maxRssKb > maxRssKbThreshold) {
violations.push(`max_rss_kb=${maxRssKb} > ${maxRssKbThreshold}`);
}
if (wallMs > maxWallMs) {
violations.push(`wall_ms=${wallMs.toFixed(0)} > ${maxWallMs}`);
}
if (cpuCoreRatio > maxCpuCoreRatio) {
violations.push(`cpu_core_ratio=${cpuCoreRatio.toFixed(3)} > ${maxCpuCoreRatio}`);
}
if (violations.length > 0) {
console.error(
`plugin lifecycle resource ceiling exceeded: phase=${phase} ${violations.join("; ")}`,
);
if (!timedOut && !signal && (code ?? 0) === 0) {
process.exit(1);
return;
}
}
if (timedOut) {
process.exit(124);
return;
}
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
}
child.on("error", (error) => {
finished = true;
clearRuntimeTimers();
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
child.on("exit", (code, signal) => {
if (parentSignalInFlight && forwardedParentSignal) {
if (!childGroupExists()) {
rethrowParentSignal(forwardedParentSignal);
}
return;
}
if (timedOut && killTimer) {
return;
}
if (childGroupExists()) {
childClosedResult = { code, signal };
childGroupDrainTimer = setInterval(finishChildClosedResultIfGroupDrained, Math.min(25, pollMs));
return;
}
finish(code, signal);
});

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
source scripts/e2e/lib/plugins/fixtures.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
export npm_config_prefix=/tmp/npm-prefix
export NPM_CONFIG_PREFIX=/tmp/npm-prefix
export PATH="/tmp/npm-prefix/bin:$PATH"
export CI=true
export OPENCLAW_DISABLE_BUNDLED_PLUGINS=1
export OPENCLAW_NO_ONBOARD=1
export OPENCLAW_NO_PROMPT=1
baseline="${OPENCLAW_UPDATE_CORRUPT_PLUGIN_BASELINE:-openclaw@latest}"
update_timeout_seconds="$(openclaw_e2e_read_positive_int_env OPENCLAW_UPDATE_CORRUPT_PLUGIN_TIMEOUT_SECONDS 900)"
echo "Installing baseline OpenClaw package: $baseline"
if ! openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g --prefix /tmp/npm-prefix --omit=optional "$baseline" >/tmp/openclaw-update-corrupt-baseline-install.log 2>&1; then
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-baseline-install.log >&2
exit 1
fi
package_root="$(openclaw_e2e_package_root /tmp/npm-prefix)"
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
export OPENCLAW_ENTRY="$entry"
npm_pack_dir="$(mktemp -d "/tmp/openclaw-corrupt-plugin-pack.XXXXXX")"
npm_registry_dir="$(mktemp -d "/tmp/openclaw-corrupt-plugin-registry.XXXXXX")"
pack_fixture_plugin "$npm_pack_dir" /tmp/demo-corrupt-plugin.tgz demo-corrupt-plugin 0.0.1 demo.corrupt "Demo Corrupt Plugin"
start_npm_fixture_registry "@openclaw/demo-corrupt-plugin" "0.0.1" /tmp/demo-corrupt-plugin.tgz "$npm_registry_dir"
echo "Installing managed external plugin..."
node "$entry" plugins install "npm:@openclaw/demo-corrupt-plugin@0.0.1" >/tmp/openclaw-corrupt-plugin-install.log 2>&1
node "$entry" plugins inspect demo-corrupt-plugin --runtime --json >/tmp/openclaw-corrupt-plugin-before.json
unset NPM_CONFIG_REGISTRY npm_config_registry
plugin_dir="$(
node -e '
const fs = require("node:fs");
const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const installPath = payload.install?.installPath ?? payload.plugin?.rootDir;
if (!installPath) {
throw new Error("missing plugin install path in inspect output");
}
process.stdout.write(installPath);
' /tmp/openclaw-corrupt-plugin-before.json
)"
rm -f "$plugin_dir/package.json"
if [ -f "$plugin_dir/package.json" ]; then
echo "Expected corrupt plugin package.json to be removed before update." >&2
exit 1
fi
echo "Updating OpenClaw with corrupt plugin present..."
set +e
openclaw_e2e_maybe_timeout "${update_timeout_seconds}s" \
node "$entry" update \
--channel beta \
--tag "${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" \
--yes \
--no-restart \
--json \
>/tmp/openclaw-update-corrupt-plugin.json \
2>/tmp/openclaw-update-corrupt-plugin.err
update_status=$?
set -e
if [ "$update_status" -ne 0 ]; then
if ! node scripts/e2e/lib/plugin-update/probe.mjs assert-legacy-post-update-plugin-failure /tmp/openclaw-update-corrupt-plugin.json; then
echo "openclaw update failed or timed out after ${update_timeout_seconds}s with corrupt plugin present" >&2
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin.err >&2
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin.json >&2
exit "$update_status"
fi
echo "Legacy updater reported post-update plugin failure after installing the new core; verifying updated entrypoint..."
set +e
OPENCLAW_UPDATE_POST_CORE=1 \
OPENCLAW_UPDATE_POST_CORE_CHANNEL=beta \
OPENCLAW_UPDATE_POST_CORE_RESULT_PATH=/tmp/openclaw-update-corrupt-plugin-post-core.json \
openclaw_e2e_maybe_timeout "${update_timeout_seconds}s" \
node "$entry" update \
--yes \
--no-restart \
--json \
>/tmp/openclaw-update-corrupt-plugin-post-core.stdout \
2>/tmp/openclaw-update-corrupt-plugin-post-core.err
post_core_status=$?
set -e
if [ "$post_core_status" -ne 0 ]; then
echo "updated OpenClaw entry failed or timed out after ${update_timeout_seconds}s during post-core plugin verification" >&2
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin-post-core.err >&2
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin-post-core.stdout >&2
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin-post-core.json >&2
exit "$post_core_status"
fi
node scripts/e2e/lib/plugin-update/probe.mjs assert-corrupt-plugin-result /tmp/openclaw-update-corrupt-plugin-post-core.json demo-corrupt-plugin
exit 0
fi
if ! node scripts/e2e/lib/plugin-update/probe.mjs assert-corrupt-update /tmp/openclaw-update-corrupt-plugin.json demo-corrupt-plugin; then
echo "corrupt update JSON payload:" >&2
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin.json >&2
echo "corrupt update stderr:" >&2
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin.err >&2
exit 1
fi

View File

@@ -0,0 +1,307 @@
// Probe script for plugin update E2E scenarios.
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";
import {
readPluginInstallRecords,
writePluginInstallIndexForE2E,
} from "../plugin-index-sqlite.mjs";
const home = os.homedir();
const OUTPUT_TAIL_BYTES = 64 * 1024;
const OUTPUT_TAIL_LINES = 120;
const OUTPUT_SCAN_WINDOW_BYTES = 8 * 1024;
const readJson = (file) => {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return {};
}
};
const pluginRecordSnapshot = () => {
const config = readJson(openclawPath("openclaw.json"));
const records = readPluginInstallRecords({ fallbackRecords: config.plugins?.installs ?? {} });
const record = records["lossless-claw"] ?? records["@example/lossless-claw"];
if (!record) {
throw new Error("missing plugin install record");
}
const { source, spec, resolvedName, resolvedVersion, resolvedSpec, integrity, shasum } = record;
return { source, spec, resolvedName, resolvedVersion, resolvedSpec, integrity, shasum };
};
function openclawPath(...parts) {
return path.join(home, ".openclaw", ...parts);
}
function writeJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
}
function seedInstallState() {
writeJson(openclawPath("extensions", "lossless-claw", "package.json"), {
name: "@example/lossless-claw",
version: "0.9.0",
});
writeJson(process.env.OPENCLAW_CONFIG_PATH, { plugins: {} });
writePluginInstallIndexForE2E({
version: 1,
warning: "DO NOT EDIT. This file is generated by OpenClaw plugin registry commands.",
hostContractVersion: "docker-e2e",
compatRegistryVersion: "docker-e2e",
migrationVersion: 1,
policyHash: "docker-e2e",
generatedAtMs: 1777118400000,
installRecords: {
"lossless-claw": {
source: "npm",
spec: "@example/lossless-claw@0.9.0",
installPath: "~/.openclaw/extensions/lossless-claw",
resolvedName: "@example/lossless-claw",
resolvedVersion: "0.9.0",
resolvedSpec: "@example/lossless-claw@0.9.0",
integrity: "sha512-same",
shasum: "same",
},
},
plugins: [],
diagnostics: [],
});
}
async function waitRegistry() {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await registryHealthy()) {
return;
}
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
}
throw new Error("Local npm metadata registry failed to start");
}
function registryHealthy() {
return new Promise((resolve) => {
const registry = process.env.NPM_CONFIG_REGISTRY ?? "http://127.0.0.1:4873";
const req = http.get(`${registry.replace(/\/$/u, "")}/@example%2flossless-claw`, (res) => {
resolve(res.statusCode === 200);
res.resume();
});
req.on("error", () => resolve(false));
req.setTimeout(200, () => {
req.destroy();
resolve(false);
});
});
}
function assertSnapshot(beforePath) {
const before = readJson(beforePath);
const after = pluginRecordSnapshot();
if (JSON.stringify(before) !== JSON.stringify(after)) {
throw new Error(
`plugin install record changed unexpectedly: ${JSON.stringify({ before, after })}`,
);
}
}
function appendBufferTail(tail, chunk, maxBytes) {
if (chunk.length >= maxBytes) {
return chunk.subarray(chunk.length - maxBytes);
}
if (tail.length + chunk.length <= maxBytes) {
return Buffer.concat([tail, chunk]);
}
return Buffer.concat([tail, chunk]).subarray(tail.length + chunk.length - maxBytes);
}
async function readOutputEvidence(logPath) {
let outputTail = Buffer.alloc(0);
let scanWindow = "";
let sawDownload = false;
let sawUpToDate = false;
for await (const chunk of fs.createReadStream(logPath)) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
const text = buffer.toString("utf8");
const searchable = `${scanWindow}${text}`;
outputTail = appendBufferTail(outputTail, buffer, OUTPUT_TAIL_BYTES);
sawDownload ||= searchable.includes("Downloading @example/lossless-claw");
sawUpToDate ||= searchable.includes("lossless-claw is up to date (0.9.0).");
scanWindow = searchable.slice(-OUTPUT_SCAN_WINDOW_BYTES);
}
return {
outputTail: outputTail
.toString("utf8")
.split(/\r?\n/u)
.slice(-OUTPUT_TAIL_LINES)
.join("\n")
.trimEnd(),
sawDownload,
sawUpToDate,
};
}
async function assertOutput(logPath) {
const evidence = await readOutputEvidence(logPath);
const failure = evidence.sawDownload
? "Unexpected npm download/reinstall path"
: !evidence.sawUpToDate
? "Expected up-to-date output missing"
: "";
if (failure) {
throw new Error(`${failure}\nOutput tail:\n${evidence.outputTail}`);
}
}
function assertCorruptUpdate(updateJsonPath, pluginId) {
const payload = readJson(updateJsonPath);
if (payload.status !== "ok") {
throw new Error(`expected core update status ok, got ${JSON.stringify(payload.status)}`);
}
const plugins = payload.postUpdate?.plugins;
if (!plugins) {
throw new Error(`missing postUpdate.plugins in update output: ${JSON.stringify(payload)}`);
}
assertCorruptPluginTolerated(plugins, pluginId);
}
function assertCorruptPluginResult(pluginJsonPath, pluginId) {
const plugins = readJson(pluginJsonPath);
assertCorruptPluginTolerated(plugins, pluginId);
}
function assertCorruptPluginTolerated(plugins, pluginId) {
const evidence = collectPluginEvidence(plugins, pluginId);
if (plugins.status === "ok") {
assertCorruptPluginCleanOrRepaired(evidence);
return;
}
if (plugins.status !== "warning") {
throw new Error(
`expected post-update plugin status warning, got ${JSON.stringify(plugins.status)}`,
);
}
assertCorruptPluginDetails(plugins, pluginId);
}
function isCorruptPluginDisabledAfterUpdate(evidence, pluginId) {
const outcome = evidence.outcome;
const message = typeof outcome?.message === "string" ? outcome.message : "";
return (
outcome?.status === "skipped" &&
message.includes(`Disabled "${pluginId}" after plugin update failure`) &&
message.includes("OpenClaw will continue without it")
);
}
function assertCorruptPluginCleanOrRepaired(evidence) {
if (evidence.outcome) {
throw new Error(
`expected clean or repaired corrupt plugin state, got ${JSON.stringify(evidence)}`,
);
}
if (evidence.warning || evidence.integrityDrift || evidence.syncMessages.length > 0) {
throw new Error(
`expected warning post-update status for corrupt plugin evidence, got ok: ${JSON.stringify(
evidence,
)}`,
);
}
}
function assertCorruptPluginDetails(plugins, pluginId) {
const evidence = collectPluginEvidence(plugins, pluginId);
const outcome = evidence.outcome;
const disabledAfterFailure = isCorruptPluginDisabledAfterUpdate(evidence, pluginId);
if (!outcome || (outcome.status !== "error" && !disabledAfterFailure)) {
throw new Error(
`expected error or disabled-after-failure outcome for ${pluginId}, got ${JSON.stringify({
outcomes: plugins.npm?.outcomes ?? [],
warnings: plugins.warnings ?? [],
sync: plugins.sync,
integrityDrifts: plugins.integrityDrifts ?? [],
})}`,
);
}
const warning = evidence.warning;
if (!warning) {
throw new Error(
`expected warning for ${pluginId}, got ${JSON.stringify(plugins.warnings ?? [])}`,
);
}
const text = [outcome.message, warning.reason, warning.message, ...(warning.guidance ?? [])]
.filter(Boolean)
.join(" ");
const expectedFragments = disabledAfterFailure
? [
`Disabled "${pluginId}" after plugin update failure`,
"OpenClaw will continue without it",
"Run openclaw update repair to retry post-update plugin repair.",
`Run openclaw plugins inspect ${pluginId} --runtime --json for details.`,
]
: [
"package.json is missing",
"Run openclaw update repair to retry post-update plugin repair.",
`Run openclaw plugins inspect ${pluginId} --runtime --json for details.`,
];
for (const expected of expectedFragments) {
if (!text.includes(expected)) {
throw new Error(`expected update output to include ${expected}: ${text}`);
}
}
}
function collectPluginEvidence(plugins, pluginId) {
const outcomes = plugins.npm?.outcomes ?? [];
const warnings = plugins.warnings ?? [];
const integrityDrifts = plugins.integrityDrifts ?? [];
const syncMessages = [...(plugins.sync?.warnings ?? []), ...(plugins.sync?.errors ?? [])].filter(
(message) => String(message).includes(pluginId),
);
return {
outcome: outcomes.find((entry) => entry?.pluginId === pluginId),
warning: warnings.find((entry) => entry?.pluginId === pluginId),
integrityDrift: integrityDrifts.find((entry) => entry?.pluginId === pluginId),
syncMessages,
};
}
function assertLegacyPostUpdatePluginFailure(updateJsonPath) {
const payload = readJson(updateJsonPath);
if (payload.status !== "error" || payload.reason !== "post-update-plugins") {
throw new Error(
`expected legacy post-update plugin failure, got ${JSON.stringify({
status: payload.status,
reason: payload.reason,
})}`,
);
}
if (!payload.after?.version) {
throw new Error(`expected core update to install a new version: ${JSON.stringify(payload)}`);
}
}
const [command, arg, arg2] = process.argv.slice(2);
const commands = {
"legacy-compat": () => console.log(legacyPackageAcceptanceCompat(arg || "") ? "1" : "0"),
seed: seedInstallState,
"wait-registry": waitRegistry,
snapshot: () => process.stdout.write(JSON.stringify(pluginRecordSnapshot(), null, 2)),
"assert-snapshot": () => assertSnapshot(arg),
"assert-output": () => assertOutput(arg),
"assert-corrupt-update": () => assertCorruptUpdate(arg, arg2),
"assert-corrupt-plugin-result": () => assertCorruptPluginResult(arg, arg2),
"assert-legacy-post-update-plugin-failure": () => assertLegacyPostUpdatePluginFailure(arg),
};
const run = commands[command];
await (
run ??
(() => {
throw new Error(`Unknown plugin update probe command: ${command || "(missing)"}`);
})
)();

View File

@@ -0,0 +1,52 @@
// Fixture npm registry server for plugin update E2E scenarios.
import fs from "node:fs";
import http from "node:http";
import { readTcpPortEnv } from "../env-limits.mjs";
const portFile = process.argv[2];
if (!portFile) {
console.error("usage: registry-server.mjs <port-file>");
process.exit(2);
}
function buildMetadata(req) {
const host = req.headers.host ?? "127.0.0.1";
return {
name: "@example/lossless-claw",
"dist-tags": { latest: "0.9.0" },
versions: {
"0.9.0": {
name: "@example/lossless-claw",
version: "0.9.0",
dist: {
integrity: "sha512-same",
shasum: "same",
tarball: `http://${host}/@example/lossless-claw/-/lossless-claw-0.9.0.tgz`,
},
},
},
};
}
const server = http.createServer((req, res) => {
if (req.url === "/@example%2flossless-claw" || req.url === "/@example%2Flossless-claw") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(buildMetadata(req)));
return;
}
res.writeHead(404, { "content-type": "text/plain" });
res.end(`not found: ${req.url}`);
});
const requestedPort =
process.env.OPENCLAW_PLUGIN_UPDATE_REGISTRY_PORT === undefined
? 0
: readTcpPortEnv("OPENCLAW_PLUGIN_UPDATE_REGISTRY_PORT");
server.listen(requestedPort, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("plugin update registry did not expose a TCP port");
}
fs.writeFileSync(portFile, `${address.port}\n`);
});

Some files were not shown because too many files have changed in this diff Show More