From 98ccb03e586ff819a4aaeb7849984f9712b3a846 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 21 Jun 2026 15:32:11 -0700 Subject: [PATCH 1/4] test(e2e): migrate Telegram injection to Vitest --- .github/workflows/e2e-vitest-scenarios.yaml | 78 ++++ .../live/phase6-messaging-helpers.ts | 347 ++++++++++++++++++ .../live/telegram-injection.test.ts | 229 ++++++++++++ 3 files changed, 654 insertions(+) create mode 100644 test/e2e-scenario/live/phase6-messaging-helpers.ts create mode 100644 test/e2e-scenario/live/telegram-injection.test.ts diff --git a/.github/workflows/e2e-vitest-scenarios.yaml b/.github/workflows/e2e-vitest-scenarios.yaml index 95c4093c6a..844109e4bb 100644 --- a/.github/workflows/e2e-vitest-scenarios.yaml +++ b/.github/workflows/e2e-vitest-scenarios.yaml @@ -4056,6 +4056,83 @@ jobs: docker logout docker.io || true rm -rf "${DOCKER_CONFIG}" + telegram-injection-vitest: + needs: generate-matrix + if: ${{ (inputs.jobs == '' && inputs.scenarios == '') || contains(format(',{0},', inputs.jobs), ',telegram-injection-vitest,') || contains(format(',{0},', inputs.scenarios), ',telegram-injection,') }} + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + FREE_STANDING_VITEST_JOB: "1" + FREE_STANDING_SCENARIO_ID: "telegram-injection" + DOCKER_CONFIG: ${{ github.workspace }}/.docker-config-telegram-injection + E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/vitest/telegram-injection + 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-telegram-injection" + OPENSHELL_GATEWAY: "nemoclaw" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Authenticate to Docker Hub + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then + echo "::notice::Docker Hub credentials not configured; continuing with anonymous pulls." + exit 0 + fi + mkdir -p "${DOCKER_CONFIG}" + chmod 700 "${DOCKER_CONFIG}" + echo "${DOCKERHUB_TOKEN}" | timeout 30s docker login docker.io --username "${DOCKERHUB_USERNAME}" --password-stdin || echo "::warning::Docker Hub login failed; continuing with anonymous pulls." + + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.0.0 + with: + node-version: 22 + cache: npm + + - name: Install root dependencies + run: npm ci --ignore-scripts + + - name: Build CLI + run: npm run build:cli + + - name: Run Telegram injection live test + # Migrated from test/e2e/test-telegram-injection.sh. Preserves the + # real OpenShell sandbox boundary for shell metacharacter payloads, + # process-table leak checks, and validateName rejection probes. + env: + NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }} + run: | + set -euo pipefail + npx vitest run --project e2e-scenarios-live \ + test/e2e-scenario/live/telegram-injection.test.ts \ + --silent=false --reporter=default + + - name: Upload Telegram injection artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-vitest-scenarios-telegram-injection + path: e2e-artifacts/vitest/telegram-injection/ + 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,') }} @@ -4226,6 +4303,7 @@ jobs: gateway-health-honest-vitest, device-auth-health-vitest, channels-add-remove-vitest, + telegram-injection-vitest, ] if: ${{ always() && github.event_name == 'workflow_dispatch' }} permissions: diff --git a/test/e2e-scenario/live/phase6-messaging-helpers.ts b/test/e2e-scenario/live/phase6-messaging-helpers.ts new file mode 100644 index 0000000000..f884b7a073 --- /dev/null +++ b/test/e2e-scenario/live/phase6-messaging-helpers.ts @@ -0,0 +1,347 @@ +// 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 { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; +import type { HostCliClient } from "../fixtures/clients/host.ts"; +import { + type SandboxClient, + sandboxAccessEnv, + trustedSandboxShellScript, + validateSandboxName, +} from "../fixtures/clients/sandbox.ts"; +import { expect } from "../fixtures/e2e-test.ts"; +import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; +import { isNvidiaEndpointRateLimitFailure } from "./messaging-providers-helpers.ts"; + +export const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); +export const CLI = process.env.NEMOCLAW_CLI_BIN ?? path.join(REPO_ROOT, "bin", "nemoclaw.js"); +export const REGISTRY_FILE = path.join( + process.env.HOME ?? os.homedir(), + ".nemoclaw", + "sandboxes.json", +); + +export const INSTALL_TIMEOUT_MS = 45 * 60_000; +export const REBUILD_TIMEOUT_MS = 30 * 60_000; +export const COMMAND_TIMEOUT_MS = 120_000; + +export type AgentKind = "openclaw" | "hermes"; +export type JsonRecord = Record; + +export type Phase6Tokens = { + telegram: string; + discord: string; + slackBot: string; + slackApp: string; + wechat: string; +}; + +export function stripAnsi(value: string): string { + return value.replace(/\u001b\[[0-9;]*m/g, ""); +} + +export function resultText(result: Pick): string { + return [result.stdout, result.stderr].filter(Boolean).join("\n"); +} + +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +export function base64(value: string): string { + return Buffer.from(value, "utf8").toString("base64"); +} + +export function isFakeSlackToken(value: string): boolean { + return /^(xoxb|xapp)-(fake|test)-/.test(value); +} + +export function phase6Tokens(suffix: string): Phase6Tokens { + return { + telegram: process.env.TELEGRAM_BOT_TOKEN ?? `test-fake-telegram-token-${suffix}`, + discord: process.env.DISCORD_BOT_TOKEN ?? `test-fake-discord-token-${suffix}`, + slackBot: process.env.SLACK_BOT_TOKEN ?? `xoxb-fake-slack-token-${suffix}`, + slackApp: process.env.SLACK_APP_TOKEN ?? `xapp-fake-slack-token-${suffix}`, + wechat: process.env.WECHAT_BOT_TOKEN ?? `test-fake-wechat-token-${suffix}`, + }; +} + +export function phase6Env(options: { + sandboxName: string; + agent?: AgentKind; + apiKey?: string; + tokens?: Phase6Tokens; + extra?: NodeJS.ProcessEnv; +}): NodeJS.ProcessEnv { + validateSandboxName(options.sandboxName); + const tokens = options.tokens; + const env: NodeJS.ProcessEnv = { + ...buildAvailabilityProbeEnv(), + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_RECREATE_SANDBOX: "1", + NEMOCLAW_FRESH: "1", + NEMOCLAW_POLICY_TIER: process.env.NEMOCLAW_POLICY_TIER ?? "open", + NEMOCLAW_SANDBOX_NAME: options.sandboxName, + OPENSHELL_GATEWAY: process.env.OPENSHELL_GATEWAY ?? "nemoclaw", + ...(options.agent ? { NEMOCLAW_AGENT: options.agent } : {}), + ...(options.apiKey + ? { NVIDIA_INFERENCE_API_KEY: options.apiKey, NVIDIA_API_KEY: options.apiKey } + : {}), + ...(tokens + ? { + TELEGRAM_BOT_TOKEN: tokens.telegram, + TELEGRAM_ALLOWED_IDS: process.env.TELEGRAM_ALLOWED_IDS ?? "123456789,987654321", + TELEGRAM_REQUIRE_MENTION: process.env.TELEGRAM_REQUIRE_MENTION ?? "0", + DISCORD_BOT_TOKEN: tokens.discord, + DISCORD_SERVER_ID: process.env.DISCORD_SERVER_ID ?? "1491590992753590594", + DISCORD_SERVER_IDS: + process.env.DISCORD_SERVER_IDS ?? + process.env.DISCORD_SERVER_ID ?? + "1491590992753590594", + DISCORD_USER_ID: process.env.DISCORD_USER_ID ?? "1005536447329222676", + DISCORD_ALLOWED_IDS: + process.env.DISCORD_ALLOWED_IDS ?? process.env.DISCORD_USER_ID ?? "1005536447329222676", + DISCORD_REQUIRE_MENTION: process.env.DISCORD_REQUIRE_MENTION ?? "0", + SLACK_BOT_TOKEN: tokens.slackBot, + SLACK_APP_TOKEN: tokens.slackApp, + SLACK_ALLOWED_USERS: process.env.SLACK_ALLOWED_USERS ?? "U0123456789,U09ABCDEFGH", + WECHAT_BOT_TOKEN: tokens.wechat, + WECHAT_ACCOUNT_ID: + process.env.WECHAT_ACCOUNT_ID ?? `e2e-fake-account-${options.sandboxName}`, + WECHAT_BASE_URL: process.env.WECHAT_BASE_URL ?? "https://ilinkai.wechat.com", + WECHAT_USER_ID: process.env.WECHAT_USER_ID ?? "wxid_e2e_operator", + WECHAT_ALLOWED_IDS: + process.env.WECHAT_ALLOWED_IDS ?? process.env.WECHAT_USER_ID ?? "wxid_e2e_operator", + } + : {}), + ...options.extra, + }; + + if (tokens?.telegram.includes("fake") && !env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY) { + env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY = "1"; + } + if ( + tokens && + !env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION && + (isFakeSlackToken(tokens.slackBot) || isFakeSlackToken(tokens.slackApp)) + ) { + env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION = "1"; + } + return env; +} + +export function redactionValues(apiKey: string | undefined, tokens?: Phase6Tokens): string[] { + return [apiKey, ...(tokens ? Object.values(tokens) : [])].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); +} + +export async function bestEffort(run: () => Promise): Promise { + try { + await run(); + } catch { + // Cleanup and diagnostics must not hide primary test failures. + } +} + +export function expectExitZero(result: ShellProbeResult, label: string): void { + expect(result.exitCode, `${label}\n${resultText(result)}`).toBe(0); +} + +export async function precleanSandbox( + host: HostCliClient, + sandboxName: string, + env: NodeJS.ProcessEnv, + redactions: string[], + prefix: string, +): Promise { + await bestEffort(() => + host.command("node", [CLI, sandboxName, "destroy", "--yes"], { + artifactName: `${prefix}-nemoclaw-destroy`, + env, + redactionValues: redactions, + timeoutMs: 15 * 60_000, + }), + ); + await bestEffort(() => + host.command("openshell", ["sandbox", "delete", sandboxName], { + artifactName: `${prefix}-openshell-sandbox-delete`, + env, + redactionValues: redactions, + timeoutMs: 120_000, + }), + ); +} + +export async function cleanupSandbox( + host: HostCliClient, + sandboxName: string, + env: NodeJS.ProcessEnv, + redactions: string[], + prefix: string, +): Promise { + await precleanSandbox(host, sandboxName, env, redactions, prefix); +} + +export async function installSandbox( + host: HostCliClient, + env: NodeJS.ProcessEnv, + redactions: string[], + artifactName: string, +): Promise { + const result = await host.command("bash", ["install.sh", "--non-interactive"], { + artifactName, + cwd: REPO_ROOT, + env, + redactionValues: redactions, + timeoutMs: INSTALL_TIMEOUT_MS, + }); + if (result.exitCode !== 0 && isNvidiaEndpointRateLimitFailure(resultText(result))) { + throw new Error(`NVIDIA_ENDPOINT_RATE_LIMIT:${artifactName}`); + } + return result; +} + +export async function rebuildSandbox( + host: HostCliClient, + sandboxName: string, + env: NodeJS.ProcessEnv, + redactions: string[], + artifactName: string, +): Promise { + return host.command("node", [CLI, sandboxName, "rebuild", "--yes"], { + artifactName, + env, + redactionValues: redactions, + timeoutMs: REBUILD_TIMEOUT_MS, + }); +} + +export async function expectSandboxReady( + host: HostCliClient, + sandboxName: string, + env: NodeJS.ProcessEnv, + redactions: string[], + artifactName: string, +): Promise { + const list = await host.command("openshell", ["sandbox", "list"], { + artifactName, + env, + redactionValues: redactions, + timeoutMs: 60_000, + }); + expectExitZero(list, "openshell sandbox list"); + const row = stripAnsi(list.stdout) + .split(/\r?\n/) + .find((line) => line.includes(sandboxName)); + expect(row, resultText(list)).toMatch(/\bReady\b/i); +} + +export function readRegistryEntry(sandboxName: string): JsonRecord { + expect(fs.existsSync(REGISTRY_FILE), `${REGISTRY_FILE} missing`).toBe(true); + const registry = JSON.parse(fs.readFileSync(REGISTRY_FILE, "utf8")) as { + sandboxes?: Record; + }; + const entry = registry.sandboxes?.[sandboxName]; + expect(entry, `registry entry ${sandboxName} missing`).toBeTruthy(); + if (!entry) throw new Error(`registry entry ${sandboxName} missing`); + return entry; +} + +export function messagingPlan(sandboxName: string): JsonRecord { + const messaging = readRegistryEntry(sandboxName).messaging; + expect(messaging && typeof messaging === "object", "registry messaging state missing").toBe(true); + const plan = (messaging as JsonRecord).plan; + expect(plan && typeof plan === "object", "registry messaging.plan missing").toBe(true); + if (!plan || typeof plan !== "object") throw new Error("registry messaging.plan missing"); + return plan as JsonRecord; +} + +export function arrayRecords(value: unknown): JsonRecord[] { + return Array.isArray(value) + ? value.filter((item): item is JsonRecord => Boolean(item) && typeof item === "object") + : []; +} + +export function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +export async function sandboxSh( + sandbox: SandboxClient, + sandboxName: string, + script: string, + options: { + artifactName: string; + redactionValues?: string[]; + timeoutMs?: number; + }, +): Promise { + return sandbox.execShell(sandboxName, trustedSandboxShellScript(script), { + artifactName: options.artifactName, + env: sandboxAccessEnv(), + redactionValues: options.redactionValues ?? [], + timeoutMs: options.timeoutMs ?? COMMAND_TIMEOUT_MS, + }); +} + +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]) => `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, +): Promise { + return host.command("docker", ["info"], { + artifactName: "phase6-docker-info", + env, + timeoutMs: 30_000, + }); +} diff --git a/test/e2e-scenario/live/telegram-injection.test.ts b/test/e2e-scenario/live/telegram-injection.test.ts new file mode 100644 index 0000000000..156852c70d --- /dev/null +++ b/test/e2e-scenario/live/telegram-injection.test.ts @@ -0,0 +1,229 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Live Vitest replacement for test/e2e/test-telegram-injection.sh. */ + +import path from "node:path"; + +import { expect, test } from "../fixtures/e2e-test.ts"; +import { shouldRunLiveE2EScenarios } from "../fixtures/live-project-gate.ts"; +import { + bestEffort, + CLI, + COMMAND_TIMEOUT_MS, + cleanupSandbox, + dockerInfo, + expectExitZero, + expectSandboxReady, + installSandbox, + phase6Env, + REPO_ROOT, + redactionValues, + resultText, + sandboxSh, + shellQuote, +} from "./phase6-messaging-helpers.ts"; + +const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-telegram-injection"; +const LIVE_TIMEOUT_MS = 35 * 60_000; + +function base64(value: string): string { + return Buffer.from(value, "utf8").toString("base64"); +} + +function openshellStdinCommand(payload: string, remoteShell: string): string { + return [ + "set -euo pipefail", + `printf %s ${shellQuote(base64(payload))} | base64 -d | openshell sandbox exec --name ${shellQuote(SANDBOX_NAME)} -- sh -lc ${shellQuote(remoteShell)}`, + ].join("; "); +} + +async function sendPayloadViaSandboxStdin( + host: import("../fixtures/clients/host.ts").HostCliClient, + payload: string, + remoteShell: string, + env: NodeJS.ProcessEnv, + artifactName: string, + redactions: string[], +) { + return host.command("bash", ["-lc", openshellStdinCommand(payload, remoteShell)], { + artifactName, + env, + redactionValues: redactions, + timeoutMs: COMMAND_TIMEOUT_MS, + }); +} + +test.skipIf(!shouldRunLiveE2EScenarios())( + "Telegram bridge-style message handling treats shell metacharacters as data", + { timeout: LIVE_TIMEOUT_MS }, + async ({ artifacts, cleanup, host, sandbox, secrets, skip }) => { + const apiKey = secrets.required("NVIDIA_INFERENCE_API_KEY"); + const env = phase6Env({ sandboxName: SANDBOX_NAME, agent: "openclaw", apiKey }); + const redactions = redactionValues(apiKey); + + await artifacts.writeJson("scenario.json", { + id: "telegram-injection", + legacySource: "test/e2e/test-telegram-injection.sh", + boundary: + "install.sh OpenClaw sandbox + OpenShell sandbox exec stdin path + process table and validateName probes", + sandboxName: SANDBOX_NAME, + contracts: [ + "command substitution payloads are literal input and do not create files", + "parameter expansion does not leak NVIDIA_INFERENCE_API_KEY", + "host and sandbox process tables do not expose the API key after setup", + "invalid sandbox names with shell metacharacters are rejected by validateName", + "normal messages and benign special characters still pass through", + ], + }); + + cleanup.add(`destroy telegram injection sandbox ${SANDBOX_NAME}`, () => + cleanupSandbox(host, SANDBOX_NAME, env, redactions, "cleanup-telegram-injection"), + ); + await cleanupSandbox(host, SANDBOX_NAME, env, redactions, "preclean-telegram-injection"); + + const docker = await dockerInfo(host, env); + expect(docker.exitCode, resultText(docker)).toBe(0); + + try { + const install = await installSandbox(host, env, redactions, "install-telegram-injection"); + expectExitZero(install, "install.sh --non-interactive"); + } catch (error) { + if (String(error).includes("NVIDIA_ENDPOINT_RATE_LIMIT")) { + skip( + "NVIDIA endpoint validation was rate-limited before Telegram injection assertions ran", + ); + return; + } + throw error; + } + await expectSandboxReady( + host, + SANDBOX_NAME, + env, + redactions, + "sandbox-list-telegram-injection", + ); + + for (const [label, marker, payload] of [ + [ + "command-substitution", + "/tmp/injection-proof-t1", + "$(touch /tmp/injection-proof-t1 && echo INJECTED)", + ], + ["backtick-substitution", "/tmp/injection-proof-t2", "`touch /tmp/injection-proof-t2`"], + [ + "single-quote-breakout", + "/tmp/injection-proof-t3", + "'; touch /tmp/injection-proof-t3; echo '", + ], + ] as const) { + await sandboxSh(sandbox, SANDBOX_NAME, `rm -f ${shellQuote(marker)}`, { + artifactName: `remove-${label}-marker`, + redactionValues: redactions, + }); + const send = await sendPayloadViaSandboxStdin( + host, + payload, + 'MSG=$(cat) && echo "Received: $MSG"', + env, + `send-${label}-payload`, + redactions, + ); + expect(send.exitCode, resultText(send)).toBe(0); + const markerCheck = await sandboxSh( + sandbox, + SANDBOX_NAME, + `test -f ${shellQuote(marker)} && echo EXPLOITED || echo SAFE`, + { artifactName: `check-${label}-marker`, redactionValues: redactions }, + ); + expectExitZero(markerCheck, `check ${label} marker`); + expect(markerCheck.stdout.trim(), resultText(markerCheck)).toBe("SAFE"); + } + + const envPayload = await sendPayloadViaSandboxStdin( + host, + "${NVIDIA_INFERENCE_API_KEY}", + 'MSG=$(cat) && echo "$MSG"', + env, + "parameter-expansion-payload", + redactions, + ); + expect(envPayload.exitCode, resultText(envPayload)).toBe(0); + expect(resultText(envPayload)).not.toContain(apiKey.slice(0, 15)); + expect(resultText(envPayload)).toContain("${NVIDIA_INFERENCE_API_KEY}"); + + const apiKeyPrefix = apiKey.slice(0, 15); + const hostPs = await host.command("ps", ["aux"], { + artifactName: "host-process-table-telegram-injection", + env, + redactionValues: redactions, + timeoutMs: 30_000, + }); + const sandboxPs = await sandboxSh(sandbox, SANDBOX_NAME, "ps aux", { + artifactName: "sandbox-process-table-telegram-injection", + redactionValues: redactions, + }); + expect(resultText(hostPs)).not.toContain(apiKeyPrefix); + expect(resultText(sandboxPs)).not.toContain(apiKeyPrefix); + + const invalidNames = [ + "foo;rm -rf /", + "--help", + "$(whoami)", + "`id`", + "foo bar", + "../etc/passwd", + "UPPERCASE", + ]; + for (const invalidName of invalidNames) { + const validation = await host.command( + "node", + [ + "-e", + `const { validateName } = require(${JSON.stringify(path.join(REPO_ROOT, "dist/lib/runner"))});\ntry { validateName(process.argv[1], "SANDBOX_NAME"); console.log("ACCEPTED"); } catch (error) { console.log("REJECTED:" + error.message); }`, + invalidName, + ], + { + artifactName: `validate-name-${invalidName.replace(/[^a-z0-9]+/gi, "-")}`, + env, + redactionValues: redactions, + timeoutMs: 30_000, + }, + ); + expectExitZero(validation, `validateName ${invalidName}`); + expect(validation.stdout, invalidName).toContain("REJECTED"); + } + + const normal = await sendPayloadViaSandboxStdin( + host, + "Hello, what is two plus two?", + 'MSG=$(cat) && echo "Received: $MSG"', + env, + "normal-message-passthrough", + redactions, + ); + expect(normal.exitCode, resultText(normal)).toBe(0); + expect(resultText(normal)).toContain("Hello, what is two plus two?"); + + const special = await sendPayloadViaSandboxStdin( + host, + "What's the meaning of life? It costs $5 & is 100% free!", + 'MSG=$(cat) && echo "$MSG"', + env, + "special-message-passthrough", + redactions, + ); + expect(special.exitCode, resultText(special)).toBe(0); + expect(resultText(special).trim()).not.toBe(""); + + await bestEffort(() => + host.command("node", [CLI, SANDBOX_NAME, "status"], { + artifactName: "post-assert-status-telegram-injection", + env, + redactionValues: redactions, + timeoutMs: 60_000, + }), + ); + }, +); From 7a1acdf8683e6d95b04d4a6685a60a67b32c26ce Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 21 Jun 2026 18:19:33 -0700 Subject: [PATCH 2/4] test(e2e): keep Telegram injection test linear --- .../live/phase6-messaging-helpers.ts | 18 +++++++++++++++ .../live/telegram-injection.test.ts | 23 ++++++++----------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/test/e2e-scenario/live/phase6-messaging-helpers.ts b/test/e2e-scenario/live/phase6-messaging-helpers.ts index f884b7a073..d4e195c611 100644 --- a/test/e2e-scenario/live/phase6-messaging-helpers.ts +++ b/test/e2e-scenario/live/phase6-messaging-helpers.ts @@ -207,6 +207,24 @@ export async function installSandbox( return result; } +export async function installSandboxOrSkipOnRateLimit( + host: HostCliClient, + env: NodeJS.ProcessEnv, + redactions: string[], + artifactName: string, + skip: (note?: string) => never, + skipMessage: string, +): Promise { + try { + return await installSandbox(host, env, redactions, artifactName); + } catch (error) { + if (String(error).includes("NVIDIA_ENDPOINT_RATE_LIMIT")) { + skip(skipMessage); + } + throw error; + } +} + export async function rebuildSandbox( host: HostCliClient, sandboxName: string, diff --git a/test/e2e-scenario/live/telegram-injection.test.ts b/test/e2e-scenario/live/telegram-injection.test.ts index 156852c70d..5c52ed0cb3 100644 --- a/test/e2e-scenario/live/telegram-injection.test.ts +++ b/test/e2e-scenario/live/telegram-injection.test.ts @@ -15,7 +15,7 @@ import { dockerInfo, expectExitZero, expectSandboxReady, - installSandbox, + installSandboxOrSkipOnRateLimit, phase6Env, REPO_ROOT, redactionValues, @@ -85,18 +85,15 @@ test.skipIf(!shouldRunLiveE2EScenarios())( const docker = await dockerInfo(host, env); expect(docker.exitCode, resultText(docker)).toBe(0); - try { - const install = await installSandbox(host, env, redactions, "install-telegram-injection"); - expectExitZero(install, "install.sh --non-interactive"); - } catch (error) { - if (String(error).includes("NVIDIA_ENDPOINT_RATE_LIMIT")) { - skip( - "NVIDIA endpoint validation was rate-limited before Telegram injection assertions ran", - ); - return; - } - throw error; - } + const install = await installSandboxOrSkipOnRateLimit( + host, + env, + redactions, + "install-telegram-injection", + skip, + "NVIDIA endpoint validation was rate-limited before Telegram injection assertions ran", + ); + expectExitZero(install, "install.sh --non-interactive"); await expectSandboxReady( host, SANDBOX_NAME, From d45654f648765dd63eff409524f7a8e6f7d82f47 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 21 Jun 2026 20:39:17 -0700 Subject: [PATCH 3/4] test(e2e): address Telegram injection review feedback --- .github/workflows/e2e-vitest-scenarios.yaml | 38 +++- .../live/phase6-messaging-helpers.ts | 170 +----------------- .../live/telegram-injection.test.ts | 102 +++++++---- 3 files changed, 111 insertions(+), 199 deletions(-) diff --git a/.github/workflows/e2e-vitest-scenarios.yaml b/.github/workflows/e2e-vitest-scenarios.yaml index 844109e4bb..58166c45ac 100644 --- a/.github/workflows/e2e-vitest-scenarios.yaml +++ b/.github/workflows/e2e-vitest-scenarios.yaml @@ -4064,7 +4064,6 @@ jobs: env: FREE_STANDING_VITEST_JOB: "1" FREE_STANDING_SCENARIO_ID: "telegram-injection" - DOCKER_CONFIG: ${{ github.workspace }}/.docker-config-telegram-injection E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/vitest/telegram-injection NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js NEMOCLAW_RUN_E2E_SCENARIOS: "1" @@ -4077,6 +4076,9 @@ jobs: with: persist-credentials: false + - name: Configure isolated Docker auth directory + run: echo "DOCKER_CONFIG=${RUNNER_TEMP}/docker-config-telegram-injection" >> "$GITHUB_ENV" + - name: Authenticate to Docker Hub env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} @@ -4090,7 +4092,20 @@ jobs: 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." + 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 @@ -4104,6 +4119,13 @@ jobs: - name: Build CLI run: npm run build:cli + - name: Install OpenShell + env: + NEMOCLAW_NON_INTERACTIVE: "1" + run: | + set -euo pipefail + env -u DOCKER_CONFIG -u DOCKERHUB_USERNAME -u DOCKERHUB_TOKEN -u NVIDIA_API_KEY -u GITHUB_TOKEN bash scripts/install-openshell.sh + - name: Run Telegram injection live test # Migrated from test/e2e/test-telegram-injection.sh. Preserves the # real OpenShell sandbox boundary for shell metacharacter payloads, @@ -4112,6 +4134,18 @@ jobs: NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }} 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 + "$OPENSHELL_BIN" --version npx vitest run --project e2e-scenarios-live \ test/e2e-scenario/live/telegram-injection.test.ts \ --silent=false --reporter=default diff --git a/test/e2e-scenario/live/phase6-messaging-helpers.ts b/test/e2e-scenario/live/phase6-messaging-helpers.ts index d4e195c611..f78c0a16d9 100644 --- a/test/e2e-scenario/live/phase6-messaging-helpers.ts +++ b/test/e2e-scenario/live/phase6-messaging-helpers.ts @@ -1,8 +1,6 @@ // 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 { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; @@ -19,26 +17,11 @@ import { isNvidiaEndpointRateLimitFailure } from "./messaging-providers-helpers. export const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); export const CLI = process.env.NEMOCLAW_CLI_BIN ?? path.join(REPO_ROOT, "bin", "nemoclaw.js"); -export const REGISTRY_FILE = path.join( - process.env.HOME ?? os.homedir(), - ".nemoclaw", - "sandboxes.json", -); export const INSTALL_TIMEOUT_MS = 45 * 60_000; -export const REBUILD_TIMEOUT_MS = 30 * 60_000; export const COMMAND_TIMEOUT_MS = 120_000; export type AgentKind = "openclaw" | "hermes"; -export type JsonRecord = Record; - -export type Phase6Tokens = { - telegram: string; - discord: string; - slackBot: string; - slackApp: string; - wechat: string; -}; export function stripAnsi(value: string): string { return value.replace(/\u001b\[[0-9;]*m/g, ""); @@ -56,30 +39,14 @@ export function base64(value: string): string { return Buffer.from(value, "utf8").toString("base64"); } -export function isFakeSlackToken(value: string): boolean { - return /^(xoxb|xapp)-(fake|test)-/.test(value); -} - -export function phase6Tokens(suffix: string): Phase6Tokens { - return { - telegram: process.env.TELEGRAM_BOT_TOKEN ?? `test-fake-telegram-token-${suffix}`, - discord: process.env.DISCORD_BOT_TOKEN ?? `test-fake-discord-token-${suffix}`, - slackBot: process.env.SLACK_BOT_TOKEN ?? `xoxb-fake-slack-token-${suffix}`, - slackApp: process.env.SLACK_APP_TOKEN ?? `xapp-fake-slack-token-${suffix}`, - wechat: process.env.WECHAT_BOT_TOKEN ?? `test-fake-wechat-token-${suffix}`, - }; -} - export function phase6Env(options: { sandboxName: string; agent?: AgentKind; apiKey?: string; - tokens?: Phase6Tokens; extra?: NodeJS.ProcessEnv; }): NodeJS.ProcessEnv { validateSandboxName(options.sandboxName); - const tokens = options.tokens; - const env: NodeJS.ProcessEnv = { + return { ...buildAvailabilityProbeEnv(), NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NEMOCLAW_NON_INTERACTIVE: "1", @@ -92,53 +59,12 @@ export function phase6Env(options: { ...(options.apiKey ? { NVIDIA_INFERENCE_API_KEY: options.apiKey, NVIDIA_API_KEY: options.apiKey } : {}), - ...(tokens - ? { - TELEGRAM_BOT_TOKEN: tokens.telegram, - TELEGRAM_ALLOWED_IDS: process.env.TELEGRAM_ALLOWED_IDS ?? "123456789,987654321", - TELEGRAM_REQUIRE_MENTION: process.env.TELEGRAM_REQUIRE_MENTION ?? "0", - DISCORD_BOT_TOKEN: tokens.discord, - DISCORD_SERVER_ID: process.env.DISCORD_SERVER_ID ?? "1491590992753590594", - DISCORD_SERVER_IDS: - process.env.DISCORD_SERVER_IDS ?? - process.env.DISCORD_SERVER_ID ?? - "1491590992753590594", - DISCORD_USER_ID: process.env.DISCORD_USER_ID ?? "1005536447329222676", - DISCORD_ALLOWED_IDS: - process.env.DISCORD_ALLOWED_IDS ?? process.env.DISCORD_USER_ID ?? "1005536447329222676", - DISCORD_REQUIRE_MENTION: process.env.DISCORD_REQUIRE_MENTION ?? "0", - SLACK_BOT_TOKEN: tokens.slackBot, - SLACK_APP_TOKEN: tokens.slackApp, - SLACK_ALLOWED_USERS: process.env.SLACK_ALLOWED_USERS ?? "U0123456789,U09ABCDEFGH", - WECHAT_BOT_TOKEN: tokens.wechat, - WECHAT_ACCOUNT_ID: - process.env.WECHAT_ACCOUNT_ID ?? `e2e-fake-account-${options.sandboxName}`, - WECHAT_BASE_URL: process.env.WECHAT_BASE_URL ?? "https://ilinkai.wechat.com", - WECHAT_USER_ID: process.env.WECHAT_USER_ID ?? "wxid_e2e_operator", - WECHAT_ALLOWED_IDS: - process.env.WECHAT_ALLOWED_IDS ?? process.env.WECHAT_USER_ID ?? "wxid_e2e_operator", - } - : {}), ...options.extra, }; - - if (tokens?.telegram.includes("fake") && !env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY) { - env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY = "1"; - } - if ( - tokens && - !env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION && - (isFakeSlackToken(tokens.slackBot) || isFakeSlackToken(tokens.slackApp)) - ) { - env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION = "1"; - } - return env; } -export function redactionValues(apiKey: string | undefined, tokens?: Phase6Tokens): string[] { - return [apiKey, ...(tokens ? Object.values(tokens) : [])].filter( - (value): value is string => typeof value === "string" && value.length > 0, - ); +export function redactionValues(apiKey: string | undefined): string[] { + return [apiKey].filter((value): value is string => typeof value === "string" && value.length > 0); } export async function bestEffort(run: () => Promise): Promise { @@ -225,21 +151,6 @@ export async function installSandboxOrSkipOnRateLimit( } } -export async function rebuildSandbox( - host: HostCliClient, - sandboxName: string, - env: NodeJS.ProcessEnv, - redactions: string[], - artifactName: string, -): Promise { - return host.command("node", [CLI, sandboxName, "rebuild", "--yes"], { - artifactName, - env, - redactionValues: redactions, - timeoutMs: REBUILD_TIMEOUT_MS, - }); -} - export async function expectSandboxReady( host: HostCliClient, sandboxName: string, @@ -260,38 +171,6 @@ export async function expectSandboxReady( expect(row, resultText(list)).toMatch(/\bReady\b/i); } -export function readRegistryEntry(sandboxName: string): JsonRecord { - expect(fs.existsSync(REGISTRY_FILE), `${REGISTRY_FILE} missing`).toBe(true); - const registry = JSON.parse(fs.readFileSync(REGISTRY_FILE, "utf8")) as { - sandboxes?: Record; - }; - const entry = registry.sandboxes?.[sandboxName]; - expect(entry, `registry entry ${sandboxName} missing`).toBeTruthy(); - if (!entry) throw new Error(`registry entry ${sandboxName} missing`); - return entry; -} - -export function messagingPlan(sandboxName: string): JsonRecord { - const messaging = readRegistryEntry(sandboxName).messaging; - expect(messaging && typeof messaging === "object", "registry messaging state missing").toBe(true); - const plan = (messaging as JsonRecord).plan; - expect(plan && typeof plan === "object", "registry messaging.plan missing").toBe(true); - if (!plan || typeof plan !== "object") throw new Error("registry messaging.plan missing"); - return plan as JsonRecord; -} - -export function arrayRecords(value: unknown): JsonRecord[] { - return Array.isArray(value) - ? value.filter((item): item is JsonRecord => Boolean(item) && typeof item === "object") - : []; -} - -export function stringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === "string") - : []; -} - export async function sandboxSh( sandbox: SandboxClient, sandboxName: string, @@ -310,49 +189,6 @@ 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]) => `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/live/telegram-injection.test.ts b/test/e2e-scenario/live/telegram-injection.test.ts index 5c52ed0cb3..c9db270223 100644 --- a/test/e2e-scenario/live/telegram-injection.test.ts +++ b/test/e2e-scenario/live/telegram-injection.test.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { expect, test } from "../fixtures/e2e-test.ts"; import { shouldRunLiveE2EScenarios } from "../fixtures/live-project-gate.ts"; import { + base64, bestEffort, CLI, COMMAND_TIMEOUT_MS, @@ -27,10 +28,6 @@ import { const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-telegram-injection"; const LIVE_TIMEOUT_MS = 35 * 60_000; -function base64(value: string): string { - return Buffer.from(value, "utf8").toString("base64"); -} - function openshellStdinCommand(payload: string, remoteShell: string): string { return [ "set -euo pipefail", @@ -54,6 +51,74 @@ async function sendPayloadViaSandboxStdin( }); } +async function assertParameterPayloadStaysLiteral( + host: import("../fixtures/clients/host.ts").HostCliClient, + env: NodeJS.ProcessEnv, + redactions: string[], +): Promise { + const payload = "${NVIDIA_INFERENCE_API_KEY}"; + const command = [ + "set -euo pipefail", + 'prefix="${NVIDIA_INFERENCE_API_KEY:0:15}"', + 'out="$(printf %s "${PAYLOAD_B64}" | base64 -d | openshell sandbox exec --name "${SANDBOX_NAME}" -- sh -lc \'MSG=$(cat) && echo "$MSG"\' 2>&1)"', + 'if printf %s "$out" | grep -Fq "$prefix"; then echo SECRET_LEAK; exit 20; fi', + 'printf "%s\\n" "$out"', + ].join("; "); + const result = await host.command("bash", ["-lc", command], { + artifactName: "parameter-expansion-payload", + env: { ...env, PAYLOAD_B64: base64(payload), SANDBOX_NAME }, + redactionValues: redactions, + timeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(result.exitCode, resultText(result)).toBe(0); + expect(resultText(result)).not.toContain("SECRET_LEAK"); + expect(resultText(result)).toContain(payload); +} + +async function assertHostProcessTableDoesNotExposeSecret( + host: import("../fixtures/clients/host.ts").HostCliClient, + env: NodeJS.ProcessEnv, + redactions: string[], +): Promise { + const command = [ + "set -euo pipefail", + 'prefix="${NVIDIA_INFERENCE_API_KEY:0:15}"', + 'matches="$(ps aux 2>/dev/null | grep -F "$prefix" | grep -v grep || true)"', + 'if [ -n "$matches" ]; then echo SECRET_LEAK; exit 21; fi', + "echo SECRET_ABSENT", + ].join("; "); + const result = await host.command("bash", ["-lc", command], { + artifactName: "host-process-table-telegram-injection", + env, + redactionValues: redactions, + timeoutMs: 30_000, + }); + expect(result.exitCode, resultText(result)).toBe(0); + expect(result.stdout.trim(), resultText(result)).toBe("SECRET_ABSENT"); +} + +async function assertSandboxProcessTableDoesNotExposeSecret( + host: import("../fixtures/clients/host.ts").HostCliClient, + env: NodeJS.ProcessEnv, + redactions: string[], +): Promise { + const command = [ + "set -euo pipefail", + 'prefix="${NVIDIA_INFERENCE_API_KEY:0:15}"', + 'out="$(openshell sandbox exec --name "${SANDBOX_NAME}" -- sh -lc \'ps aux\' 2>&1)"', + 'if printf %s "$out" | grep -Fq "$prefix"; then echo SECRET_LEAK; exit 22; fi', + "echo SECRET_ABSENT", + ].join("; "); + const result = await host.command("bash", ["-lc", command], { + artifactName: "sandbox-process-table-telegram-injection", + env: { ...env, SANDBOX_NAME }, + redactionValues: redactions, + timeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(result.exitCode, resultText(result)).toBe(0); + expect(result.stdout.trim(), resultText(result)).toBe("SECRET_ABSENT"); +} + test.skipIf(!shouldRunLiveE2EScenarios())( "Telegram bridge-style message handling treats shell metacharacters as data", { timeout: LIVE_TIMEOUT_MS }, @@ -138,31 +203,9 @@ test.skipIf(!shouldRunLiveE2EScenarios())( expect(markerCheck.stdout.trim(), resultText(markerCheck)).toBe("SAFE"); } - const envPayload = await sendPayloadViaSandboxStdin( - host, - "${NVIDIA_INFERENCE_API_KEY}", - 'MSG=$(cat) && echo "$MSG"', - env, - "parameter-expansion-payload", - redactions, - ); - expect(envPayload.exitCode, resultText(envPayload)).toBe(0); - expect(resultText(envPayload)).not.toContain(apiKey.slice(0, 15)); - expect(resultText(envPayload)).toContain("${NVIDIA_INFERENCE_API_KEY}"); - - const apiKeyPrefix = apiKey.slice(0, 15); - const hostPs = await host.command("ps", ["aux"], { - artifactName: "host-process-table-telegram-injection", - env, - redactionValues: redactions, - timeoutMs: 30_000, - }); - const sandboxPs = await sandboxSh(sandbox, SANDBOX_NAME, "ps aux", { - artifactName: "sandbox-process-table-telegram-injection", - redactionValues: redactions, - }); - expect(resultText(hostPs)).not.toContain(apiKeyPrefix); - expect(resultText(sandboxPs)).not.toContain(apiKeyPrefix); + await assertParameterPayloadStaysLiteral(host, env, redactions); + await assertHostProcessTableDoesNotExposeSecret(host, env, redactions); + await assertSandboxProcessTableDoesNotExposeSecret(host, env, redactions); const invalidNames = [ "foo;rm -rf /", @@ -171,7 +214,6 @@ test.skipIf(!shouldRunLiveE2EScenarios())( "`id`", "foo bar", "../etc/passwd", - "UPPERCASE", ]; for (const invalidName of invalidNames) { const validation = await host.command( From c4931918644f1f38eea70a27d7c9c5be9bd8ec9d Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 22 Jun 2026 10:16:57 -0400 Subject: [PATCH 4/4] test(e2e): cover telegram SSH path --- .github/workflows/e2e-vitest-scenarios.yaml | 2 +- .../live/telegram-injection.test.ts | 119 +++++++++++++++++- tools/e2e-scenarios/workflow-boundary.mts | 105 ++++++++++++++++ 3 files changed, 220 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e-vitest-scenarios.yaml b/.github/workflows/e2e-vitest-scenarios.yaml index 58166c45ac..ad6ecbd76e 100644 --- a/.github/workflows/e2e-vitest-scenarios.yaml +++ b/.github/workflows/e2e-vitest-scenarios.yaml @@ -4124,7 +4124,7 @@ jobs: NEMOCLAW_NON_INTERACTIVE: "1" run: | set -euo pipefail - env -u DOCKER_CONFIG -u DOCKERHUB_USERNAME -u DOCKERHUB_TOKEN -u NVIDIA_API_KEY -u GITHUB_TOKEN bash scripts/install-openshell.sh + 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 Telegram injection live test # Migrated from test/e2e/test-telegram-injection.sh. Preserves the diff --git a/test/e2e-scenario/live/telegram-injection.test.ts b/test/e2e-scenario/live/telegram-injection.test.ts index c9db270223..ee504ad9f5 100644 --- a/test/e2e-scenario/live/telegram-injection.test.ts +++ b/test/e2e-scenario/live/telegram-injection.test.ts @@ -35,6 +35,26 @@ function openshellStdinCommand(payload: string, remoteShell: string): string { ].join("; "); } +function openshellSshStdinCommand(payload: string, remoteShell: string): string { + return [ + "set -euo pipefail", + 'ssh_config="$(mktemp)"', + `trap 'rm -f "$ssh_config"' EXIT`, + `openshell sandbox ssh-config ${shellQuote(SANDBOX_NAME)} > "$ssh_config"`, + [ + `printf %s ${shellQuote(base64(payload))} | base64 -d`, + "| timeout 30s ssh", + '-F "$ssh_config"', + "-o StrictHostKeyChecking=no", + "-o UserKnownHostsFile=/dev/null", + "-o ConnectTimeout=10", + "-o LogLevel=ERROR", + shellQuote(`openshell-${SANDBOX_NAME}`), + shellQuote(remoteShell), + ].join(" "), + ].join("; "); +} + async function sendPayloadViaSandboxStdin( host: import("../fixtures/clients/host.ts").HostCliClient, payload: string, @@ -51,6 +71,22 @@ async function sendPayloadViaSandboxStdin( }); } +async function sendPayloadViaOpenShellSshStdin( + host: import("../fixtures/clients/host.ts").HostCliClient, + payload: string, + remoteShell: string, + env: NodeJS.ProcessEnv, + artifactName: string, + redactions: string[], +) { + return host.command("bash", ["-lc", openshellSshStdinCommand(payload, remoteShell)], { + artifactName, + env, + redactionValues: redactions, + timeoutMs: COMMAND_TIMEOUT_MS, + }); +} + async function assertParameterPayloadStaysLiteral( host: import("../fixtures/clients/host.ts").HostCliClient, env: NodeJS.ProcessEnv, @@ -60,7 +96,7 @@ async function assertParameterPayloadStaysLiteral( const command = [ "set -euo pipefail", 'prefix="${NVIDIA_INFERENCE_API_KEY:0:15}"', - 'out="$(printf %s "${PAYLOAD_B64}" | base64 -d | openshell sandbox exec --name "${SANDBOX_NAME}" -- sh -lc \'MSG=$(cat) && echo "$MSG"\' 2>&1)"', + `out="$(printf %s "\${PAYLOAD_B64}" | base64 -d | openshell sandbox exec --name ${shellQuote(SANDBOX_NAME)} -- sh -lc 'MSG=$(cat) && echo "$MSG"' 2>&1)"`, 'if printf %s "$out" | grep -Fq "$prefix"; then echo SECRET_LEAK; exit 20; fi', 'printf "%s\\n" "$out"', ].join("; "); @@ -75,6 +111,45 @@ async function assertParameterPayloadStaysLiteral( expect(resultText(result)).toContain(payload); } +async function assertSshParameterPayloadStaysLiteral( + host: import("../fixtures/clients/host.ts").HostCliClient, + env: NodeJS.ProcessEnv, + redactions: string[], +): Promise { + const payload = "${NVIDIA_INFERENCE_API_KEY}"; + const command = [ + "set -euo pipefail", + 'prefix="${NVIDIA_INFERENCE_API_KEY:0:15}"', + 'ssh_config="$(mktemp)"', + 'out_file="$(mktemp)"', + `trap 'rm -f "$ssh_config" "$out_file"' EXIT`, + `openshell sandbox ssh-config ${shellQuote(SANDBOX_NAME)} > "$ssh_config"`, + [ + `printf %s ${shellQuote(base64(payload))} | base64 -d`, + "| timeout 30s ssh", + '-F "$ssh_config"', + "-o StrictHostKeyChecking=no", + "-o UserKnownHostsFile=/dev/null", + "-o ConnectTimeout=10", + "-o LogLevel=ERROR", + shellQuote(`openshell-${SANDBOX_NAME}`), + shellQuote('MSG=$(cat) && echo "$MSG"'), + '> "$out_file" 2>&1', + ].join(" "), + 'if grep -Fq "$prefix" "$out_file"; then echo SECRET_LEAK; exit 23; fi', + 'cat "$out_file"', + ].join("; "); + const result = await host.command("bash", ["-lc", command], { + artifactName: "ssh-parameter-expansion-payload", + env, + redactionValues: redactions, + timeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(result.exitCode, resultText(result)).toBe(0); + expect(resultText(result)).not.toContain("SECRET_LEAK"); + expect(resultText(result)).toContain(payload); +} + async function assertHostProcessTableDoesNotExposeSecret( host: import("../fixtures/clients/host.ts").HostCliClient, env: NodeJS.ProcessEnv, @@ -105,7 +180,7 @@ async function assertSandboxProcessTableDoesNotExposeSecret( const command = [ "set -euo pipefail", 'prefix="${NVIDIA_INFERENCE_API_KEY:0:15}"', - 'out="$(openshell sandbox exec --name "${SANDBOX_NAME}" -- sh -lc \'ps aux\' 2>&1)"', + `out="$(openshell sandbox exec --name ${shellQuote(SANDBOX_NAME)} -- sh -lc 'ps aux' 2>&1)"`, 'if printf %s "$out" | grep -Fq "$prefix"; then echo SECRET_LEAK; exit 22; fi', "echo SECRET_ABSENT", ].join("; "); @@ -124,17 +199,21 @@ test.skipIf(!shouldRunLiveE2EScenarios())( { timeout: LIVE_TIMEOUT_MS }, async ({ artifacts, cleanup, host, sandbox, secrets, skip }) => { const apiKey = secrets.required("NVIDIA_INFERENCE_API_KEY"); - const env = phase6Env({ sandboxName: SANDBOX_NAME, agent: "openclaw", apiKey }); + const env = phase6Env({ + sandboxName: SANDBOX_NAME, + agent: "openclaw", + apiKey, + }); const redactions = redactionValues(apiKey); await artifacts.writeJson("scenario.json", { id: "telegram-injection", legacySource: "test/e2e/test-telegram-injection.sh", boundary: - "install.sh OpenClaw sandbox + OpenShell sandbox exec stdin path + process table and validateName probes", + "install.sh OpenClaw sandbox + OpenShell sandbox exec and ssh-config stdin paths + process table and validateName probes", sandboxName: SANDBOX_NAME, contracts: [ - "command substitution payloads are literal input and do not create files", + "command substitution payloads are literal input through exec and ssh-config paths and do not create files", "parameter expansion does not leak NVIDIA_INFERENCE_API_KEY", "host and sandbox process tables do not expose the API key after setup", "invalid sandbox names with shell metacharacters are rejected by validateName", @@ -201,9 +280,37 @@ test.skipIf(!shouldRunLiveE2EScenarios())( ); expectExitZero(markerCheck, `check ${label} marker`); expect(markerCheck.stdout.trim(), resultText(markerCheck)).toBe("SAFE"); + + const sshMarker = marker.replace("/tmp/injection-proof-", "/tmp/injection-proof-ssh-"); + await sandboxSh(sandbox, SANDBOX_NAME, `rm -f ${shellQuote(sshMarker)}`, { + artifactName: `remove-${label}-ssh-marker`, + redactionValues: redactions, + }); + const sshPayload = payload.replace(marker, sshMarker); + const sshSend = await sendPayloadViaOpenShellSshStdin( + host, + sshPayload, + 'MSG=$(cat) && echo "Received: $MSG"', + env, + `send-${label}-ssh-payload`, + redactions, + ); + expect(sshSend.exitCode, resultText(sshSend)).toBe(0); + const sshMarkerCheck = await sandboxSh( + sandbox, + SANDBOX_NAME, + `test -f ${shellQuote(sshMarker)} && echo EXPLOITED || echo SAFE`, + { + artifactName: `check-${label}-ssh-marker`, + redactionValues: redactions, + }, + ); + expectExitZero(sshMarkerCheck, `check ${label} ssh marker`); + expect(sshMarkerCheck.stdout.trim(), resultText(sshMarkerCheck)).toBe("SAFE"); } await assertParameterPayloadStaysLiteral(host, env, redactions); + await assertSshParameterPayloadStaysLiteral(host, env, redactions); await assertHostProcessTableDoesNotExposeSecret(host, env, redactions); await assertSandboxProcessTableDoesNotExposeSecret(host, env, redactions); @@ -214,6 +321,7 @@ test.skipIf(!shouldRunLiveE2EScenarios())( "`id`", "foo bar", "../etc/passwd", + "UPPERCASE", ]; for (const invalidName of invalidNames) { const validation = await host.command( @@ -221,6 +329,7 @@ test.skipIf(!shouldRunLiveE2EScenarios())( [ "-e", `const { validateName } = require(${JSON.stringify(path.join(REPO_ROOT, "dist/lib/runner"))});\ntry { validateName(process.argv[1], "SANDBOX_NAME"); console.log("ACCEPTED"); } catch (error) { console.log("REJECTED:" + error.message); }`, + "--", invalidName, ], { diff --git a/tools/e2e-scenarios/workflow-boundary.mts b/tools/e2e-scenarios/workflow-boundary.mts index 8decf10480..9bc3545766 100644 --- a/tools/e2e-scenarios/workflow-boundary.mts +++ b/tools/e2e-scenarios/workflow-boundary.mts @@ -3474,6 +3474,110 @@ function validateChannelsAddRemoveVitestJob(errors: string[], jobs: WorkflowReco } } + +function validateTelegramInjectionVitestJob(errors: string[], jobs: WorkflowRecord): void { + const jobName = "telegram-injection-vitest"; + const scenarioName = "telegram-injection"; + const job = asRecord(jobs[jobName]); + if (Object.keys(job).length === 0) { + errors.push("workflow missing telegram-injection-vitest job"); + return; + } + + if (job["runs-on"] !== "ubuntu-latest") { + errors.push("telegram-injection-vitest job must run on ubuntu-latest"); + } + if (job["timeout-minutes"] !== 45) { + errors.push("telegram-injection-vitest job must keep the 45 minute timeout"); + } + validateFreeStandingJobSelector(errors, jobs, jobName, scenarioName); + + const jobEnv = asRecord(job.env); + if ("DOCKER_CONFIG" in jobEnv) { + errors.push("telegram-injection-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, "telegram-injection-vitest job", jobEnv, secret); + } + + const steps = asSteps(job.steps); + requireNoDispatchInputInterpolation(errors, steps); + for (const step of steps) { + const stepName = `telegram-injection-vitest step '${step.name ?? step.uses ?? ""}'`; + const stepEnv = asRecord(step.env); + if (step.name !== "Run Telegram injection 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 configureDockerAuth = requireJobStep(errors, jobName, steps, "Configure isolated Docker auth directory"); + requireRunContains( + errors, + configureDockerAuth, + 'echo "DOCKER_CONFIG=${RUNNER_TEMP}/docker-config-telegram-injection" >> "$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("telegram-injection-vitest Docker Hub auth must receive DOCKERHUB_USERNAME from secrets"); + } + if (dockerLoginEnv.DOCKERHUB_TOKEN !== "${{ secrets.DOCKERHUB_TOKEN }}") { + errors.push("telegram-injection-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"); + 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 Telegram injection live test"); + const runVitestEnv = asRecord(runVitest?.env); + if (runVitestEnv.NVIDIA_INFERENCE_API_KEY !== "${{ secrets.NVIDIA_INFERENCE_API_KEY }}") { + errors.push("telegram-injection-vitest step must receive NVIDIA_INFERENCE_API_KEY from secrets"); + } + requireRunContains(errors, runVitest, "npx vitest run --project e2e-scenarios-live"); + requireRunContains(errors, runVitest, "test/e2e-scenario/live/telegram-injection.test.ts"); + + const upload = requireJobStep(errors, jobName, steps, "Upload Telegram injection artifacts"); + const uploadWith = asRecord(upload?.with); + const uploadPath = stringValue(uploadWith.path); + requireUploadPathContains(errors, uploadPath, "e2e-artifacts/vitest/telegram-injection/"); + if (uploadWith["include-hidden-files"] !== false) { + errors.push("telegram-injection-vitest artifact upload must set include-hidden-files: false"); + } + if (uploadWith["if-no-files-found"] !== "ignore") { + errors.push("telegram-injection-vitest artifact upload must ignore missing fixture artifacts"); + } + if (uploadWith["retention-days"] !== 14) { + errors.push("telegram-injection-vitest artifact upload retention-days must be 14"); + } + + const cleanup = requireJobStep(errors, jobName, steps, "Clean up Docker auth"); + if (cleanup?.if !== "always()") { + errors.push("telegram-injection-vitest Docker auth cleanup must always run"); + } + requireRunContains(errors, cleanup, "docker logout docker.io"); + requireRunContains(errors, cleanup, 'rm -rf "${DOCKER_CONFIG}"'); +} + function validateBedrockRuntimeCompatibleAnthropicVitestJob( errors: string[], jobs: WorkflowRecord, @@ -4022,6 +4126,7 @@ export function validateE2eVitestScenariosWorkflowBoundary( ); validateChannelsAddRemoveVitestJob(errors, jobs); + validateTelegramInjectionVitestJob(errors, jobs); const reportToPr = asRecord(jobs["report-to-pr"]); if (Object.keys(reportToPr).length === 0) {