diff --git a/.github/workflows/e2e-vitest-scenarios.yaml b/.github/workflows/e2e-vitest-scenarios.yaml index 4d76141cebc..19024471884 100644 --- a/.github/workflows/e2e-vitest-scenarios.yaml +++ b/.github/workflows/e2e-vitest-scenarios.yaml @@ -4542,6 +4542,116 @@ jobs: docker logout docker.io || true rm -rf "${DOCKER_CONFIG}" + openclaw-discord-pairing-vitest: + needs: generate-matrix + if: ${{ (inputs.jobs == '' && inputs.scenarios == '') || contains(format(',{0},', inputs.jobs), ',openclaw-discord-pairing-vitest,') || contains(format(',{0},', inputs.scenarios), ',openclaw-discord-pairing,') }} + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + FREE_STANDING_VITEST_JOB: "1" + FREE_STANDING_SCENARIO_ID: "openclaw-discord-pairing" + E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/vitest/openclaw-discord-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-discord-pairing" + OPENSHELL_GATEWAY: "nemoclaw" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Configure isolated Docker auth directory + run: echo "DOCKER_CONFIG=${RUNNER_TEMP}/docker-config-openclaw-discord-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}" + login_succeeded=0 + for attempt in 1 2 3; do + if echo "${DOCKERHUB_TOKEN}" | timeout 30s docker login docker.io --username "${DOCKERHUB_USERNAME}" --password-stdin; then + login_succeeded=1 + break + fi + if [[ "$attempt" -lt 3 ]]; then + echo "::warning::Docker Hub login attempt ${attempt} failed; retrying." + sleep 5 + fi + done + if [[ "$login_succeeded" -ne 1 ]]; then + echo "::warning::Docker Hub login failed after 3 attempts; continuing with anonymous pulls." + fi + + - 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 Discord pairing live test + # Migrated from test/e2e/test-openclaw-discord-pairing.sh. Preserves + # fake Discord Gateway token rewrite and connect-shell pairing approval. + env: + NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }} + DISCORD_BOT_TOKEN: test-fake-discord-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-discord-pairing.test.ts \ + --silent=false --reporter=default + + - name: Upload OpenClaw Discord pairing artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-vitest-scenarios-openclaw-discord-pairing + path: e2e-artifacts/vitest/openclaw-discord-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}" + tunnel-lifecycle-vitest: needs: generate-matrix if: ${{ (inputs.jobs == '' && inputs.scenarios == '') || contains(format(',{0},', inputs.jobs), ',tunnel-lifecycle-vitest,') || contains(format(',{0},', inputs.scenarios), ',tunnel-lifecycle,') }} @@ -4657,6 +4767,7 @@ jobs: docker logout docker.io || true rm -rf "${DOCKER_CONFIG}" + # ── PR result comment (mirrors nightly-e2e.yaml's report-to-pr) ─────────── # Posts a results table on the open PR for the dispatching branch (or the # PR identified by `inputs.pr_number`). `if: always()` so the comment lands @@ -4728,6 +4839,7 @@ jobs: channels-add-remove-vitest, tunnel-lifecycle-vitest, telegram-injection-vitest, + openclaw-discord-pairing-vitest, channels-stop-start-vitest, ] if: ${{ always() && github.event_name == 'workflow_dispatch' }} diff --git a/test/e2e-scenario/live/openclaw-discord-pairing.test.ts b/test/e2e-scenario/live/openclaw-discord-pairing.test.ts new file mode 100644 index 00000000000..d6732a8803d --- /dev/null +++ b/test/e2e-scenario/live/openclaw-discord-pairing.test.ts @@ -0,0 +1,191 @@ +// 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-discord-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, + DISCORD_DM_CHANNEL, + extractPairingResult, + issuePairingRequest, + PAIRING_USER, + pairingEnv, + pairingRedactions, + runDiscordGatewayProof, + startFakeDiscordGateway, + writePairingArtifacts, +} from "./openclaw-pairing-helpers.ts"; +import { + dockerInfo, + expectExitZero, + expectSandboxReady, + installSandboxOrSkipOnRateLimit, + resultText, + sandboxSh, + shellQuote, +} from "./phase6-messaging-helpers.ts"; + +const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-openclaw-discord-pairing"; +const DISCORD_TOKEN = process.env.DISCORD_BOT_TOKEN ?? "test-fake-discord-pairing-e2e"; +const LIVE_TIMEOUT_MS = 55 * 60_000; + +function assertDiscordGatewayCapture(captureFile: string, expectedToken: string): void { + const rows = fs + .readFileSync(captureFile, "utf8") + .trim() + .split(/\n+/) + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); + const identify = rows.filter((row) => row.event === "identify").at(-1); + expect(identify, "fake Discord Gateway did not capture IDENTIFY").toBeTruthy(); + expect(identify).not.toHaveProperty("token"); + expect(JSON.stringify(rows), "fake Discord Gateway capture persisted raw token").not.toContain( + expectedToken, + ); + expect(identify?.tokenMatchesExpected, "Discord token rewrite").toBe(true); + expect(identify?.tokenLooksPlaceholder, "Discord placeholder leaked").toBe(false); +} + +test.skipIf(!shouldRunLiveE2EScenarios())( + "OpenClaw Discord 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: "discord", + discordToken: DISCORD_TOKEN, + }); + const redactions = pairingRedactions({ apiKey, discordToken: DISCORD_TOKEN }); + + await artifacts.writeJson("scenario.json", { + id: "openclaw-discord-pairing", + legacySource: "test/e2e/test-openclaw-discord-pairing.sh", + boundary: + "install.sh Discord OpenClaw sandbox + fake Discord Gateway token rewrite + runtime pairing request + connect-shell approval", + sandboxName: SANDBOX_NAME, + pairingUser: PAIRING_USER.discord, + dmChannel: DISCORD_DM_CHANNEL, + }); + + cleanup.add(`destroy Discord pairing sandbox ${SANDBOX_NAME}`, () => + cleanupPairingSandbox(host, SANDBOX_NAME, env, redactions, "cleanup-discord-pairing"), + ); + await cleanupPairingSandbox(host, SANDBOX_NAME, env, redactions, "preclean-discord-pairing"); + + const docker = await dockerInfo(host, env); + expect(docker.exitCode, resultText(docker)).toBe(0); + + const install = await installSandboxOrSkipOnRateLimit( + host, + env, + redactions, + "install-discord-pairing", + skip, + "NVIDIA endpoint validation was rate-limited before Discord pairing assertions ran", + ); + expectExitZero(install, "install.sh --non-interactive with Discord"); + await expectSandboxReady(host, SANDBOX_NAME, env, redactions, "sandbox-list-discord-pairing"); + + const provider = await host.command( + "openshell", + ["provider", "get", `${SANDBOX_NAME}-discord-bridge`], + { + artifactName: "provider-get-discord-pairing", + env, + redactionValues: redactions, + timeoutMs: 60_000, + }, + ); + expectExitZero(provider, "Discord provider exists"); + + const configScript = + "import json; cfg=json.load(open('/sandbox/.openclaw/openclaw.json')); account=(cfg.get('channels',{}).get('discord',{}).get('accounts',{}).get('default') or {}); proxy=cfg.get('proxy') or {}; print(json.dumps({'token': account.get('token',''), 'dmPolicy': account.get('dmPolicy',''), 'allowFrom': account.get('allowFrom', []), 'accountProxy': account.get('proxy',''), 'managedProxy': proxy.get('proxyUrl','')}))"; + const config = await sandboxSh( + sandbox, + SANDBOX_NAME, + `python3 -c ${shellQuote(configScript)}`, + { artifactName: "discord-openclaw-config", redactionValues: redactions }, + ); + expectExitZero(config, "Discord OpenClaw config"); + const configSummary = JSON.parse(config.stdout.trim()) as { + token: string; + dmPolicy: string; + allowFrom: string[]; + accountProxy: string; + managedProxy: string; + }; + expect(configSummary.token).toContain("openshell:resolve:env:"); + expect(configSummary.token).toContain("DISCORD_BOT_TOKEN"); + expect(configSummary.dmPolicy).not.toBe("allowlist"); + expect(configSummary.accountProxy, "Discord account proxy").toMatch(/^http:\/\//); + expect(configSummary.managedProxy, "OpenClaw managed proxy").toMatch(/^http:\/\//); + + await assertOpenClawStateRoot(sandbox, SANDBOX_NAME, "discord", redactions); + + const fakeGateway = await startFakeDiscordGateway( + host, + cleanup, + env, + DISCORD_TOKEN, + redactions, + ); + await applyFakePolicy({ + host, + sandboxName: SANDBOX_NAME, + api: fakeGateway, + protocol: "websocket", + rewrite: "websocket-credential-rewrite", + env, + redactions, + artifactName: "apply-discord-gateway-policy", + }); + const gatewayProof = await runDiscordGatewayProof({ + sandbox, + sandboxName: SANDBOX_NAME, + port: fakeGateway.port, + redactions, + }); + expectExitZero(gatewayProof, "Discord Gateway protocol proof"); + expect(resultText(gatewayProof)).toContain("UPGRADE"); + expect(resultText(gatewayProof)).toContain("HELLO"); + expect(resultText(gatewayProof)).toContain("IDENTIFY_SENT_PLACEHOLDER"); + expect(resultText(gatewayProof)).toContain("READY"); + expect(resultText(gatewayProof)).toContain("HEARTBEAT_ACK"); + assertDiscordGatewayCapture(fakeGateway.captureFile, DISCORD_TOKEN); + + const issue = await issuePairingRequest({ + sandbox, + sandboxName: SANDBOX_NAME, + channel: "discord", + redactions, + }); + expectExitZero(issue, "Discord pairing request creation"); + const pairing = extractPairingResult(resultText(issue), "DISCORD_PAIRING_E2E_RESULT"); + expect(pairing.senderId).toBe(PAIRING_USER.discord); + expect(pairing.channelId).toBe(DISCORD_DM_CHANNEL); + expect(pairing.replyText, "Discord pairing reply includes generated code").toContain( + pairing.code, + ); + expect(pairing.replyText, "Discord pairing reply includes sender identity").toContain( + PAIRING_USER.discord, + ); + await writePairingArtifacts(artifacts, "discord", { ...pairing, user: PAIRING_USER.discord }); + + await approveAndAssertPairing({ + sandbox, + sandboxName: SANDBOX_NAME, + channel: "discord", + code: pairing.code, + redactions, + }); + }, +); diff --git a/test/e2e-scenario/live/openclaw-pairing-helpers.ts b/test/e2e-scenario/live/openclaw-pairing-helpers.ts new file mode 100644 index 00000000000..f97e95cff67 --- /dev/null +++ b/test/e2e-scenario/live/openclaw-pairing-helpers.ts @@ -0,0 +1,564 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ArtifactSink } from "../fixtures/artifacts.ts"; +import type { CleanupRegistry } from "../fixtures/cleanup.ts"; +import type { HostCliClient } from "../fixtures/clients/host.ts"; +import type { SandboxClient } from "../fixtures/clients/sandbox.ts"; +import { expect } from "../fixtures/e2e-test.ts"; +import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; +import { type FakeDockerApi, startFakeDockerApi } from "./messaging-providers-helpers.ts"; +import { + bestEffort, + cleanupSandbox, + expectExitZero, + phase6Env, + resultText, + sandboxEncodedSh, + sandboxNode, + shellQuote, + sandboxSh, +} from "./phase6-messaging-helpers.ts"; + +export type PairingChannel = "discord"; + +export const PAIRING_USER = { + discord: process.env.NEMOCLAW_DISCORD_PAIRING_USER ?? "1005536447329222676", +}; + +export const DISCORD_DM_CHANNEL = process.env.NEMOCLAW_DISCORD_DM_CHANNEL ?? "1199988877766655554"; + +export function pairingEnv(options: { + sandboxName: string; + apiKey: string; + channel: PairingChannel; + discordToken?: string; +}): NodeJS.ProcessEnv { + return phase6Env({ + sandboxName: options.sandboxName, + agent: "openclaw", + apiKey: options.apiKey, + extra: { + DISCORD_BOT_TOKEN: options.discordToken ?? "test-fake-discord-pairing-e2e", + }, + }); +} + +export function pairingRedactions(options: { apiKey: string; discordToken?: string }): string[] { + return [options.apiKey, options.discordToken].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); +} + +export async function cleanupPairingSandbox( + host: HostCliClient, + sandboxName: string, + env: NodeJS.ProcessEnv, + redactions: string[], + prefix: string, +): Promise { + await cleanupSandbox(host, sandboxName, env, redactions, prefix); + await bestEffort(() => + host.command("openshell", ["gateway", "destroy", "-g", "nemoclaw"], { + artifactName: `${prefix}-openshell-gateway-destroy`, + env, + redactionValues: redactions, + timeoutMs: 120_000, + }), + ); +} + +export async function startFakeDiscordGateway( + host: HostCliClient, + cleanup: CleanupRegistry, + env: NodeJS.ProcessEnv, + token: string, + redactions: string[], +): Promise { + return startFakeDockerApi(host, cleanup.add.bind(cleanup), { + kind: "discord-gateway", + imageScript: "fake-discord-gateway.cjs", + containerPrefix: "nemoclaw-fake-discord-pairing", + portEnv: "FAKE_DISCORD_GATEWAY_PORT", + portFileEnv: "FAKE_DISCORD_GATEWAY_PORT_FILE", + captureFileEnv: "FAKE_DISCORD_GATEWAY_CAPTURE_FILE", + expectedEnv: { FAKE_DISCORD_GATEWAY_EXPECTED_TOKEN: token }, + env, + redactionValues: redactions, + }); +} + +export async function applyFakePolicy(options: { + host: HostCliClient; + sandboxName: string; + api: FakeDockerApi; + protocol: "rest" | "websocket"; + rewrite: "request-body-credential-rewrite" | "websocket-credential-rewrite"; + env: NodeJS.ProcessEnv; + redactions: string[]; + artifactName: string; +}): Promise { + const methods = options.protocol === "rest" ? ["GET", "POST"] : ["GET", "WEBSOCKET_TEXT"]; + const args = [ + "policy", + "update", + options.sandboxName, + "--add-endpoint", + `host.openshell.internal:${options.api.port}:read-write:${options.protocol}:enforce:${options.rewrite},allowed-ip=10.0.0.0/8,allowed-ip=172.16.0.0/12,allowed-ip=192.168.0.0/16`, + ]; + for (const method of methods) + args.push("--add-allow", `host.openshell.internal:${options.api.port}:${method}:/**`); + args.push("--binary", "/usr/local/bin/node", "--binary", "/usr/bin/node", "--wait"); + const result = await options.host.command("openshell", args, { + artifactName: options.artifactName, + env: options.env, + redactionValues: options.redactions, + timeoutMs: 120_000, + }); + expectExitZero(result, options.artifactName); +} + +export async function assertOpenClawStateRoot( + sandbox: SandboxClient, + sandboxName: string, + channel: PairingChannel, + redactions: string[], +): Promise { + const env = await sandboxSh( + sandbox, + sandboxName, + '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"', + { artifactName: `${channel}-openclaw-state-env`, redactionValues: redactions }, + ); + expectExitZero(env, "OpenClaw state env"); + expect(resultText(env)).toContain("OPENCLAW_HOME=/sandbox"); + expect(resultText(env)).toContain("OPENCLAW_STATE_DIR=/sandbox/.openclaw"); + expect(resultText(env)).toContain("OPENCLAW_CONFIG_PATH=/sandbox/.openclaw/openclaw.json"); + expect(resultText(env)).toContain("OPENCLAW_OAUTH_DIR=/sandbox/.openclaw/credentials"); + + const list = await sandboxSh( + sandbox, + sandboxName, + `openclaw pairing list ${channel} --json 2>&1`, + { + artifactName: `${channel}-initial-pairing-list`, + redactionValues: redactions, + }, + ); + expectExitZero(list, `openclaw pairing list ${channel}`); + expect(resultText(list)).toMatch(new RegExp(`"channel"\\s*:\\s*"${channel}"`)); +} + +// Source-of-truth boundary: the live pairing probe imports the conversation +// runtime from the active `openclaw` binary installed in the sandbox. 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. +export const LOAD_CONVERSATION_RUNTIME_SOURCE = String.raw` +import fs from "node:fs"; +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 {} + } + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return null; +} + +async function loadConversationRuntime() { + const candidates = []; + const binaryRoot = findOpenClawPackageRootFromBinary(); + if (binaryRoot) candidates.push(binaryRoot); + for (const root of [...new Set(candidates)]) { + 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: " + candidates.join(", ")); +} +`; + +export const DISCORD_PAIRING_SCRIPT = String.raw` +set -eu +set -a +[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh +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' +__LOAD_CONVERSATION_RUNTIME_SOURCE__ +const { issuePairingChallenge, upsertChannelPairingRequest } = await loadConversationRuntime(); +const senderId = process.env.DISCORD_PAIRING_USER; +const channelId = process.env.DISCORD_DM_CHANNEL; +let replyText = ""; +const result = await issuePairingChallenge({ + channel: "discord", + senderId, + senderIdLine: "Discord user id: " + senderId, + meta: { accountId: "default", channelId, isDirectMessage: true }, + upsertPairingRequest: async ({ id, meta }) => upsertChannelPairingRequest({ channel: "discord", id, accountId: "default", meta }), + sendPairingReply: async (text) => { replyText = text; }, +}); +if (!result.created || !result.code) throw new Error("pairing challenge was not created: " + JSON.stringify(result)); +console.log("DISCORD_PAIRING_E2E_RESULT " + JSON.stringify({ code: result.code, senderId, channelId, replyText })); +NODE +`.replace("__LOAD_CONVERSATION_RUNTIME_SOURCE__", LOAD_CONVERSATION_RUNTIME_SOURCE); + +export type PairingResult = { + code: string; + senderId: string; + channelId: string; + replyText: string; +}; + +export function extractPairingResult(output: string, marker: string): PairingResult { + 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 Partial; + if (!data.code) throw new Error(`missing pairing code in ${line}`); + if (!data.senderId) throw new Error(`missing pairing sender in ${line}`); + if (!data.channelId) throw new Error(`missing pairing channel in ${line}`); + if (!data.replyText) throw new Error(`missing pairing reply text in ${line}`); + return { + code: data.code, + senderId: data.senderId, + channelId: data.channelId, + replyText: data.replyText, + }; +} + +export async function issuePairingRequest(options: { + sandbox: SandboxClient; + sandboxName: string; + channel: PairingChannel; + redactions: 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, + }, + ); +} + +export function buildPairingPendingCommand( + channel: PairingChannel, + code: string, + user: string, +): string { + return `test -f /sandbox/.openclaw/credentials/${channel}-pairing.json && grep -F ${shellQuote(code)} /sandbox/.openclaw/credentials/${channel}-pairing.json && grep -F ${shellQuote(user)} /sandbox/.openclaw/credentials/${channel}-pairing.json`; +} + +export function buildPairingApproveCommand(channel: PairingChannel, code: string): string { + return `openclaw pairing approve ${channel} ${shellQuote(code)} 2>&1`; +} + +export function buildPairingAllowFromCommand(channel: PairingChannel, user: string): string { + return `test -f /sandbox/.openclaw/credentials/${channel}-default-allowFrom.json && grep -F ${shellQuote(user)} /sandbox/.openclaw/credentials/${channel}-default-allowFrom.json`; +} + +export async function approveAndAssertPairing(options: { + sandbox: SandboxClient; + sandboxName: string; + channel: PairingChannel; + code: string; + redactions: string[]; +}): Promise { + const user = PAIRING_USER[options.channel]; + const pending = await sandboxSh( + options.sandbox, + options.sandboxName, + buildPairingPendingCommand(options.channel, options.code, user), + { artifactName: `${options.channel}-pending-file`, redactionValues: options.redactions }, + ); + expectExitZero(pending, `${options.channel} pending file`); + + const list = await sandboxSh( + options.sandbox, + options.sandboxName, + `openclaw pairing list ${options.channel} --json 2>&1`, + { + artifactName: `${options.channel}-pairing-list-before-approve`, + redactionValues: options.redactions, + }, + ); + expectExitZero(list, `${options.channel} pairing list before approval`); + if (!resultText(list).includes(options.code) || !resultText(list).includes(user)) { + throw new Error(`${options.channel} pairing list did not include pending request`); + } + + const approve = await sandboxSh( + options.sandbox, + options.sandboxName, + buildPairingApproveCommand(options.channel, options.code), + { artifactName: `${options.channel}-pairing-approve`, redactionValues: options.redactions }, + ); + expectExitZero(approve, `${options.channel} pairing approve`); + if (!resultText(approve).includes("Approved") || !resultText(approve).includes(user)) { + throw new Error(`${options.channel} approve output did not include Approved and user`); + } + + const after = await sandboxSh( + options.sandbox, + options.sandboxName, + `openclaw pairing list ${options.channel} --json 2>&1`, + { + artifactName: `${options.channel}-pairing-list-after-approve`, + redactionValues: options.redactions, + }, + ); + expectExitZero(after, `${options.channel} pairing list after approval`); + if (resultText(after).includes(options.code)) { + throw new Error(`${options.channel} approved pairing code still pending`); + } + + const allow = await sandboxSh( + options.sandbox, + options.sandboxName, + buildPairingAllowFromCommand(options.channel, user), + { artifactName: `${options.channel}-allow-from`, redactionValues: options.redactions }, + ); + expectExitZero(allow, `${options.channel} allowFrom file`); + + const repeat = await sandboxSh( + options.sandbox, + options.sandboxName, + buildPairingApproveCommand(options.channel, options.code), + { artifactName: `${options.channel}-repeat-approve`, redactionValues: options.redactions }, + ); + if (repeat.exitCode === 0 || !resultText(repeat).includes("No pending pairing request found")) { + throw new Error( + `${options.channel} repeat approval did not fail closed: ${resultText(repeat)}`, + ); + } +} + +// 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. +export const DISCORD_GATEWAY_PROOF_SOURCE = String.raw` +import crypto from "node:crypto"; +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")); + process.exit(0); +} + +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]); + } + if (body.length <= 0xffff) { + const header = Buffer.alloc(4); + header[0] = 0x81; + header[1] = 0x80 | 126; + header.writeUInt16BE(body.length, 2); + return Buffer.concat([header, mask, masked]); + } + const header = Buffer.alloc(10); + header[0] = 0x81; + header[1] = 0x80 | 127; + header.writeBigUInt64BE(BigInt(body.length), 2); + return Buffer.concat([header, mask, masked]); +} + +function encodeClientClose(code) { + const body = Buffer.alloc(2); + body.writeUInt16BE(code, 0); + const mask = crypto.randomBytes(4); + for (let i = 0; i < body.length; i += 1) body[i] ^= mask[i % 4]; + return Buffer.concat([Buffer.from([0x88, 0x80 | 2]), mask, body]); +} + +function decodeFrame(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, + }; +} + +const proxy = proxyTarget(); +const socket = proxy + ? net.createConnection({ host: proxy.host, port: proxy.port }) + : net.createConnection({ host, port }); +const timer = setTimeout(() => { + try { socket.destroy(); } catch {} + finish("TIMEOUT"); +}, 20000); +let handshake = Buffer.alloc(0); +let framed = Buffer.alloc(0); +let upgraded = false; +let sawReady = false; + +socket.on("connect", () => { + const key = crypto.randomBytes(16).toString("base64"); + const requestTarget = proxy + ? "http://" + host + ":" + port + "/gateway?v=10&encoding=json" + : "/gateway?v=10&encoding=json"; + 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); + finish("HTTP_" + statusLine); + } + upgraded = true; + results.push("UPGRADE"); + framed = Buffer.concat([framed, handshake.slice(end + 4)]); + } else { + framed = Buffer.concat([framed, chunk]); + } + + while (framed.length > 0) { + const frame = decodeFrame(framed); + if (!frame) break; + framed = framed.slice(frame.totalLength); + if (frame.opcode === 1) { + const message = JSON.parse(frame.payload.toString("utf8")); + if (message.op === 10) { + results.push("HELLO"); + socket.write(encodeClientText(JSON.stringify({ + op: 2, + d: { + token: identifyToken, + intents: 0, + properties: { os: "linux", browser: "nemoclaw-e2e", device: "nemoclaw-e2e" }, + }, + }))); + results.push("IDENTIFY_SENT_PLACEHOLDER"); + } else if (message.op === 0 && message.t === "READY") { + sawReady = true; + results.push("READY"); + socket.write(encodeClientText(JSON.stringify({ op: 1, d: message.s ?? null }))); + } else if (message.op === 11) { + results.push("HEARTBEAT_ACK"); + socket.write(encodeClientClose(1000)); + clearTimeout(timer); + finish(); + } + } else if (frame.opcode === 8) { + const code = frame.payload.length >= 2 ? frame.payload.readUInt16BE(0) : 0; + clearTimeout(timer); + finish("CLOSE_" + code); + } + } +}); + +socket.on("error", (error) => { + clearTimeout(timer); + finish("ERROR " + error.message); +}); +socket.on("close", () => { + clearTimeout(timer); + if (!sawReady) finish("CLOSED"); +}); +`; + +export async function runDiscordGatewayProof(options: { + sandbox: SandboxClient; + sandboxName: string; + port: string; + redactions: string[]; +}): Promise { + return sandboxNode( + options.sandbox, + options.sandboxName, + DISCORD_GATEWAY_PROOF_SOURCE, + { FAKE_DISCORD_GATEWAY_PORT: options.port }, + { + artifactName: "discord-gateway-proof", + redactionValues: options.redactions, + timeoutMs: 60_000, + }, + ); +} + +export async function writePairingArtifacts( + artifacts: ArtifactSink, + channel: PairingChannel, + data: Record, +): Promise { + await artifacts.writeJson(`${channel}-pairing-result.json`, data); +} diff --git a/test/e2e-scenario/live/phase6-messaging-helpers.ts b/test/e2e-scenario/live/phase6-messaging-helpers.ts index f78c0a16d93..bfa4c7c49ae 100644 --- a/test/e2e-scenario/live/phase6-messaging-helpers.ts +++ b/test/e2e-scenario/live/phase6-messaging-helpers.ts @@ -189,6 +189,55 @@ export async function sandboxSh( }); } +export async function sandboxEncodedSh( + sandbox: SandboxClient, + sandboxName: string, + script: string, + args: string[], + options: { + artifactName: string; + redactionValues?: string[]; + timeoutMs?: number; + }, +): Promise { + const command = [ + "tmp=$(mktemp)", + "trap 'rm -f \"$tmp\"' EXIT", + `printf %s ${shellQuote(base64(script))} | base64 -d > "$tmp"`, + `sh "$tmp" ${args.map(shellQuote).join(" ")}`, + ].join("; "); + return sandboxSh(sandbox, sandboxName, command, options); +} + +export async function sandboxNode( + sandbox: SandboxClient, + sandboxName: string, + source: string, + env: Record, + options: { + artifactName: string; + redactionValues?: string[]; + timeoutMs?: number; + }, +): Promise { + const exports = Object.entries(env) + .map(([key, value]) => { + /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || + (() => { + throw new Error(`invalid env key: ${key}`); + })(); + return `export ${key}=${shellQuote(value)}`; + }) + .join("\n"); + return sandboxEncodedSh( + sandbox, + sandboxName, + `${exports}\nnode --input-type=module <<'NODE'\n${source}\nNODE\n`, + [], + options, + ); +} + export async function dockerInfo( host: HostCliClient, env: NodeJS.ProcessEnv, diff --git a/test/e2e-scenario/support-tests/openclaw-discord-legacy-capture.test.ts b/test/e2e-scenario/support-tests/openclaw-discord-legacy-capture.test.ts new file mode 100644 index 00000000000..83130be4d10 --- /dev/null +++ b/test/e2e-scenario/support-tests/openclaw-discord-legacy-capture.test.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); + +function legacyCaptureCheckerSource(): string { + const legacyScript = fs.readFileSync( + path.join(REPO_ROOT, "test/e2e/test-openclaw-discord-pairing.sh"), + "utf8", + ); + return ( + /check_fake_discord_gateway_capture\(\) \{[\s\S]*?node - "\$FAKE_DISCORD_GATEWAY_CAPTURE_FILE" "\$DISCORD_TOKEN" <<'NODE'\n(?[\s\S]*?)\nNODE\n\}/.exec( + legacyScript, + )?.groups?.source ?? + (() => { + throw new Error("legacy Discord capture checker not found"); + })() + ); +} + +describe("legacy OpenClaw Discord pairing capture check", () => { + it("accepts redacted identify rows without persisting the raw token", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "legacy-discord-capture-")); + try { + const captureFile = path.join(tmp, "capture.jsonl"); + const sentinel = "test-sentinel-discord-token"; + fs.writeFileSync( + captureFile, + `${JSON.stringify({ + event: "identify", + tokenMatchesExpected: true, + tokenLooksPlaceholder: false, + })}\n`, + ); + + const result = spawnSync(process.execPath, ["-", captureFile, sentinel], { + input: legacyCaptureCheckerSource(), + encoding: "utf8", + }); + + expect(fs.readFileSync(captureFile, "utf8")).not.toContain(sentinel); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("OK"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); 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 new file mode 100644 index 00000000000..fc9f2fb93df --- /dev/null +++ b/test/e2e-scenario/support-tests/openclaw-discord-pairing-helpers.test.ts @@ -0,0 +1,247 @@ +// 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 crypto from "node:crypto"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + buildPairingApproveCommand, + buildPairingPendingCommand, + DISCORD_GATEWAY_PROOF_SOURCE, + LOAD_CONVERSATION_RUNTIME_SOURCE, +} from "../live/openclaw-pairing-helpers.ts"; +import { sandboxNode } from "../live/phase6-messaging-helpers.ts"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); + +let child: ChildProcess | undefined; + +afterEach(() => { + child?.kill("SIGTERM"); + child = undefined; +}); + +function encodeClientText(payload: string): Buffer { + 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]; + const header = [ + { max: 125, encode: (length: number) => Buffer.from([0x81, 0x80 | length]) }, + { + max: 0xffff, + encode: (length: number) => { + const value = Buffer.alloc(4); + value[0] = 0x81; + value[1] = 0x80 | 126; + value.writeUInt16BE(length, 2); + return value; + }, + }, + { + max: Number.MAX_SAFE_INTEGER, + encode: (length: number) => { + const value = Buffer.alloc(10); + value[0] = 0x81; + value[1] = 0x80 | 127; + value.writeBigUInt64BE(BigInt(length), 2); + return value; + }, + }, + ] + .find(({ max }) => body.length <= max) + ?.encode(body.length); + return Buffer.concat([header ?? Buffer.alloc(0), mask, masked]); +} + +async function waitForPort(portFile: string): Promise { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + try { + return Number(fs.readFileSync(portFile, "utf8").trim()); + } catch { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + throw new Error("fake Discord Gateway did not write a port file"); +} + +async function sendDiscordIdentify(port: number, token: string): Promise { + await new Promise((resolve, reject) => { + const socket = net.createConnection({ host: "127.0.0.1", port }); + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error("timed out waiting for fake Discord Gateway")); + }, 5_000); + let buffer = Buffer.alloc(0); + + socket.on("connect", () => { + const key = crypto.randomBytes(16).toString("base64"); + socket.write( + [ + "GET /gateway?v=10&encoding=json HTTP/1.1", + `Host: 127.0.0.1:${port}`, + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Key: ${key}`, + "Sec-WebSocket-Version: 13", + "\r\n", + ].join("\r\n"), + ); + }); + socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + buffer.toString("latin1").includes("\r\n\r\n") + ? (() => { + socket.write( + encodeClientText( + JSON.stringify({ + op: 2, + d: { + token, + intents: 0, + properties: { os: "linux", browser: "nemoclaw-e2e", device: "nemoclaw-e2e" }, + }, + }), + ), + ); + clearTimeout(timer); + socket.end(); + resolve(); + })() + : undefined; + }); + socket.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +describe("OpenClaw Discord pairing helper contracts", () => { + it("shell-quotes pairing code and user without command substitution", () => { + const code = "abc$(touch /tmp/e2e-should-not-run)"; + const user = "user`touch /tmp/e2e-should-not-run`"; + + const pendingCommand = buildPairingPendingCommand("discord", code, user); + const approveCommand = buildPairingApproveCommand("discord", code); + + expect(pendingCommand).toContain("'abc$(touch /tmp/e2e-should-not-run)'"); + expect(pendingCommand).toContain("'user`touch /tmp/e2e-should-not-run`'"); + expect(approveCommand).toContain("'abc$(touch /tmp/e2e-should-not-run)'"); + expect(pendingCommand).not.toContain('"abc$(touch /tmp/e2e-should-not-run)"'); + expect(approveCommand).not.toContain('"abc$(touch /tmp/e2e-should-not-run)"'); + }); + + it("fails closed when the active OpenClaw package lacks the conversation runtime", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-runtime-missing-")); + try { + const packageRoot = path.join(tmp, "openclaw-package"); + const packageBin = path.join(packageRoot, "bin"); + const pathBin = path.join(tmp, "path-bin"); + fs.mkdirSync(packageBin, { recursive: true }); + fs.mkdirSync(pathBin, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw" }), + ); + fs.writeFileSync(path.join(packageBin, "openclaw"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + fs.symlinkSync(path.join(packageBin, "openclaw"), path.join(pathBin, "openclaw")); + + const result = spawnSync(process.execPath, ["--input-type=module"], { + input: `${LOAD_CONVERSATION_RUNTIME_SOURCE}\nawait loadConversationRuntime();\n`, + encoding: "utf8", + env: { ...process.env, PATH: `${pathBin}:${process.env.PATH ?? ""}` }, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toEqual( + 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")); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + 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, + encoding: "utf8", + }); + + expect(result.status, result.stderr).toBe(0); + expect(DISCORD_GATEWAY_PROOF_SOURCE).toContain('"\\r\\n"'); + expect(DISCORD_GATEWAY_PROOF_SOURCE).toContain("IDENTIFY_SENT_PLACEHOLDER"); + }); + + it("rejects malformed sandboxNode env keys before sandbox execution", async () => { + const execShell = vi.fn(async () => { + throw new Error("execShell should not run"); + }); + + await expect( + sandboxNode( + { execShell } as never, + "openclaw-discord-env-key", + "console.log('ok');", + { "BAD=$(touch /tmp/e2e-should-not-run)": "value" }, + { artifactName: "discord-invalid-env-key" }, + ), + ).rejects.toThrow("invalid env key"); + expect(execShell).not.toHaveBeenCalled(); + }); + + it("fake Discord Gateway capture omits raw identify token while preserving rewrite booleans", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fake-discord-gateway-")); + const captureFile = path.join(tmp, "capture.jsonl"); + const portFile = path.join(tmp, "port"); + const sentinel = "test-sentinel-discord-token"; + try { + child = spawn( + process.execPath, + [path.join(REPO_ROOT, "test/e2e/lib/fake-discord-gateway.cjs")], + { + env: { + ...process.env, + FAKE_DISCORD_GATEWAY_HOST: "127.0.0.1", + FAKE_DISCORD_GATEWAY_PORT: "0", + FAKE_DISCORD_GATEWAY_PORT_FILE: portFile, + FAKE_DISCORD_GATEWAY_CAPTURE_FILE: captureFile, + FAKE_DISCORD_GATEWAY_EXPECTED_TOKEN: sentinel, + }, + stdio: "ignore", + }, + ); + const port = await waitForPort(portFile); + await sendDiscordIdentify(port, sentinel); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serialized = fs.readFileSync(captureFile, "utf8"); + const identify = serialized + .trim() + .split(/\n+/) + .map((line) => JSON.parse(line) as Record) + .find((row) => row.event === "identify"); + + expect(serialized).not.toContain(sentinel); + expect(serialized).not.toContain("malformed_text"); + expect(identify).not.toHaveProperty("token"); + expect(identify?.tokenMatchesExpected).toBe(true); + expect(identify?.tokenLooksPlaceholder).toBe(false); + } finally { + child?.kill("SIGTERM"); + child = undefined; + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/test/e2e-scenario/support-tests/openclaw-discord-workflow-boundary.test.ts b/test/e2e-scenario/support-tests/openclaw-discord-workflow-boundary.test.ts new file mode 100644 index 00000000000..484e76b5b33 --- /dev/null +++ b/test/e2e-scenario/support-tests/openclaw-discord-workflow-boundary.test.ts @@ -0,0 +1,77 @@ +// 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 Discord pairing workflow boundary", () => { + it("rejects 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 discordJob = parsedWorkflow.jobs["openclaw-discord-pairing-vitest"]; + discordJob.env.DOCKER_CONFIG = + "${{ github.workspace }}/.docker-config-openclaw-discord-pairing"; + const checkout = discordJob.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 = discordJob.steps.find((step) => step.name === "Set up Node") as { + uses: string; + }; + setupNode.uses = "actions/setup-node@v4"; + const installRootDependencies = discordJob.steps.find( + (step) => step.name === "Install root dependencies", + ) as Record; + Object.assign(installRootDependencies, { run: "npm install" }); + const buildCli = discordJob.steps.find((step) => step.name === "Build CLI") as Record< + string, + unknown + >; + Object.assign(buildCli, { run: "echo skipping build" }); + const liveStep = discordJob.steps.find( + (step) => step.name === "Run OpenClaw Discord pairing live test", + ) as { env: Record }; + liveStep.env.NVIDIA_API_KEY = "${{ secrets.NVIDIA_API_KEY }}"; + const installOpenShell = discordJob.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-discord-pairing-vitest job must not set DOCKER_CONFIG at job level", + "openclaw-discord-pairing-vitest checkout action must be pinned to a full commit SHA", + "openclaw-discord-pairing-vitest checkout step must set persist-credentials=false", + "openclaw-discord-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-discord-pairing-vitest step 'Run OpenClaw Discord pairing live test' env must not include NVIDIA_API_KEY", + "step 'Install OpenShell CLI' run script must include env -u DOCKER_CONFIG", + ]), + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/test/e2e/lib/fake-discord-gateway.cjs b/test/e2e/lib/fake-discord-gateway.cjs index e987f1e06a5..cc2fbe15026 100755 --- a/test/e2e/lib/fake-discord-gateway.cjs +++ b/test/e2e/lib/fake-discord-gateway.cjs @@ -106,7 +106,6 @@ function handleGatewayMessage(socket, payload) { const token = message && message.d && message.d.token; record({ event: "identify", - token, tokenMatchesExpected: token === expectedToken, tokenLooksPlaceholder: typeof token === "string" && token.includes("openshell:resolve:env:"), }); diff --git a/test/e2e/test-openclaw-discord-pairing.sh b/test/e2e/test-openclaw-discord-pairing.sh index 29e6f64e17c..194ee9dfa01 100755 --- a/test/e2e/test-openclaw-discord-pairing.sh +++ b/test/e2e/test-openclaw-discord-pairing.sh @@ -185,8 +185,8 @@ check_fake_discord_gateway_capture() { const fs = require("fs"); const file = process.argv[2]; const expected = process.argv[3]; -const rows = fs - .readFileSync(file, "utf8") +const serialized = fs.readFileSync(file, "utf8"); +const rows = serialized .trim() .split(/\n+/) .filter(Boolean) @@ -197,7 +197,7 @@ if (!identify) { console.log("NO_IDENTIFY"); process.exit(2); } -if (identify.tokenMatchesExpected !== true || identify.token !== expected) { +if (identify.tokenMatchesExpected !== true) { console.log("BAD_TOKEN_REWRITE"); process.exit(3); } @@ -205,6 +205,14 @@ if (identify.tokenLooksPlaceholder) { console.log("PLACEHOLDER_LEAK"); process.exit(4); } +if (Object.prototype.hasOwnProperty.call(identify, "token")) { + console.log("RAW_TOKEN_CAPTURED"); + process.exit(5); +} +if (serialized.includes(expected)) { + console.log("RAW_TOKEN_LEAK"); + process.exit(6); +} console.log("OK"); NODE } diff --git a/tools/e2e-scenarios/workflow-boundary.mts b/tools/e2e-scenarios/workflow-boundary.mts index af38c8bca5e..09d2b254813 100644 --- a/tools/e2e-scenarios/workflow-boundary.mts +++ b/tools/e2e-scenarios/workflow-boundary.mts @@ -3821,6 +3821,160 @@ function validateChannelsAddRemoveVitestJob(errors: string[], jobs: WorkflowReco } } +function validateOpenClawDiscordPairingVitestJob(errors: string[], jobs: WorkflowRecord): void { + const jobName = "openclaw-discord-pairing-vitest"; + const scenarioName = "openclaw-discord-pairing"; + const job = asRecord(jobs[jobName]); + if (Object.keys(job).length === 0) { + errors.push("workflow missing openclaw-discord-pairing-vitest job"); + return; + } + + if (job["runs-on"] !== "ubuntu-latest") { + errors.push("openclaw-discord-pairing-vitest job must run on ubuntu-latest"); + } + if (job["timeout-minutes"] !== 60) { + errors.push("openclaw-discord-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-discord-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-discord-pairing-vitest job", jobEnv, secret); + } + + const steps = asSteps(job.steps); + requireNoDispatchInputInterpolation(errors, steps); + for (const step of steps) { + const stepName = `openclaw-discord-pairing-vitest step '${step.name ?? step.uses ?? ""}'`; + const stepEnv = asRecord(step.env); + if (step.name !== "Run OpenClaw Discord 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-discord-pairing-vitest job missing checkout step"); + requireFullShaAction(errors, checkout, "openclaw-discord-pairing-vitest checkout"); + if (asRecord(checkout?.with)["persist-credentials"] !== false) { + errors.push("openclaw-discord-pairing-vitest checkout step must set persist-credentials=false"); + } + + const setupNode = namedStep(steps, "Set up Node"); + if (!setupNode) errors.push("openclaw-discord-pairing-vitest job missing step: Set up Node"); + requireFullShaAction(errors, setupNode, "openclaw-discord-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-discord-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-discord-pairing-vitest Docker Hub auth must receive DOCKERHUB_USERNAME from secrets", + ); + } + if (dockerLoginEnv.DOCKERHUB_TOKEN !== "${{ secrets.DOCKERHUB_TOKEN }}") { + errors.push( + "openclaw-discord-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 Discord pairing live test", + ); + const runVitestEnv = asRecord(runVitest?.env); + if (runVitestEnv.NVIDIA_INFERENCE_API_KEY !== "${{ secrets.NVIDIA_INFERENCE_API_KEY }}") { + errors.push( + "openclaw-discord-pairing-vitest step must receive NVIDIA_INFERENCE_API_KEY from secrets", + ); + } + if (runVitestEnv.DISCORD_BOT_TOKEN !== "test-fake-discord-pairing-e2e") { + errors.push("openclaw-discord-pairing-vitest step must use fake Discord token"); + } + requireRunContains(errors, runVitest, "npx vitest run --project e2e-scenarios-live"); + requireRunContains(errors, runVitest, "test/e2e-scenario/live/openclaw-discord-pairing.test.ts"); + + const upload = requireJobStep( + errors, + jobName, + steps, + "Upload OpenClaw Discord pairing artifacts", + ); + requireFullShaAction(errors, upload, "openclaw-discord-pairing-vitest upload-artifact"); + const uploadWith = asRecord(upload?.with); + const uploadPath = stringValue(uploadWith.path); + requireUploadPathContains(errors, uploadPath, "e2e-artifacts/vitest/openclaw-discord-pairing/"); + if (uploadWith["include-hidden-files"] !== false) { + errors.push( + "openclaw-discord-pairing-vitest artifact upload must set include-hidden-files: false", + ); + } + if (uploadWith["if-no-files-found"] !== "ignore") { + errors.push( + "openclaw-discord-pairing-vitest artifact upload must ignore missing fixture artifacts", + ); + } + if (uploadWith["retention-days"] !== 14) { + errors.push("openclaw-discord-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-discord-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"; @@ -4694,6 +4848,7 @@ export function validateE2eVitestScenariosWorkflowBoundary( ); validateChannelsAddRemoveVitestJob(errors, jobs); + validateOpenClawDiscordPairingVitestJob(errors, jobs); validateChannelsStopStartVitestJob(errors, jobs); validateTelegramInjectionVitestJob(errors, jobs);