diff --git a/.github/workflows/e2e-vitest-scenarios.yaml b/.github/workflows/e2e-vitest-scenarios.yaml index 19024471884..95d936c86e6 100644 --- a/.github/workflows/e2e-vitest-scenarios.yaml +++ b/.github/workflows/e2e-vitest-scenarios.yaml @@ -4439,6 +4439,106 @@ 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" + 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: Configure isolated Docker auth directory + run: | + set -euo pipefail + echo "DOCKER_CONFIG=${RUNNER_TEMP}/docker-config-openclaw-slack-pairing" >> "$GITHUB_ENV" + + - 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: Install OpenShell CLI + run: | + set -euo pipefail + env -u DOCKER_CONFIG -u DOCKERHUB_USERNAME -u DOCKERHUB_TOKEN -u NVIDIA_API_KEY -u NVIDIA_INFERENCE_API_KEY -u GITHUB_TOKEN bash scripts/install-openshell.sh + + - 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 + export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH" + if command -v openshell >/dev/null 2>&1; then + OPENSHELL_BIN="$(command -v openshell)" + elif [ -x "$HOME/.local/bin/openshell" ]; then + OPENSHELL_BIN="$HOME/.local/bin/openshell" + else + echo "::error::OpenShell CLI not found after install" + ls -la /usr/local/bin/openshell "$HOME/.local/bin/openshell" 2>&1 || true + exit 1 + fi + export OPENSHELL_BIN + echo "Using OPENSHELL_BIN=$OPENSHELL_BIN" + "$OPENSHELL_BIN" --version + 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,') }} @@ -4840,6 +4940,7 @@ jobs: tunnel-lifecycle-vitest, telegram-injection-vitest, openclaw-discord-pairing-vitest, + openclaw-slack-pairing-vitest, channels-stop-start-vitest, ] if: ${{ always() && github.event_name == 'workflow_dispatch' }} diff --git a/test/e2e-scenario/live/openclaw-pairing-helpers.ts b/test/e2e-scenario/live/openclaw-pairing-helpers.ts index f97e95cff67..4402f8d57ba 100644 --- a/test/e2e-scenario/live/openclaw-pairing-helpers.ts +++ b/test/e2e-scenario/live/openclaw-pairing-helpers.ts @@ -16,13 +16,14 @@ import { resultText, sandboxEncodedSh, sandboxNode, - shellQuote, sandboxSh, + shellQuote, } from "./phase6-messaging-helpers.ts"; -export type PairingChannel = "discord"; +export type PairingChannel = "slack" | "discord"; export const PAIRING_USER = { + slack: process.env.NEMOCLAW_SLACK_PAIRING_USER ?? "U3730E2E", discord: process.env.NEMOCLAW_DISCORD_PAIRING_USER ?? "1005536447329222676", }; @@ -32,20 +33,35 @@ export function pairingEnv(options: { sandboxName: string; apiKey: string; channel: PairingChannel; + slackBot?: string; + slackApp?: string; discordToken?: string; }): NodeJS.ProcessEnv { + const extra: NodeJS.ProcessEnv = + options.channel === "slack" + ? { + SLACK_BOT_TOKEN: options.slackBot ?? "xoxb-fake-slack-pairing-e2e", + SLACK_APP_TOKEN: options.slackApp ?? "xapp-fake-slack-pairing-e2e", + NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION: "1", + } + : { + DISCORD_BOT_TOKEN: options.discordToken ?? "test-fake-discord-pairing-e2e", + }; return phase6Env({ sandboxName: options.sandboxName, agent: "openclaw", apiKey: options.apiKey, - extra: { - DISCORD_BOT_TOKEN: options.discordToken ?? "test-fake-discord-pairing-e2e", - }, + extra, }); } -export function pairingRedactions(options: { apiKey: string; discordToken?: string }): string[] { - return [options.apiKey, options.discordToken].filter( +export function pairingRedactions(options: { + apiKey: string; + slackBot?: string; + slackApp?: string; + discordToken?: string; +}): string[] { + return [options.apiKey, options.slackBot, options.slackApp, options.discordToken].filter( (value): value is string => typeof value === "string" && value.length > 0, ); } @@ -68,6 +84,55 @@ export async function cleanupPairingSandbox( ); } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function policyEndpointBlock(text: string, host: string): string { + const lines = text.split(/\r?\n/); + const hostPattern = new RegExp(`^\\s*-\\s+host:\\s*["']?${escapeRegExp(host)}["']?\\s*$`); + const start = lines.findIndex((line) => hostPattern.test(line)); + expect(start, `Slack policy includes endpoint block for ${host}`).toBeGreaterThanOrEqual(0); + const next = lines.findIndex((line, index) => index > start && /^\s*-\s+host:\s*/.test(line)); + return lines.slice(start, next === -1 ? undefined : next).join("\n"); +} + +export async function assertSlackPresetPolicySemantics(options: { + host: HostCliClient; + sandboxName: string; + env: NodeJS.ProcessEnv; + redactions: string[]; +}): Promise { + const policy = await options.host.command( + "openshell", + ["policy", "get", "--full", options.sandboxName], + { + artifactName: "slack-preset-policy-before-fake-overrides", + env: options.env, + redactionValues: options.redactions, + timeoutMs: 60_000, + }, + ); + expectExitZero(policy, "Slack preset policy before fake-host overrides"); + const text = resultText(policy); + const requiredRestHosts = ["slack.com", "api.slack.com", "hooks.slack.com"]; + const requiredWebsocketHosts = ["wss-primary.slack.com", "wss-backup.slack.com"]; + for (const host of requiredRestHosts) { + const block = policyEndpointBlock(text, host); + expect( + block, + `Slack REST endpoint ${host} preserves request-body credential rewrite`, + ).toContain("request_body_credential_rewrite: true"); + } + for (const host of requiredWebsocketHosts) { + const block = policyEndpointBlock(text, host); + expect( + block, + `Slack websocket endpoint ${host} preserves websocket credential rewrite`, + ).toContain("websocket_credential_rewrite: true"); + } +} + export async function startFakeDiscordGateway( host: HostCliClient, cleanup: CleanupRegistry, @@ -88,6 +153,31 @@ export async function startFakeDiscordGateway( }); } +export async function startFakeSlackApi( + host: HostCliClient, + cleanup: CleanupRegistry, + env: NodeJS.ProcessEnv, + botToken: string, + appToken: string, + redactions: string[], +): Promise { + return startFakeDockerApi(host, cleanup.add.bind(cleanup), { + kind: "slack", + imageScript: "fake-slack-api.cjs", + containerPrefix: "nemoclaw-fake-slack-pairing", + portEnv: "FAKE_SLACK_API_PORT", + portFileEnv: "FAKE_SLACK_API_PORT_FILE", + captureFileEnv: "FAKE_SLACK_API_CAPTURE_FILE", + expectedEnv: { + FAKE_SLACK_API_EXPECTED_BOT_TOKEN: botToken, + FAKE_SLACK_API_EXPECTED_APP_TOKEN: appToken, + FAKE_SLACK_API_SOCKET_USER_ID: PAIRING_USER.slack, + }, + env, + redactionValues: redactions, + }); +} + export async function applyFakePolicy(options: { host: HostCliClient; sandboxName: string; @@ -150,12 +240,14 @@ export async function assertOpenClawStateRoot( } // Source-of-truth boundary: the live pairing probe imports the conversation -// runtime from the active `openclaw` binary installed in the sandbox. The invalid +// runtime from the active `openclaw` binary installed in the sandbox. Connect +// shells may shadow that binary with a shell function, so the locator asks bash +// for `type -P openclaw` and intentionally ignores functions/aliases. The invalid // state is an active OpenClaw package without `dist/plugin-sdk/conversation-runtime.js`; -// this Discord migration fails closed for that installer/package drift instead of -// searching secondary global installs. A support test covers the no-runtime path. -// Remove this locator once OpenClaw exposes a stable CLI/import for issuing pairing -// challenges from E2E probes. +// this pairing migration fails closed for that installer/package drift instead of +// searching secondary global installs. Support tests cover shell-function shadows +// and the no-runtime path. Remove this locator once OpenClaw exposes a stable +// CLI/import for issuing pairing challenges from E2E probes. export const LOAD_CONVERSATION_RUNTIME_SOURCE = String.raw` import fs from "node:fs"; import path from "node:path"; @@ -164,11 +256,11 @@ import { pathToFileURL } from "node:url"; function findOpenClawPackageRootFromBinary() { let binary = ""; - try { binary = execFileSync("sh", ["-lc", "command -v openclaw"], { encoding: "utf8" }).trim(); } catch { return null; } + try { binary = execFileSync("bash", ["-lc", "type -P openclaw || 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); + try { if (fs.statSync(current).isFile()) current = path.dirname(current); } catch { return null; } for (let depth = 0; depth < 8; depth += 1) { const manifest = path.join(current, "package.json"); if (fs.existsSync(manifest)) { @@ -203,11 +295,11 @@ set -a set +a discord_pairing_user="$1" discord_dm_channel="$2" -: "\${OPENCLAW_HOME:?OPENCLAW_HOME missing}" -: "\${OPENCLAW_STATE_DIR:?OPENCLAW_STATE_DIR missing}" -: "\${OPENCLAW_CONFIG_PATH:?OPENCLAW_CONFIG_PATH missing}" -: "\${OPENCLAW_OAUTH_DIR:?OPENCLAW_OAUTH_DIR missing}" -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:-}" DISCORD_PAIRING_USER="$discord_pairing_user" DISCORD_DM_CHANNEL="$discord_dm_channel" node --input-type=module <<'NODE' +: "${"$"}{OPENCLAW_HOME:?OPENCLAW_HOME missing}" +: "${"$"}{OPENCLAW_STATE_DIR:?OPENCLAW_STATE_DIR missing}" +: "${"$"}{OPENCLAW_CONFIG_PATH:?OPENCLAW_CONFIG_PATH missing}" +: "${"$"}{OPENCLAW_OAUTH_DIR:?OPENCLAW_OAUTH_DIR missing}" +exec env HOME=/sandbox PATH="/usr/local/bin:/usr/bin:/bin:${"$"}{PATH:-}" 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:-}" DISCORD_PAIRING_USER="$discord_pairing_user" DISCORD_DM_CHANNEL="$discord_dm_channel" node --input-type=module <<'NODE' __LOAD_CONVERSATION_RUNTIME_SOURCE__ const { issuePairingChallenge, upsertChannelPairingRequest } = await loadConversationRuntime(); const senderId = process.env.DISCORD_PAIRING_USER; @@ -226,6 +318,198 @@ console.log("DISCORD_PAIRING_E2E_RESULT " + JSON.stringify({ code: result.code, NODE `.replace("__LOAD_CONVERSATION_RUNTIME_SOURCE__", LOAD_CONVERSATION_RUNTIME_SOURCE); +// Source-of-truth boundary: the Slack live probe owns only validation for its +// localized fake API port and proxy environment because those values are injected +// by the Vitest harness before the probe opens direct Node socket/http clients. +// Invalid state: a malformed fake port or proxy env, or a proxy destination other +// than the NemoClaw/OpenShell gateway proxy emitted by scripts/nemoclaw-start.sh, +// would otherwise hide the real pairing failure behind a low-level network error +// or route the fake Slack websocket through an unexpected host. Source-fix +// constraint: do not change global sandbox proxy generation for this probe; fail +// closed here before network access. Support tests cover malformed values and an +// unexpected-but-valid HTTP proxy host. Remove this localized parser once the +// Slack probe delegates Socket Mode/REST traffic to a shared fake-provider client +// instead of hand-rolled sockets. +export const SLACK_PROBE_INPUT_VALIDATION_SOURCE = String.raw` +function parseFakeSlackPort() { + const raw = process.env.FAKE_SLACK_API_PORT || ""; + const port = Number(raw); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("FAKE_SLACK_API_PORT must be an integer in 1..65535"); + return port; +} +function parseProxyTarget() { + const raw = process.env.HTTP_PROXY || process.env.http_proxy || ""; + if (!raw) return null; + let parsed; + try { + parsed = new URL(raw); + } catch { + throw new Error("HTTP proxy for Slack pairing probe is malformed"); + } + if (parsed.protocol !== "http:") throw new Error("Slack pairing probe only supports HTTP proxies"); + const port = Number(parsed.port || "80"); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("HTTP proxy port for Slack pairing probe is invalid"); + if (parsed.hostname !== "10.200.0.1" || port !== 3128) throw new Error("unexpected HTTP proxy for Slack pairing probe"); + return { host: parsed.hostname, port }; +} +`; + +export const SLACK_PAIRING_SCRIPT = String.raw` +set -eu +set -a +[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh +set +a +fake_slack_api_port="$1" +slack_pairing_user="$2" +: "${"$"}{OPENCLAW_HOME:?OPENCLAW_HOME missing}" +: "${"$"}{OPENCLAW_STATE_DIR:?OPENCLAW_STATE_DIR missing}" +: "${"$"}{OPENCLAW_CONFIG_PATH:?OPENCLAW_CONFIG_PATH missing}" +: "${"$"}{OPENCLAW_OAUTH_DIR:?OPENCLAW_OAUTH_DIR missing}" +exec env HOME=/sandbox PATH="/usr/local/bin:/usr/bin:/bin:${"$"}{PATH:-}" 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="host.openshell.internal" FAKE_SLACK_API_PORT="$fake_slack_api_port" SLACK_PAIRING_USER="$slack_pairing_user" node --input-type=module <<'NODE' +__LOAD_CONVERSATION_RUNTIME_SOURCE__ +import crypto from "node:crypto"; +import http from "node:http"; +import net from "node:net"; + +__SLACK_PROBE_INPUT_VALIDATION_SOURCE__ +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 = "host.openshell.internal"; + const port = parseFakeSlackPort(); + 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 = "host.openshell.internal"; + const port = parseFakeSlackPort(); + 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))); + else 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 envelope = 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); +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) => { 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 })); +NODE +` + .replace("__LOAD_CONVERSATION_RUNTIME_SOURCE__", LOAD_CONVERSATION_RUNTIME_SOURCE) + .replace("__SLACK_PROBE_INPUT_VALIDATION_SOURCE__", SLACK_PROBE_INPUT_VALIDATION_SOURCE); + export type PairingResult = { code: string; senderId: string; @@ -249,23 +533,31 @@ export function extractPairingResult(output: string, marker: string): PairingRes }; } +export function extractPairingCode(output: string, marker: string): string { + const line = output.split(/\r?\n/).find((candidate) => candidate.startsWith(`${marker} `)); + if (!line) throw new Error(`missing ${marker} line: ${output.slice(0, 500)}`); + const data = JSON.parse(line.slice(marker.length + 1)) as { code?: string }; + if (!data.code) throw new Error(`missing pairing code in ${line}`); + return data.code; +} + export async function issuePairingRequest(options: { sandbox: SandboxClient; sandboxName: string; channel: PairingChannel; redactions: string[]; + fakeSlackPort?: string; }): Promise { - return sandboxEncodedSh( - options.sandbox, - options.sandboxName, - DISCORD_PAIRING_SCRIPT, - [PAIRING_USER.discord, DISCORD_DM_CHANNEL], - { - artifactName: `${options.channel}-issue-pairing-request`, - redactionValues: options.redactions, - timeoutMs: 120_000, - }, - ); + const script = options.channel === "slack" ? SLACK_PAIRING_SCRIPT : DISCORD_PAIRING_SCRIPT; + const args = + options.channel === "slack" + ? [options.fakeSlackPort ?? "", PAIRING_USER.slack] + : [PAIRING_USER.discord, DISCORD_DM_CHANNEL]; + return sandboxEncodedSh(options.sandbox, options.sandboxName, script, args, { + artifactName: `${options.channel}-issue-pairing-request`, + redactionValues: options.redactions, + timeoutMs: 120_000, + }); } export function buildPairingPendingCommand( @@ -363,6 +655,14 @@ export async function approveAndAssertPairing(options: { // Ported from test/e2e/lib/discord-gateway-proof.sh run_fake_discord_gateway_node_client. // Keep the request framing as raw source so CRLF sequences remain JavaScript // escapes inside the sandbox node heredoc rather than literal line breaks. +// Source-of-truth boundary: the Discord Gateway proof owns validation for its +// localized sandbox HTTP proxy env because it opens a raw Node socket to the fake +// gateway. Invalid state: malformed proxy env, non-HTTP proxies, invalid ports, +// or proxy destinations other than the NemoClaw/OpenShell gateway proxy would +// hide handshake failures behind low-level network errors or route the proof +// through an unexpected host. Source-fix constraint: keep global sandbox proxy +// generation unchanged; fail closed here before network access. Remove this +// parser once the proof uses a shared fake-provider websocket client. export const DISCORD_GATEWAY_PROOF_SOURCE = String.raw` import crypto from "node:crypto"; import net from "node:net"; @@ -370,23 +670,8 @@ import net from "node:net"; const host = "host.openshell.internal"; const port = Number(process.env.FAKE_DISCORD_GATEWAY_PORT); const identifyToken = "openshell:resolve:env:DISCORD_BOT_TOKEN"; -const proxyUrl = process.env.HTTP_PROXY || process.env.http_proxy || ""; const results = []; -function proxyTarget() { - if (!proxyUrl) return null; - try { - const parsed = new URL(proxyUrl); - if (parsed.protocol !== "http:") return null; - return { - host: parsed.hostname, - port: Number(parsed.port || "80"), - }; - } catch { - return null; - } -} - function finish(message) { if (message) results.push(message); console.log(results.join("\n")); @@ -445,7 +730,23 @@ function decodeFrame(buffer) { }; } -const proxy = proxyTarget(); +function parseProxyTarget() { + const raw = process.env.HTTP_PROXY || process.env.http_proxy || ""; + if (!raw) return null; + let parsed; + try { + parsed = new URL(raw); + } catch { + throw new Error("HTTP proxy for Discord Gateway proof is malformed"); + } + if (parsed.protocol !== "http:") throw new Error("Discord Gateway proof only supports HTTP proxies"); + const proxyPort = Number(parsed.port || "80"); + if (!Number.isInteger(proxyPort) || proxyPort < 1 || proxyPort > 65535) throw new Error("HTTP proxy port for Discord Gateway proof is invalid"); + if (parsed.hostname !== "10.200.0.1" || proxyPort !== 3128) throw new Error("unexpected HTTP proxy for Discord Gateway proof"); + return { host: parsed.hostname, port: proxyPort }; +} + +const proxy = parseProxyTarget(); const socket = proxy ? net.createConnection({ host: proxy.host, port: proxy.port }) : net.createConnection({ host, port }); 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..3df9f1de99e --- /dev/null +++ b/test/e2e-scenario/live/openclaw-slack-pairing.test.ts @@ -0,0 +1,185 @@ +// 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, + assertSlackPresetPolicySemantics, + cleanupPairingSandbox, + extractPairingCode, + issuePairingRequest, + PAIRING_USER, + pairingEnv, + pairingRedactions, + startFakeSlackApi, + writePairingArtifacts, +} from "./openclaw-pairing-helpers.ts"; +import { + dockerInfo, + expectExitZero, + expectSandboxReady, + installSandboxOrSkipOnRateLimit, + 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, expectedCode: string, expectedUser: 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); + expect(String(post?.text ?? ""), "Slack pairing reply includes generated code").toContain( + expectedCode, + ); + const replyText = String(post?.text ?? ""); + expect( + replyText.includes(expectedUser) || replyText.includes(`Slack user ID: ${expectedUser}`), + "Slack pairing reply includes sender identity", + ).toBe(true); +} + +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 cleanupPairingSandbox(host, SANDBOX_NAME, env, redactions, "preclean-slack-pairing"); + + const docker = await dockerInfo(host, env); + expect(docker.exitCode, resultText(docker)).toBe(0); + + const install = await installSandboxOrSkipOnRateLimit( + host, + env, + redactions, + "install-slack-pairing", + skip, + "NVIDIA endpoint validation was rate-limited before Slack pairing assertions ran", + ); + expectExitZero(install, "install.sh --non-interactive with Slack"); + 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); + await assertSlackPresetPolicySemantics({ + host, + sandboxName: SANDBOX_NAME, + env, + 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, code, PAIRING_USER.slack); + await writePairingArtifacts(artifacts, "slack", { code, user: PAIRING_USER.slack }); + + await approveAndAssertPairing({ + sandbox, + sandboxName: SANDBOX_NAME, + channel: "slack", + code, + redactions, + }); + }, +); diff --git a/test/e2e-scenario/support-tests/openclaw-discord-pairing-helpers.test.ts b/test/e2e-scenario/support-tests/openclaw-discord-pairing-helpers.test.ts index fc9f2fb93df..7fdc8ed5c3a 100644 --- a/test/e2e-scenario/support-tests/openclaw-discord-pairing-helpers.test.ts +++ b/test/e2e-scenario/support-tests/openclaw-discord-pairing-helpers.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { type ChildProcess, spawn, spawnSync } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs"; import net from "node:net"; @@ -14,6 +14,7 @@ import { buildPairingPendingCommand, DISCORD_GATEWAY_PROOF_SOURCE, LOAD_CONVERSATION_RUNTIME_SOURCE, + SLACK_PROBE_INPUT_VALIDATION_SOURCE, } from "../live/openclaw-pairing-helpers.ts"; import { sandboxNode } from "../live/phase6-messaging-helpers.ts"; @@ -138,6 +139,50 @@ describe("OpenClaw Discord pairing helper contracts", () => { expect(approveCommand).not.toContain('"abc$(touch /tmp/e2e-should-not-run)"'); }); + it("finds the active OpenClaw package when shell startup shadows openclaw with a function", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-runtime-shadowed-")); + try { + const packageRoot = path.join(tmp, "openclaw-package"); + const packageBin = path.join(packageRoot, "bin"); + const pathBin = path.join(tmp, "path-bin"); + const home = path.join(tmp, "home"); + const runtimeDir = path.join(packageRoot, "dist/plugin-sdk"); + fs.mkdirSync(packageBin, { recursive: true }); + fs.mkdirSync(pathBin, { recursive: true }); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw" }), + ); + fs.writeFileSync( + path.join(runtimeDir, "conversation-runtime.js"), + "export const issuePairingChallenge = true;\n", + ); + fs.writeFileSync(path.join(packageBin, "openclaw"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + fs.symlinkSync(path.join(packageBin, "openclaw"), path.join(pathBin, "openclaw")); + fs.writeFileSync( + path.join(home, ".bashrc"), + "openclaw() { echo shadowed-shell-function; }\nexport -f openclaw\n", + ); + + const result = spawnSync(process.execPath, ["--input-type=module"], { + input: `${LOAD_CONVERSATION_RUNTIME_SOURCE}\nconst runtime = await loadConversationRuntime();\nconsole.log(runtime.issuePairingChallenge);\n`, + encoding: "utf8", + env: { + ...process.env, + BASH_ENV: path.join(home, ".bashrc"), + PATH: `${pathBin}:${process.env.PATH ?? ""}`, + }, + }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("true"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("fails closed when the active OpenClaw package lacks the conversation runtime", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-runtime-missing-")); try { @@ -164,15 +209,57 @@ describe("OpenClaw Discord pairing helper contracts", () => { expect.stringContaining("OpenClaw conversation runtime not found; checked:"), ); expect(result.stderr).toEqual(expect.stringContaining(packageRoot)); - expect(result.stderr).toEqual( - expect.not.stringContaining("/usr/local/lib/node_modules/openclaw"), - ); - expect(result.stderr).toEqual(expect.not.stringContaining("/usr/lib/node_modules/openclaw")); + expect(result.stderr).toEqual(expect.not.stringContaining("/usr/local/bin/openclaw")); + expect(result.stderr).toEqual(expect.not.stringContaining("/usr/bin/openclaw")); + expect(result.stderr).toEqual(expect.not.stringContaining("shadowed-shell-function")); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); + it.each([ + { + name: "missing fake port", + env: { FAKE_SLACK_API_PORT: "", HTTP_PROXY: "", http_proxy: "" }, + error: "FAKE_SLACK_API_PORT must be an integer in 1..65535", + }, + { + name: "out-of-range fake port", + env: { FAKE_SLACK_API_PORT: "70000", HTTP_PROXY: "", http_proxy: "" }, + error: "FAKE_SLACK_API_PORT must be an integer in 1..65535", + }, + { + name: "malformed proxy", + env: { FAKE_SLACK_API_PORT: "12345", HTTP_PROXY: "http://[", http_proxy: "" }, + error: "HTTP proxy for Slack pairing probe is malformed", + }, + { + name: "non-HTTP proxy", + env: { FAKE_SLACK_API_PORT: "12345", HTTP_PROXY: "socks5://127.0.0.1:1080", http_proxy: "" }, + error: "Slack pairing probe only supports HTTP proxies", + }, + { + name: "invalid proxy port", + env: { FAKE_SLACK_API_PORT: "12345", HTTP_PROXY: "http://127.0.0.1:70000", http_proxy: "" }, + error: "HTTP proxy for Slack pairing probe is malformed", + }, + { + name: "unexpected valid proxy host", + env: { FAKE_SLACK_API_PORT: "12345", HTTP_PROXY: "http://127.0.0.1:3128", http_proxy: "" }, + error: "unexpected HTTP proxy for Slack pairing probe", + }, + ])("fails closed on invalid Slack probe input before network access: $name", ({ env, error }) => { + const result = spawnSync(process.execPath, ["--input-type=module"], { + input: `${SLACK_PROBE_INPUT_VALIDATION_SOURCE}\nlet networkAttempted = false;\ntry { parseFakeSlackPort(); parseProxyTarget(); networkAttempted = true; } catch (error) { console.error(error.message); console.error("NETWORK_ATTEMPTED=" + networkAttempted); process.exit(1); }\n`, + encoding: "utf8", + env: { ...process.env, ...env }, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toEqual(expect.stringContaining(error)); + expect(result.stderr).toEqual(expect.stringContaining("NETWORK_ATTEMPTED=false")); + }); + it("keeps Discord Gateway proof source valid for sandbox node heredoc", () => { const result = spawnSync(process.execPath, ["--input-type=module", "--check"], { input: DISCORD_GATEWAY_PROOF_SOURCE, @@ -184,6 +271,42 @@ describe("OpenClaw Discord pairing helper contracts", () => { expect(DISCORD_GATEWAY_PROOF_SOURCE).toContain("IDENTIFY_SENT_PLACEHOLDER"); }); + it.each([ + { + name: "malformed proxy", + env: { HTTP_PROXY: "http://[", http_proxy: "" }, + error: "HTTP proxy for Discord Gateway proof is malformed", + }, + { + name: "non-HTTP proxy", + env: { HTTP_PROXY: "socks5://127.0.0.1:1080", http_proxy: "" }, + error: "Discord Gateway proof only supports HTTP proxies", + }, + { + name: "invalid proxy port", + env: { HTTP_PROXY: "http://127.0.0.1:70000", http_proxy: "" }, + error: "HTTP proxy for Discord Gateway proof is malformed", + }, + { + name: "unexpected valid proxy host", + env: { HTTP_PROXY: "http://127.0.0.1:3128", http_proxy: "" }, + error: "unexpected HTTP proxy for Discord Gateway proof", + }, + ])("fails closed on invalid Discord Gateway proxy input before network access: $name", ({ + env, + error, + }) => { + const result = spawnSync(process.execPath, ["--input-type=module"], { + input: `${DISCORD_GATEWAY_PROOF_SOURCE}\n`, + encoding: "utf8", + env: { ...process.env, FAKE_DISCORD_GATEWAY_PORT: "12345", ...env }, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toEqual(expect.stringContaining(error)); + expect(result.stderr).not.toContain("ECONNREFUSED"); + }); + it("rejects malformed sandboxNode env keys before sandbox execution", async () => { const execShell = vi.fn(async () => { throw new Error("execShell should not run"); diff --git a/test/e2e-scenario/support-tests/openclaw-slack-workflow-boundary.test.ts b/test/e2e-scenario/support-tests/openclaw-slack-workflow-boundary.test.ts new file mode 100644 index 00000000000..a466d3cbda3 --- /dev/null +++ b/test/e2e-scenario/support-tests/openclaw-slack-workflow-boundary.test.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; +import { validateE2eVitestScenariosWorkflowBoundary } from "../../../tools/e2e-scenarios/workflow-boundary.mts"; + +describe("OpenClaw Slack pairing workflow boundary", () => { + it("rejects workspace Docker auth, secret, checkout, dependency, build, and installer drift", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-vitest-workflow-")); + const workflowPath = path.join(tmp, "workflow.yaml"); + const workflow = fs.readFileSync( + path.join(process.cwd(), ".github/workflows/e2e-vitest-scenarios.yaml"), + "utf8", + ); + const parsedWorkflow = YAML.parse(workflow) as { + jobs: Record< + string, + { + env: Record; + steps: Array>; + } + >; + }; + const slackJob = parsedWorkflow.jobs["openclaw-slack-pairing-vitest"]; + slackJob.env.DOCKER_CONFIG = "${{ github.workspace }}/.docker-config-openclaw-slack-pairing"; + const checkout = slackJob.steps.find((step) => + String(step.uses).startsWith("actions/checkout@"), + ) as { uses: string; with: Record }; + checkout.uses = "actions/checkout@v4"; + checkout.with["persist-credentials"] = true; + const setupNode = slackJob.steps.find((step) => step.name === "Set up Node") as { + uses: string; + }; + setupNode.uses = "actions/setup-node@v4"; + const configureDockerAuth = slackJob.steps.find( + (step) => step.name === "Configure isolated Docker auth directory", + ) as Record; + configureDockerAuth.run = + 'echo "DOCKER_CONFIG=${{ github.workspace }}/.docker-config-openclaw-slack-pairing" >> "$GITHUB_ENV"'; + const installRootDependencies = slackJob.steps.find( + (step) => step.name === "Install root dependencies", + ) as Record; + Object.assign(installRootDependencies, { run: "npm install" }); + const buildCli = slackJob.steps.find((step) => step.name === "Build CLI") as Record< + string, + unknown + >; + Object.assign(buildCli, { run: "echo skipping build" }); + const liveStep = slackJob.steps.find( + (step) => step.name === "Run OpenClaw Slack pairing live test", + ) as { env: Record }; + liveStep.env.NVIDIA_API_KEY = "${{ secrets.NVIDIA_API_KEY }}"; + liveStep.env.SLACK_APP_TOKEN = "real-ish-token"; + const installOpenShell = slackJob.steps.find( + (step) => step.name === "Install OpenShell CLI", + ) as Record; + Object.assign(installOpenShell, { run: "bash scripts/install-openshell.sh" }); + fs.writeFileSync(workflowPath, YAML.stringify(parsedWorkflow)); + + try { + expect(validateE2eVitestScenariosWorkflowBoundary(workflowPath)).toEqual( + expect.arrayContaining([ + "openclaw-slack-pairing-vitest job must not set DOCKER_CONFIG at job level", + 'step \'Configure isolated Docker auth directory\' run script must include echo "DOCKER_CONFIG=${RUNNER_TEMP}/docker-config-openclaw-slack-pairing" >> "$GITHUB_ENV"', + "step 'Configure isolated Docker auth directory' run script must not include ${{ github.workspace }}", + "openclaw-slack-pairing-vitest checkout action must be pinned to a full commit SHA", + "openclaw-slack-pairing-vitest checkout step must set persist-credentials=false", + "openclaw-slack-pairing-vitest setup-node action must be pinned to a full commit SHA", + "step 'Install root dependencies' run script must include npm ci --ignore-scripts", + "step 'Build CLI' run script must include npm run build:cli", + "openclaw-slack-pairing-vitest step 'Run OpenClaw Slack pairing live test' env must not include NVIDIA_API_KEY", + "openclaw-slack-pairing-vitest step must use fake Slack app token", + "step 'Install OpenShell CLI' run script must include env -u DOCKER_CONFIG", + ]), + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/tools/e2e-scenarios/workflow-boundary.mts b/tools/e2e-scenarios/workflow-boundary.mts index 09d2b254813..f7a245f5325 100644 --- a/tools/e2e-scenarios/workflow-boundary.mts +++ b/tools/e2e-scenarios/workflow-boundary.mts @@ -3975,6 +3975,153 @@ function validateOpenClawDiscordPairingVitestJob(errors: string[], jobs: Workflo requireRunContains(errors, cleanup, 'rm -rf "${DOCKER_CONFIG}"'); } +function validateOpenClawSlackPairingVitestJob(errors: string[], jobs: WorkflowRecord): void { + const jobName = "openclaw-slack-pairing-vitest"; + const scenarioName = "openclaw-slack-pairing"; + const job = asRecord(jobs[jobName]); + if (Object.keys(job).length === 0) { + errors.push("workflow missing openclaw-slack-pairing-vitest job"); + return; + } + + if (job["runs-on"] !== "ubuntu-latest") { + errors.push("openclaw-slack-pairing-vitest job must run on ubuntu-latest"); + } + if (job["timeout-minutes"] !== 60) { + errors.push("openclaw-slack-pairing-vitest job must keep the 60 minute timeout"); + } + validateFreeStandingJobSelector(errors, jobs, jobName, scenarioName); + + const jobEnv = asRecord(job.env); + if ("DOCKER_CONFIG" in jobEnv) { + errors.push("openclaw-slack-pairing-vitest job must not set DOCKER_CONFIG at job level"); + } + for (const secret of ["NVIDIA_INFERENCE_API_KEY", "NVIDIA_API_KEY", ...COMMON_SECRET_ENV_NAMES]) { + requireEnvDoesNotExposeSecret(errors, "openclaw-slack-pairing-vitest job", jobEnv, secret); + } + + const steps = asSteps(job.steps); + requireNoDispatchInputInterpolation(errors, steps); + for (const step of steps) { + const stepName = `openclaw-slack-pairing-vitest step '${step.name ?? step.uses ?? ""}'`; + const stepEnv = asRecord(step.env); + if (step.name !== "Run OpenClaw Slack pairing live test") { + requireEnvDoesNotExposeSecret(errors, stepName, stepEnv, "NVIDIA_INFERENCE_API_KEY"); + } + requireEnvDoesNotExposeSecret(errors, stepName, stepEnv, "NVIDIA_API_KEY"); + if (step.name !== "Authenticate to Docker Hub") { + requireEnvDoesNotExposeSecret(errors, stepName, stepEnv, "DOCKERHUB_USERNAME"); + requireEnvDoesNotExposeSecret(errors, stepName, stepEnv, "DOCKERHUB_TOKEN"); + requireNoDockerHubAuthInRun(errors, stepName, stringValue(step.run)); + } + requireEnvDoesNotExposeSecret(errors, stepName, stepEnv, "GITHUB_TOKEN"); + } + + const checkout = steps.find((step) => stringValue(step.uses).startsWith("actions/checkout@")); + if (!checkout) errors.push("openclaw-slack-pairing-vitest job missing checkout step"); + requireFullShaAction(errors, checkout, "openclaw-slack-pairing-vitest checkout"); + if (asRecord(checkout?.with)["persist-credentials"] !== false) { + errors.push("openclaw-slack-pairing-vitest checkout step must set persist-credentials=false"); + } + + const setupNode = namedStep(steps, "Set up Node"); + if (!setupNode) errors.push("openclaw-slack-pairing-vitest job missing step: Set up Node"); + requireFullShaAction(errors, setupNode, "openclaw-slack-pairing-vitest setup-node"); + + const installRootDependencies = requireJobStep( + errors, + jobName, + steps, + "Install root dependencies", + ); + requireRunContains(errors, installRootDependencies, "npm ci --ignore-scripts"); + + const buildCli = requireJobStep(errors, jobName, steps, "Build CLI"); + requireRunContains(errors, buildCli, "npm run build:cli"); + + const configureDockerAuth = requireJobStep( + errors, + jobName, + steps, + "Configure isolated Docker auth directory", + ); + requireRunContains( + errors, + configureDockerAuth, + 'echo "DOCKER_CONFIG=${RUNNER_TEMP}/docker-config-openclaw-slack-pairing" >> "$GITHUB_ENV"', + ); + requireRunDoesNotContain(errors, configureDockerAuth, "${{ runner.temp }}"); + requireRunDoesNotContain(errors, configureDockerAuth, "${{ github.workspace }}"); + + const dockerLogin = requireJobStep(errors, jobName, steps, "Authenticate to Docker Hub"); + const dockerLoginEnv = asRecord(dockerLogin?.env); + if (dockerLoginEnv.DOCKERHUB_USERNAME !== "${{ secrets.DOCKERHUB_USERNAME }}") { + errors.push( + "openclaw-slack-pairing-vitest Docker Hub auth must receive DOCKERHUB_USERNAME from secrets", + ); + } + if (dockerLoginEnv.DOCKERHUB_TOKEN !== "${{ secrets.DOCKERHUB_TOKEN }}") { + errors.push( + "openclaw-slack-pairing-vitest Docker Hub auth must receive DOCKERHUB_TOKEN from secrets", + ); + } + requireRunContains(errors, dockerLogin, 'mkdir -p "${DOCKER_CONFIG}"'); + requireRunContains(errors, dockerLogin, 'chmod 700 "${DOCKER_CONFIG}"'); + requireRunContains(errors, dockerLogin, "docker login docker.io"); + requireRunContains(errors, dockerLogin, "--password-stdin"); + + const installOpenShell = requireJobStep(errors, jobName, steps, "Install OpenShell CLI"); + requireRunContains(errors, installOpenShell, "bash scripts/install-openshell.sh"); + requireRunContains(errors, installOpenShell, "env -u DOCKER_CONFIG"); + requireRunContains(errors, installOpenShell, "-u DOCKERHUB_USERNAME"); + requireRunContains(errors, installOpenShell, "-u DOCKERHUB_TOKEN"); + requireRunContains(errors, installOpenShell, "-u NVIDIA_API_KEY"); + requireRunContains(errors, installOpenShell, "-u NVIDIA_INFERENCE_API_KEY"); + requireRunContains(errors, installOpenShell, "-u GITHUB_TOKEN"); + + const runVitest = requireJobStep(errors, jobName, steps, "Run OpenClaw Slack pairing live test"); + const runVitestEnv = asRecord(runVitest?.env); + if (runVitestEnv.NVIDIA_INFERENCE_API_KEY !== "${{ secrets.NVIDIA_INFERENCE_API_KEY }}") { + errors.push( + "openclaw-slack-pairing-vitest step must receive NVIDIA_INFERENCE_API_KEY from secrets", + ); + } + if (runVitestEnv.SLACK_BOT_TOKEN !== "xoxb-fake-slack-pairing-e2e") { + errors.push("openclaw-slack-pairing-vitest step must use fake Slack bot token"); + } + if (runVitestEnv.SLACK_APP_TOKEN !== "xapp-fake-slack-pairing-e2e") { + errors.push("openclaw-slack-pairing-vitest step must use fake Slack app token"); + } + requireRunContains(errors, runVitest, "npx vitest run --project e2e-scenarios-live"); + requireRunContains(errors, runVitest, "test/e2e-scenario/live/openclaw-slack-pairing.test.ts"); + + const upload = requireJobStep(errors, jobName, steps, "Upload OpenClaw Slack pairing artifacts"); + requireFullShaAction(errors, upload, "openclaw-slack-pairing-vitest upload-artifact"); + const uploadWith = asRecord(upload?.with); + const uploadPath = stringValue(uploadWith.path); + requireUploadPathContains(errors, uploadPath, "e2e-artifacts/vitest/openclaw-slack-pairing/"); + if (uploadWith["include-hidden-files"] !== false) { + errors.push( + "openclaw-slack-pairing-vitest artifact upload must set include-hidden-files: false", + ); + } + if (uploadWith["if-no-files-found"] !== "ignore") { + errors.push( + "openclaw-slack-pairing-vitest artifact upload must ignore missing fixture artifacts", + ); + } + if (uploadWith["retention-days"] !== 14) { + errors.push("openclaw-slack-pairing-vitest artifact upload retention-days must be 14"); + } + + const cleanup = requireJobStep(errors, jobName, steps, "Clean up Docker auth"); + if (cleanup?.if !== "always()") { + errors.push("openclaw-slack-pairing-vitest Docker auth cleanup must always run"); + } + requireRunContains(errors, cleanup, "docker logout docker.io"); + requireRunContains(errors, cleanup, 'rm -rf "${DOCKER_CONFIG}"'); +} + function validateChannelsStopStartVitestJob(errors: string[], jobs: WorkflowRecord): void { const jobName = "channels-stop-start-vitest"; const scenarioName = "channels-stop-start"; @@ -4849,6 +4996,7 @@ export function validateE2eVitestScenariosWorkflowBoundary( validateChannelsAddRemoveVitestJob(errors, jobs); validateOpenClawDiscordPairingVitestJob(errors, jobs); + validateOpenClawSlackPairingVitestJob(errors, jobs); validateChannelsStopStartVitestJob(errors, jobs); validateTelegramInjectionVitestJob(errors, jobs);