diff --git a/.coderabbit.yaml b/.coderabbit.yaml index e0918c12060..bcd1465809a 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -131,10 +131,12 @@ reviews: - `sandbox-survival-e2e` — gateway restart recovery - `sandbox-operations-e2e` — process recovery after gateway kill - `cloud-e2e` — full onboard + cloud inference + - `openclaw-slack-pairing-e2e` — gateway/connect-shell OpenClaw + pairing state root parity for Slack To run selectively: ``` - gh workflow run nightly-e2e.yaml --ref -f jobs=sandbox-survival-e2e,sandbox-operations-e2e,cloud-e2e + gh workflow run nightly-e2e.yaml --ref -f jobs=sandbox-survival-e2e,sandbox-operations-e2e,cloud-e2e,openclaw-slack-pairing-e2e ``` - path: "scripts/lib/sandbox-init.sh" @@ -354,6 +356,51 @@ reviews: gh workflow run nightly-e2e.yaml --ref -f jobs=hermes-slack-e2e ``` + - path: "test/e2e/test-openclaw-slack-pairing.sh" + instructions: | + This script validates OpenClaw Slack DM pairing across the gateway + and connect-shell runtime contexts with a hermetic fake Slack Socket + Mode event and fake chat.postMessage reply. + + **E2E test recommendation:** + - `openclaw-slack-pairing-e2e` - Slack pairing request approval and + allowFrom state root parity + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=openclaw-slack-pairing-e2e + ``` + + - path: "test/e2e/lib/fake-slack-api.cjs" + instructions: | + This helper backs hermetic Slack REST and Socket Mode tests. Changes + can alter Slack credential rewrite assertions, fake Socket Mode + envelopes, or chat.postMessage capture. + + **E2E test recommendation:** + - `messaging-providers-e2e` - Slack REST provider placeholder rewrite + - `openclaw-slack-pairing-e2e` - fake Slack Socket Mode pairing flow + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=messaging-providers-e2e,openclaw-slack-pairing-e2e + ``` + + - path: "test/e2e/lib/slack-api-proof.sh" + instructions: | + This helper applies hermetic Slack REST and Socket Mode policies for + messaging E2E scripts. Changes can affect REST credential rewrite + probes and native websocket policy coverage. + + **E2E test recommendation:** + - `messaging-providers-e2e` - Slack REST provider placeholder rewrite + - `openclaw-slack-pairing-e2e` - fake Slack Socket Mode pairing flow + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=messaging-providers-e2e,openclaw-slack-pairing-e2e + ``` + - path: "nemoclaw-blueprint/policies/**" instructions: | This directory contains network policy definitions and presets. diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index 12dea245995..c494710d030 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -7,6 +7,9 @@ # messaging-providers-e2e Validates messaging credential provider/placeholder/L7-proxy chain # for Telegram + Discord + Slack. Uses fake tokens. Slack additionally # exercises OpenShell provider-shaped alias resolution (#2085 follow-up). +# openclaw-slack-pairing-e2e +# Validates hermetic Slack Socket Mode pairing request approval across +# gateway and connect-shell OpenClaw state roots (#3730/#3737). # messaging-compatible-endpoint-e2e # Validates Telegram + OpenAI-compatible endpoint inference routing # through inference.local with a hermetic local mock (#2766). @@ -66,6 +69,7 @@ on: Comma-separated job names to run (empty = all). Valid: cloud-e2e, cloud-onboard-e2e, cloud-inference-e2e, skill-agent-e2e, docs-validation-e2e, messaging-providers-e2e, + openclaw-slack-pairing-e2e, messaging-compatible-endpoint-e2e, kimi-inference-compat-e2e, token-rotation-e2e, sandbox-survival-e2e, @@ -337,6 +341,43 @@ jobs: /tmp/nemoclaw-e2e-whatsapp-*.log if-no-files-found: ignore + # ── OpenClaw Slack Pairing E2E (#3730/#3737) ────────────────── + # Hermetic Socket Mode inbound event + chat.postMessage reply path, then + # connect-shell `openclaw pairing approve slack ` against shared state. + openclaw-slack-pairing-e2e: + if: >- + github.repository == 'NVIDIA/NemoClaw' && + (github.event_name != 'workflow_dispatch' || + inputs.jobs == '' || + contains(format(',{0},', inputs.jobs), ',openclaw-slack-pairing-e2e,')) + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.target_ref || github.ref }} + + - name: Run OpenClaw Slack pairing E2E test + env: + NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} + NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" + NEMOCLAW_POLICY_TIER: "open" + NEMOCLAW_SANDBOX_NAME: "e2e-openclaw-slack-pairing" + GITHUB_TOKEN: ${{ github.token }} + SLACK_BOT_TOKEN: "xoxb-fake-slack-pairing-e2e" + SLACK_APP_TOKEN: "xapp-fake-slack-pairing-e2e" + run: bash test/e2e/test-openclaw-slack-pairing.sh + + - name: Upload install log on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: install-log-openclaw-slack-pairing + path: /tmp/nemoclaw-e2e-openclaw-slack-pairing-install.log + if-no-files-found: ignore + # ── Messaging + compatible endpoint regression (#2766) ─────── # Hermetic Telegram + OpenAI-compatible endpoint path. Uses a local mock # endpoint and fake Telegram token, then asserts sandbox inference.local @@ -2031,6 +2072,7 @@ jobs: skill-agent-e2e, docs-validation-e2e, messaging-providers-e2e, + openclaw-slack-pairing-e2e, messaging-compatible-endpoint-e2e, channels-stop-start-e2e, brave-search-e2e, @@ -2124,6 +2166,7 @@ jobs: skill-agent-e2e, docs-validation-e2e, messaging-providers-e2e, + openclaw-slack-pairing-e2e, messaging-compatible-endpoint-e2e, channels-stop-start-e2e, brave-search-e2e, @@ -2274,6 +2317,7 @@ jobs: skill-agent-e2e, docs-validation-e2e, messaging-providers-e2e, + openclaw-slack-pairing-e2e, messaging-compatible-endpoint-e2e, channels-stop-start-e2e, brave-search-e2e, diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index fa2bdcbf33c..71c996c03ee 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -199,13 +199,16 @@ if [ -z "$_DASHBOARD_PORT_RAW" ]; then fi else _DASHBOARD_PORT="$(printf '%s' "$_DASHBOARD_PORT_RAW" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + _DASHBOARD_PORT_VALID=1 case "$_DASHBOARD_PORT" in *[!0-9]* | '') - echo "[SECURITY] Invalid NEMOCLAW_DASHBOARD_PORT='${NEMOCLAW_DASHBOARD_PORT}' — must be an integer between 1024 and 65535" >&2 - exit 1 + _DASHBOARD_PORT_VALID=0 ;; esac - if ! [ "$_DASHBOARD_PORT" -ge 1024 ] || ! [ "$_DASHBOARD_PORT" -le 65535 ]; then + if [ "$_DASHBOARD_PORT_VALID" -eq 1 ] && { [ "$_DASHBOARD_PORT" -lt 1024 ] || [ "$_DASHBOARD_PORT" -gt 65535 ]; }; then + _DASHBOARD_PORT_VALID=0 + fi + if [ "$_DASHBOARD_PORT_VALID" -ne 1 ]; then echo "[SECURITY] Invalid NEMOCLAW_DASHBOARD_PORT='${NEMOCLAW_DASHBOARD_PORT}' — must be an integer between 1024 and 65535" >&2 exit 1 fi @@ -225,6 +228,18 @@ export OPENCLAW_GATEWAY_PORT="$_DASHBOARD_PORT" export OPENCLAW_GATEWAY_URL="ws://127.0.0.1:${_DASHBOARD_PORT}" OPENCLAW="$(command -v openclaw)" # Resolve once, use absolute path everywhere _SANDBOX_HOME="/sandbox" # Home dir for the sandbox user (useradd -d /sandbox in Dockerfile.base) +_OPENCLAW_STATE_DIR="${_SANDBOX_HOME}/.openclaw" +_OPENCLAW_CREDENTIALS_DIR="${_OPENCLAW_STATE_DIR}/credentials" + +# OpenClaw 2026.4.x stores channel pairing requests under +# resolveOAuthDir(resolveStateDir(...))/-pairing.json. The gateway +# runs as the gateway user while connect-shell commands run as sandbox, so +# relying on HOME/os.homedir() can split pending requests across users. Force +# every OpenClaw process in the sandbox to the persistent shared state root. +export OPENCLAW_HOME="${_SANDBOX_HOME}" +export OPENCLAW_STATE_DIR="${_OPENCLAW_STATE_DIR}" +export OPENCLAW_CONFIG_PATH="${_OPENCLAW_STATE_DIR}/openclaw.json" +export OPENCLAW_OAUTH_DIR="${_OPENCLAW_CREDENTIALS_DIR}" # ── Config integrity check (delegates to shared library) ──────── # verify_config_integrity_if_locked is provided by sandbox-init.sh. OpenClaw @@ -1580,6 +1595,13 @@ export http_proxy="$_PROXY_URL" export https_proxy="$_PROXY_URL" export no_proxy="$_NO_PROXY_VAL" PROXYEOF + local _openclaw_env_name _openclaw_env_value _escaped_openclaw_env_value + for _openclaw_env_name in OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH OPENCLAW_OAUTH_DIR; do + _openclaw_env_value="${!_openclaw_env_name:-}" + [ -n "$_openclaw_env_value" ] || continue + _escaped_openclaw_env_value="$(printf '%s' "$_openclaw_env_value" | sed "s/'/'\\\\''/g")" + printf "export %s='%s'\n" "$_openclaw_env_name" "$_escaped_openclaw_env_value" + done if [ -n "${OPENCLAW_GATEWAY_PORT:-}" ]; then _escaped_gateway_port="$(printf '%s' "$OPENCLAW_GATEWAY_PORT" | sed "s/'/'\\\\''/g")" printf "export OPENCLAW_GATEWAY_PORT='%s'\n" "$_escaped_gateway_port" diff --git a/test/e2e/docs/parity-map.yaml b/test/e2e/docs/parity-map.yaml index 1516ce781b1..b794e6107d1 100644 --- a/test/e2e/docs/parity-map.yaml +++ b/test/e2e/docs/parity-map.yaml @@ -7113,6 +7113,236 @@ scripts: reason: legacy assertion is obsolete or negative cleanup behavior after scenario migration reviewer: e2e-maintainers approved_at: '2026-05-13' + test-openclaw-slack-pairing.sh: + scenario: ubuntu-repo-cloud-openclaw + status: deferred + bucket: providers-messaging + assertions: + - legacy: 'NVIDIA_API_KEY not set' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'NVIDIA_API_KEY is set' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Docker is not running' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Docker is running' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Pre-cleanup complete' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Slack network policy pre-merged into base policy' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Cannot pre-merge Slack policy: missing base policy or preset file' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Install completed' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'install.sh failed (exit $install_exit)' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: "Sandbox '$SANDBOX_NAME' is Ready" + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: "Sandbox '$SANDBOX_NAME' not Ready (list: ${sandbox_list:0:300})" + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Slack bot/app providers exist in OpenShell' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Slack bot/app providers missing in OpenShell' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Connect-shell OpenClaw env resolves to /sandbox/.openclaw' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Connect-shell OpenClaw env does not resolve to the shared state root' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'openclaw pairing list slack works in connect shell' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'openclaw pairing list slack failed before request creation: ${pairing_list_empty:0:300}' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Hermetic fake Slack API started on host port ${FAKE_SLACK_API_PORT}' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Failed to start hermetic fake Slack API' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Applied REST policy for fake Slack chat.postMessage' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: "Failed to apply fake Slack REST policy: $(tail -20 /tmp/nemoclaw-fake-slack-pairing-rest-policy.log 2>/dev/null | tr '\\n' ' ' | cut -c1-300)" + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Applied websocket policy for fake Slack Socket Mode' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: "Failed to apply fake Slack websocket policy: $(tail -20 /tmp/nemoclaw-fake-slack-pairing-ws-policy.log 2>/dev/null | tr '\\n' ' ' | cut -c1-300)" + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'OpenShell-tracked Slack Socket Mode handler created a pairing request' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'OpenShell-tracked Slack Socket Mode pairing request creation failed' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Pairing code extracted from fake Slack reply path' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Failed to extract pairing code' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Fake Slack saw rewritten xapp websocket frame and xoxb chat.postMessage' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Fake Slack capture did not prove Slack token rewriting: ${capture_check:0:300}' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Runtime-created Slack pending request is in the shared OpenClaw state root' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Slack pending request missing from /sandbox/.openclaw/credentials/slack-pairing.json' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Connect-shell openclaw pairing list sees runtime-created Slack request' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Connect-shell openclaw pairing list does not see the Slack request' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Connect-shell openclaw pairing approve approved the Slack request' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Connect-shell openclaw pairing approve failed: ${approve_output:0:500}' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'openclaw pairing list slack failed after approval: ${pairing_list_after:0:300}' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Approved Slack pairing code is still pending' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Approved Slack pairing code was consumed' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Slack allowFrom store contains the approved user' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Slack allowFrom store missing approved user' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Second approval fails closed after request consumption' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'Second approval did not report missing pending request: ${repeat_approve:0:300}' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: "Cleanup: Sandbox '$SANDBOX_NAME' intentionally kept" + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: "Cleanup: Sandbox '$SANDBOX_NAME' still present after cleanup" + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: "Cleanup: Sandbox '$SANDBOX_NAME' removed" + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs test-openclaw-plugin-runtime-exdev.sh: scenario: '' status: deferred diff --git a/test/e2e/lib/fake-slack-api.cjs b/test/e2e/lib/fake-slack-api.cjs index c2b2d2363d0..30b72a19eff 100755 --- a/test/e2e/lib/fake-slack-api.cjs +++ b/test/e2e/lib/fake-slack-api.cjs @@ -5,6 +5,7 @@ "use strict"; const fs = require("fs"); +const crypto = require("crypto"); const http = require("http"); const host = process.env.FAKE_SLACK_API_HOST || "0.0.0.0"; @@ -14,7 +15,11 @@ const portFile = process.env.FAKE_SLACK_API_PORT_FILE || ""; const captureFile = process.env.FAKE_SLACK_API_CAPTURE_FILE || ""; const expectedBotToken = process.env.FAKE_SLACK_API_EXPECTED_BOT_TOKEN || ""; const expectedAppToken = process.env.FAKE_SLACK_API_EXPECTED_APP_TOKEN || ""; +const socketUserId = process.env.FAKE_SLACK_API_SOCKET_USER_ID || "U3730E2E"; +const socketChannelId = process.env.FAKE_SLACK_API_SOCKET_CHANNEL_ID || "D3730E2E"; +const socketTeamId = process.env.FAKE_SLACK_API_SOCKET_TEAM_ID || "T3730E2E"; const MAX_BODY_BYTES = 1024 * 1024; +const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; if (!Number.isInteger(port) || port < 0 || port > 65535) { console.error(`FAKE_SLACK_API_PORT must be an integer between 0 and 65535 (received: ${rawPort})`); @@ -36,6 +41,121 @@ function expectedTokenForPath(pathname) { return expectedBotToken; } +function tokenLooksPlaceholder(value) { + return ( + typeof value === "string" && + (value.includes("openshell:resolve:env:") || value.includes("OPENSHELL-RESOLVE-ENV-")) + ); +} + +function slackResponseFor(pathname, authAccepted, message = {}) { + if (pathname === "/api/chat.postMessage") { + return { + status: 200, + body: authAccepted + ? { + ok: true, + channel: message.channel || socketChannelId, + ts: "1710000000.000200", + message: { + type: "message", + channel: message.channel || socketChannelId, + text: message.text || "", + ts: "1710000000.000200", + ...(message.threadTs ? { thread_ts: message.threadTs } : {}), + }, + } + : { + ok: false, + error: "bad_auth", + endpoint: pathname, + }, + }; + } + if (!authAccepted) { + return { status: 401, body: { ok: false, error: "bad_auth", endpoint: pathname } }; + } + return { status: 200, body: { ok: false, error: "invalid_auth", endpoint: pathname } }; +} + +function encodeServerText(payload) { + const body = Buffer.from(payload, "utf8"); + if (body.length < 126) { + return Buffer.concat([Buffer.from([0x81, body.length]), body]); + } + if (body.length < 65536) { + const header = Buffer.alloc(4); + header[0] = 0x81; + header[1] = 126; + header.writeUInt16BE(body.length, 2); + return Buffer.concat([header, body]); + } + const header = Buffer.alloc(10); + header[0] = 0x81; + header[1] = 127; + header.writeBigUInt64BE(BigInt(body.length), 2); + return Buffer.concat([header, body]); +} + +function decodeClientFrame(buffer) { + if (buffer.length < 2) return null; + const opcode = buffer[0] & 0x0f; + const masked = (buffer[1] & 0x80) !== 0; + let payloadLength = buffer[1] & 0x7f; + let offset = 2; + if (payloadLength === 126) { + if (buffer.length < 4) return null; + payloadLength = buffer.readUInt16BE(2); + offset = 4; + } else if (payloadLength === 127) { + if (buffer.length < 10) return null; + payloadLength = Number(buffer.readBigUInt64BE(2)); + offset = 10; + } + const maskOffset = offset; + if (masked) offset += 4; + if (buffer.length < offset + payloadLength) return null; + const payload = Buffer.from(buffer.slice(offset, offset + payloadLength)); + if (masked) { + const mask = buffer.slice(maskOffset, maskOffset + 4); + for (let i = 0; i < payload.length; i += 1) { + payload[i] ^= mask[i % 4]; + } + } + return { + opcode, + payload, + totalLength: offset + payloadLength, + }; +} + +function sendSocketModeEvent(socket) { + const envelope = { + envelope_id: "slack-e2e-envelope-3730", + type: "events_api", + accepts_response_payload: true, + payload: { + token: "verification-token", + team_id: socketTeamId, + api_app_id: "A3730E2E", + type: "event_callback", + event_id: "Ev3730E2E", + event_time: Math.floor(Date.now() / 1000), + authorizations: [{ team_id: socketTeamId, user_id: "UOPENCLAWBOT", is_bot: true }], + event: { + type: "message", + channel_type: "im", + channel: socketChannelId, + user: socketUserId, + text: "pair me", + ts: `${Math.floor(Date.now() / 1000)}.000000`, + }, + }, + }; + socket.write(encodeServerText(JSON.stringify(envelope))); + record({ event: "websocket-event-sent", path: "/socket-mode", envelopeId: envelope.envelope_id }); +} + const server = http.createServer((req, res) => { const chunks = []; let bodyBytes = 0; @@ -73,12 +193,8 @@ const server = http.createServer((req, res) => { const tokenMatchesExpected = authorization === expectedAuthorization; const bodyMatchesExpected = bodyToken === expectedToken; const authAccepted = tokenMatchesExpected && bodyMatchesExpected; - const tokenLooksPlaceholder = - typeof authorization === "string" && - (authorization.includes("openshell:resolve:env:") || - authorization.includes("OPENSHELL-RESOLVE-ENV-") || - body.includes("openshell:resolve:env:") || - body.includes("OPENSHELL-RESOLVE-ENV-")); + const requestTokenLooksPlaceholder = + tokenLooksPlaceholder(authorization) || tokenLooksPlaceholder(body); record({ event: "request", @@ -86,7 +202,7 @@ const server = http.createServer((req, res) => { path: pathname, tokenMatchesExpected, bodyMatchesExpected, - tokenLooksPlaceholder, + tokenLooksPlaceholder: requestTokenLooksPlaceholder, authorizationPresent: Boolean(authorization), bodyTokenPresent: Boolean(bodyToken), authorizationRedacted: true, @@ -101,45 +217,80 @@ const server = http.createServer((req, res) => { : {}), }); - if (pathname === "/api/chat.postMessage") { - res.writeHead(200, { - "content-type": "application/json", - }); - res.end( - JSON.stringify( - authAccepted - ? { - ok: true, - channel, - ts: "1710000000.000200", - message: { - type: "message", - channel, - text, - ts: "1710000000.000200", - ...(threadTs ? { thread_ts: threadTs } : {}), - }, - } - : { - ok: false, - error: "bad_auth", - endpoint: pathname, - }, - ), - ); - return; - } - - res.writeHead(authAccepted ? 200 : 401, { + const response = slackResponseFor(pathname, authAccepted, { channel, text, threadTs }); + res.writeHead(response.status, { "content-type": "application/json", }); - res.end( - JSON.stringify({ - ok: false, - error: authAccepted ? "invalid_auth" : "bad_auth", - endpoint: pathname, - }), - ); + res.end(JSON.stringify(response.body)); + }); +}); + +server.on("upgrade", (req, socket) => { + const pathname = new URL(req.url || "/", "http://fake-slack.local").pathname; + if (pathname !== "/socket-mode") { + socket.destroy(); + return; + } + + const key = req.headers["sec-websocket-key"]; + if (typeof key !== "string" || !key) { + socket.destroy(); + return; + } + + const accept = crypto.createHash("sha1").update(`${key}${WS_GUID}`).digest("base64"); + socket.write( + [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${accept}`, + "\r\n", + ].join("\r\n"), + ); + record({ event: "websocket-upgrade", path: pathname }); + + let buffer = Buffer.alloc(0); + let sentEvent = false; + + socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length > 0) { + const frame = decodeClientFrame(buffer); + if (!frame) break; + buffer = buffer.slice(frame.totalLength); + if (frame.opcode === 8) { + socket.end(); + return; + } + if (frame.opcode !== 1) continue; + const text = frame.payload.toString("utf8"); + let token = ""; + let messageType = ""; + let envelopeId = ""; + try { + const parsed = JSON.parse(text); + token = typeof parsed.token === "string" ? parsed.token : ""; + messageType = typeof parsed.type === "string" ? parsed.type : ""; + envelopeId = typeof parsed.envelope_id === "string" ? parsed.envelope_id : ""; + } catch { + // Capture classification below is still useful for malformed frames. + } + record({ + event: "websocket-message", + path: pathname, + messageType, + tokenMatchesExpected: token === expectedAppToken, + tokenLooksPlaceholder: tokenLooksPlaceholder(text), + textRedacted: true, + }); + if (!sentEvent) { + sentEvent = true; + sendSocketModeEvent(socket); + } else if (envelopeId === "slack-e2e-envelope-3730") { + socket.end(); + } + } }); }); diff --git a/test/e2e/lib/slack-api-proof.sh b/test/e2e/lib/slack-api-proof.sh index e9b94cbcc18..3e15ac42b0b 100755 --- a/test/e2e/lib/slack-api-proof.sh +++ b/test/e2e/lib/slack-api-proof.sh @@ -94,6 +94,21 @@ apply_fake_slack_api_policy() { --wait } +apply_fake_slack_socket_mode_policy() { + local sandbox_name="$1" + local port="$2" + local host="${FAKE_SLACK_API_HOST:-host.openshell.internal}" + local allowed_ip_options + allowed_ip_options="$(fake_slack_api_allowed_ip_options)" + openshell policy update "$sandbox_name" \ + --add-endpoint "${host}:${port}:read-write:websocket:enforce:websocket-credential-rewrite,${allowed_ip_options}" \ + --add-allow "${host}:${port}:GET:/**" \ + --add-allow "${host}:${port}:WEBSOCKET_TEXT:/**" \ + --binary /usr/local/bin/node \ + --binary /usr/bin/node \ + --wait +} + run_fake_slack_api_node_request() { local port="$1" local path="$2" diff --git a/test/e2e/test-openclaw-slack-pairing.sh b/test/e2e/test-openclaw-slack-pairing.sh new file mode 100755 index 00000000000..d1a83bee913 --- /dev/null +++ b/test/e2e/test-openclaw-slack-pairing.sh @@ -0,0 +1,849 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# OpenClaw Slack pairing E2E (#3730/#3737). +# +# This test keeps Slack hermetic while covering the failure boundary from the +# DGX Spark report: +# 1. Slack-style Socket Mode event reaches sandbox code over native websocket +# policy with xapp placeholder rewriting. +# 2. OpenShell-tracked Slack Socket Mode flow writes a Slack pending request. +# 3. Connect-shell `openclaw pairing approve slack ` finds and approves +# the request created by the runtime flow. +# 4. Approval creates the Slack allowFrom store entry where OpenClaw resolves it. +# +# Environment variables: +# NEMOCLAW_NON_INTERACTIVE=1 - required +# NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 - required +# NVIDIA_API_KEY - required for onboarding +# NEMOCLAW_SANDBOX_NAME - sandbox name (default: e2e-openclaw-slack-pairing) +# SLACK_BOT_TOKEN - defaults to a fake xoxb- token +# SLACK_APP_TOKEN - defaults to a fake xapp- token +# +# Usage: +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ +# NVIDIA_API_KEY=nvapi-... bash test/e2e/test-openclaw-slack-pairing.sh + +# shellcheck disable=SC2016 +# SC2016: Single-quoted strings are intentional for commands evaluated inside +# the sandbox rather than on the host. + +set -uo pipefail + +PASS=0 +FAIL=0 +SKIP=0 +TOTAL=0 + +pass() { + ((PASS++)) + ((TOTAL++)) + printf '\033[32m PASS: %s\033[0m\n' "$1" +} +fail() { + ((FAIL++)) + ((TOTAL++)) + printf '\033[31m FAIL: %s\033[0m\n' "$1" +} +skip() { + ((SKIP++)) + ((TOTAL++)) + printf '\033[33m SKIP: %s\033[0m\n' "$1" +} +section() { + echo "" + printf '\033[1;36m=== %s ===\033[0m\n' "$1" +} +info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } + +run_with_timeout() { + local seconds="$1" + shift + if command -v timeout >/dev/null 2>&1; then + timeout "$seconds" "$@" + elif command -v gtimeout >/dev/null 2>&1; then + gtimeout "$seconds" "$@" + else + "$@" + fi +} + +if [ -d /workspace ] && [ -f /workspace/install.sh ]; then + REPO="/workspace" +elif [ -f "$(cd "$(dirname "$0")/../.." && pwd)/install.sh" ]; then + REPO="$(cd "$(dirname "$0")/../.." && pwd)" +else + echo "ERROR: Cannot find repo root." + exit 1 +fi + +SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-openclaw-slack-pairing}" +OPENSHELL_BIN="${NEMOCLAW_OPENSHELL_BIN:-openshell}" +SLACK_TOKEN="${SLACK_BOT_TOKEN:-xoxb-fake-slack-pairing-e2e}" +SLACK_APP="${SLACK_APP_TOKEN:-xapp-fake-slack-pairing-e2e}" +SLACK_PAIRING_USER="${NEMOCLAW_SLACK_PAIRING_USER:-U3730E2E}" + +export NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" +export NEMOCLAW_RECREATE_SANDBOX=1 +export NEMOCLAW_FRESH=1 +export NEMOCLAW_POLICY_TIER="${NEMOCLAW_POLICY_TIER:-open}" +export SLACK_BOT_TOKEN="$SLACK_TOKEN" +export SLACK_APP_TOKEN="$SLACK_APP" + +openshell() { + if [ "$OPENSHELL_BIN" = "openshell" ]; then + command openshell "$@" + else + "$OPENSHELL_BIN" "$@" + fi +} + +sandbox_exec() { + local cmd="$1" + local ssh_config + ssh_config="$(mktemp)" + openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null + + local result status + result=$(run_with_timeout 60 ssh -F "$ssh_config" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + "$cmd" \ + 2>&1) + status=$? + + rm -f "$ssh_config" + printf '%s\n' "$result" + return "$status" +} + +quote_for_remote_sh() { + local value="${1:-}" + printf "'%s'" "$(printf '%s' "$value" | sed "s/'/'\\\\''/g")" +} + +sandbox_exec_sh_script() { + local script="$1" + shift + local encoded remote_cmd arg + encoded="$(printf '%s' "$script" | base64 | tr -d '\n')" + remote_cmd="tmp=\$(mktemp); trap 'rm -f \"\$tmp\"' EXIT; printf %s $(quote_for_remote_sh "$encoded") | base64 -d > \"\$tmp\"; sh \"\$tmp\"" + for arg in "$@"; do + remote_cmd+=" $(quote_for_remote_sh "$arg")" + done + openshell sandbox exec --name "$SANDBOX_NAME" -- sh -lc "$remote_cmd" +} + +# shellcheck source=test/e2e/lib/sandbox-teardown.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib/sandbox-teardown.sh" +register_sandbox_for_teardown "$SANDBOX_NAME" + +# shellcheck source=test/e2e/lib/slack-api-proof.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib/slack-api-proof.sh" + +check_fake_slack_pairing_capture() { + node - "$FAKE_SLACK_API_CAPTURE_FILE" <<'NODE' +const fs = require("fs"); +const file = process.argv[2]; +const rows = fs + .readFileSync(file, "utf8") + .trim() + .split(/\n+/) + .filter(Boolean) + .map((line) => JSON.parse(line)); + +const ws = rows + .filter((row) => row.event === "websocket-message" && row.messageType === "socket_mode_client_hello") + .at(-1); +if (!ws) { + console.log("NO_WEBSOCKET_MESSAGE"); + process.exit(2); +} +if (ws.tokenMatchesExpected !== true) { + console.log("BAD_WEBSOCKET_TOKEN_REWRITE"); + process.exit(3); +} +if (ws.tokenLooksPlaceholder) { + console.log("WEBSOCKET_PLACEHOLDER_LEAK"); + process.exit(4); +} + +const post = rows + .filter((row) => row.event === "request" && row.path === "/api/chat.postMessage") + .at(-1); +if (!post) { + console.log("NO_CHAT_POSTMESSAGE"); + process.exit(5); +} +if (post.authorization !== undefined || post.body !== undefined) { + console.log("RAW_CAPTURE_LEAK"); + process.exit(6); +} +if (post.tokenMatchesExpected !== true || post.bodyMatchesExpected !== true) { + console.log("BAD_CHAT_POSTMESSAGE_TOKEN_REWRITE"); + process.exit(7); +} +if (post.tokenLooksPlaceholder) { + console.log("CHAT_POSTMESSAGE_PLACEHOLDER_LEAK"); + process.exit(8); +} +console.log("OK"); +NODE +} + +section "Phase 0: Prerequisites" + +if [ -z "${NVIDIA_API_KEY:-}" ]; then + fail "NVIDIA_API_KEY not set" + exit 1 +fi +pass "NVIDIA_API_KEY is set" + +if ! docker info >/dev/null 2>&1; then + fail "Docker is not running" + exit 1 +fi +pass "Docker is running" + +info "Sandbox name: $SANDBOX_NAME" +info "Slack bot token: configured (${#SLACK_TOKEN} chars)" +info "Slack app token: configured (${#SLACK_APP} chars)" + +section "Phase 1: Install NemoClaw with Slack enabled" + +cd "$REPO" || exit 1 + +info "Pre-cleanup..." +if command -v nemoclaw >/dev/null 2>&1; then + nemoclaw "$SANDBOX_NAME" destroy --yes 2>/dev/null || true +fi +if openshell --version >/dev/null 2>&1; then + openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true + openshell gateway destroy -g nemoclaw 2>/dev/null || true +fi +pass "Pre-cleanup complete" + +# Keep this in sync with the Slack boot-time pre-merge in +# test-messaging-providers.sh. Slack presets normally apply after the sandbox +# first starts; pre-merging avoids a slow first-boot Slack SDK CONNECT failure. +BASE_POLICY="$REPO/nemoclaw-blueprint/policies/openclaw-sandbox.yaml" +SLACK_PRESET="$REPO/nemoclaw-blueprint/policies/presets/slack.yaml" +if [ -f "$BASE_POLICY" ] && [ -f "$SLACK_PRESET" ] && ! grep -q "api.slack.com" "$BASE_POLICY"; then + BASE_POLICY_BAK="$(mktemp)" + cp "$BASE_POLICY" "$BASE_POLICY_BAK" + _previous_exit_trap=$(trap -p EXIT | sed "s/^trap -- '//;s/' EXIT$//") + trap ''"${_previous_exit_trap:+$_previous_exit_trap;}"' cp "$BASE_POLICY_BAK" "$BASE_POLICY" 2>/dev/null || true; rm -f "$BASE_POLICY_BAK"' EXIT + info "Pre-merging Slack network policy into base sandbox policy..." + cat >>"$BASE_POLICY" <<'SLACK_POLICY_EOF' + + # ── Slack — pre-merged for Slack pairing E2E (#3730) ────────── + slack: + name: slack + endpoints: + - host: slack.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: api.slack.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: hooks.slack.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: wss-primary.slack.com + port: 443 + protocol: websocket + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: WEBSOCKET_TEXT, path: "/**" } + - host: wss-backup.slack.com + port: 443 + protocol: websocket + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: WEBSOCKET_TEXT, path: "/**" } + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/bin/node } +SLACK_POLICY_EOF + pass "Slack network policy pre-merged into base policy" +else + if grep -q "api.slack.com" "$BASE_POLICY" 2>/dev/null; then + info "Slack policy already present in base policy — skipping pre-merge" + else + fail "Cannot pre-merge Slack policy: missing base policy or preset file" + exit 1 + fi +fi + +INSTALL_LOG="/tmp/nemoclaw-e2e-openclaw-slack-pairing-install.log" +info "Running install.sh --non-interactive..." +bash install.sh --non-interactive >"$INSTALL_LOG" 2>&1 & +install_pid=$! +tail -f "$INSTALL_LOG" --pid=$install_pid 2>/dev/null & +tail_pid=$! +wait $install_pid +install_exit=$? +kill $tail_pid 2>/dev/null || true +wait $tail_pid 2>/dev/null || true + +if [ -f "$HOME/.bashrc" ]; then + # shellcheck source=/dev/null + source "$HOME/.bashrc" 2>/dev/null || true +fi +export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" +if [ -s "$NVM_DIR/nvm.sh" ]; then + # shellcheck source=/dev/null + . "$NVM_DIR/nvm.sh" +fi +if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then + export PATH="$HOME/.local/bin:$PATH" +fi + +if [ $install_exit -eq 0 ]; then + pass "Install completed" +else + fail "install.sh failed (exit $install_exit)" + info "Last 40 lines of install log:" + tail -40 "$INSTALL_LOG" 2>/dev/null || true + exit 1 +fi + +sandbox_list=$(openshell sandbox list 2>&1 || true) +if echo "$sandbox_list" | grep -q "$SANDBOX_NAME.*Ready"; then + pass "Sandbox '$SANDBOX_NAME' is Ready" +else + fail "Sandbox '$SANDBOX_NAME' not Ready (list: ${sandbox_list:0:300})" + exit 1 +fi + +if openshell provider get "${SANDBOX_NAME}-slack-bridge" >/dev/null 2>&1 \ + && openshell provider get "${SANDBOX_NAME}-slack-app" >/dev/null 2>&1; then + pass "Slack bot/app providers exist in OpenShell" +else + fail "Slack bot/app providers missing in OpenShell" +fi + +section "Phase 2: Runtime state root contract" + +state_env=$(sandbox_exec 'printf "OPENCLAW_HOME=%s\nOPENCLAW_STATE_DIR=%s\nOPENCLAW_CONFIG_PATH=%s\nOPENCLAW_OAUTH_DIR=%s\n" "$OPENCLAW_HOME" "$OPENCLAW_STATE_DIR" "$OPENCLAW_CONFIG_PATH" "$OPENCLAW_OAUTH_DIR"') +state_env_status=$? +info "OpenClaw env from connect shell: ${state_env//$'\n'/; }" +if [ $state_env_status -eq 0 ] \ + && echo "$state_env" | grep -q '^OPENCLAW_HOME=/sandbox$' \ + && echo "$state_env" | grep -q '^OPENCLAW_STATE_DIR=/sandbox/.openclaw$' \ + && echo "$state_env" | grep -q '^OPENCLAW_CONFIG_PATH=/sandbox/.openclaw/openclaw.json$' \ + && echo "$state_env" | grep -q '^OPENCLAW_OAUTH_DIR=/sandbox/.openclaw/credentials$'; then + pass "Connect-shell OpenClaw env resolves to /sandbox/.openclaw" +else + fail "Connect-shell OpenClaw env does not resolve to the shared state root" +fi + +pairing_list_empty=$(sandbox_exec 'openclaw pairing list slack --json 2>&1') +pairing_list_empty_status=$? +info "Initial pairing list: ${pairing_list_empty:0:300}" +if [ $pairing_list_empty_status -eq 0 ] \ + && echo "$pairing_list_empty" | grep -q '"channel"[[:space:]]*:[[:space:]]*"slack"'; then + pass "openclaw pairing list slack works in connect shell" +else + fail "openclaw pairing list slack failed before request creation: ${pairing_list_empty:0:300}" +fi + +section "Phase 3: Hermetic Slack Socket Mode pairing request" + +if start_fake_slack_api "$SLACK_TOKEN" "$SLACK_APP"; then + pass "Hermetic fake Slack API started on host port ${FAKE_SLACK_API_PORT}" +else + fail "Failed to start hermetic fake Slack API" + exit 1 +fi + +if apply_fake_slack_api_policy "$SANDBOX_NAME" "$FAKE_SLACK_API_PORT" >/tmp/nemoclaw-fake-slack-pairing-rest-policy.log 2>&1; then + pass "Applied REST policy for fake Slack chat.postMessage" +else + fail "Failed to apply fake Slack REST policy: $(tail -20 /tmp/nemoclaw-fake-slack-pairing-rest-policy.log 2>/dev/null | tr '\n' ' ' | cut -c1-300)" +fi + +if apply_fake_slack_socket_mode_policy "$SANDBOX_NAME" "$FAKE_SLACK_API_PORT" >/tmp/nemoclaw-fake-slack-pairing-ws-policy.log 2>&1; then + pass "Applied websocket policy for fake Slack Socket Mode" +else + fail "Failed to apply fake Slack websocket policy: $(tail -20 /tmp/nemoclaw-fake-slack-pairing-ws-policy.log 2>/dev/null | tr '\n' ' ' | cut -c1-300)" +fi + +gateway_issue_script=$( + cat <<'SCRIPT' + set -a + [ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh + set +a + fake_slack_api_port="$1" + slack_pairing_user="$2" + fake_slack_api_host="$3" + pairing_e2e_mode="$4" + : "${OPENCLAW_HOME:?OPENCLAW_HOME missing from runtime shell env}" + : "${OPENCLAW_STATE_DIR:?OPENCLAW_STATE_DIR missing from runtime shell env}" + : "${OPENCLAW_CONFIG_PATH:?OPENCLAW_CONFIG_PATH missing from runtime shell env}" + : "${OPENCLAW_OAUTH_DIR:?OPENCLAW_OAUTH_DIR missing from runtime shell env}" + printf 'GATEWAY_OPENCLAW_ENV uid=%s gid=%s OPENCLAW_STATE_DIR=%s OPENCLAW_OAUTH_DIR=%s\n' "$(id -u)" "$(id -g)" "$OPENCLAW_STATE_DIR" "$OPENCLAW_OAUTH_DIR" + exec env \ + HOME=/sandbox \ + OPENCLAW_HOME="$OPENCLAW_HOME" \ + OPENCLAW_STATE_DIR="$OPENCLAW_STATE_DIR" \ + OPENCLAW_CONFIG_PATH="$OPENCLAW_CONFIG_PATH" \ + OPENCLAW_OAUTH_DIR="$OPENCLAW_OAUTH_DIR" \ + HTTP_PROXY="${HTTP_PROXY:-}" \ + HTTPS_PROXY="${HTTPS_PROXY:-}" \ + http_proxy="${http_proxy:-}" \ + https_proxy="${https_proxy:-}" \ + NO_PROXY="${NO_PROXY:-}" \ + no_proxy="${no_proxy:-}" \ + NODE_OPTIONS="${NODE_OPTIONS:-}" \ + FAKE_SLACK_API_HOST="$fake_slack_api_host" \ + FAKE_SLACK_API_PORT="$fake_slack_api_port" \ + SLACK_PAIRING_USER="$slack_pairing_user" \ + PAIRING_E2E_MODE="$pairing_e2e_mode" \ + node --input-type=module <<'NODE' +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +function findOpenClawPackageRootFromBinary() { + let binary = ""; + try { + binary = execFileSync("sh", ["-lc", "command -v openclaw"], { encoding: "utf8" }).trim(); + } catch { + return null; + } + if (!binary) return null; + + let current = ""; + try { + current = fs.realpathSync(binary); + } catch { + return null; + } + if (fs.statSync(current).isFile()) current = path.dirname(current); + + for (let depth = 0; depth < 8; depth += 1) { + const manifest = path.join(current, "package.json"); + if (fs.existsSync(manifest)) { + try { + const pkg = JSON.parse(fs.readFileSync(manifest, "utf8")); + if (pkg?.name === "openclaw") return current; + } catch { + // Keep walking toward the filesystem root. + } + } + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return null; +} + +function loadConversationRuntime() { + const candidates = []; + const binaryRoot = findOpenClawPackageRootFromBinary(); + if (binaryRoot) candidates.push(binaryRoot); + try { + const globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim(); + if (globalRoot) candidates.push(path.join(globalRoot, "openclaw")); + } catch { + // Keep the explicit global-root fallbacks below. + } + candidates.push( + "/usr/local/lib/node_modules/openclaw", + "/usr/lib/node_modules/openclaw", + ); + const uniqueCandidates = [...new Set(candidates)]; + for (const root of uniqueCandidates) { + const runtime = path.join(root, "dist/plugin-sdk/conversation-runtime.js"); + if (fs.existsSync(runtime)) return import(pathToFileURL(runtime).href); + } + throw new Error(`OpenClaw conversation runtime not found; checked: ${uniqueCandidates.join(", ")}`); +} + +function parseProxyTarget() { + const raw = process.env.HTTP_PROXY || process.env.http_proxy || ""; + if (!raw) return null; + try { + const parsed = new URL(raw); + if (parsed.protocol !== "http:") return null; + return { host: parsed.hostname, port: Number(parsed.port || "80") }; + } catch { + return null; + } +} + +function encodeClientText(payload) { + const body = Buffer.from(payload, "utf8"); + const mask = crypto.randomBytes(4); + const masked = Buffer.alloc(body.length); + for (let i = 0; i < body.length; i += 1) masked[i] = body[i] ^ mask[i % 4]; + if (body.length < 126) { + return Buffer.concat([Buffer.from([0x81, 0x80 | body.length]), mask, masked]); + } + const header = Buffer.alloc(4); + header[0] = 0x81; + header[1] = 0x80 | 126; + header.writeUInt16BE(body.length, 2); + return Buffer.concat([header, mask, masked]); +} + +function decodeServerFrame(buffer) { + if (buffer.length < 2) return null; + const opcode = buffer[0] & 0x0f; + let payloadLength = buffer[1] & 0x7f; + let offset = 2; + if (payloadLength === 126) { + if (buffer.length < 4) return null; + payloadLength = buffer.readUInt16BE(2); + offset = 4; + } else if (payloadLength === 127) { + if (buffer.length < 10) return null; + payloadLength = Number(buffer.readBigUInt64BE(2)); + offset = 10; + } + if (buffer.length < offset + payloadLength) return null; + return { + opcode, + payload: buffer.slice(offset, offset + payloadLength), + totalLength: offset + payloadLength, + }; +} + +function receiveSlackSocketEvent() { + const host = process.env.FAKE_SLACK_API_HOST || "host.openshell.internal"; + const port = Number(process.env.FAKE_SLACK_API_PORT); + const proxy = parseProxyTarget(); + + return new Promise((resolve, reject) => { + const socket = proxy + ? net.createConnection({ host: proxy.host, port: proxy.port }) + : net.createConnection({ host, port }); + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error("timed out waiting for fake Slack Socket Mode event")); + }, 30000); + + let handshake = Buffer.alloc(0); + let framed = Buffer.alloc(0); + let upgraded = false; + + socket.on("connect", () => { + const key = crypto.randomBytes(16).toString("base64"); + const requestTarget = proxy + ? `http://${host}:${port}/socket-mode` + : "/socket-mode"; + socket.write([ + `GET ${requestTarget} HTTP/1.1`, + `Host: ${host}:${port}`, + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Key: ${key}`, + "Sec-WebSocket-Version: 13", + "\r\n", + ].join("\r\n")); + }); + + socket.on("data", (chunk) => { + if (!upgraded) { + handshake = Buffer.concat([handshake, chunk]); + const end = handshake.indexOf("\r\n\r\n"); + if (end === -1) return; + const statusLine = handshake.slice(0, end).toString("latin1").split("\r\n")[0] || ""; + if (!statusLine.includes("101")) { + clearTimeout(timer); + socket.destroy(); + reject(new Error(`fake Slack websocket upgrade failed: ${statusLine}`)); + return; + } + upgraded = true; + framed = Buffer.concat([framed, handshake.slice(end + 4)]); + socket.write(encodeClientText(JSON.stringify({ + type: "socket_mode_client_hello", + token: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + }))); + } else { + framed = Buffer.concat([framed, chunk]); + } + + while (framed.length > 0) { + const frame = decodeServerFrame(framed); + if (!frame) break; + framed = framed.slice(frame.totalLength); + if (frame.opcode !== 1) continue; + const envelope = JSON.parse(frame.payload.toString("utf8")); + socket.write(encodeClientText(JSON.stringify({ envelope_id: envelope.envelope_id }))); + clearTimeout(timer); + socket.end(); + socket.destroy(); + resolve(envelope); + return; + } + }); + + socket.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +function postPairingReply(text, channel) { + const host = process.env.FAKE_SLACK_API_HOST || "host.openshell.internal"; + const port = Number(process.env.FAKE_SLACK_API_PORT); + const token = "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"; + const data = new URLSearchParams({ token, channel, text }).toString(); + + return new Promise((resolve, reject) => { + const req = http.request({ + hostname: host, + port, + path: "/api/chat.postMessage", + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/x-www-form-urlencoded", + "Content-Length": Buffer.byteLength(data), + }, + timeout: 30000, + }, (res) => { + let body = ""; + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => { + if (res.statusCode !== 200) { + reject(new Error(`chat.postMessage failed: ${res.statusCode} ${body.slice(0, 200)}`)); + return; + } + resolve(body); + }); + }); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(new Error("chat.postMessage timed out")); + }); + req.write(data); + req.end(); + }); +} + +const { + issuePairingChallenge, + upsertChannelPairingRequest, +} = await loadConversationRuntime(); + +const mode = process.env.PAIRING_E2E_MODE || "full"; +const directGateway = mode === "direct-gateway"; +const socketProbeOnly = mode === "socket-probe"; +const envelope = directGateway + ? { + payload: { + team_id: "T3730E2E", + event: { + type: "message", + channel: "D3730E2E", + user: process.env.SLACK_PAIRING_USER, + }, + }, + } + : await receiveSlackSocketEvent(); +const event = envelope?.payload?.event; +if (!event || event.type !== "message" || !event.user || !event.channel) { + throw new Error(`unexpected fake Slack envelope: ${JSON.stringify(envelope).slice(0, 400)}`); +} +if (event.user !== process.env.SLACK_PAIRING_USER) { + throw new Error(`unexpected fake Slack user: ${event.user}`); +} + +if (socketProbeOnly) { + await postPairingReply("Slack pairing E2E websocket probe", event.channel); + console.log(`SLACK_SOCKET_PROBE_RESULT ${JSON.stringify({ + senderId: event.user, + channelId: event.channel, + })}`); + process.exit(0); +} + +let replyText = ""; +const result = await issuePairingChallenge({ + channel: "slack", + senderId: event.user, + senderIdLine: `Slack user ID: ${event.user}`, + meta: { + accountId: "default", + channelId: event.channel, + teamId: envelope.payload?.team_id || "", + }, + upsertPairingRequest: async ({ id, meta }) => upsertChannelPairingRequest({ + channel: "slack", + id, + accountId: "default", + meta, + }), + sendPairingReply: async (text) => { + if (directGateway) { + replyText = text; + } else { + await postPairingReply(text, event.channel); + } + }, +}); + +if (!result.created || !result.code) { + throw new Error(`pairing challenge was not created: ${JSON.stringify(result)}`); +} + +console.log(`PAIRING_E2E_RESULT ${JSON.stringify({ + code: result.code, + senderId: event.user, + channelId: event.channel, + replyText, +})}`); +NODE +SCRIPT +) +# Drive the hermetic Slack flow through OpenShell's tracked sandbox execution +# path so the request lands in the same state root that the approval CLI reads. +# The gateway-user env inheritance is covered by nemoclaw-start regression tests. +gateway_issue_output=$(sandbox_exec_sh_script "$gateway_issue_script" "$FAKE_SLACK_API_PORT" "$SLACK_PAIRING_USER" "$FAKE_SLACK_API_HOST" full 2>&1) +gateway_issue_status=$? +info "Slack pairing issue output: ${gateway_issue_output:0:600}" +if [ $gateway_issue_status -eq 0 ] && echo "$gateway_issue_output" | grep -q '^PAIRING_E2E_RESULT '; then + pass "OpenShell-tracked Slack Socket Mode handler created a pairing request" +else + fail "OpenShell-tracked Slack Socket Mode pairing request creation failed" +fi + +pairing_result_line=$(printf '%s\n' "$gateway_issue_output" | grep '^PAIRING_E2E_RESULT ' | tail -1 || true) +pairing_json="${pairing_result_line#PAIRING_E2E_RESULT }" +pairing_code=$(node -e 'const data = JSON.parse(process.argv[1]); process.stdout.write(data.code || "");' "$pairing_json" 2>/dev/null || true) +if [ -n "$pairing_code" ]; then + pass "Pairing code extracted from fake Slack reply path" +else + fail "Failed to extract pairing code" + pairing_code="__missing_pairing_code__" +fi + +capture_check=$(check_fake_slack_pairing_capture 2>&1 || true) +if [ "$capture_check" = "OK" ]; then + pass "Fake Slack saw rewritten xapp websocket frame and xoxb chat.postMessage" +else + fail "Fake Slack capture did not prove Slack token rewriting: ${capture_check:0:300}" +fi + +section "Phase 4: Connect-shell approval" + +pending_file_check=$(sandbox_exec "test -f /sandbox/.openclaw/credentials/slack-pairing.json && grep -F '$pairing_code' /sandbox/.openclaw/credentials/slack-pairing.json && grep -F '$SLACK_PAIRING_USER' /sandbox/.openclaw/credentials/slack-pairing.json") +pending_file_status=$? +if [ $pending_file_status -eq 0 ] \ + && echo "$pending_file_check" | grep -qF "$pairing_code" \ + && echo "$pending_file_check" | grep -qF "$SLACK_PAIRING_USER"; then + pass "Runtime-created Slack pending request is in the shared OpenClaw state root" +else + fail "Slack pending request missing from /sandbox/.openclaw/credentials/slack-pairing.json" +fi + +pairing_list=$(sandbox_exec 'openclaw pairing list slack --json 2>&1') +pairing_list_status=$? +info "Pairing list after fake Slack event: ${pairing_list:0:500}" +if [ $pairing_list_status -eq 0 ] \ + && echo "$pairing_list" | grep -qF "$pairing_code" \ + && echo "$pairing_list" | grep -qF "$SLACK_PAIRING_USER"; then + pass "Connect-shell openclaw pairing list sees runtime-created Slack request" +else + fail "Connect-shell openclaw pairing list does not see the Slack request" +fi + +approve_output=$(sandbox_exec "openclaw pairing approve slack '$pairing_code' 2>&1") +approve_status=$? +info "Pairing approve output: ${approve_output:0:500}" +if [ $approve_status -eq 0 ] \ + && echo "$approve_output" | grep -q "Approved" \ + && echo "$approve_output" | grep -qF "$SLACK_PAIRING_USER"; then + pass "Connect-shell openclaw pairing approve approved the Slack request" +else + fail "Connect-shell openclaw pairing approve failed: ${approve_output:0:500}" +fi + +pairing_list_after=$(sandbox_exec 'openclaw pairing list slack --json 2>&1') +pairing_list_after_status=$? +if [ $pairing_list_after_status -ne 0 ]; then + fail "openclaw pairing list slack failed after approval: ${pairing_list_after:0:300}" +elif echo "$pairing_list_after" | grep -qF "$pairing_code"; then + fail "Approved Slack pairing code is still pending" +else + pass "Approved Slack pairing code was consumed" +fi + +allow_from_check=$(sandbox_exec "test -f /sandbox/.openclaw/credentials/slack-default-allowFrom.json && grep -F '$SLACK_PAIRING_USER' /sandbox/.openclaw/credentials/slack-default-allowFrom.json") +allow_from_status=$? +if [ $allow_from_status -eq 0 ] \ + && echo "$allow_from_check" | grep -qF "$SLACK_PAIRING_USER"; then + pass "Slack allowFrom store contains the approved user" +else + fail "Slack allowFrom store missing approved user" +fi + +repeat_approve=$(sandbox_exec "openclaw pairing approve slack '$pairing_code' 2>&1") +if echo "$repeat_approve" | grep -q "No pending pairing request found"; then + pass "Second approval fails closed after request consumption" +else + fail "Second approval did not report missing pending request: ${repeat_approve:0:300}" +fi + +section "Phase 5: Cleanup" + +if [[ "${NEMOCLAW_E2E_KEEP_SANDBOX:-}" = "1" ]]; then + skip "Cleanup: NEMOCLAW_E2E_KEEP_SANDBOX=1 — leaving sandbox '$SANDBOX_NAME' for inspection" +else + nemoclaw "$SANDBOX_NAME" destroy --yes 2>/dev/null || true + openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true +fi + +if [[ "${NEMOCLAW_E2E_KEEP_SANDBOX:-}" = "1" ]]; then + pass "Cleanup: Sandbox '$SANDBOX_NAME' intentionally kept" +elif openshell sandbox list 2>&1 | grep -q "$SANDBOX_NAME"; then + fail "Cleanup: Sandbox '$SANDBOX_NAME' still present after cleanup" +else + pass "Cleanup: Sandbox '$SANDBOX_NAME' removed" +fi + +echo "" +echo "========================================" +echo " OpenClaw Slack Pairing E2E Results:" +echo " Passed: $PASS" +echo " Failed: $FAIL" +echo " Skipped: $SKIP" +echo " Total: $TOTAL" +echo "========================================" + +if [ "$FAIL" -eq 0 ]; then + printf '\n\033[1;32m OpenClaw Slack pairing E2E PASSED.\033[0m\n' + exit 0 +else + printf '\n\033[1;31m %d test(s) FAILED.\033[0m\n' "$FAIL" + exit 1 +fi diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index f081ac10b1a..1ab028378f2 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -222,6 +222,10 @@ describe("nemoclaw-start non-root fallback", () => { 'printf "OPENCLAW_GATEWAY_PORT=%s\\n" "$OPENCLAW_GATEWAY_PORT"', 'printf "OPENCLAW_GATEWAY_URL=%s\\n" "$OPENCLAW_GATEWAY_URL"', 'printf "SANDBOX_HOME=%s\\n" "$_SANDBOX_HOME"', + 'printf "OPENCLAW_HOME=%s\\n" "$OPENCLAW_HOME"', + 'printf "OPENCLAW_STATE_DIR=%s\\n" "$OPENCLAW_STATE_DIR"', + 'printf "OPENCLAW_CONFIG_PATH=%s\\n" "$OPENCLAW_CONFIG_PATH"', + 'printf "OPENCLAW_OAUTH_DIR=%s\\n" "$OPENCLAW_OAUTH_DIR"', 'printf "CMD=%s\\n" "${NEMOCLAW_CMD[*]}"', ].join("\n"); fs.writeFileSync(scriptPath, script, { mode: 0o700 }); @@ -247,6 +251,10 @@ describe("nemoclaw-start non-root fallback", () => { expect(injected.stdout).toContain("OPENCLAW_GATEWAY_PORT=19000"); expect(injected.stdout).toContain("OPENCLAW_GATEWAY_URL=ws://127.0.0.1:19000"); expect(injected.stdout).toContain("SANDBOX_HOME=/sandbox"); + expect(injected.stdout).toContain("OPENCLAW_HOME=/sandbox"); + expect(injected.stdout).toContain("OPENCLAW_STATE_DIR=/sandbox/.openclaw"); + expect(injected.stdout).toContain("OPENCLAW_CONFIG_PATH=/sandbox/.openclaw/openclaw.json"); + expect(injected.stdout).toContain("OPENCLAW_OAUTH_DIR=/sandbox/.openclaw/credentials"); expect(injected.stdout).toContain("CMD=openclaw agent --agent main"); const bakedCustomPort = runScenario("set -- nemoclaw-start openclaw agent", { @@ -257,6 +265,10 @@ describe("nemoclaw-start non-root fallback", () => { expect(bakedCustomPort.stdout).toContain("PUBLIC_PORT=18790"); expect(bakedCustomPort.stdout).toContain("OPENCLAW_GATEWAY_PORT=18790"); expect(bakedCustomPort.stdout).toContain("OPENCLAW_GATEWAY_URL=ws://127.0.0.1:18790"); + expect(bakedCustomPort.stdout).toContain("OPENCLAW_STATE_DIR=/sandbox/.openclaw"); + expect(bakedCustomPort.stdout).toContain( + "OPENCLAW_OAUTH_DIR=/sandbox/.openclaw/credentials", + ); expect(bakedCustomPort.stdout).toContain("CMD=openclaw agent"); const baked = runScenario("set -- nemoclaw-start openclaw agent", { @@ -268,7 +280,15 @@ describe("nemoclaw-start non-root fallback", () => { expect(baked.stdout).toContain("OPENCLAW_GATEWAY_PORT=18789"); expect(baked.stdout).toContain("OPENCLAW_GATEWAY_URL=ws://127.0.0.1:18789"); expect(baked.stdout).toContain("SANDBOX_HOME=/sandbox"); + expect(baked.stdout).toContain("OPENCLAW_STATE_DIR=/sandbox/.openclaw"); expect(baked.stdout).toContain("CMD=openclaw agent"); + + const invalidHighPort = runScenario("set -- nemoclaw-start openclaw agent", { + NEMOCLAW_DASHBOARD_PORT: "70000", + }); + expect(invalidHighPort.status).toBe(1); + expect(invalidHighPort.stderr).toContain("Invalid NEMOCLAW_DASHBOARD_PORT='70000'"); + expect(invalidHighPort.stderr).toContain("must be an integer between 1024 and 65535"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -487,6 +507,10 @@ describe("nemoclaw-start gateway token export (#1114)", () => { `export OPENCLAW_GATEWAY_TOKEN=${JSON.stringify(initialToken)}`, `export OPENCLAW_GATEWAY_PORT=${JSON.stringify(port)}`, `export OPENCLAW_GATEWAY_URL=${JSON.stringify(`ws://127.0.0.1:${port}`)}`, + 'export OPENCLAW_HOME="/sandbox"', + 'export OPENCLAW_STATE_DIR="/sandbox/.openclaw"', + 'export OPENCLAW_CONFIG_PATH="/sandbox/.openclaw/openclaw.json"', + 'export OPENCLAW_OAUTH_DIR="/sandbox/.openclaw/credentials"', `PUBLIC_PORT=${JSON.stringify(port)}`, 'CHAT_UI_URL="https://remote.example.test/ui"', 'PROXY_HOST="10.200.0.1"', @@ -563,6 +587,21 @@ describe("nemoclaw-start gateway token export (#1114)", () => { expect(envFile).toContain("export OPENCLAW_GATEWAY_TOKEN='token'"); }); + it("#3730: writes OpenClaw state env for connect-shell pairing approval", () => { + const { result, envFile } = runGatewayTokenHarness( + JSON.stringify({ gateway: { auth: { token: "token" } } }), + ); + + expect(result.status).toBe(0); + expect(envFile).toContain("export OPENCLAW_HOME='/sandbox'"); + expect(envFile).toContain("export OPENCLAW_STATE_DIR='/sandbox/.openclaw'"); + expect(envFile).toContain("export OPENCLAW_CONFIG_PATH='/sandbox/.openclaw/openclaw.json'"); + expect(envFile).toContain("export OPENCLAW_OAUTH_DIR='/sandbox/.openclaw/credentials'"); + expect(envFile.indexOf("export OPENCLAW_STATE_DIR=")).toBeLessThan( + envFile.indexOf("export OPENCLAW_GATEWAY_TOKEN="), + ); + }); + it("#3256: generates a gateway token before writing the runtime shell env", () => { const { result, envFile, configAfter, hashAfter } = runGatewayTokenHarness( JSON.stringify({ gateway: { auth: {} } }), @@ -1375,7 +1414,7 @@ describe("nemoclaw-start gateway launch signal handling", () => { fs.mkdirSync(fakeBin); fs.writeFileSync( path.join(fakeBin, "openclaw"), - `#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(openclawLog)}\nprintf 'gateway stdout marker\\n'\nprintf 'gateway stderr marker\\n' >&2\nexec sleep 30\n`, + `#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(openclawLog)}\nprintf 'state=%s oauth=%s home=%s config=%s\\n' "$OPENCLAW_STATE_DIR" "$OPENCLAW_OAUTH_DIR" "$OPENCLAW_HOME" "$OPENCLAW_CONFIG_PATH" >> ${JSON.stringify(openclawLog)}\nprintf 'gateway stdout marker\\n'\nprintf 'gateway stderr marker\\n' >&2\nexec sleep 30\n`, { mode: 0o755 }, ); fs.writeFileSync( @@ -1391,6 +1430,10 @@ describe("nemoclaw-start gateway launch signal handling", () => { "set -euo pipefail", `export PATH=${JSON.stringify(`${fakeBin}:${process.env.PATH || ""}`)}`, `OPENCLAW=${JSON.stringify(path.join(fakeBin, "openclaw"))}`, + "export OPENCLAW_HOME=/sandbox", + "export OPENCLAW_STATE_DIR=/sandbox/.openclaw", + "export OPENCLAW_CONFIG_PATH=/sandbox/.openclaw/openclaw.json", + "export OPENCLAW_OAUTH_DIR=/sandbox/.openclaw/credentials", '_DASHBOARD_PORT="19000"', "start_persistent_gateway_log_mirror() { sleep 30 & GATEWAY_LOG_PERSIST_PID=$!; }", "start_auto_pair() { sleep 30 & AUTO_PAIR_PID=$!; }", @@ -1431,6 +1474,9 @@ describe("nemoclaw-start gateway launch signal handling", () => { const { result, openclaw, gateway } = runLaunchBlock("non-root"); expect(result.status).toBe(0); expect(openclaw).toContain("gateway run --port 19000"); + expect(openclaw).toContain( + "state=/sandbox/.openclaw oauth=/sandbox/.openclaw/credentials home=/sandbox config=/sandbox/.openclaw/openclaw.json", + ); expect(gateway).toContain("gateway stdout marker"); expect(gateway).toContain("gateway stderr marker"); expect(result.stdout).not.toContain("gateway stdout marker"); @@ -1446,10 +1492,13 @@ describe("nemoclaw-start gateway launch signal handling", () => { }); it("launches the root gateway through gosu with the configured port and tracks child PIDs", () => { - const { result, gosu } = runLaunchBlock("root"); + const { result, openclaw, gosu } = runLaunchBlock("root"); expect(result.status).toBe(0); expect(gosu).toContain("user=gateway"); expect(gosu).toContain("gateway run --port 19000"); + expect(openclaw).toContain( + "state=/sandbox/.openclaw oauth=/sandbox/.openclaw/credentials home=/sandbox config=/sandbox/.openclaw/openclaw.json", + ); const gatewayPid = result.stdout.match(/GATEWAY_PID=(\d+)/)?.[1]; expect(gatewayPid).toBeTruthy(); expect(result.stdout).toContain(`WAIT_PID=${gatewayPid}`); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index ac617ad3af9..dcdef43c5d1 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -116,6 +116,13 @@ describe("sandbox provisioning: unified .openclaw layout (#2227)", () => { expect(fs.statSync(openclawDir).isDirectory()).toBe(true); expect(fs.statSync(path.join(openclawDir, "exec-approvals.json")).isFile()).toBe(true); expect(fs.statSync(path.join(openclawDir, "update-check.json")).isFile()).toBe(true); + for (const dir of ["credentials", "devices", "identity", "logs", "telegram"]) { + const stateDir = path.join(openclawDir, dir); + expect(fs.statSync(stateDir).isDirectory()).toBe(true); + expect(fs.lstatSync(stateDir).isSymbolicLink()).toBe(false); + expect(fs.statSync(stateDir).mode & 0o020).toBe(0o020); + expect(fs.statSync(stateDir).mode & 0o2000).toBe(0o2000); + } expect(fs.existsSync(path.join(sandboxRoot, ".openclaw-data"))).toBe(false); expect(fs.lstatSync(path.join(openclawDir, "exec-approvals.json")).isSymbolicLink()).toBe( false,