diff --git a/.github/workflows/e2e-vitest-scenarios.yaml b/.github/workflows/e2e-vitest-scenarios.yaml index 127241cff23..6ff974ed78b 100644 --- a/.github/workflows/e2e-vitest-scenarios.yaml +++ b/.github/workflows/e2e-vitest-scenarios.yaml @@ -4210,6 +4210,84 @@ jobs: docker logout docker.io || true rm -rf "${DOCKER_CONFIG}" + openclaw-slack-pairing-vitest: + needs: generate-matrix + if: ${{ (inputs.jobs == '' && inputs.scenarios == '') || contains(format(',{0},', inputs.jobs), ',openclaw-slack-pairing-vitest,') || contains(format(',{0},', inputs.scenarios), ',openclaw-slack-pairing,') }} + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + FREE_STANDING_VITEST_JOB: "1" + FREE_STANDING_SCENARIO_ID: "openclaw-slack-pairing" + DOCKER_CONFIG: ${{ github.workspace }}/.docker-config-openclaw-slack-pairing + E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/vitest/openclaw-slack-pairing + NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js + NEMOCLAW_RUN_E2E_SCENARIOS: "1" + NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" + NEMOCLAW_SANDBOX_NAME: "e2e-openclaw-slack-pairing" + OPENSHELL_GATEWAY: "nemoclaw" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Authenticate to Docker Hub + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then + echo "::notice::Docker Hub credentials not configured; continuing with anonymous pulls." + exit 0 + fi + mkdir -p "${DOCKER_CONFIG}" + chmod 700 "${DOCKER_CONFIG}" + echo "${DOCKERHUB_TOKEN}" | timeout 30s docker login docker.io --username "${DOCKERHUB_USERNAME}" --password-stdin || echo "::warning::Docker Hub login failed; continuing with anonymous pulls." + + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.0.0 + with: + node-version: 22 + cache: npm + + - name: Install root dependencies + run: npm ci --ignore-scripts + + - name: Build CLI + run: npm run build:cli + + - name: Run OpenClaw Slack pairing live test + # Migrated from test/e2e/test-openclaw-slack-pairing.sh. Preserves + # fake Slack Socket Mode/REST token rewrite and connect-shell approval. + env: + NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }} + SLACK_BOT_TOKEN: xoxb-fake-slack-pairing-e2e + SLACK_APP_TOKEN: xapp-fake-slack-pairing-e2e + run: | + set -euo pipefail + npx vitest run --project e2e-scenarios-live \ + test/e2e-scenario/live/openclaw-slack-pairing.test.ts \ + --silent=false --reporter=default + + - name: Upload OpenClaw Slack pairing artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-vitest-scenarios-openclaw-slack-pairing + path: e2e-artifacts/vitest/openclaw-slack-pairing/ + include-hidden-files: false + if-no-files-found: ignore + retention-days: 14 + + - name: Clean up Docker auth + if: always() + run: | + set -euo pipefail + docker logout docker.io || true + rm -rf "${DOCKER_CONFIG}" + issue-2478-crash-loop-recovery-vitest: needs: generate-matrix if: ${{ (inputs.jobs == '' && inputs.scenarios == '') || contains(format(',{0},', inputs.jobs), ',issue-2478-crash-loop-recovery-vitest,') || contains(format(',{0},', inputs.scenarios), ',issue-2478-crash-loop-recovery,') }} @@ -4382,6 +4460,7 @@ jobs: channels-add-remove-vitest, telegram-injection-vitest, openclaw-discord-pairing-vitest, + openclaw-slack-pairing-vitest, ] if: ${{ always() && github.event_name == 'workflow_dispatch' }} permissions: diff --git a/test/e2e-scenario/live/openclaw-slack-pairing.test.ts b/test/e2e-scenario/live/openclaw-slack-pairing.test.ts new file mode 100644 index 00000000000..dce4d0985a9 --- /dev/null +++ b/test/e2e-scenario/live/openclaw-slack-pairing.test.ts @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Live Vitest replacement for test/e2e/test-openclaw-slack-pairing.sh. */ + +import fs from "node:fs"; + +import { expect, test } from "../fixtures/e2e-test.ts"; +import { shouldRunLiveE2EScenarios } from "../fixtures/live-project-gate.ts"; +import { + applyFakePolicy, + approveAndAssertPairing, + assertOpenClawStateRoot, + cleanupPairingSandbox, + extractPairingCode, + issuePairingRequest, + PAIRING_USER, + pairingEnv, + pairingRedactions, + premergeSlackPolicyIfNeeded, + startFakeSlackApi, + writePairingArtifacts, +} from "./openclaw-pairing-helpers.ts"; +import { + dockerInfo, + expectExitZero, + expectSandboxReady, + installSandbox, + resultText, +} from "./phase6-messaging-helpers.ts"; + +const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-openclaw-slack-pairing"; +const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN ?? "xoxb-fake-slack-pairing-e2e"; +const SLACK_APP_TOKEN = process.env.SLACK_APP_TOKEN ?? "xapp-fake-slack-pairing-e2e"; +const LIVE_TIMEOUT_MS = 55 * 60_000; + +function assertSlackCapture(captureFile: string): void { + const rows = fs + .readFileSync(captureFile, "utf8") + .trim() + .split(/\n+/) + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); + const ws = rows + .filter( + (row) => row.event === "websocket-message" && row.messageType === "socket_mode_client_hello", + ) + .at(-1); + expect(ws, "fake Slack did not capture Socket Mode hello").toBeTruthy(); + expect(ws?.tokenMatchesExpected, "Slack xapp websocket token rewrite").toBe(true); + expect(ws?.tokenLooksPlaceholder, "Slack xapp placeholder leaked").toBe(false); + + const post = rows + .filter((row) => row.event === "request" && row.path === "/api/chat.postMessage") + .at(-1); + expect(post, "fake Slack did not capture chat.postMessage").toBeTruthy(); + expect(post?.authorization, "raw Slack authorization should not be captured").toBeUndefined(); + expect(post?.body, "raw Slack body should not be captured").toBeUndefined(); + expect(post?.tokenMatchesExpected, "Slack xoxb auth rewrite").toBe(true); + expect(post?.bodyMatchesExpected, "Slack xoxb body rewrite").toBe(true); + expect(post?.tokenLooksPlaceholder, "Slack xoxb placeholder leaked").toBe(false); +} + +test.skipIf(!shouldRunLiveE2EScenarios())( + "OpenClaw Slack Socket Mode pairing request is shared with connect-shell approval", + { timeout: LIVE_TIMEOUT_MS }, + async ({ artifacts, cleanup, host, sandbox, secrets, skip }) => { + const apiKey = secrets.required("NVIDIA_INFERENCE_API_KEY"); + const env = pairingEnv({ + sandboxName: SANDBOX_NAME, + apiKey, + channel: "slack", + slackBot: SLACK_BOT_TOKEN, + slackApp: SLACK_APP_TOKEN, + }); + const redactions = pairingRedactions({ + apiKey, + slackBot: SLACK_BOT_TOKEN, + slackApp: SLACK_APP_TOKEN, + }); + + await artifacts.writeJson("scenario.json", { + id: "openclaw-slack-pairing", + legacySource: "test/e2e/test-openclaw-slack-pairing.sh", + boundary: + "install.sh Slack OpenClaw sandbox + fake Slack REST/websocket token rewrite + runtime pairing request + connect-shell approval", + sandboxName: SANDBOX_NAME, + pairingUser: PAIRING_USER.slack, + }); + + cleanup.add(`destroy Slack pairing sandbox ${SANDBOX_NAME}`, () => + cleanupPairingSandbox(host, SANDBOX_NAME, env, redactions, "cleanup-slack-pairing"), + ); + await premergeSlackPolicyIfNeeded(cleanup); + await cleanupPairingSandbox(host, SANDBOX_NAME, env, redactions, "preclean-slack-pairing"); + + const docker = await dockerInfo(host, env); + expect(docker.exitCode, resultText(docker)).toBe(0); + + try { + const install = await installSandbox(host, env, redactions, "install-slack-pairing"); + expectExitZero(install, "install.sh --non-interactive with Slack"); + } catch (error) { + if (String(error).includes("NVIDIA_ENDPOINT_RATE_LIMIT")) { + skip("NVIDIA endpoint validation was rate-limited before Slack pairing assertions ran"); + return; + } + throw error; + } + await expectSandboxReady(host, SANDBOX_NAME, env, redactions, "sandbox-list-slack-pairing"); + + for (const providerName of [`${SANDBOX_NAME}-slack-bridge`, `${SANDBOX_NAME}-slack-app`]) { + const provider = await host.command("openshell", ["provider", "get", providerName], { + artifactName: `provider-get-${providerName}`, + env, + redactionValues: redactions, + timeoutMs: 60_000, + }); + expectExitZero(provider, `${providerName} exists`); + } + + await assertOpenClawStateRoot(sandbox, SANDBOX_NAME, "slack", redactions); + + const fakeSlack = await startFakeSlackApi( + host, + cleanup, + env, + SLACK_BOT_TOKEN, + SLACK_APP_TOKEN, + redactions, + ); + await applyFakePolicy({ + host, + sandboxName: SANDBOX_NAME, + api: fakeSlack, + protocol: "rest", + rewrite: "request-body-credential-rewrite", + env, + redactions, + artifactName: "apply-slack-rest-policy", + }); + await applyFakePolicy({ + host, + sandboxName: SANDBOX_NAME, + api: fakeSlack, + protocol: "websocket", + rewrite: "websocket-credential-rewrite", + env, + redactions, + artifactName: "apply-slack-websocket-policy", + }); + + const issue = await issuePairingRequest({ + sandbox, + sandboxName: SANDBOX_NAME, + channel: "slack", + redactions, + fakeSlackPort: fakeSlack.port, + }); + expectExitZero(issue, "Slack pairing request creation"); + const code = extractPairingCode(resultText(issue), "PAIRING_E2E_RESULT"); + assertSlackCapture(fakeSlack.captureFile); + await writePairingArtifacts(artifacts, "slack", { code, user: PAIRING_USER.slack }); + + await approveAndAssertPairing({ + sandbox, + sandboxName: SANDBOX_NAME, + channel: "slack", + code, + redactions, + }); + }, +);