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
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:
56
scripts/e2e/Dockerfile
Normal file
56
scripts/e2e/Dockerfile
Normal 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"]
|
||||
31
scripts/e2e/Dockerfile.qr-import
Normal file
31
scripts/e2e/Dockerfile.qr-import
Normal 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 . .
|
||||
43
scripts/e2e/agent-bundle-mcp-tools-docker.sh
Executable file
43
scripts/e2e/agent-bundle-mcp-tools-docker.sh
Executable 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"
|
||||
57
scripts/e2e/agents-delete-shared-workspace-docker.sh
Normal file
57
scripts/e2e/agents-delete-shared-workspace-docker.sh
Normal 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"
|
||||
'
|
||||
115
scripts/e2e/browser-cdp-snapshot-docker.sh
Executable file
115
scripts/e2e/browser-cdp-snapshot-docker.sh
Executable 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."
|
||||
10
scripts/e2e/build-image.sh
Normal file
10
scripts/e2e/build-image.sh
Normal 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"
|
||||
218
scripts/e2e/bun-global-install-smoke.sh
Executable file
218
scripts/e2e/bun-global-install-smoke.sh
Executable 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 "$@"
|
||||
96
scripts/e2e/bundled-plugin-install-uninstall-docker.sh
Executable file
96
scripts/e2e/bundled-plugin-install-uninstall-docker.sh
Executable 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"
|
||||
324
scripts/e2e/channel-plugin-trust-docker.sh
Normal file
324
scripts/e2e/channel-plugin-trust-docker.sh
Normal 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
|
||||
30
scripts/e2e/codex-media-path-docker.sh
Normal file
30
scripts/e2e/codex-media-path-docker.sh
Normal 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
|
||||
365
scripts/e2e/codex-npm-plugin-live-docker.sh
Normal file
365
scripts/e2e/codex-npm-plugin-live-docker.sh
Normal 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"
|
||||
112
scripts/e2e/codex-on-demand-docker.sh
Executable file
112
scripts/e2e/codex-on-demand-docker.sh
Executable 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"
|
||||
295
scripts/e2e/commitments-safety-docker-client.ts
Normal file
295
scripts/e2e/commitments-safety-docker-client.ts
Normal 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");
|
||||
38
scripts/e2e/commitments-safety-docker.sh
Executable file
38
scripts/e2e/commitments-safety-docker.sh
Executable 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"
|
||||
90
scripts/e2e/config-reload-source-docker.sh
Executable file
90
scripts/e2e/config-reload-source-docker.sh
Executable 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."
|
||||
43
scripts/e2e/crestodian-first-run-docker.sh
Normal file
43
scripts/e2e/crestodian-first-run-docker.sh
Normal 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"
|
||||
68
scripts/e2e/crestodian-first-run-spec.json
Normal file
68
scripts/e2e/crestodian-first-run-spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
133
scripts/e2e/crestodian-planner-docker-client.mjs
Normal file
133
scripts/e2e/crestodian-planner-docker-client.mjs
Normal 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);
|
||||
},
|
||||
);
|
||||
43
scripts/e2e/crestodian-planner-docker.sh
Executable file
43
scripts/e2e/crestodian-planner-docker.sh
Executable 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"
|
||||
270
scripts/e2e/crestodian-rescue-docker-client.ts
Normal file
270
scripts/e2e/crestodian-rescue-docker-client.ts
Normal 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);
|
||||
});
|
||||
43
scripts/e2e/crestodian-rescue-docker.sh
Executable file
43
scripts/e2e/crestodian-rescue-docker.sh
Executable 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
251
scripts/e2e/cron-cli-docker.sh
Executable 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"
|
||||
326
scripts/e2e/cron-mcp-cleanup-docker-client.ts
Normal file
326
scripts/e2e/cron-mcp-cleanup-docker-client.ts
Normal 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();
|
||||
}
|
||||
84
scripts/e2e/cron-mcp-cleanup-docker.sh
Normal file
84
scripts/e2e/cron-mcp-cleanup-docker.sh
Normal 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"
|
||||
135
scripts/e2e/cron-mcp-cleanup-seed.ts
Normal file
135
scripts/e2e/cron-mcp-cleanup-seed.ts
Normal 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();
|
||||
49
scripts/e2e/docker-openai-seed.ts
Normal file
49
scripts/e2e/docker-openai-seed.ts
Normal 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;
|
||||
}
|
||||
32
scripts/e2e/doctor-install-switch-docker.sh
Executable file
32
scripts/e2e/doctor-install-switch-docker.sh
Executable 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
|
||||
74
scripts/e2e/gateway-network-docker.sh
Normal file
74
scripts/e2e/gateway-network-docker.sh
Normal 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"
|
||||
105
scripts/e2e/kitchen-sink-plugin-docker.sh
Normal file
105
scripts/e2e/kitchen-sink-plugin-docker.sh
Normal 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"
|
||||
81
scripts/e2e/kitchen-sink-rpc-docker.sh
Executable file
81
scripts/e2e/kitchen-sink-rpc-docker.sh
Executable 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"
|
||||
2813
scripts/e2e/kitchen-sink-rpc-walk.mjs
Normal file
2813
scripts/e2e/kitchen-sink-rpc-walk.mjs
Normal file
File diff suppressed because it is too large
Load Diff
229
scripts/e2e/lib/agent-turn-output.mjs
Normal file
229
scripts/e2e/lib/agent-turn-output.mjs
Normal 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}`);
|
||||
}
|
||||
62
scripts/e2e/lib/auth-profile-store-assertions.mjs
Normal file
62
scripts/e2e/lib/auth-profile-store-assertions.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
68
scripts/e2e/lib/bounded-response-text.mjs
Normal file
68
scripts/e2e/lib/bounded-response-text.mjs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
71
scripts/e2e/lib/browser-cdp-snapshot/assert-snapshot.mjs
Normal file
71
scripts/e2e/lib/browser-cdp-snapshot/assert-snapshot.mjs
Normal 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");
|
||||
24
scripts/e2e/lib/browser-cdp-snapshot/fixture-server.mjs
Normal file
24
scripts/e2e/lib/browser-cdp-snapshot/fixture-server.mjs
Normal 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");
|
||||
207
scripts/e2e/lib/bun-global-install/assertions.mjs
Normal file
207
scripts/e2e/lib/bun-global-install/assertions.mjs
Normal 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();
|
||||
307
scripts/e2e/lib/bundled-plugin-install-uninstall/probe.mjs
Normal file
307
scripts/e2e/lib/bundled-plugin-install-uninstall/probe.mjs
Normal 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)"}`);
|
||||
}
|
||||
1559
scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs
Normal file
1559
scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs
Normal file
File diff suppressed because it is too large
Load Diff
107
scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh
Normal file
107
scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh
Normal 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))"
|
||||
497
scripts/e2e/lib/clawhub-fixture-server.cjs
Normal file
497
scripts/e2e/lib/clawhub-fixture-server.cjs
Normal 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);
|
||||
},
|
||||
);
|
||||
57
scripts/e2e/lib/codex-install-utils.mjs
Normal file
57
scripts/e2e/lib/codex-install-utils.mjs
Normal 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));
|
||||
}
|
||||
178
scripts/e2e/lib/codex-media-path/client.mjs
Normal file
178
scripts/e2e/lib/codex-media-path/client.mjs
Normal 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();
|
||||
}
|
||||
104
scripts/e2e/lib/codex-media-path/fake-codex-app-server.mjs
Normal file
104
scripts/e2e/lib/codex-media-path/fake-codex-app-server.mjs
Normal 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, {});
|
||||
});
|
||||
43
scripts/e2e/lib/codex-media-path/jsonl-request-tail.mjs
Normal file
43
scripts/e2e/lib/codex-media-path/jsonl-request-tail.mjs
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
21
scripts/e2e/lib/codex-media-path/limits.mjs
Normal file
21
scripts/e2e/lib/codex-media-path/limits.mjs
Normal 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;
|
||||
}
|
||||
56
scripts/e2e/lib/codex-media-path/scenario.sh
Normal file
56
scripts/e2e/lib/codex-media-path/scenario.sh
Normal 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"
|
||||
79
scripts/e2e/lib/codex-media-path/write-config.mjs
Normal file
79
scripts/e2e/lib/codex-media-path/write-config.mjs
Normal 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 });
|
||||
518
scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs
Normal file
518
scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs
Normal 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();
|
||||
127
scripts/e2e/lib/codex-on-demand/assertions.mjs
Normal file
127
scripts/e2e/lib/codex-on-demand/assertions.mjs
Normal 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",
|
||||
});
|
||||
39
scripts/e2e/lib/config-reload/assert-log.mjs
Normal file
39
scripts/e2e/lib/config-reload/assert-log.mjs
Normal 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");
|
||||
}
|
||||
52
scripts/e2e/lib/config-reload/log-scanner.mjs
Normal file
52
scripts/e2e/lib/config-reload/log-scanner.mjs
Normal 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
7
scripts/e2e/lib/config-reload/mutate-metadata.mjs
Normal file
7
scripts/e2e/lib/config-reload/mutate-metadata.mjs
Normal 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");
|
||||
143
scripts/e2e/lib/docker-stats/assert-resource-ceiling.mjs
Normal file
143
scripts/e2e/lib/docker-stats/assert-resource-ceiling.mjs
Normal 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}%`,
|
||||
);
|
||||
}
|
||||
293
scripts/e2e/lib/doctor-install-switch/scenario.sh
Normal file
293
scripts/e2e/lib/doctor-install-switch/scenario.sh
Normal 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
|
||||
7
scripts/e2e/lib/doctor-install-switch/shims/loginctl
Executable file
7
scripts/e2e/lib/doctor-install-switch/shims/loginctl
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
case "$*" in
|
||||
*show-user*) echo "Linger=yes" ;;
|
||||
*enable-linger*) ;;
|
||||
esac
|
||||
34
scripts/e2e/lib/doctor-install-switch/shims/systemctl
Executable file
34
scripts/e2e/lib/doctor-install-switch/shims/systemctl
Executable 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
|
||||
25
scripts/e2e/lib/doctor-install-switch/write-wrapper.mjs
Normal file
25
scripts/e2e/lib/doctor-install-switch/write-wrapper.mjs
Normal 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 },
|
||||
);
|
||||
23
scripts/e2e/lib/env-limits.mjs
Normal file
23
scripts/e2e/lib/env-limits.mjs
Normal 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;
|
||||
}
|
||||
17
scripts/e2e/lib/fixture.mjs
Normal file
17
scripts/e2e/lib/fixture.mjs
Normal 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);
|
||||
25
scripts/e2e/lib/fixtures/common.mjs
Normal file
25
scripts/e2e/lib/fixtures/common.mjs
Normal 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);
|
||||
}
|
||||
};
|
||||
122
scripts/e2e/lib/fixtures/config.mjs
Normal file
122
scripts/e2e/lib/fixtures/config.mjs
Normal 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,
|
||||
};
|
||||
100
scripts/e2e/lib/fixtures/mock-openai-config.mjs
Normal file
100
scripts/e2e/lib/fixtures/mock-openai-config.mjs
Normal 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,
|
||||
};
|
||||
}
|
||||
173
scripts/e2e/lib/fixtures/plugins.mjs
Normal file
173
scripts/e2e/lib/fixtures/plugins.mjs
Normal 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,
|
||||
};
|
||||
103
scripts/e2e/lib/fixtures/workspace.mjs
Normal file
103
scripts/e2e/lib/fixtures/workspace.mjs
Normal 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,
|
||||
};
|
||||
17
scripts/e2e/lib/gateway-frame-payload.mjs
Normal file
17
scripts/e2e/lib/gateway-frame-payload.mjs
Normal 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;
|
||||
}
|
||||
152
scripts/e2e/lib/gateway-network/client.mjs
Normal file
152
scripts/e2e/lib/gateway-network/client.mjs
Normal 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 });
|
||||
}
|
||||
19
scripts/e2e/lib/gateway-network/limits.mjs
Normal file
19
scripts/e2e/lib/gateway-network/limits.mjs
Normal 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);
|
||||
}
|
||||
67
scripts/e2e/lib/gateway-network/ws-frames.mjs
Normal file
67
scripts/e2e/lib/gateway-network/ws-frames.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
142
scripts/e2e/lib/incremental-line-reader.mjs
Normal file
142
scripts/e2e/lib/incremental-line-reader.mjs
Normal 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
698
scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs
Normal file
698
scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs
Normal 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();
|
||||
240
scripts/e2e/lib/kitchen-sink-plugin/sweep.sh
Normal file
240
scripts/e2e/lib/kitchen-sink-plugin/sweep.sh
Normal 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
|
||||
635
scripts/e2e/lib/live-plugin-tool/assertions.mjs
Normal file
635
scripts/e2e/lib/live-plugin-tool/assertions.mjs
Normal 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();
|
||||
41
scripts/e2e/lib/mcp-code-mode-probe-server.ts
Normal file
41
scripts/e2e/lib/mcp-code-mode-probe-server.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
61
scripts/e2e/lib/mcp-code-mode-validation.ts
Normal file
61
scripts/e2e/lib/mcp-code-mode-validation.ts
Normal 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;
|
||||
}
|
||||
124
scripts/e2e/lib/mock-openai-http.mjs
Normal file
124
scripts/e2e/lib/mock-openai-http.mjs
Normal 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();
|
||||
}
|
||||
275
scripts/e2e/lib/npm-onboard-channel-agent/assertions.mjs
Normal file
275
scripts/e2e/lib/npm-onboard-channel-agent/assertions.mjs
Normal 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();
|
||||
14
scripts/e2e/lib/npm-telegram-live/prepare-package.mjs
Normal file
14
scripts/e2e/lib/npm-telegram-live/prepare-package.mjs
Normal 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`);
|
||||
}
|
||||
87
scripts/e2e/lib/onboard/assert-config.mjs
Normal file
87
scripts/e2e/lib/onboard/assert-config.mjs
Normal 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);
|
||||
}
|
||||
58
scripts/e2e/lib/onboard/log-contains.mjs
Normal file
58
scripts/e2e/lib/onboard/log-contains.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
299
scripts/e2e/lib/onboard/scenario.sh
Normal file
299
scripts/e2e/lib/onboard/scenario.sh
Normal 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
|
||||
21
scripts/e2e/lib/onboard/write-config.mjs
Normal file
21
scripts/e2e/lib/onboard/write-config.mjs
Normal 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`);
|
||||
205
scripts/e2e/lib/openai-chat-tools/client.mjs
Normal file
205
scripts/e2e/lib/openai-chat-tools/client.mjs
Normal 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,
|
||||
}),
|
||||
);
|
||||
86
scripts/e2e/lib/openai-chat-tools/scenario.sh
Normal file
86
scripts/e2e/lib/openai-chat-tools/scenario.sh
Normal 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"
|
||||
90
scripts/e2e/lib/openai-chat-tools/write-config.mjs
Normal file
90
scripts/e2e/lib/openai-chat-tools/write-config.mjs
Normal 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 });
|
||||
140
scripts/e2e/lib/openai-web-search-minimal/assertions.mjs
Normal file
140
scripts/e2e/lib/openai-web-search-minimal/assertions.mjs
Normal 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();
|
||||
201
scripts/e2e/lib/openai-web-search-minimal/client.mjs
Normal file
201
scripts/e2e/lib/openai-web-search-minimal/client.mjs
Normal 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,
|
||||
};
|
||||
166
scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs
Normal file
166
scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs
Normal 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}`);
|
||||
});
|
||||
91
scripts/e2e/lib/openai-web-search-minimal/scenario.sh
Normal file
91
scripts/e2e/lib/openai-web-search-minimal/scenario.sh
Normal 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"
|
||||
63
scripts/e2e/lib/openwebui/http-probe.mjs
Normal file
63
scripts/e2e/lib/openwebui/http-probe.mjs
Normal 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();
|
||||
}
|
||||
12
scripts/e2e/lib/package-compat.mjs
Normal file
12
scripts/e2e/lib/package-compat.mjs
Normal 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");
|
||||
}
|
||||
104
scripts/e2e/lib/parallels-macos-common.sh
Normal file
104
scripts/e2e/lib/parallels-macos-common.sh
Normal 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" "$@"
|
||||
}
|
||||
25
scripts/e2e/lib/parallels-package-common.sh
Normal file
25
scripts/e2e/lib/parallels-package-common.sh
Normal 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
|
||||
}
|
||||
10
scripts/e2e/lib/parallels-package/build-info-commit.mjs
Normal file
10
scripts/e2e/lib/parallels-package/build-info-commit.mjs
Normal 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 ?? "");
|
||||
}
|
||||
22
scripts/e2e/lib/parallels-package/log-progress-extract.mjs
Normal file
22
scripts/e2e/lib/parallels-package/log-progress-extract.mjs
Normal 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) ?? "");
|
||||
227
scripts/e2e/lib/plugin-index-sqlite.mjs
Normal file
227
scripts/e2e/lib/plugin-index-sqlite.mjs
Normal 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();
|
||||
}
|
||||
}
|
||||
388
scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs
Normal file
388
scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs
Normal 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);
|
||||
});
|
||||
110
scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh
Normal file
110
scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh
Normal 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
|
||||
307
scripts/e2e/lib/plugin-update/probe.mjs
Normal file
307
scripts/e2e/lib/plugin-update/probe.mjs
Normal 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)"}`);
|
||||
})
|
||||
)();
|
||||
52
scripts/e2e/lib/plugin-update/registry-server.mjs
Normal file
52
scripts/e2e/lib/plugin-update/registry-server.mjs
Normal 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
Reference in New Issue
Block a user