From b09dedd1ed280b859552d2faeee556edd074885c Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 31 Aug 2026 00:44:25 -0400 Subject: [PATCH 01/45] fix(e2e): restore fake API port publishing Signed-off-by: Julie Yaunches --- test/e2e/live/messaging-providers-helpers.ts | 12 ++- ...messaging-providers-runtime-proofs.test.ts | 86 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 78489f03336..e80e8013fb8 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -620,10 +620,20 @@ export async function startFakeDockerApi( const network = uniqueContainerName("nemoclaw-fake-api-network"); fs.writeFileSync(captureFile, ""); + // Docker ignores -p on internal-only networks. Disable IP masquerading on a + // regular bridge so host ports publish without NAT egress. const networkCreate = await runHost( host, "docker", - ["network", "create", "--internal", network], + [ + "network", + "create", + "--driver", + "bridge", + "--opt", + "com.docker.network.bridge.enable_ip_masquerade=false", + network, + ], { artifactName: `create-fake-${options.kind}-api-network`, env: options.env, diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 11a67a618c9..cbaa41d960e 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -9,6 +9,9 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { buildProcessTokenProbe } from "../fixtures/process-token-probe.ts"; +import type { CommandRunner } from "../fixtures/clients/command.ts"; +import { HostCliClient } from "../fixtures/clients/host.ts"; +import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { buildSandboxNodeInvocation, buildSandboxShellInvocation, @@ -16,6 +19,7 @@ import { messagingEnv, OPENSHELL_EXEC_ARGUMENT_LIMIT_BYTES, parseRuntimeProofPort, + startFakeDockerApi, } from "../live/messaging-providers-helpers.ts"; import { parseInstalledSlackProof, @@ -37,7 +41,89 @@ async function waitFor(predicate: () => boolean, message: string): Promise expect(matched, message).toBe(true); } +function successfulShellResult(command: string[], stdout = ""): ShellProbeResult { + return { + command, + durationMs: 0, + exitCode: 0, + signal: null, + timedOut: false, + stdout, + stderr: "", + artifacts: { stdout: "", stderr: "", result: "" }, + }; +} + describe("messaging provider installed-runtime proofs", () => { + it("publishes fake Docker API ports without masqueraded egress", async () => { + const invocations: string[][] = []; + const cleanupTasks: Array<() => Promise> = []; + const responses: Array<(invocation: string[]) => ShellProbeResult> = [ + (invocation) => successfulShellResult(invocation), + (invocation) => { + const mountSuffix = ":/tmp/fake"; + const mount = invocation.find((argument) => argument.endsWith(mountSuffix)); + expect(mount).toBeDefined(); + fs.writeFileSync(path.join(mount!.slice(0, -mountSuffix.length), "port"), "8080\n"); + return successfulShellResult(invocation, "fake-container-id\n"); + }, + (invocation) => successfulShellResult(invocation, "0.0.0.0:41080\n"), + (invocation) => successfulShellResult(invocation, "0.0.0.0:41081\n"), + (invocation) => successfulShellResult(invocation), + (invocation) => successfulShellResult(invocation), + ]; + const runner: CommandRunner = { + async run(command) { + const invocation = [command.command, ...command.args]; + invocations.push(invocation); + const respond = responses.shift(); + expect(respond).toBeDefined(); + return respond!(invocation); + }, + }; + + try { + const api = await startFakeDockerApi( + new HostCliClient(runner), + (_name, run) => cleanupTasks.push(run), + { + kind: "slack", + imageScript: "fake-slack-api.cjs", + containerPrefix: "nemoclaw-fake-slack-test", + portEnv: "FAKE_SLACK_API_PORT", + portFileEnv: "FAKE_SLACK_API_PORT_FILE", + captureFileEnv: "FAKE_SLACK_API_CAPTURE_FILE", + expectedEnv: { + FAKE_SLACK_API_EXPECTED_BOT_TOKEN: "xoxb-fake-slack-network-test", + FAKE_SLACK_API_EXPECTED_APP_TOKEN: "xapp-fake-slack-network-test", + }, + redactionValues: [], + env: {}, + }, + ); + const networkCreate = invocations.find( + ([command, group, action]) => + command === "docker" && group === "network" && action === "create", + ); + expect(networkCreate?.slice(0, -1)).toEqual([ + "docker", + "network", + "create", + "--driver", + "bridge", + "--opt", + "com.docker.network.bridge.enable_ip_masquerade=false", + ]); + expect(networkCreate).not.toContain("--internal"); + expect(api.port).toBe("41080"); + expect(api.alternatePort).toBe("41081"); + } finally { + await cleanupTasks + .reverse() + .reduce((previous, cleanupTask) => previous.then(cleanupTask), Promise.resolve()); + } + }); + it("uses a synthetic WeChat token even when the host exports one", () => { const previousToken = process.env.WECHAT_BOT_TOKEN; process.env.WECHAT_BOT_TOKEN = "host-wechat-token-must-not-reach-the-fake-api"; From 20fd192a25fd0dea4e7a74f0671659834270925b Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 31 Aug 2026 01:14:29 -0400 Subject: [PATCH 02/45] fix(e2e): isolate fake API port proxy Signed-off-by: Julie Yaunches --- test/e2e/fixtures/host-mock-firewall.ts | 4 +- test/e2e/lib/fake-api-port-proxy.mts | 31 ++++ test/e2e/live/messaging-providers-helpers.ts | 138 ++++++++++++++++-- ...messaging-providers-runtime-proofs.test.ts | 60 +++++++- 4 files changed, 210 insertions(+), 23 deletions(-) create mode 100644 test/e2e/lib/fake-api-port-proxy.mts diff --git a/test/e2e/fixtures/host-mock-firewall.ts b/test/e2e/fixtures/host-mock-firewall.ts index 59482240c35..e5bfcd46ad2 100644 --- a/test/e2e/fixtures/host-mock-firewall.ts +++ b/test/e2e/fixtures/host-mock-firewall.ts @@ -24,7 +24,7 @@ interface DockerNetworkInspect { Options?: unknown; } -interface BridgeTopology { +export interface BridgeTopology { bridgeInterface: string; gatewayIp: string; prefixLength: number; @@ -65,7 +65,7 @@ function parseBridgeCidr(value: string): { network: number; prefixLength: number return (network & mask) >>> 0 === network ? { network, prefixLength } : undefined; } -function parseBridgeTopology(stdout: string): BridgeTopology { +export function parseBridgeTopology(stdout: string): BridgeTopology { let parsed: unknown; try { parsed = JSON.parse(stdout); diff --git a/test/e2e/lib/fake-api-port-proxy.mts b/test/e2e/lib/fake-api-port-proxy.mts new file mode 100644 index 00000000000..50b876187c2 --- /dev/null +++ b/test/e2e/lib/fake-api-port-proxy.mts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import net from "node:net"; + +const target = process.env.NEMOCLAW_FAKE_API_PROXY_TARGET ?? ""; +const portList = process.env.NEMOCLAW_FAKE_API_PROXY_PORTS ?? ""; +if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(target)) { + throw new Error("NEMOCLAW_FAKE_API_PROXY_TARGET must be a Docker container name"); +} +const ports = portList === "8080" ? [8080] : portList === "8080,8081" ? [8080, 8081] : undefined; +if (!ports) throw new Error("NEMOCLAW_FAKE_API_PROXY_PORTS must be 8080 or 8080,8081"); + +for (const port of ports) { + const server = net.createServer((client) => { + const upstream = net.createConnection({ host: target, port }); + const close = () => { + client.destroy(); + upstream.destroy(); + }; + client.on("error", close); + upstream.on("error", close); + client.pipe(upstream); + upstream.pipe(client); + }); + server.once("error", (error) => { + process.stderr.write(`fake API proxy listener ${port} failed: ${error.message}\n`); + process.exit(1); + }); + server.listen(port, "0.0.0.0"); +} diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index e80e8013fb8..fb58db1dbb8 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -20,6 +20,7 @@ import { validateSandboxName, } from "../fixtures/clients/sandbox.ts"; import { expect } from "../fixtures/e2e-test.ts"; +import { parseBridgeTopology } from "../fixtures/host-mock-firewall.ts"; import { CLI_ENTRYPOINT, REPO_ROOT } from "../fixtures/paths.ts"; import { buildProcessTokenProbe } from "../fixtures/process-token-probe.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; @@ -41,6 +42,8 @@ export const LIVE_TIMEOUT_MS = 90 * 60_000; export const OPENSHELL_EXEC_ARGUMENT_LIMIT_BYTES = 32_768; const FAKE_API_IMAGE = "node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c"; +const DEFAULT_OPENSHELL_DOCKER_NETWORK = "openshell-docker"; +const DOCKER_NETWORK_NAME_PATTERN = /^[a-zA-Z0-9_.-]{1,128}$/; // Leave ample headroom beneath OpenShell's strict per-argument ceiling. const SANDBOX_SOURCE_CHUNK_BYTES = 16_384; @@ -617,12 +620,62 @@ export async function startFakeDockerApi( const portFile = path.join(dir, "port"); const captureFile = path.join(dir, "capture.jsonl"); const container = uniqueContainerName(options.containerPrefix); - const network = uniqueContainerName("nemoclaw-fake-api-network"); + const proxy = uniqueContainerName(`${options.containerPrefix}-proxy`); + const network = uniqueContainerName("nemoclaw-fake-api-internal"); + const proxyNetwork = uniqueContainerName("nemoclaw-fake-api-proxy"); fs.writeFileSync(captureFile, ""); - // Docker ignores -p on internal-only networks. Disable IP masquerading on a - // regular bridge so host ports publish without NAT egress. + const openShellNetwork = + options.env.OPENSHELL_DOCKER_NETWORK_NAME?.trim() || DEFAULT_OPENSHELL_DOCKER_NETWORK; + if (!DOCKER_NETWORK_NAME_PATTERN.test(openShellNetwork)) { + fs.rmSync(dir, { recursive: true, force: true }); + throw new Error(`OpenShell Docker network name is invalid: ${openShellNetwork}`); + } + const networkInspect = await runHost(host, "docker", ["network", "inspect", openShellNetwork], { + artifactName: `inspect-fake-${options.kind}-api-openshell-network`, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 30_000, + }); + let gatewayIp: string; + try { + expectExitZero(networkInspect, "inspect OpenShell Docker network for fake API proxy"); + ({ gatewayIp } = parseBridgeTopology(networkInspect.stdout)); + } catch (error) { + fs.rmSync(dir, { recursive: true, force: true }); + throw error; + } + const networkCreate = await runHost( + host, + "docker", + ["network", "create", "--internal", network], + { + artifactName: `create-fake-${options.kind}-api-network`, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 30_000, + }, + ); + try { + expectExitZero(networkCreate, `create fake ${options.kind} API network`); + } catch (error) { + fs.rmSync(dir, { recursive: true, force: true }); + throw error; + } + cleanup(`remove ${network}`, async () => { + const remove = await runHost(host, "docker", ["network", "rm", network], { + artifactName: `cleanup-${network}`, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 60_000, + }); + if (remove.exitCode !== 0 && !/No such network:/iu.test(resultText(remove))) { + expectExitZero(remove, `remove fake ${options.kind} API network ${network}`); + } + }); + + const proxyNetworkCreate = await runHost( host, "docker", [ @@ -632,30 +685,30 @@ export async function startFakeDockerApi( "bridge", "--opt", "com.docker.network.bridge.enable_ip_masquerade=false", - network, + proxyNetwork, ], { - artifactName: `create-fake-${options.kind}-api-network`, + artifactName: `create-fake-${options.kind}-api-proxy-network`, env: options.env, redactionValues: options.redactionValues, timeoutMs: 30_000, }, ); try { - expectExitZero(networkCreate, `create fake ${options.kind} API network`); + expectExitZero(proxyNetworkCreate, `create fake ${options.kind} API proxy network`); } catch (error) { fs.rmSync(dir, { recursive: true, force: true }); throw error; } - cleanup(`remove ${network}`, async () => { - const remove = await runHost(host, "docker", ["network", "rm", network], { - artifactName: `cleanup-${network}`, + cleanup(`remove ${proxyNetwork}`, async () => { + const remove = await runHost(host, "docker", ["network", "rm", proxyNetwork], { + artifactName: `cleanup-${proxyNetwork}`, env: options.env, redactionValues: options.redactionValues, timeoutMs: 60_000, }); if (remove.exitCode !== 0 && !/No such network:/iu.test(resultText(remove))) { - expectExitZero(remove, `remove fake ${options.kind} API network ${network}`); + expectExitZero(remove, `remove fake ${options.kind} API proxy network ${proxyNetwork}`); } }); @@ -667,8 +720,6 @@ export async function startFakeDockerApi( container, "--network", network, - "-p", - "0:8080", "-e", `${options.portEnv}=8080`, "-e", @@ -677,7 +728,7 @@ export async function startFakeDockerApi( `${options.captureFileEnv}=/tmp/fake/capture.jsonl`, ]; if (options.kind === "slack") { - dockerArgs.push("-p", "0:8081", "-e", "FAKE_SLACK_API_WEBSOCKET_PORT=8081"); + dockerArgs.push("-e", "FAKE_SLACK_API_WEBSOCKET_PORT=8081"); } for (const [key, value] of Object.entries(options.expectedEnv)) { dockerArgs.push("-e", `${key}=${value}`); @@ -717,9 +768,66 @@ export async function startFakeDockerApi( }); expectExitZero(start, `start fake ${options.kind} API`); + cleanup(`remove ${proxy}`, async () => { + const remove = await runHost(host, "docker", ["rm", "-f", proxy], { + artifactName: `cleanup-${proxy}`, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 60_000, + }); + if (remove.exitCode !== 0 && !/No such container:/iu.test(resultText(remove))) { + expectExitZero(remove, `remove fake ${options.kind} API proxy container ${proxy}`); + } + }); + const proxyPorts = options.kind === "slack" ? "8080,8081" : "8080"; + const proxyArgs = [ + "create", + "--rm", + "--name", + proxy, + "--network", + proxyNetwork, + "-p", + `${gatewayIp}:0:8080`, + ]; + if (options.kind === "slack") proxyArgs.push("-p", `${gatewayIp}:0:8081`); + proxyArgs.push( + "-e", + `NEMOCLAW_FAKE_API_PROXY_TARGET=${container}`, + "-e", + `NEMOCLAW_FAKE_API_PROXY_PORTS=${proxyPorts}`, + "-v", + `${FAKE_LIB_DIR}:/opt/nemoclaw-e2e:ro`, + FAKE_API_IMAGE, + "node", + "--experimental-strip-types", + "/opt/nemoclaw-e2e/fake-api-port-proxy.mts", + ); + const proxyCreate = await runHost(host, "docker", proxyArgs, { + artifactName: `create-fake-${options.kind}-api-proxy`, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 120_000, + }); + expectExitZero(proxyCreate, `create fake ${options.kind} API proxy`); + const proxyConnect = await runHost(host, "docker", ["network", "connect", network, proxy], { + artifactName: `connect-fake-${options.kind}-api-proxy`, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 30_000, + }); + expectExitZero(proxyConnect, `connect fake ${options.kind} API proxy to internal network`); + const proxyStart = await runHost(host, "docker", ["start", proxy], { + artifactName: `start-fake-${options.kind}-api-proxy`, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 30_000, + }); + expectExitZero(proxyStart, `start fake ${options.kind} API proxy`); + for (let attempt = 0; attempt < 100; attempt += 1) { if (fs.existsSync(portFile) && fs.statSync(portFile).size > 0) { - const restPort = await runHost(host, "docker", ["port", container, "8080/tcp"], { + const restPort = await runHost(host, "docker", ["port", proxy, "8080/tcp"], { artifactName: `port-fake-${options.kind}-api`, env: options.env, redactionValues: options.redactionValues, @@ -728,7 +836,7 @@ export async function startFakeDockerApi( const publishedRestPort = restPort.stdout.trim().split(":").at(-1)?.trim() ?? ""; let publishedWebsocketPort = ""; if (options.kind === "slack") { - const websocketPort = await runHost(host, "docker", ["port", container, "8081/tcp"], { + const websocketPort = await runHost(host, "docker", ["port", proxy, "8081/tcp"], { artifactName: "port-fake-slack-websocket-api", env: options.env, redactionValues: options.redactionValues, diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index cbaa41d960e..fc4126a72ca 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -55,10 +55,23 @@ function successfulShellResult(command: string[], stdout = ""): ShellProbeResult } describe("messaging provider installed-runtime proofs", () => { - it("publishes fake Docker API ports without masqueraded egress", async () => { + it("keeps fake Docker APIs internal and binds proxy ports to the OpenShell bridge", async () => { const invocations: string[][] = []; const cleanupTasks: Array<() => Promise> = []; const responses: Array<(invocation: string[]) => ShellProbeResult> = [ + (invocation) => + successfulShellResult( + invocation, + JSON.stringify([ + { + Driver: "bridge", + Id: "0123456789abcdef0123456789abcdef", + IPAM: { Config: [{ Subnet: "172.18.0.0/16", Gateway: "172.18.0.1" }] }, + Options: {}, + }, + ]), + ), + (invocation) => successfulShellResult(invocation), (invocation) => successfulShellResult(invocation), (invocation) => { const mountSuffix = ":/tmp/fake"; @@ -67,8 +80,13 @@ describe("messaging provider installed-runtime proofs", () => { fs.writeFileSync(path.join(mount!.slice(0, -mountSuffix.length), "port"), "8080\n"); return successfulShellResult(invocation, "fake-container-id\n"); }, - (invocation) => successfulShellResult(invocation, "0.0.0.0:41080\n"), - (invocation) => successfulShellResult(invocation, "0.0.0.0:41081\n"), + (invocation) => successfulShellResult(invocation), + (invocation) => successfulShellResult(invocation), + (invocation) => successfulShellResult(invocation), + (invocation) => successfulShellResult(invocation, "172.18.0.1:41080\n"), + (invocation) => successfulShellResult(invocation, "172.18.0.1:41081\n"), + (invocation) => successfulShellResult(invocation), + (invocation) => successfulShellResult(invocation), (invocation) => successfulShellResult(invocation), (invocation) => successfulShellResult(invocation), ]; @@ -101,11 +119,24 @@ describe("messaging provider installed-runtime proofs", () => { env: {}, }, ); - const networkCreate = invocations.find( + const networkCreates = invocations.filter( ([command, group, action]) => command === "docker" && group === "network" && action === "create", ); - expect(networkCreate?.slice(0, -1)).toEqual([ + expect(invocations).toContainEqual(["docker", "network", "inspect", "openshell-docker"]); + const internalNetworkCreate = networkCreates.find((invocation) => + invocation.includes("--internal"), + ); + const proxyNetworkCreate = networkCreates.find((invocation) => + invocation.includes("com.docker.network.bridge.enable_ip_masquerade=false"), + ); + expect(internalNetworkCreate?.slice(0, -1)).toEqual([ + "docker", + "network", + "create", + "--internal", + ]); + expect(proxyNetworkCreate?.slice(0, -1)).toEqual([ "docker", "network", "create", @@ -114,7 +145,24 @@ describe("messaging provider installed-runtime proofs", () => { "--opt", "com.docker.network.bridge.enable_ip_masquerade=false", ]); - expect(networkCreate).not.toContain("--internal"); + const containerRun = invocations.find( + ([command, action]) => command === "docker" && action === "run", + ); + expect(containerRun).toContain(internalNetworkCreate?.at(-1)); + expect(containerRun).not.toContain("-p"); + expect(containerRun).not.toContain("0:8080"); + expect(containerRun?.join("\n")).toContain("xoxb-fake-slack-network-test"); + expect(containerRun?.join("\n")).toContain("xapp-fake-slack-network-test"); + const proxyCreate = invocations.find( + ([command, action]) => command === "docker" && action === "create", + ); + expect(proxyCreate).toContain(proxyNetworkCreate?.at(-1)); + expect(proxyCreate).toContain("172.18.0.1:0:8080"); + expect(proxyCreate).toContain("172.18.0.1:0:8081"); + expect(proxyCreate).not.toContain("0:8080"); + expect(proxyCreate?.join("\n")).not.toContain("xoxb-fake-slack-network-test"); + expect(proxyCreate?.join("\n")).not.toContain("xapp-fake-slack-network-test"); + expect(proxyCreate).toContain("/opt/nemoclaw-e2e/fake-api-port-proxy.mts"); expect(api.port).toBe("41080"); expect(api.alternatePort).toBe("41081"); } finally { From fc8bc2b351cf7826df57f1ccaf7507c72f00a879 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 31 Aug 2026 02:06:45 -0400 Subject: [PATCH 03/45] fix(e2e): verify fake API proxy readiness Signed-off-by: Julie Yaunches --- .../pull-requests/pr-risk-plan.test.ts | 15 ++ test/e2e/lib/fake-api-port-readiness.mts | 202 ++++++++++++++++++ test/e2e/live/messaging-providers-helpers.ts | 95 +++++++- ...messaging-providers-runtime-proofs.test.ts | 178 ++++++++------- tools/e2e/workflow-boundary.mts | 11 +- 5 files changed, 400 insertions(+), 101 deletions(-) create mode 100644 test/e2e/lib/fake-api-port-readiness.mts diff --git a/test/automation/pull-requests/pr-risk-plan.test.ts b/test/automation/pull-requests/pr-risk-plan.test.ts index 5ce6a3fe972..0e76b4687de 100644 --- a/test/automation/pull-requests/pr-risk-plan.test.ts +++ b/test/automation/pull-requests/pr-risk-plan.test.ts @@ -369,6 +369,21 @@ describe("deterministic PR risk plan", () => { expect(result.requiredJobs).toEqual([]); }); + it.each([ + "test/e2e/lib/fake-api-port-proxy.mts", + "test/e2e/lib/fake-api-port-readiness.mts", + "test/e2e/live/messaging-providers-helpers.ts", + ])("selects every fake Docker API consumer for %s", (changedFile) => { + expect(focusedE2eJobsForChangedFiles([changedFile])).toEqual( + [ + "hermes-discord", + "messaging-providers", + "openclaw-discord-pairing", + "openclaw-slack-pairing", + ].map((id) => ({ id, matchedFiles: [changedFile] })), + ); + }); + it("maps a shared gateway live test to every catalogue fixture (#7921)", () => { const changedFiles = ["test/e2e/live/openshell-gateway-upgrade.test.ts"]; const focusedE2eJobs = catalogueTargetsForChangedFiles(changedFiles).map((target) => ({ diff --git a/test/e2e/lib/fake-api-port-readiness.mts b/test/e2e/lib/fake-api-port-readiness.mts new file mode 100644 index 00000000000..d726cdcb019 --- /dev/null +++ b/test/e2e/lib/fake-api-port-readiness.mts @@ -0,0 +1,202 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import crypto from "node:crypto"; +import http from "node:http"; +import net from "node:net"; +import { pathToFileURL } from "node:url"; + +const ATTEMPT_TIMEOUT_MS = 1_000; +const READINESS_TIMEOUT_MS = 10_000; +const RETRY_DELAY_MS = 100; +const MAX_HANDSHAKE_BYTES = 8_192; +const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +function requirePort(raw: string, name: string): number { + if (!/^[1-9][0-9]{0,4}$/u.test(raw)) throw new Error(`${name} must be a decimal port`); + const port = Number(raw); + if (port > 65_535) throw new Error(`${name} must be at most 65535`); + return port; +} + +function probeHttp(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let bodyBytes = 0; + let request: http.ClientRequest; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + request.destroy(); + if (error) reject(error); + else resolve(); + }; + const timer = setTimeout( + () => finish(new Error("REST readiness timed out")), + ATTEMPT_TIMEOUT_MS, + ); + request = http.get( + { + host, + port, + path: "/__nemoclaw_e2e_readiness", + }, + (response) => { + response.on("data", (chunk) => { + bodyBytes += chunk.length; + if (bodyBytes > MAX_HANDSHAKE_BYTES) { + finish(new Error("REST readiness response exceeded 8192 bytes")); + } + }); + response.once("end", () => finish()); + response.once("error", (error) => finish(error)); + }, + ); + request.once("error", (error) => finish(error)); + }); +} + +function encodeClientText(payload: string): Buffer { + const body = Buffer.from(payload, "utf8"); + const mask = crypto.randomBytes(4); + const masked = Buffer.alloc(body.length); + for (let index = 0; index < body.length; index += 1) { + masked[index] = body[index] ^ mask[index % mask.length]; + } + return Buffer.concat([Buffer.from([0x81, 0x80 | body.length]), mask, masked]); +} + +function hasServerTextFrame(buffer: Buffer): boolean { + if (buffer.length < 2) return false; + const opcode = buffer[0] & 0x0f; + let payloadLength = buffer[1] & 0x7f; + let offset = 2; + if (payloadLength === 126) { + if (buffer.length < 4) return false; + payloadLength = buffer.readUInt16BE(2); + offset = 4; + } else if (payloadLength === 127) { + if (buffer.length < 10) return false; + const length = buffer.readBigUInt64BE(2); + if (length > BigInt(MAX_HANDSHAKE_BYTES)) return false; + payloadLength = Number(length); + offset = 10; + } + return opcode === 1 && buffer.length >= offset + payloadLength; +} + +function probeWebsocket(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const key = crypto.randomBytes(16).toString("base64"); + const expectedAccept = crypto.createHash("sha1").update(`${key}${WS_GUID}`).digest("base64"); + const socket = net.createConnection({ host, port }); + let settled = false; + let response = Buffer.alloc(0); + let upgraded = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + socket.destroy(); + if (error) reject(error); + else resolve(); + }; + socket.setTimeout(ATTEMPT_TIMEOUT_MS, () => finish(new Error("WebSocket readiness timed out"))); + socket.once("error", (error) => finish(error)); + socket.once("connect", () => { + socket.write( + [ + "GET /socket-mode 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) => { + response = Buffer.concat([response, Buffer.from(chunk)]); + if (response.length > MAX_HANDSHAKE_BYTES) { + finish(new Error("WebSocket readiness response exceeded 8192 bytes")); + return; + } + if (!upgraded) { + const end = response.indexOf("\r\n\r\n"); + if (end === -1) return; + const headers = response.slice(0, end).toString("latin1"); + const status = headers.split("\r\n", 1)[0] ?? ""; + const acceptLine = headers + .split("\r\n") + .find((line) => /^sec-websocket-accept:/iu.test(line)); + const accept = acceptLine?.slice(acceptLine.indexOf(":") + 1).trim(); + if (!/^HTTP\/1[.]1 101\b/u.test(status) || accept !== expectedAccept) { + finish(new Error("WebSocket readiness handshake was rejected")); + return; + } + upgraded = true; + response = response.slice(end + 4); + socket.write(encodeClientText(JSON.stringify({ type: "nemoclaw_readiness" }))); + } + if (hasServerTextFrame(response)) finish(); + }); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function proveFakeApiPortReadiness(options: { + host: string; + restPort: number; + websocketPort?: number; +}): Promise { + if (net.isIP(options.host) === 0) throw new Error("readiness host must be an IP address"); + if (!Number.isInteger(options.restPort) || options.restPort < 1 || options.restPort > 65_535) { + throw new Error("REST port must be an integer between 1 and 65535"); + } + if ( + options.websocketPort !== undefined && + (!Number.isInteger(options.websocketPort) || + options.websocketPort < 1 || + options.websocketPort > 65_535) + ) { + throw new Error("WebSocket port must be an integer between 1 and 65535"); + } + const deadline = Date.now() + READINESS_TIMEOUT_MS; + let lastError = "readiness probe did not run"; + do { + try { + await probeHttp(options.host, options.restPort); + if (options.websocketPort !== undefined) { + await probeWebsocket(options.host, options.websocketPort); + } + return; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await sleep(RETRY_DELAY_MS); + } while (Date.now() < deadline); + throw new Error(`fake API port readiness failed: ${lastError}`); +} + +async function main(): Promise { + const [host = "", restPortRaw = "", websocketPortRaw] = process.argv.slice(2); + await proveFakeApiPortReadiness({ + host, + restPort: requirePort(restPortRaw, "REST port"), + ...(websocketPortRaw === undefined + ? {} + : { websocketPort: requirePort(websocketPortRaw, "WebSocket port") }), + }); + process.stdout.write("fake API proxy ports are ready\n"); +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index fb58db1dbb8..7c40aef335c 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -34,6 +34,7 @@ export const BASE_POLICY = path.join( "openclaw-sandbox.yaml", ); export const FAKE_LIB_DIR = path.join(REPO_ROOT, "test", "e2e", "lib"); +const FAKE_API_PORT_READINESS = path.join(FAKE_LIB_DIR, "fake-api-port-readiness.mts"); export const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? `e2e-msg-${process.pid}`; export const INSTALL_TIMEOUT_MS = 45 * 60_000; export const REBUILD_TIMEOUT_MS = 25 * 60_000; @@ -599,6 +600,43 @@ if [ -n "$match" ]; then printf '%s\n' "$match"; else echo ABSENT; fi`; return sandboxOutput(sandbox, probe, artifactName, redactionValues); } +async function collectFakeApiStartupDiagnostics( + host: HostCliClient, + options: { + kind: string; + proxy: string; + container: string; + env: NodeJS.ProcessEnv; + redactionValues: string[]; + }, +): Promise { + for (const [role, name] of [ + ["proxy", options.proxy], + ["api", options.container], + ] as const) { + try { + await runHost(host, "docker", ["inspect", "--format", "{{json .State}}", name], { + artifactName: `failure-fake-${options.kind}-api-${role}-inspect`, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 30_000, + }); + } catch { + // Preserve the readiness failure when a diagnostic command cannot start. + } + try { + await runHost(host, "docker", ["logs", "--tail", "200", name], { + artifactName: `failure-fake-${options.kind}-api-${role}-logs`, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 30_000, + }); + } catch { + // Preserve the readiness failure when a diagnostic command cannot start. + } + } +} + export async function startFakeDockerApi( host: HostCliClient, cleanup: (name: string, run: () => Promise) => void, @@ -825,6 +863,7 @@ export async function startFakeDockerApi( }); expectExitZero(proxyStart, `start fake ${options.kind} API proxy`); + let readinessFailure = "published ports were unavailable"; for (let attempt = 0; attempt < 100; attempt += 1) { if (fs.existsSync(portFile) && fs.statSync(portFile).size > 0) { const restPort = await runHost(host, "docker", ["port", proxy, "8080/tcp"], { @@ -833,6 +872,10 @@ export async function startFakeDockerApi( redactionValues: options.redactionValues, timeoutMs: 30_000, }); + if (restPort.exitCode !== 0) { + readinessFailure = `REST port lookup failed with exit ${restPort.exitCode ?? "unknown"}`; + break; + } const publishedRestPort = restPort.stdout.trim().split(":").at(-1)?.trim() ?? ""; let publishedWebsocketPort = ""; if (options.kind === "slack") { @@ -842,23 +885,57 @@ export async function startFakeDockerApi( redactionValues: options.redactionValues, timeoutMs: 30_000, }); + if (websocketPort.exitCode !== 0) { + readinessFailure = `WebSocket port lookup failed with exit ${websocketPort.exitCode ?? "unknown"}`; + break; + } publishedWebsocketPort = websocketPort.stdout.trim().split(":").at(-1)?.trim() ?? ""; } if (publishedRestPort && (options.kind !== "slack" || publishedWebsocketPort)) { - return { - kind: options.kind, - port: publishedRestPort, - ...(options.kind === "slack" ? { alternatePort: publishedWebsocketPort } : {}), - dir, - captureFile, - container, - }; + const readiness = await runHost( + host, + "node", + [ + "--experimental-strip-types", + FAKE_API_PORT_READINESS, + gatewayIp, + publishedRestPort, + ...(options.kind === "slack" ? [publishedWebsocketPort] : []), + ], + { + artifactName: `prove-fake-${options.kind}-api-proxy-readiness`, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 15_000, + }, + ); + if (readiness.exitCode === 0) { + return { + kind: options.kind, + port: publishedRestPort, + ...(options.kind === "slack" ? { alternatePort: publishedWebsocketPort } : {}), + dir, + captureFile, + container, + }; + } + readinessFailure = `traffic readiness failed with exit ${readiness.exitCode ?? "unknown"}`; + break; } } await sleep(100); } - throw new Error(`fake ${options.kind} API did not publish a port`); + await collectFakeApiStartupDiagnostics(host, { + kind: options.kind, + proxy, + container, + env: options.env, + redactionValues: options.redactionValues, + }); + throw new Error( + `fake ${options.kind} API proxy ${proxy} did not become ready for API container ${container}: ${readinessFailure}`, + ); } export async function applyRestRewritePolicy( diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index fc4126a72ca..e68c28a4752 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -30,6 +30,10 @@ import { parseInstalledWechatProof } from "../live/messaging-providers-wechat-ru const FAKE_TELEGRAM_API = path.resolve(import.meta.dirname, "../lib/fake-telegram-api.cjs"); const FAKE_SLACK_API = path.resolve(import.meta.dirname, "../lib/fake-slack-api.cjs"); const FAKE_WECHAT_API = path.resolve(import.meta.dirname, "../lib/fake-wechat-api.mts"); +const FAKE_API_PORT_READINESS = path.resolve( + import.meta.dirname, + "../lib/fake-api-port-readiness.mts", +); async function waitFor(predicate: () => boolean, message: string): Promise { const deadline = Date.now() + 5_000; @@ -55,56 +59,53 @@ function successfulShellResult(command: string[], stdout = ""): ShellProbeResult } describe("messaging provider installed-runtime proofs", () => { - it("keeps fake Docker APIs internal and binds proxy ports to the OpenShell bridge", async () => { - const invocations: string[][] = []; + it("collects diagnostics when published fake API proxy ports do not carry traffic", async () => { + const artifactNames: string[] = []; const cleanupTasks: Array<() => Promise> = []; - const responses: Array<(invocation: string[]) => ShellProbeResult> = [ - (invocation) => - successfulShellResult( - invocation, - JSON.stringify([ - { - Driver: "bridge", - Id: "0123456789abcdef0123456789abcdef", - IPAM: { Config: [{ Subnet: "172.18.0.0/16", Gateway: "172.18.0.1" }] }, - Options: {}, - }, - ]), - ), - (invocation) => successfulShellResult(invocation), - (invocation) => successfulShellResult(invocation), - (invocation) => { - const mountSuffix = ":/tmp/fake"; - const mount = invocation.find((argument) => argument.endsWith(mountSuffix)); - expect(mount).toBeDefined(); - fs.writeFileSync(path.join(mount!.slice(0, -mountSuffix.length), "port"), "8080\n"); - return successfulShellResult(invocation, "fake-container-id\n"); - }, - (invocation) => successfulShellResult(invocation), - (invocation) => successfulShellResult(invocation), - (invocation) => successfulShellResult(invocation), - (invocation) => successfulShellResult(invocation, "172.18.0.1:41080\n"), - (invocation) => successfulShellResult(invocation, "172.18.0.1:41081\n"), - (invocation) => successfulShellResult(invocation), - (invocation) => successfulShellResult(invocation), - (invocation) => successfulShellResult(invocation), - (invocation) => successfulShellResult(invocation), - ]; const runner: CommandRunner = { - async run(command) { + async run(command, options) { const invocation = [command.command, ...command.args]; - invocations.push(invocation); - const respond = responses.shift(); - expect(respond).toBeDefined(); - return respond!(invocation); + const artifactName = options?.artifactName ?? ""; + artifactNames.push(...(artifactName ? [artifactName] : [])); + switch (artifactName) { + case "inspect-fake-slack-api-openshell-network": + return successfulShellResult( + invocation, + JSON.stringify([ + { + Driver: "bridge", + Id: "0123456789abcdef0123456789abcdef", + IPAM: { Config: [{ Subnet: "172.18.0.0/16", Gateway: "172.18.0.1" }] }, + Options: {}, + }, + ]), + ); + case "start-fake-slack-api": { + const mountSuffix = ":/tmp/fake"; + const mount = invocation.find((argument) => argument.endsWith(mountSuffix)); + expect(mount).toBeDefined(); + fs.writeFileSync(path.join(mount!.slice(0, -mountSuffix.length), "port"), "8080\n"); + return successfulShellResult(invocation, "fake-container-id\n"); + } + case "port-fake-slack-api": + return successfulShellResult(invocation, "172.18.0.1:41080\n"); + case "port-fake-slack-websocket-api": + return successfulShellResult(invocation, "172.18.0.1:41081\n"); + case "prove-fake-slack-api-proxy-readiness": + return { + ...successfulShellResult(invocation), + exitCode: 1, + stderr: "fake API port readiness failed: connection refused", + }; + default: + return successfulShellResult(invocation); + } }, }; try { - const api = await startFakeDockerApi( - new HostCliClient(runner), - (_name, run) => cleanupTasks.push(run), - { + await expect( + startFakeDockerApi(new HostCliClient(runner), (_name, run) => cleanupTasks.push(run), { kind: "slack", imageScript: "fake-slack-api.cjs", containerPrefix: "nemoclaw-fake-slack-test", @@ -117,54 +118,14 @@ describe("messaging provider installed-runtime proofs", () => { }, redactionValues: [], env: {}, - }, - ); - const networkCreates = invocations.filter( - ([command, group, action]) => - command === "docker" && group === "network" && action === "create", - ); - expect(invocations).toContainEqual(["docker", "network", "inspect", "openshell-docker"]); - const internalNetworkCreate = networkCreates.find((invocation) => - invocation.includes("--internal"), - ); - const proxyNetworkCreate = networkCreates.find((invocation) => - invocation.includes("com.docker.network.bridge.enable_ip_masquerade=false"), - ); - expect(internalNetworkCreate?.slice(0, -1)).toEqual([ - "docker", - "network", - "create", - "--internal", - ]); - expect(proxyNetworkCreate?.slice(0, -1)).toEqual([ - "docker", - "network", - "create", - "--driver", - "bridge", - "--opt", - "com.docker.network.bridge.enable_ip_masquerade=false", + }), + ).rejects.toThrow(/proxy .* did not become ready for API container .*traffic readiness/u); + expect(artifactNames.filter((name) => name.startsWith("failure-fake-slack-api-"))).toEqual([ + "failure-fake-slack-api-proxy-inspect", + "failure-fake-slack-api-proxy-logs", + "failure-fake-slack-api-api-inspect", + "failure-fake-slack-api-api-logs", ]); - const containerRun = invocations.find( - ([command, action]) => command === "docker" && action === "run", - ); - expect(containerRun).toContain(internalNetworkCreate?.at(-1)); - expect(containerRun).not.toContain("-p"); - expect(containerRun).not.toContain("0:8080"); - expect(containerRun?.join("\n")).toContain("xoxb-fake-slack-network-test"); - expect(containerRun?.join("\n")).toContain("xapp-fake-slack-network-test"); - const proxyCreate = invocations.find( - ([command, action]) => command === "docker" && action === "create", - ); - expect(proxyCreate).toContain(proxyNetworkCreate?.at(-1)); - expect(proxyCreate).toContain("172.18.0.1:0:8080"); - expect(proxyCreate).toContain("172.18.0.1:0:8081"); - expect(proxyCreate).not.toContain("0:8080"); - expect(proxyCreate?.join("\n")).not.toContain("xoxb-fake-slack-network-test"); - expect(proxyCreate?.join("\n")).not.toContain("xapp-fake-slack-network-test"); - expect(proxyCreate).toContain("/opt/nemoclaw-e2e/fake-api-port-proxy.mts"); - expect(api.port).toBe("41080"); - expect(api.alternatePort).toBe("41081"); } finally { await cleanupTasks .reverse() @@ -224,6 +185,41 @@ describe("messaging provider installed-runtime proofs", () => { expect(listening).toHaveLength(2); expect(listening.map((entry) => entry.kind).sort()).toEqual(["rest", "websocket"]); expect(new Set(listening.map((entry) => entry.port)).size).toBe(2); + const restPort = Number(fs.readFileSync(portFile, "utf8").trim()); + const websocketPort = Number( + listening.find((entry) => entry.kind === "websocket")?.port ?? 0, + ); + const readiness = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + FAKE_API_PORT_READINESS, + "127.0.0.1", + String(restPort), + String(websocketPort), + ], + { encoding: "utf8", timeout: 15_000 }, + ); + expect(readiness.status, readiness.stderr).toBe(0); + const traffic = fs + .readFileSync(captureFile, "utf8") + .trim() + .split(/\r?\n/u) + .filter(Boolean) + .map((line) => JSON.parse(line)); + expect(traffic).toContainEqual( + expect.objectContaining({ event: "request", path: "/__nemoclaw_e2e_readiness" }), + ); + expect(traffic).toContainEqual( + expect.objectContaining({ event: "websocket-upgrade", path: "/socket-mode" }), + ); + expect(traffic).toContainEqual( + expect.objectContaining({ + event: "websocket-message", + messageType: "nemoclaw_readiness", + path: "/socket-mode", + }), + ); } finally { child.kill("SIGTERM"); await new Promise((resolve) => @@ -231,7 +227,7 @@ describe("messaging provider installed-runtime proofs", () => { ); fs.rmSync(dir, { recursive: true, force: true }); } - }, 10_000); + }, 15_000); it("keeps raw process-probe tokens out of argv and fails closed", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-process-token-probe-")); diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index f62dd67153e..1a69400e787 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -702,8 +702,17 @@ const RESTORED_GATEWAY_PAIRING_RUNTIME_FILES = new Set([ "src/lib/actions/sandbox/restore-gateway-pairing.ts", "src/lib/adapters/openshell/restore-gateway-pairing.ts", ]); +const FAKE_DOCKER_API_CONSUMER_JOBS = [ + "hermes-discord", + "messaging-providers", + "openclaw-discord-pairing", + "openclaw-slack-pairing", +] as const; const LIVE_E2E_OWNING_FILE_JOBS = new Map([ ["test/e2e/lib/fake-wechat-api.mts", ["messaging-providers"]], + ["test/e2e/lib/fake-api-port-proxy.mts", FAKE_DOCKER_API_CONSUMER_JOBS], + ["test/e2e/lib/fake-api-port-readiness.mts", FAKE_DOCKER_API_CONSUMER_JOBS], + ["test/e2e/live/messaging-providers-helpers.ts", FAKE_DOCKER_API_CONSUMER_JOBS], ["test/e2e/live/openclaw-plugin-runtime-exdev-lifecycle.ts", ["openclaw-plugin-runtime-exdev"]], ]); @@ -717,7 +726,7 @@ export function focusedE2eJobsForChangedFiles( addMapValue(matchedFilesByJob, job, file); } for (const job of LIVE_E2E_OWNING_FILE_JOBS.get(file) ?? []) { - if (inventory.allowedJobs.includes(job)) addMapValue(matchedFilesByJob, job, file); + addMapValue(matchedFilesByJob, job, file); } if (RESTORED_GATEWAY_PAIRING_RUNTIME_FILES.has(file)) { addMapValue(matchedFilesByJob, "snapshot-commands", file); From bb2f0b977bd6c8ab23066d6c2f34a91b86e79e40 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 31 Aug 2026 02:29:32 -0400 Subject: [PATCH 04/45] fix(e2e): use native proxy traffic probes Signed-off-by: Julie Yaunches --- test/e2e/lib/fake-api-port-readiness.mts | 176 ++++++------------ test/e2e/lib/fake-slack-api.cjs | 8 + test/e2e/live/messaging-providers-helpers.ts | 24 +-- ...messaging-providers-runtime-proofs.test.ts | 84 ++++++++- 4 files changed, 157 insertions(+), 135 deletions(-) diff --git a/test/e2e/lib/fake-api-port-readiness.mts b/test/e2e/lib/fake-api-port-readiness.mts index d726cdcb019..2e387f3a36f 100644 --- a/test/e2e/lib/fake-api-port-readiness.mts +++ b/test/e2e/lib/fake-api-port-readiness.mts @@ -1,16 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import crypto from "node:crypto"; -import http from "node:http"; -import net from "node:net"; +import { isIP } from "node:net"; import { pathToFileURL } from "node:url"; const ATTEMPT_TIMEOUT_MS = 1_000; -const READINESS_TIMEOUT_MS = 10_000; +const PORT_TRAFFIC_TIMEOUT_MS = 10_000; const RETRY_DELAY_MS = 100; -const MAX_HANDSHAKE_BYTES = 8_192; -const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const MAX_REPLY_BYTES = 8_192; +const PORT_TRAFFIC_PROBE = "nemoclaw_port_traffic_probe"; +const PORT_TRAFFIC_REPLY = "nemoclaw_port_traffic_reply"; function requirePort(raw: string, name: string): number { if (!/^[1-9][0-9]{0,4}$/u.test(raw)) throw new Error(`${name} must be a decimal port`); @@ -19,127 +18,76 @@ function requirePort(raw: string, name: string): number { return port; } -function probeHttp(host: string, port: number): Promise { - return new Promise((resolve, reject) => { - let settled = false; - let bodyBytes = 0; - let request: http.ClientRequest; - const finish = (error?: Error) => { - if (settled) return; - settled = true; - clearTimeout(timer); - request.destroy(); - if (error) reject(error); - else resolve(); - }; - const timer = setTimeout( - () => finish(new Error("REST readiness timed out")), - ATTEMPT_TIMEOUT_MS, - ); - request = http.get( - { - host, - port, - path: "/__nemoclaw_e2e_readiness", - }, - (response) => { - response.on("data", (chunk) => { - bodyBytes += chunk.length; - if (bodyBytes > MAX_HANDSHAKE_BYTES) { - finish(new Error("REST readiness response exceeded 8192 bytes")); - } - }); - response.once("end", () => finish()); - response.once("error", (error) => finish(error)); - }, - ); - request.once("error", (error) => finish(error)); - }); -} - -function encodeClientText(payload: string): Buffer { - const body = Buffer.from(payload, "utf8"); - const mask = crypto.randomBytes(4); - const masked = Buffer.alloc(body.length); - for (let index = 0; index < body.length; index += 1) { - masked[index] = body[index] ^ mask[index % mask.length]; - } - return Buffer.concat([Buffer.from([0x81, 0x80 | body.length]), mask, masked]); +function urlHost(host: string): string { + return isIP(host) === 6 ? `[${host}]` : host; } -function hasServerTextFrame(buffer: Buffer): boolean { - if (buffer.length < 2) return false; - const opcode = buffer[0] & 0x0f; - let payloadLength = buffer[1] & 0x7f; - let offset = 2; - if (payloadLength === 126) { - if (buffer.length < 4) return false; - payloadLength = buffer.readUInt16BE(2); - offset = 4; - } else if (payloadLength === 127) { - if (buffer.length < 10) return false; - const length = buffer.readBigUInt64BE(2); - if (length > BigInt(MAX_HANDSHAKE_BYTES)) return false; - payloadLength = Number(length); - offset = 10; +async function probeHttp(host: string, port: number): Promise { + const response = await fetch(`http://${urlHost(host)}:${port}/__nemoclaw_e2e_port_traffic`, { + signal: AbortSignal.timeout(ATTEMPT_TIMEOUT_MS), + }); + const reader = response.body?.getReader(); + if (!reader) return; + let replyBytes = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) return; + replyBytes += value.byteLength; + if (replyBytes > MAX_REPLY_BYTES) { + await reader.cancel(); + throw new Error("REST port traffic reply exceeded 8192 bytes"); + } } - return opcode === 1 && buffer.length >= offset + payloadLength; } function probeWebsocket(host: string, port: number): Promise { return new Promise((resolve, reject) => { - const key = crypto.randomBytes(16).toString("base64"); - const expectedAccept = crypto.createHash("sha1").update(`${key}${WS_GUID}`).digest("base64"); - const socket = net.createConnection({ host, port }); + const socket = new WebSocket(`ws://${urlHost(host)}:${port}/socket-mode`); let settled = false; - let response = Buffer.alloc(0); - let upgraded = false; const finish = (error?: Error) => { if (settled) return; settled = true; - socket.destroy(); + clearTimeout(timer); + try { + socket.close(); + } catch { + // Preserve the traffic-check result when the native client is not open. + } if (error) reject(error); else resolve(); }; - socket.setTimeout(ATTEMPT_TIMEOUT_MS, () => finish(new Error("WebSocket readiness timed out"))); - socket.once("error", (error) => finish(error)); - socket.once("connect", () => { - socket.write( - [ - "GET /socket-mode HTTP/1.1", - `Host: ${host}:${port}`, - "Upgrade: websocket", - "Connection: Upgrade", - `Sec-WebSocket-Key: ${key}`, - "Sec-WebSocket-Version: 13", - "\r\n", - ].join("\r\n"), - ); + const timer = setTimeout(() => { + finish(new Error("WebSocket port traffic timed out")); + }, ATTEMPT_TIMEOUT_MS); + socket.addEventListener("open", () => { + socket.send(JSON.stringify({ type: PORT_TRAFFIC_PROBE })); }); - socket.on("data", (chunk) => { - response = Buffer.concat([response, Buffer.from(chunk)]); - if (response.length > MAX_HANDSHAKE_BYTES) { - finish(new Error("WebSocket readiness response exceeded 8192 bytes")); + socket.addEventListener("message", (event) => { + if (typeof event.data !== "string") { + finish(new Error("WebSocket port traffic reply was not text")); return; } - if (!upgraded) { - const end = response.indexOf("\r\n\r\n"); - if (end === -1) return; - const headers = response.slice(0, end).toString("latin1"); - const status = headers.split("\r\n", 1)[0] ?? ""; - const acceptLine = headers - .split("\r\n") - .find((line) => /^sec-websocket-accept:/iu.test(line)); - const accept = acceptLine?.slice(acceptLine.indexOf(":") + 1).trim(); - if (!/^HTTP\/1[.]1 101\b/u.test(status) || accept !== expectedAccept) { - finish(new Error("WebSocket readiness handshake was rejected")); + if (Buffer.byteLength(event.data, "utf8") > MAX_REPLY_BYTES) { + finish(new Error("WebSocket port traffic reply exceeded 8192 bytes")); + return; + } + try { + const reply = JSON.parse(event.data); + if (reply?.type !== PORT_TRAFFIC_REPLY) { + finish(new Error("WebSocket port traffic reply was not recognized")); return; } - upgraded = true; - response = response.slice(end + 4); - socket.write(encodeClientText(JSON.stringify({ type: "nemoclaw_readiness" }))); + } catch { + finish(new Error("WebSocket port traffic reply was not valid JSON")); + return; } - if (hasServerTextFrame(response)) finish(); + finish(); + }); + socket.addEventListener("error", () => { + finish(new Error("WebSocket port traffic connection failed")); + }); + socket.addEventListener("close", () => { + finish(new Error("WebSocket port traffic closed before the reply")); }); }); } @@ -148,12 +96,12 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -export async function proveFakeApiPortReadiness(options: { +export async function proveFakeApiPortTraffic(options: { host: string; restPort: number; websocketPort?: number; }): Promise { - if (net.isIP(options.host) === 0) throw new Error("readiness host must be an IP address"); + if (isIP(options.host) === 0) throw new Error("port traffic host must be an IP address"); if (!Number.isInteger(options.restPort) || options.restPort < 1 || options.restPort > 65_535) { throw new Error("REST port must be an integer between 1 and 65535"); } @@ -165,8 +113,8 @@ export async function proveFakeApiPortReadiness(options: { ) { throw new Error("WebSocket port must be an integer between 1 and 65535"); } - const deadline = Date.now() + READINESS_TIMEOUT_MS; - let lastError = "readiness probe did not run"; + const deadline = Date.now() + PORT_TRAFFIC_TIMEOUT_MS; + let lastError: string | undefined; do { try { await probeHttp(options.host, options.restPort); @@ -179,19 +127,19 @@ export async function proveFakeApiPortReadiness(options: { } await sleep(RETRY_DELAY_MS); } while (Date.now() < deadline); - throw new Error(`fake API port readiness failed: ${lastError}`); + throw new Error(`fake API port traffic check failed: ${lastError ?? "no reply"}`); } async function main(): Promise { const [host = "", restPortRaw = "", websocketPortRaw] = process.argv.slice(2); - await proveFakeApiPortReadiness({ + await proveFakeApiPortTraffic({ host, restPort: requirePort(restPortRaw, "REST port"), ...(websocketPortRaw === undefined ? {} : { websocketPort: requirePort(websocketPortRaw, "WebSocket port") }), }); - process.stdout.write("fake API proxy ports are ready\n"); + process.stdout.write("fake API proxy ports carry traffic\n"); } if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { diff --git a/test/e2e/lib/fake-slack-api.cjs b/test/e2e/lib/fake-slack-api.cjs index 0935ff8f0d7..3122a3c416d 100755 --- a/test/e2e/lib/fake-slack-api.cjs +++ b/test/e2e/lib/fake-slack-api.cjs @@ -20,6 +20,7 @@ const expectedAppToken = process.env.FAKE_SLACK_API_EXPECTED_APP_TOKEN || ""; const socketUserId = process.env.FAKE_SLACK_API_SOCKET_USER_ID || "U3730E2E"; const socketChannelId = process.env.FAKE_SLACK_API_SOCKET_CHANNEL_ID || "D3730E2E"; const socketTeamId = process.env.FAKE_SLACK_API_SOCKET_TEAM_ID || "T3730E2E"; +const suppressPortTrafficReply = process.env.FAKE_SLACK_API_SUPPRESS_PORT_TRAFFIC_REPLY === "1"; const MAX_BODY_BYTES = 1024 * 1024; const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; @@ -298,6 +299,13 @@ function handleUpgrade(req, socket) { tokenLooksPlaceholder: tokenLooksPlaceholder(text), textRedacted: true, }); + if (messageType === "nemoclaw_port_traffic_probe") { + if (!suppressPortTrafficReply) { + socket.write(encodeServerText(JSON.stringify({ type: "nemoclaw_port_traffic_reply" }))); + record({ event: "websocket-port-traffic-reply", path: pathname }); + } + continue; + } if (!sentEvent) { sentEvent = true; sendSocketModeEvent(socket); diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 7c40aef335c..261b15e1f48 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -34,7 +34,7 @@ export const BASE_POLICY = path.join( "openclaw-sandbox.yaml", ); export const FAKE_LIB_DIR = path.join(REPO_ROOT, "test", "e2e", "lib"); -const FAKE_API_PORT_READINESS = path.join(FAKE_LIB_DIR, "fake-api-port-readiness.mts"); +const FAKE_API_PORT_TRAFFIC = path.join(FAKE_LIB_DIR, "fake-api-port-readiness.mts"); export const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? `e2e-msg-${process.pid}`; export const INSTALL_TIMEOUT_MS = 45 * 60_000; export const REBUILD_TIMEOUT_MS = 25 * 60_000; @@ -622,7 +622,7 @@ async function collectFakeApiStartupDiagnostics( timeoutMs: 30_000, }); } catch { - // Preserve the readiness failure when a diagnostic command cannot start. + // Preserve the port traffic failure when a diagnostic command cannot start. } try { await runHost(host, "docker", ["logs", "--tail", "200", name], { @@ -632,7 +632,7 @@ async function collectFakeApiStartupDiagnostics( timeoutMs: 30_000, }); } catch { - // Preserve the readiness failure when a diagnostic command cannot start. + // Preserve the port traffic failure when a diagnostic command cannot start. } } } @@ -863,7 +863,7 @@ export async function startFakeDockerApi( }); expectExitZero(proxyStart, `start fake ${options.kind} API proxy`); - let readinessFailure = "published ports were unavailable"; + let trafficFailure = "published ports were unavailable"; for (let attempt = 0; attempt < 100; attempt += 1) { if (fs.existsSync(portFile) && fs.statSync(portFile).size > 0) { const restPort = await runHost(host, "docker", ["port", proxy, "8080/tcp"], { @@ -873,7 +873,7 @@ export async function startFakeDockerApi( timeoutMs: 30_000, }); if (restPort.exitCode !== 0) { - readinessFailure = `REST port lookup failed with exit ${restPort.exitCode ?? "unknown"}`; + trafficFailure = `REST port lookup failed with exit ${restPort.exitCode ?? "unknown"}`; break; } const publishedRestPort = restPort.stdout.trim().split(":").at(-1)?.trim() ?? ""; @@ -886,30 +886,30 @@ export async function startFakeDockerApi( timeoutMs: 30_000, }); if (websocketPort.exitCode !== 0) { - readinessFailure = `WebSocket port lookup failed with exit ${websocketPort.exitCode ?? "unknown"}`; + trafficFailure = `WebSocket port lookup failed with exit ${websocketPort.exitCode ?? "unknown"}`; break; } publishedWebsocketPort = websocketPort.stdout.trim().split(":").at(-1)?.trim() ?? ""; } if (publishedRestPort && (options.kind !== "slack" || publishedWebsocketPort)) { - const readiness = await runHost( + const traffic = await runHost( host, "node", [ "--experimental-strip-types", - FAKE_API_PORT_READINESS, + FAKE_API_PORT_TRAFFIC, gatewayIp, publishedRestPort, ...(options.kind === "slack" ? [publishedWebsocketPort] : []), ], { - artifactName: `prove-fake-${options.kind}-api-proxy-readiness`, + artifactName: `prove-fake-${options.kind}-api-proxy-traffic`, env: options.env, redactionValues: options.redactionValues, timeoutMs: 15_000, }, ); - if (readiness.exitCode === 0) { + if (traffic.exitCode === 0) { return { kind: options.kind, port: publishedRestPort, @@ -919,7 +919,7 @@ export async function startFakeDockerApi( container, }; } - readinessFailure = `traffic readiness failed with exit ${readiness.exitCode ?? "unknown"}`; + trafficFailure = `proxy traffic check failed with exit ${traffic.exitCode ?? "unknown"}`; break; } } @@ -934,7 +934,7 @@ export async function startFakeDockerApi( redactionValues: options.redactionValues, }); throw new Error( - `fake ${options.kind} API proxy ${proxy} did not become ready for API container ${container}: ${readinessFailure}`, + `fake ${options.kind} API proxy ${proxy} did not carry traffic to API container ${container}: ${trafficFailure}`, ); } diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index e68c28a4752..dc1e5b30d9a 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -30,7 +30,7 @@ import { parseInstalledWechatProof } from "../live/messaging-providers-wechat-ru const FAKE_TELEGRAM_API = path.resolve(import.meta.dirname, "../lib/fake-telegram-api.cjs"); const FAKE_SLACK_API = path.resolve(import.meta.dirname, "../lib/fake-slack-api.cjs"); const FAKE_WECHAT_API = path.resolve(import.meta.dirname, "../lib/fake-wechat-api.mts"); -const FAKE_API_PORT_READINESS = path.resolve( +const FAKE_API_PORT_TRAFFIC = path.resolve( import.meta.dirname, "../lib/fake-api-port-readiness.mts", ); @@ -91,11 +91,11 @@ describe("messaging provider installed-runtime proofs", () => { return successfulShellResult(invocation, "172.18.0.1:41080\n"); case "port-fake-slack-websocket-api": return successfulShellResult(invocation, "172.18.0.1:41081\n"); - case "prove-fake-slack-api-proxy-readiness": + case "prove-fake-slack-api-proxy-traffic": return { ...successfulShellResult(invocation), exitCode: 1, - stderr: "fake API port readiness failed: connection refused", + stderr: "fake API port traffic check failed: connection refused", }; default: return successfulShellResult(invocation); @@ -119,7 +119,7 @@ describe("messaging provider installed-runtime proofs", () => { redactionValues: [], env: {}, }), - ).rejects.toThrow(/proxy .* did not become ready for API container .*traffic readiness/u); + ).rejects.toThrow(/proxy .* did not carry traffic to API container .*traffic check/u); expect(artifactNames.filter((name) => name.startsWith("failure-fake-slack-api-"))).toEqual([ "failure-fake-slack-api-proxy-inspect", "failure-fake-slack-api-proxy-logs", @@ -189,18 +189,18 @@ describe("messaging provider installed-runtime proofs", () => { const websocketPort = Number( listening.find((entry) => entry.kind === "websocket")?.port ?? 0, ); - const readiness = spawnSync( + const trafficCheck = spawnSync( process.execPath, [ "--experimental-strip-types", - FAKE_API_PORT_READINESS, + FAKE_API_PORT_TRAFFIC, "127.0.0.1", String(restPort), String(websocketPort), ], { encoding: "utf8", timeout: 15_000 }, ); - expect(readiness.status, readiness.stderr).toBe(0); + expect(trafficCheck.status, trafficCheck.stderr).toBe(0); const traffic = fs .readFileSync(captureFile, "utf8") .trim() @@ -208,7 +208,7 @@ describe("messaging provider installed-runtime proofs", () => { .filter(Boolean) .map((line) => JSON.parse(line)); expect(traffic).toContainEqual( - expect.objectContaining({ event: "request", path: "/__nemoclaw_e2e_readiness" }), + expect.objectContaining({ event: "request", path: "/__nemoclaw_e2e_port_traffic" }), ); expect(traffic).toContainEqual( expect.objectContaining({ event: "websocket-upgrade", path: "/socket-mode" }), @@ -216,10 +216,13 @@ describe("messaging provider installed-runtime proofs", () => { expect(traffic).toContainEqual( expect.objectContaining({ event: "websocket-message", - messageType: "nemoclaw_readiness", + messageType: "nemoclaw_port_traffic_probe", path: "/socket-mode", }), ); + expect(traffic).toContainEqual( + expect.objectContaining({ event: "websocket-port-traffic-reply", path: "/socket-mode" }), + ); } finally { child.kill("SIGTERM"); await new Promise((resolve) => @@ -229,6 +232,69 @@ describe("messaging provider installed-runtime proofs", () => { } }, 15_000); + it("rejects a fake Slack websocket that upgrades without a port traffic reply", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fake-slack-no-reply-")); + const portFile = path.join(dir, "port"); + const captureFile = path.join(dir, "capture.jsonl"); + const child = spawn(process.execPath, [FAKE_SLACK_API], { + env: { + ...process.env, + FAKE_SLACK_API_HOST: "127.0.0.1", + FAKE_SLACK_API_PORT: "0", + FAKE_SLACK_API_WEBSOCKET_PORT: "0", + FAKE_SLACK_API_PORT_FILE: portFile, + FAKE_SLACK_API_CAPTURE_FILE: captureFile, + FAKE_SLACK_API_EXPECTED_BOT_TOKEN: "xoxb-fake-slack-no-reply-test", + FAKE_SLACK_API_EXPECTED_APP_TOKEN: "xapp-fake-slack-no-reply-test", + FAKE_SLACK_API_SUPPRESS_PORT_TRAFFIC_REPLY: "1", + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + }); + + try { + await waitFor(() => fs.existsSync(portFile), `fake Slack listeners did not start: ${stderr}`); + const listening = fs + .readFileSync(captureFile, "utf8") + .trim() + .split(/\r?\n/u) + .filter(Boolean) + .map((line) => JSON.parse(line)) + .filter((entry) => entry.event === "listening"); + const restPort = Number(fs.readFileSync(portFile, "utf8").trim()); + const websocketPort = Number( + listening.find((entry) => entry.kind === "websocket")?.port ?? 0, + ); + const trafficCheck = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + FAKE_API_PORT_TRAFFIC, + "127.0.0.1", + String(restPort), + String(websocketPort), + ], + { encoding: "utf8", timeout: 15_000 }, + ); + expect(trafficCheck.status).toBe(1); + expect(trafficCheck.stderr).toContain("WebSocket port traffic timed out"); + const traffic = fs.readFileSync(captureFile, "utf8"); + expect(traffic).toContain('"event":"websocket-upgrade"'); + expect(traffic).toContain('"messageType":"nemoclaw_port_traffic_probe"'); + expect(traffic).not.toContain('"event":"websocket-port-traffic-reply"'); + } finally { + child.kill("SIGTERM"); + await new Promise((resolve) => + child.exitCode !== null ? resolve() : child.once("exit", () => resolve()), + ); + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 20_000); + it("keeps raw process-probe tokens out of argv and fails closed", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-process-token-probe-")); const token = `xoxb-nemoclaw-process-probe-secret-${process.pid}`; From 34fb3a8167422aaa69a3d63d26ca707343201a39 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 31 Aug 2026 02:53:20 -0400 Subject: [PATCH 05/45] fix(e2e): complete fake Slack ownership Signed-off-by: Julie Yaunches --- .../pull-requests/pr-risk-plan.test.ts | 10 + ...messaging-providers-runtime-proofs.test.ts | 198 ++++++++---------- tools/e2e/workflow-boundary.mts | 1 + 3 files changed, 103 insertions(+), 106 deletions(-) diff --git a/test/automation/pull-requests/pr-risk-plan.test.ts b/test/automation/pull-requests/pr-risk-plan.test.ts index 0e76b4687de..3e022b9a741 100644 --- a/test/automation/pull-requests/pr-risk-plan.test.ts +++ b/test/automation/pull-requests/pr-risk-plan.test.ts @@ -384,6 +384,16 @@ describe("deterministic PR risk plan", () => { ); }); + it("selects both fake Slack API consumers", () => { + const changedFile = "test/e2e/lib/fake-slack-api.cjs"; + expect(focusedE2eJobsForChangedFiles([changedFile])).toEqual( + ["messaging-providers", "openclaw-slack-pairing"].map((id) => ({ + id, + matchedFiles: [changedFile], + })), + ); + }); + it("maps a shared gateway live test to every catalogue fixture (#7921)", () => { const changedFiles = ["test/e2e/live/openshell-gateway-upgrade.test.ts"]; const focusedE2eJobs = catalogueTargetsForChangedFiles(changedFiles).map((target) => ({ diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index dc1e5b30d9a..30cd5c96cd2 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -58,6 +58,87 @@ function successfulShellResult(command: string[], stdout = ""): ShellProbeResult }; } +type FakeSlackPortFixture = { + restPort: number; + websocketPort: number; + captureFile: string; + stop: () => Promise; +}; + +function readFakeSlackCapture(captureFile: string): Array> { + return fs + .readFileSync(captureFile, "utf8") + .trim() + .split(/\r?\n/u) + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +async function startFakeSlackPortFixture(options?: { + suppressPortTrafficReply?: boolean; +}): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fake-slack-ports-")); + const portFile = path.join(dir, "port"); + const captureFile = path.join(dir, "capture.jsonl"); + const child = spawn(process.execPath, [FAKE_SLACK_API], { + env: { + ...process.env, + FAKE_SLACK_API_HOST: "127.0.0.1", + FAKE_SLACK_API_PORT: "0", + FAKE_SLACK_API_WEBSOCKET_PORT: "0", + FAKE_SLACK_API_PORT_FILE: portFile, + FAKE_SLACK_API_CAPTURE_FILE: captureFile, + FAKE_SLACK_API_EXPECTED_BOT_TOKEN: "xoxb-fake-slack-port-test", + FAKE_SLACK_API_EXPECTED_APP_TOKEN: "xapp-fake-slack-port-test", + ...(options?.suppressPortTrafficReply + ? { FAKE_SLACK_API_SUPPRESS_PORT_TRAFFIC_REPLY: "1" } + : {}), + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + }); + const stop = async () => { + child.kill("SIGTERM"); + await new Promise((resolve) => + child.exitCode !== null ? resolve() : child.once("exit", () => resolve()), + ); + fs.rmSync(dir, { recursive: true, force: true }); + }; + + try { + await waitFor(() => fs.existsSync(portFile), `fake Slack listeners did not start: ${stderr}`); + const listening = readFakeSlackCapture(captureFile).filter( + (entry) => entry.event === "listening", + ); + const restPort = Number(fs.readFileSync(portFile, "utf8").trim()); + const websocketPort = Number( + listening.find((entry) => entry.kind === "websocket")?.port ?? 0, + ); + return { restPort, websocketPort, captureFile, stop }; + } catch (error) { + await stop(); + throw error; + } +} + +function runFakeSlackPortTrafficCheck(fixture: FakeSlackPortFixture) { + return spawnSync( + process.execPath, + [ + "--experimental-strip-types", + FAKE_API_PORT_TRAFFIC, + "127.0.0.1", + String(fixture.restPort), + String(fixture.websocketPort), + ], + { encoding: "utf8", timeout: 15_000 }, + ); +} + describe("messaging provider installed-runtime proofs", () => { it("collects diagnostics when published fake API proxy ports do not carry traffic", async () => { const artifactNames: string[] = []; @@ -151,62 +232,18 @@ describe("messaging provider installed-runtime proofs", () => { }); it("publishes independent fake Slack REST and websocket ports", async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fake-slack-ports-")); - const portFile = path.join(dir, "port"); - const captureFile = path.join(dir, "capture.jsonl"); - const child = spawn(process.execPath, [FAKE_SLACK_API], { - env: { - ...process.env, - FAKE_SLACK_API_HOST: "127.0.0.1", - FAKE_SLACK_API_PORT: "0", - FAKE_SLACK_API_WEBSOCKET_PORT: "0", - FAKE_SLACK_API_PORT_FILE: portFile, - FAKE_SLACK_API_CAPTURE_FILE: captureFile, - FAKE_SLACK_API_EXPECTED_BOT_TOKEN: "xoxb-fake-slack-port-test", - FAKE_SLACK_API_EXPECTED_APP_TOKEN: "xapp-fake-slack-port-test", - }, - stdio: ["ignore", "ignore", "pipe"], - }); - let stderr = ""; - child.stderr?.setEncoding("utf8"); - child.stderr?.on("data", (chunk: string) => { - stderr += chunk; - }); + const fixture = await startFakeSlackPortFixture(); try { - await waitFor(() => fs.existsSync(portFile), `fake Slack listeners did not start: ${stderr}`); - const listening = fs - .readFileSync(captureFile, "utf8") - .trim() - .split(/\r?\n/u) - .filter(Boolean) - .map((line) => JSON.parse(line)) - .filter((entry) => entry.event === "listening"); + const listening = readFakeSlackCapture(fixture.captureFile).filter( + (entry) => entry.event === "listening", + ); expect(listening).toHaveLength(2); expect(listening.map((entry) => entry.kind).sort()).toEqual(["rest", "websocket"]); expect(new Set(listening.map((entry) => entry.port)).size).toBe(2); - const restPort = Number(fs.readFileSync(portFile, "utf8").trim()); - const websocketPort = Number( - listening.find((entry) => entry.kind === "websocket")?.port ?? 0, - ); - const trafficCheck = spawnSync( - process.execPath, - [ - "--experimental-strip-types", - FAKE_API_PORT_TRAFFIC, - "127.0.0.1", - String(restPort), - String(websocketPort), - ], - { encoding: "utf8", timeout: 15_000 }, - ); + const trafficCheck = runFakeSlackPortTrafficCheck(fixture); expect(trafficCheck.status, trafficCheck.stderr).toBe(0); - const traffic = fs - .readFileSync(captureFile, "utf8") - .trim() - .split(/\r?\n/u) - .filter(Boolean) - .map((line) => JSON.parse(line)); + const traffic = readFakeSlackCapture(fixture.captureFile); expect(traffic).toContainEqual( expect.objectContaining({ event: "request", path: "/__nemoclaw_e2e_port_traffic" }), ); @@ -224,74 +261,23 @@ describe("messaging provider installed-runtime proofs", () => { expect.objectContaining({ event: "websocket-port-traffic-reply", path: "/socket-mode" }), ); } finally { - child.kill("SIGTERM"); - await new Promise((resolve) => - child.exitCode !== null ? resolve() : child.once("exit", () => resolve()), - ); - fs.rmSync(dir, { recursive: true, force: true }); + await fixture.stop(); } }, 15_000); it("rejects a fake Slack websocket that upgrades without a port traffic reply", async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fake-slack-no-reply-")); - const portFile = path.join(dir, "port"); - const captureFile = path.join(dir, "capture.jsonl"); - const child = spawn(process.execPath, [FAKE_SLACK_API], { - env: { - ...process.env, - FAKE_SLACK_API_HOST: "127.0.0.1", - FAKE_SLACK_API_PORT: "0", - FAKE_SLACK_API_WEBSOCKET_PORT: "0", - FAKE_SLACK_API_PORT_FILE: portFile, - FAKE_SLACK_API_CAPTURE_FILE: captureFile, - FAKE_SLACK_API_EXPECTED_BOT_TOKEN: "xoxb-fake-slack-no-reply-test", - FAKE_SLACK_API_EXPECTED_APP_TOKEN: "xapp-fake-slack-no-reply-test", - FAKE_SLACK_API_SUPPRESS_PORT_TRAFFIC_REPLY: "1", - }, - stdio: ["ignore", "ignore", "pipe"], - }); - let stderr = ""; - child.stderr?.setEncoding("utf8"); - child.stderr?.on("data", (chunk: string) => { - stderr += chunk; - }); + const fixture = await startFakeSlackPortFixture({ suppressPortTrafficReply: true }); try { - await waitFor(() => fs.existsSync(portFile), `fake Slack listeners did not start: ${stderr}`); - const listening = fs - .readFileSync(captureFile, "utf8") - .trim() - .split(/\r?\n/u) - .filter(Boolean) - .map((line) => JSON.parse(line)) - .filter((entry) => entry.event === "listening"); - const restPort = Number(fs.readFileSync(portFile, "utf8").trim()); - const websocketPort = Number( - listening.find((entry) => entry.kind === "websocket")?.port ?? 0, - ); - const trafficCheck = spawnSync( - process.execPath, - [ - "--experimental-strip-types", - FAKE_API_PORT_TRAFFIC, - "127.0.0.1", - String(restPort), - String(websocketPort), - ], - { encoding: "utf8", timeout: 15_000 }, - ); + const trafficCheck = runFakeSlackPortTrafficCheck(fixture); expect(trafficCheck.status).toBe(1); expect(trafficCheck.stderr).toContain("WebSocket port traffic timed out"); - const traffic = fs.readFileSync(captureFile, "utf8"); + const traffic = fs.readFileSync(fixture.captureFile, "utf8"); expect(traffic).toContain('"event":"websocket-upgrade"'); expect(traffic).toContain('"messageType":"nemoclaw_port_traffic_probe"'); expect(traffic).not.toContain('"event":"websocket-port-traffic-reply"'); } finally { - child.kill("SIGTERM"); - await new Promise((resolve) => - child.exitCode !== null ? resolve() : child.once("exit", () => resolve()), - ); - fs.rmSync(dir, { recursive: true, force: true }); + await fixture.stop(); } }, 20_000); diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index 1a69400e787..69df87099d4 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -710,6 +710,7 @@ const FAKE_DOCKER_API_CONSUMER_JOBS = [ ] as const; const LIVE_E2E_OWNING_FILE_JOBS = new Map([ ["test/e2e/lib/fake-wechat-api.mts", ["messaging-providers"]], + ["test/e2e/lib/fake-slack-api.cjs", ["messaging-providers", "openclaw-slack-pairing"]], ["test/e2e/lib/fake-api-port-proxy.mts", FAKE_DOCKER_API_CONSUMER_JOBS], ["test/e2e/lib/fake-api-port-readiness.mts", FAKE_DOCKER_API_CONSUMER_JOBS], ["test/e2e/live/messaging-providers-helpers.ts", FAKE_DOCKER_API_CONSUMER_JOBS], From e1a64ca6069d69b42e38e939499ce7e0fb104169 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 31 Aug 2026 10:18:29 -0400 Subject: [PATCH 06/45] test(e2e): bound fake Slack runtime proof Signed-off-by: Julie Yaunches --- test/e2e/support/messaging-providers-runtime-proofs.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 30cd5c96cd2..e354ae447f8 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -263,7 +263,7 @@ describe("messaging provider installed-runtime proofs", () => { } finally { await fixture.stop(); } - }, 15_000); + }, 25_000); it("rejects a fake Slack websocket that upgrades without a port traffic reply", async () => { const fixture = await startFakeSlackPortFixture({ suppressPortTrafficReply: true }); From 2bd195be3fbb292d9539528573b1cbd8357ba2fd Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 31 Aug 2026 11:35:22 -0400 Subject: [PATCH 07/45] test(e2e): share Slack pairing fixture Signed-off-by: Julie Yaunches --- test/e2e/live/openclaw-slack-pairing.test.ts | 28 +++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/test/e2e/live/openclaw-slack-pairing.test.ts b/test/e2e/live/openclaw-slack-pairing.test.ts index ad02c20ac69..bc0ef3f3c82 100644 --- a/test/e2e/live/openclaw-slack-pairing.test.ts +++ b/test/e2e/live/openclaw-slack-pairing.test.ts @@ -160,15 +160,7 @@ test("OpenClaw Slack Socket Mode pairing request is shared with connect-shell ap }); progress.phase("route Slack API and websocket traffic through managed policies"); - const fakeSlackRest = await startFakeSlackApi( - host, - cleanup, - env, - SLACK_BOT_TOKEN, - SLACK_APP_TOKEN, - redactions, - ); - const fakeSlackWebSocket = await startFakeSlackApi( + const fakeSlack = await startFakeSlackApi( host, cleanup, env, @@ -176,10 +168,18 @@ test("OpenClaw Slack Socket Mode pairing request is shared with connect-shell ap SLACK_APP_TOKEN, redactions, ); + expect( + fakeSlack.alternatePort, + "fake Slack API must publish an independent app-token port", + ).toMatch(/^[1-9][0-9]*$/u); + const fakeSlackWebSocket = { + ...fakeSlack, + port: fakeSlack.alternatePort!, + }; await applyFakePolicy({ host, sandboxName: SANDBOX_NAME, - api: fakeSlackRest, + api: fakeSlack, protocol: "rest", rewrite: "request-body-credential-rewrite", providerName: `${SANDBOX_NAME}-slack-bridge`, @@ -205,16 +205,12 @@ test("OpenClaw Slack Socket Mode pairing request is shared with connect-shell ap sandboxName: SANDBOX_NAME, channel: "slack", redactions, - fakeSlackPort: fakeSlackRest.port, + fakeSlackPort: fakeSlack.port, fakeSlackWebSocketPort: fakeSlackWebSocket.port, }); expectExitZero(issue, "Slack pairing request creation"); const code = extractPairingCode(resultText(issue), "PAIRING_E2E_RESULT"); - assertSlackCapture( - [fakeSlackRest.captureFile, fakeSlackWebSocket.captureFile], - code, - PAIRING_USER.slack, - ); + assertSlackCapture([fakeSlack.captureFile], code, PAIRING_USER.slack); await writePairingArtifacts(artifacts, "slack", { code, user: PAIRING_USER.slack }); progress.phase("approve the Slack code through connect-shell"); From 6bf488108e01b0f03fdab61ceb009c575a3f5062 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 31 Aug 2026 11:48:14 -0400 Subject: [PATCH 08/45] test(e2e): map Slack fixture proof Signed-off-by: Julie Yaunches --- test/e2e/mock-parity.json | 1 + test/e2e/support/messaging-providers-runtime-proofs.test.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index a9920555ddd..17b93ccb783 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -636,6 +636,7 @@ "fast": [ "src/lib/onboard/sandbox-create-plan.test.ts", "test/e2e/support/openclaw-discord-pairing-helpers.test.ts", + "test/e2e/support/messaging-providers-runtime-proofs.test.ts", "test/onboarding/onboard-messaging.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts" diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index e354ae447f8..54fe5c34502 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -231,10 +231,11 @@ describe("messaging provider installed-runtime proofs", () => { } }); - it("publishes independent fake Slack REST and websocket ports", async () => { + it("publishes independent fake Slack REST and websocket ports from one fixture", async () => { const fixture = await startFakeSlackPortFixture(); try { + expect(fixture.restPort).not.toBe(fixture.websocketPort); const listening = readFakeSlackCapture(fixture.captureFile).filter( (entry) => entry.event === "listening", ); From fa028aaeaf89a387e1f3b18e233efac67ba06073 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 31 Aug 2026 12:00:41 -0400 Subject: [PATCH 09/45] test(e2e): assert distinct Slack ports Signed-off-by: Julie Yaunches --- test/e2e/live/openclaw-slack-pairing.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/e2e/live/openclaw-slack-pairing.test.ts b/test/e2e/live/openclaw-slack-pairing.test.ts index bc0ef3f3c82..42a0ba7d00d 100644 --- a/test/e2e/live/openclaw-slack-pairing.test.ts +++ b/test/e2e/live/openclaw-slack-pairing.test.ts @@ -172,6 +172,7 @@ test("OpenClaw Slack Socket Mode pairing request is shared with connect-shell ap fakeSlack.alternatePort, "fake Slack API must publish an independent app-token port", ).toMatch(/^[1-9][0-9]*$/u); + expect(fakeSlack.alternatePort).not.toBe(fakeSlack.port); const fakeSlackWebSocket = { ...fakeSlack, port: fakeSlack.alternatePort!, From bfd48e4d738bf5ad17148706612a7646f21de3cf Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 31 Aug 2026 21:00:44 -0400 Subject: [PATCH 10/45] fix(e2e): clean up fake API proxy merge Signed-off-by: Julie Yaunches --- .../pull-requests/pr-risk-plan.test.ts | 1 - test/e2e/fixtures/host-mock-firewall.ts | 4 +- test/e2e/lib/fake-api-port-proxy.mts | 31 ----- ...messaging-providers-runtime-proofs.test.ts | 117 +++++++----------- tools/e2e/workflow-boundary.mts | 1 - 5 files changed, 44 insertions(+), 110 deletions(-) delete mode 100644 test/e2e/lib/fake-api-port-proxy.mts diff --git a/test/automation/pull-requests/pr-risk-plan.test.ts b/test/automation/pull-requests/pr-risk-plan.test.ts index 3e022b9a741..6bd1b134eee 100644 --- a/test/automation/pull-requests/pr-risk-plan.test.ts +++ b/test/automation/pull-requests/pr-risk-plan.test.ts @@ -370,7 +370,6 @@ describe("deterministic PR risk plan", () => { }); it.each([ - "test/e2e/lib/fake-api-port-proxy.mts", "test/e2e/lib/fake-api-port-readiness.mts", "test/e2e/live/messaging-providers-helpers.ts", ])("selects every fake Docker API consumer for %s", (changedFile) => { diff --git a/test/e2e/fixtures/host-mock-firewall.ts b/test/e2e/fixtures/host-mock-firewall.ts index e5bfcd46ad2..59482240c35 100644 --- a/test/e2e/fixtures/host-mock-firewall.ts +++ b/test/e2e/fixtures/host-mock-firewall.ts @@ -24,7 +24,7 @@ interface DockerNetworkInspect { Options?: unknown; } -export interface BridgeTopology { +interface BridgeTopology { bridgeInterface: string; gatewayIp: string; prefixLength: number; @@ -65,7 +65,7 @@ function parseBridgeCidr(value: string): { network: number; prefixLength: number return (network & mask) >>> 0 === network ? { network, prefixLength } : undefined; } -export function parseBridgeTopology(stdout: string): BridgeTopology { +function parseBridgeTopology(stdout: string): BridgeTopology { let parsed: unknown; try { parsed = JSON.parse(stdout); diff --git a/test/e2e/lib/fake-api-port-proxy.mts b/test/e2e/lib/fake-api-port-proxy.mts deleted file mode 100644 index 50b876187c2..00000000000 --- a/test/e2e/lib/fake-api-port-proxy.mts +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import net from "node:net"; - -const target = process.env.NEMOCLAW_FAKE_API_PROXY_TARGET ?? ""; -const portList = process.env.NEMOCLAW_FAKE_API_PROXY_PORTS ?? ""; -if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(target)) { - throw new Error("NEMOCLAW_FAKE_API_PROXY_TARGET must be a Docker container name"); -} -const ports = portList === "8080" ? [8080] : portList === "8080,8081" ? [8080, 8081] : undefined; -if (!ports) throw new Error("NEMOCLAW_FAKE_API_PROXY_PORTS must be 8080 or 8080,8081"); - -for (const port of ports) { - const server = net.createServer((client) => { - const upstream = net.createConnection({ host: target, port }); - const close = () => { - client.destroy(); - upstream.destroy(); - }; - client.on("error", close); - upstream.on("error", close); - client.pipe(upstream); - upstream.pipe(client); - }); - server.once("error", (error) => { - process.stderr.write(`fake API proxy listener ${port} failed: ${error.message}\n`); - process.exit(1); - }); - server.listen(port, "0.0.0.0"); -} diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index f64b4dc9b07..745c1ccfaea 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -30,6 +30,10 @@ import { parseInstalledWechatProof } from "../live/messaging-providers-wechat-ru const FAKE_TELEGRAM_API = path.resolve(import.meta.dirname, "../lib/fake-telegram-api.cjs"); const FAKE_SLACK_API = path.resolve(import.meta.dirname, "../lib/fake-slack-api.cjs"); const FAKE_WECHAT_API = path.resolve(import.meta.dirname, "../lib/fake-wechat-api.mts"); +const FAKE_API_PORT_TRAFFIC = path.resolve( + import.meta.dirname, + "../lib/fake-api-port-readiness.mts", +); async function waitFor(predicate: () => boolean, message: string): Promise { const deadline = Date.now() + 5_000; @@ -159,18 +163,24 @@ function fakeDockerHost(publishedAddress = "127.0.0.1"): { const calls: string[][] = []; const host = { command: async (command: string, args: string[]) => { - if (command === "node") return successfulCommand(); - expect(command).toBe("docker"); - calls.push([...args]); - args - .filter((argument) => args[0] === "run" && argument.endsWith(":/tmp/fake")) - .forEach((fakeApiMount) => { - const fixtureDir = fakeApiMount.slice(0, -":/tmp/fake".length); - fs.writeFileSync(path.join(fixtureDir, "port"), "8080"); - }); - return args[0] === "port" - ? successfulCommand(`${publishedAddress}:${args[2] === "8081/tcp" ? "32101" : "32100"}\n`) - : successfulCommand(); + switch (command) { + case "node": + return successfulCommand(); + default: + expect(command).toBe("docker"); + calls.push([...args]); + args + .filter((argument) => args[0] === "run" && argument.endsWith(":/tmp/fake")) + .forEach((fakeApiMount) => { + const fixtureDir = fakeApiMount.slice(0, -":/tmp/fake".length); + fs.writeFileSync(path.join(fixtureDir, "port"), "8080"); + }); + return args[0] === "port" + ? successfulCommand( + `${publishedAddress}:${args[2] === "8081/tcp" ? "32101" : "32100"}\n`, + ) + : successfulCommand(); + } }, } as unknown as HostCliClient; return { calls, host }; @@ -189,26 +199,27 @@ describe("messaging provider installed-runtime proofs", () => { const invocation = [command.command, ...command.args]; const artifactName = options?.artifactName ?? ""; artifactNames.push(...(artifactName ? [artifactName] : [])); - if (artifactName === "start-fake-slack-api") { - const mountSuffix = ":/tmp/fake"; - const mount = invocation.find((argument) => argument.endsWith(mountSuffix)); - expect(mount).toBeDefined(); - fs.writeFileSync(path.join(mount!.slice(0, -mountSuffix.length), "port"), "8080\n"); - } - if (artifactName === "port-fake-slack-api") { - return successfulShellResult(invocation, "127.0.0.1:41080\n"); - } - if (artifactName === "port-fake-slack-websocket-api") { - return successfulShellResult(invocation, "127.0.0.1:41081\n"); + switch (artifactName) { + case "start-fake-slack-api": { + const mountSuffix = ":/tmp/fake"; + const mount = invocation.find((argument) => argument.endsWith(mountSuffix)); + expect(mount).toBeDefined(); + fs.writeFileSync(path.join(mount!.slice(0, -mountSuffix.length), "port"), "8080\n"); + return successfulShellResult(invocation); + } + case "port-fake-slack-api": + return successfulShellResult(invocation, "127.0.0.1:41080\n"); + case "port-fake-slack-websocket-api": + return successfulShellResult(invocation, "127.0.0.1:41081\n"); + case "prove-fake-slack-api-proxy-traffic": + return { + ...successfulShellResult(invocation), + exitCode: 1, + stderr: "fake API port traffic check failed: connection refused", + }; + default: + return successfulShellResult(invocation); } - if (artifactName === "prove-fake-slack-api-proxy-traffic") { - return { - ...successfulShellResult(invocation), - exitCode: 1, - stderr: "fake API port traffic check failed: connection refused", - }; - } - return successfulShellResult(invocation); }, }; @@ -335,50 +346,6 @@ describe("messaging provider installed-runtime proofs", () => { } }); - it("publishes independent fake Slack REST and websocket ports", async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fake-slack-ports-")); - const portFile = path.join(dir, "port"); - const captureFile = path.join(dir, "capture.jsonl"); - const child = spawn(process.execPath, [FAKE_SLACK_API], { - env: { - ...process.env, - FAKE_SLACK_API_HOST: "127.0.0.1", - FAKE_SLACK_API_PORT: "0", - FAKE_SLACK_API_WEBSOCKET_PORT: "0", - FAKE_SLACK_API_PORT_FILE: portFile, - FAKE_SLACK_API_CAPTURE_FILE: captureFile, - FAKE_SLACK_API_EXPECTED_BOT_TOKEN: "xoxb-fake-slack-port-test", - FAKE_SLACK_API_EXPECTED_APP_TOKEN: "xapp-fake-slack-port-test", - }, - stdio: ["ignore", "ignore", "pipe"], - }); - let stderr = ""; - child.stderr?.setEncoding("utf8"); - child.stderr?.on("data", (chunk: string) => { - stderr += chunk; - }); - - try { - await waitFor(() => fs.existsSync(portFile), `fake Slack listeners did not start: ${stderr}`); - const listening = fs - .readFileSync(captureFile, "utf8") - .trim() - .split(/\r?\n/u) - .filter(Boolean) - .map((line) => JSON.parse(line)) - .filter((entry) => entry.event === "listening"); - expect(listening).toHaveLength(2); - expect(listening.map((entry) => entry.kind).sort()).toEqual(["rest", "websocket"]); - expect(new Set(listening.map((entry) => entry.port)).size).toBe(2); - } finally { - child.kill("SIGTERM"); - await new Promise((resolve) => - child.exitCode !== null ? resolve() : child.once("exit", () => resolve()), - ); - fs.rmSync(dir, { recursive: true, force: true }); - } - }, 10_000); - it("publishes independent fake Slack REST and websocket ports from one fixture", async () => { const fixture = await startFakeSlackPortFixture(); diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index 69df87099d4..b8120454f64 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -711,7 +711,6 @@ const FAKE_DOCKER_API_CONSUMER_JOBS = [ const LIVE_E2E_OWNING_FILE_JOBS = new Map([ ["test/e2e/lib/fake-wechat-api.mts", ["messaging-providers"]], ["test/e2e/lib/fake-slack-api.cjs", ["messaging-providers", "openclaw-slack-pairing"]], - ["test/e2e/lib/fake-api-port-proxy.mts", FAKE_DOCKER_API_CONSUMER_JOBS], ["test/e2e/lib/fake-api-port-readiness.mts", FAKE_DOCKER_API_CONSUMER_JOBS], ["test/e2e/live/messaging-providers-helpers.ts", FAKE_DOCKER_API_CONSUMER_JOBS], ["test/e2e/live/openclaw-plugin-runtime-exdev-lifecycle.ts", ["openclaw-plugin-runtime-exdev"]], From da3e6703abb18899af33b0b46ee09a7e62f5063c Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 31 Aug 2026 21:54:02 -0400 Subject: [PATCH 11/45] fix(e2e): diagnose fake API startup failures Signed-off-by: Julie Yaunches --- test/e2e/live/messaging-providers-helpers.ts | 23 +++++-- ...messaging-providers-runtime-proofs.test.ts | 67 +++++++++++++++++-- 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 39fb629c351..7044dd8954c 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -635,16 +635,19 @@ async function collectFakeApiStartupDiagnostics( host: HostCliClient, options: { kind: string; - proxy: string; + proxy?: string; container: string; env: NodeJS.ProcessEnv; redactionValues: string[]; }, ): Promise { - for (const [role, name] of [ - ["proxy", options.proxy], - ["api", options.container], - ] as const) { + const targets = options.proxy + ? ([ + ["proxy", options.proxy], + ["api", options.container], + ] as const) + : ([["api", options.container]] as const); + for (const [role, name] of targets) { try { await runHost(host, "docker", ["inspect", "--format", "{{json .State}}", name], { artifactName: `failure-fake-${options.kind}-api-${role}-inspect`, @@ -785,7 +788,15 @@ export async function startFakeDockerApi( } await sleep(100); } - if (!apiReady) throw new Error(`fake ${options.kind} API did not become ready`); + if (!apiReady) { + await collectFakeApiStartupDiagnostics(host, { + kind: options.kind, + container, + env: options.env, + redactionValues: options.redactionValues, + }); + throw new Error(`fake ${options.kind} API container ${container} did not become ready`); + } cleanup(`remove ${proxyContainer}`, async () => { const remove = await runHost(host, "docker", ["rm", "-f", proxyContainer], { diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 745c1ccfaea..4c2c7b59f4e 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -6,7 +6,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { buildProcessTokenProbe } from "../fixtures/process-token-probe.ts"; import type { CommandRunner } from "../fixtures/clients/command.ts"; @@ -115,9 +115,7 @@ async function startFakeSlackPortFixture(options?: { (entry) => entry.event === "listening", ); const restPort = Number(fs.readFileSync(portFile, "utf8").trim()); - const websocketPort = Number( - listening.find((entry) => entry.kind === "websocket")?.port ?? 0, - ); + const websocketPort = Number(listening.find((entry) => entry.kind === "websocket")?.port ?? 0); return { restPort, websocketPort, captureFile, stop }; } catch (error) { await stop(); @@ -191,6 +189,65 @@ async function runCleanup(actions: CleanupAction[]): Promise { } describe("messaging provider installed-runtime proofs", () => { + it("collects API diagnostics when the fake container does not become ready", async () => { + vi.useFakeTimers(); + const artifactNames: string[] = []; + const cleanupTasks: Array<() => Promise> = []; + let container = ""; + const runner: CommandRunner = { + async run(command, options) { + const invocation = [command.command, ...command.args]; + const artifactName = options?.artifactName ?? ""; + artifactNames.push(...(artifactName ? [artifactName] : [])); + switch (artifactName) { + case "start-fake-slack-api": + container = invocation[invocation.indexOf("--name") + 1] ?? ""; + break; + default: + break; + } + return successfulShellResult(invocation); + }, + }; + + try { + const start = expect( + startFakeDockerApi(new HostCliClient(runner), (_name, run) => cleanupTasks.push(run), { + kind: "slack", + imageScript: "fake-slack-api.cjs", + containerPrefix: "nemoclaw-fake-slack-not-ready-test", + portEnv: "FAKE_SLACK_API_PORT", + portFileEnv: "FAKE_SLACK_API_PORT_FILE", + captureFileEnv: "FAKE_SLACK_API_CAPTURE_FILE", + expectedEnv: { + FAKE_SLACK_API_EXPECTED_BOT_TOKEN: "xoxb-fake-slack-not-ready-test", + FAKE_SLACK_API_EXPECTED_APP_TOKEN: "xapp-fake-slack-not-ready-test", + }, + redactionValues: [], + env: {}, + }), + ).rejects.toThrow(/fake slack API container .* did not become ready/u); + + await vi.runAllTimersAsync(); + await start; + expect(container).not.toBe(""); + expect(artifactNames.filter((name) => name.startsWith("failure-fake-slack-api-"))).toEqual([ + "failure-fake-slack-api-api-inspect", + "failure-fake-slack-api-api-logs", + ]); + } finally { + vi.useRealTimers(); + await cleanupTasks + .reverse() + .reduce((previous, cleanupTask) => previous.then(cleanupTask), Promise.resolve()); + } + + expect(artifactNames).toContain(`cleanup-${container}`); + expect(artifactNames).toContainEqual( + expect.stringMatching(/^cleanup-nemoclaw-fake-api-network-/u), + ); + }); + it("collects diagnostics when published fake API proxy ports do not carry traffic", async () => { const artifactNames: string[] = []; const cleanupTasks: Array<() => Promise> = []; @@ -257,7 +314,7 @@ describe("messaging provider installed-runtime proofs", () => { ["discord-gateway", ["127.0.0.1::8080"], ["8080/tcp"]], ["slack", ["127.0.0.1::8080", "127.0.0.1::8081"], ["8080/tcp", "8081/tcp"]], ] as const)( - "proxies the isolated fake %s API through loopback-only ephemeral ports", + "configures the fake %s API with an internal backend and loopback-only proxy ports", async (kind, expectedPublications, expectedPortQueries) => { const { calls, host } = fakeDockerHost(); const cleanup: CleanupAction[] = []; From 093dbe3152b336cd57010d9516b384ef62857877 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 31 Aug 2026 22:01:56 -0400 Subject: [PATCH 12/45] test(e2e): assert structured Slack traffic captures Signed-off-by: Julie Yaunches --- .../messaging-providers-runtime-proofs.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 4c2c7b59f4e..8285bd86dd1 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -445,10 +445,20 @@ describe("messaging provider installed-runtime proofs", () => { const trafficCheck = runFakeSlackPortTrafficCheck(fixture); expect(trafficCheck.status).toBe(1); expect(trafficCheck.stderr).toContain("WebSocket port traffic timed out"); - const traffic = fs.readFileSync(fixture.captureFile, "utf8"); - expect(traffic).toContain('"event":"websocket-upgrade"'); - expect(traffic).toContain('"messageType":"nemoclaw_port_traffic_probe"'); - expect(traffic).not.toContain('"event":"websocket-port-traffic-reply"'); + const traffic = readFakeSlackCapture(fixture.captureFile); + expect(traffic).toContainEqual( + expect.objectContaining({ event: "websocket-upgrade", path: "/socket-mode" }), + ); + expect(traffic).toContainEqual( + expect.objectContaining({ + event: "websocket-message", + messageType: "nemoclaw_port_traffic_probe", + path: "/socket-mode", + }), + ); + expect(traffic).not.toContainEqual( + expect.objectContaining({ event: "websocket-port-traffic-reply" }), + ); } finally { await fixture.stop(); } From 7ef3742a4799988778580f6da5393f892d9d281e Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 31 Aug 2026 22:21:04 -0400 Subject: [PATCH 13/45] fix(e2e): require fake API REST readiness reply Signed-off-by: Julie Yaunches --- .../pull-requests/pr-risk-plan.test.ts | 10 +++++ test/e2e/lib/fake-api-port-readiness.mts | 19 ++++++++- test/e2e/lib/fake-discord-gateway.cjs | 15 +++++++ test/e2e/lib/fake-slack-api.cjs | 23 ++++++++++- test/e2e/lib/fake-telegram-api.cjs | 11 ++++- test/e2e/lib/fake-wechat-api.mts | 6 +++ ...messaging-providers-runtime-proofs.test.ts | 40 +++++++++++++++++-- tools/e2e/workflow-boundary.mts | 2 + 8 files changed, 118 insertions(+), 8 deletions(-) diff --git a/test/automation/pull-requests/pr-risk-plan.test.ts b/test/automation/pull-requests/pr-risk-plan.test.ts index 6bd1b134eee..bf2a9e26e2e 100644 --- a/test/automation/pull-requests/pr-risk-plan.test.ts +++ b/test/automation/pull-requests/pr-risk-plan.test.ts @@ -393,6 +393,16 @@ describe("deterministic PR risk plan", () => { ); }); + it.each([ + ["test/e2e/lib/fake-discord-gateway.cjs", ["hermes-discord", "openclaw-discord-pairing"]], + ["test/e2e/lib/fake-telegram-api.cjs", ["messaging-providers"]], + ["test/e2e/lib/fake-wechat-api.mts", ["messaging-providers"]], + ] as const)("selects each direct fake API consumer for %s", (changedFile, jobs) => { + expect(focusedE2eJobsForChangedFiles([changedFile])).toEqual( + jobs.map((id) => ({ id, matchedFiles: [changedFile] })), + ); + }); + it("maps a shared gateway live test to every catalogue fixture (#7921)", () => { const changedFiles = ["test/e2e/live/openshell-gateway-upgrade.test.ts"]; const focusedE2eJobs = catalogueTargetsForChangedFiles(changedFiles).map((target) => ({ diff --git a/test/e2e/lib/fake-api-port-readiness.mts b/test/e2e/lib/fake-api-port-readiness.mts index 2e387f3a36f..d9a7f925d04 100644 --- a/test/e2e/lib/fake-api-port-readiness.mts +++ b/test/e2e/lib/fake-api-port-readiness.mts @@ -26,17 +26,32 @@ async function probeHttp(host: string, port: number): Promise { const response = await fetch(`http://${urlHost(host)}:${port}/__nemoclaw_e2e_port_traffic`, { signal: AbortSignal.timeout(ATTEMPT_TIMEOUT_MS), }); + if (!response.ok) { + await response.body?.cancel(); + throw new Error(`REST port traffic returned HTTP ${response.status}`); + } const reader = response.body?.getReader(); - if (!reader) return; + if (!reader) throw new Error("REST port traffic reply had no body"); let replyBytes = 0; + const chunks: Uint8Array[] = []; for (;;) { const { done, value } = await reader.read(); - if (done) return; + if (done) break; replyBytes += value.byteLength; if (replyBytes > MAX_REPLY_BYTES) { await reader.cancel(); throw new Error("REST port traffic reply exceeded 8192 bytes"); } + chunks.push(value); + } + let reply: { type?: unknown }; + try { + reply = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { type?: unknown }; + } catch { + throw new Error("REST port traffic reply was not valid JSON"); + } + if (reply?.type !== PORT_TRAFFIC_REPLY) { + throw new Error("REST port traffic reply was not recognized"); } } diff --git a/test/e2e/lib/fake-discord-gateway.cjs b/test/e2e/lib/fake-discord-gateway.cjs index 5e93290ab44..0dfbe146e9a 100755 --- a/test/e2e/lib/fake-discord-gateway.cjs +++ b/test/e2e/lib/fake-discord-gateway.cjs @@ -13,6 +13,8 @@ const port = Number(process.env.FAKE_DISCORD_GATEWAY_PORT || "0"); const portFile = process.env.FAKE_DISCORD_GATEWAY_PORT_FILE || ""; const captureFile = process.env.FAKE_DISCORD_GATEWAY_CAPTURE_FILE || ""; const expectedToken = process.env.FAKE_DISCORD_GATEWAY_EXPECTED_TOKEN || ""; +const PORT_TRAFFIC_REQUEST_LINE = "GET /__nemoclaw_e2e_port_traffic HTTP/1.1"; +const PORT_TRAFFIC_REPLY = JSON.stringify({ type: "nemoclaw_port_traffic_reply" }); if (require.main === module && !expectedToken) { console.error("FAKE_DISCORD_GATEWAY_EXPECTED_TOKEN is required"); @@ -173,6 +175,19 @@ const server = net.createServer((socket) => { .split("\r\n") .find((line) => line.toLowerCase().startsWith("sec-websocket-key:")); const key = keyLine ? keyLine.slice(keyLine.indexOf(":") + 1).trim() : ""; + if (requestLine === PORT_TRAFFIC_REQUEST_LINE) { + socket.end( + [ + "HTTP/1.1 200 OK", + "Content-Type: application/json", + `Content-Length: ${Buffer.byteLength(PORT_TRAFFIC_REPLY)}`, + "Connection: close", + "", + PORT_TRAFFIC_REPLY, + ].join("\r\n"), + ); + return; + } if (!key) { socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); return; diff --git a/test/e2e/lib/fake-slack-api.cjs b/test/e2e/lib/fake-slack-api.cjs index 3122a3c416d..5a1cc2ab89b 100755 --- a/test/e2e/lib/fake-slack-api.cjs +++ b/test/e2e/lib/fake-slack-api.cjs @@ -21,11 +21,17 @@ const socketUserId = process.env.FAKE_SLACK_API_SOCKET_USER_ID || "U3730E2E"; const socketChannelId = process.env.FAKE_SLACK_API_SOCKET_CHANNEL_ID || "D3730E2E"; const socketTeamId = process.env.FAKE_SLACK_API_SOCKET_TEAM_ID || "T3730E2E"; const suppressPortTrafficReply = process.env.FAKE_SLACK_API_SUPPRESS_PORT_TRAFFIC_REPLY === "1"; +const portTrafficStatus = Number(process.env.FAKE_SLACK_API_PORT_TRAFFIC_STATUS || "200"); +const portTrafficReply = + process.env.FAKE_SLACK_API_PORT_TRAFFIC_REPLY || "nemoclaw_port_traffic_reply"; const MAX_BODY_BYTES = 1024 * 1024; const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const PORT_TRAFFIC_PATH = "/__nemoclaw_e2e_port_traffic"; if (!Number.isInteger(port) || port < 0 || port > 65535) { - console.error(`FAKE_SLACK_API_PORT must be an integer between 0 and 65535 (received: ${rawPort})`); + console.error( + `FAKE_SLACK_API_PORT must be an integer between 0 and 65535 (received: ${rawPort})`, + ); process.exit(2); } @@ -42,7 +48,14 @@ if (port !== 0 && websocketPort === port) { } if (!expectedBotToken || !expectedAppToken) { - console.error("FAKE_SLACK_API_EXPECTED_BOT_TOKEN and FAKE_SLACK_API_EXPECTED_APP_TOKEN are required"); + console.error( + "FAKE_SLACK_API_EXPECTED_BOT_TOKEN and FAKE_SLACK_API_EXPECTED_APP_TOKEN are required", + ); + process.exit(2); +} + +if (!Number.isInteger(portTrafficStatus) || portTrafficStatus < 100 || portTrafficStatus > 599) { + console.error("FAKE_SLACK_API_PORT_TRAFFIC_STATUS must be an integer between 100 and 599"); process.exit(2); } @@ -232,6 +245,12 @@ function handleRequest(req, res) { : {}), }); + if (pathname === PORT_TRAFFIC_PATH) { + res.writeHead(portTrafficStatus, { "content-type": "application/json" }); + res.end(JSON.stringify({ type: portTrafficReply })); + return; + } + const response = slackResponseFor(pathname, authAccepted, { channel, text, threadTs }); res.writeHead(response.status, { "content-type": "application/json", diff --git a/test/e2e/lib/fake-telegram-api.cjs b/test/e2e/lib/fake-telegram-api.cjs index a4388e28241..2c34fa65a42 100755 --- a/test/e2e/lib/fake-telegram-api.cjs +++ b/test/e2e/lib/fake-telegram-api.cjs @@ -14,9 +14,13 @@ const portFile = process.env.FAKE_TELEGRAM_API_PORT_FILE || ""; const captureFile = process.env.FAKE_TELEGRAM_API_CAPTURE_FILE || ""; const expectedToken = process.env.FAKE_TELEGRAM_API_EXPECTED_TOKEN || ""; const MAX_BODY_BYTES = 1024 * 1024; +const PORT_TRAFFIC_PATH = "/__nemoclaw_e2e_port_traffic"; +const PORT_TRAFFIC_REPLY = "nemoclaw_port_traffic_reply"; if (!Number.isInteger(port) || port < 0 || port > 65535) { - console.error(`FAKE_TELEGRAM_API_PORT must be an integer between 0 and 65535 (received: ${rawPort})`); + console.error( + `FAKE_TELEGRAM_API_PORT must be an integer between 0 and 65535 (received: ${rawPort})`, + ); process.exit(2); } @@ -106,6 +110,11 @@ const server = http.createServer((req, res) => { textLength: fields.text ? String(fields.text).length : 0, }); + if (url.pathname === PORT_TRAFFIC_PATH) { + writeJson(res, 200, { type: PORT_TRAFFIC_REPLY }); + return; + } + if (!match) { writeJson(res, 404, { ok: false, error_code: 404, description: "not found" }); return; diff --git a/test/e2e/lib/fake-wechat-api.mts b/test/e2e/lib/fake-wechat-api.mts index 45d72ffe761..2fac65c502a 100755 --- a/test/e2e/lib/fake-wechat-api.mts +++ b/test/e2e/lib/fake-wechat-api.mts @@ -14,6 +14,8 @@ const expectedToken = process.env.FAKE_WECHAT_API_EXPECTED_TOKEN || ""; const expectedTarget = process.env.FAKE_WECHAT_API_EXPECTED_TARGET || ""; const expectedText = process.env.FAKE_WECHAT_API_EXPECTED_TEXT || ""; const MAX_BODY_BYTES = 1024 * 1024; +const PORT_TRAFFIC_PATH = "/__nemoclaw_e2e_port_traffic"; +const PORT_TRAFFIC_REPLY = "nemoclaw_port_traffic_reply"; if (!Number.isInteger(port) || port < 0 || port > 65_535) { throw new Error( @@ -61,6 +63,10 @@ const server = http.createServer((request, response) => { request.on("end", () => { if (bodyTooLarge) return; + if (request.method === "GET" && request.url === PORT_TRAFFIC_PATH) { + writeJson(response, 200, { type: PORT_TRAFFIC_REPLY }); + return; + } const authorization = String(request.headers.authorization || ""); const token = authorization.match(/^Bearer (.+)$/u)?.[1] ?? ""; let body: Record = {}; diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 8285bd86dd1..85e53cc0638 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -76,6 +76,8 @@ function readFakeSlackCapture(captureFile: string): Array { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fake-slack-ports-")); const portFile = path.join(dir, "port"); @@ -93,6 +95,12 @@ async function startFakeSlackPortFixture(options?: { ...(options?.suppressPortTrafficReply ? { FAKE_SLACK_API_SUPPRESS_PORT_TRAFFIC_REPLY: "1" } : {}), + ...(options?.restPortTrafficStatus === undefined + ? {} + : { FAKE_SLACK_API_PORT_TRAFFIC_STATUS: String(options.restPortTrafficStatus) }), + ...(options?.restPortTrafficReply === undefined + ? {} + : { FAKE_SLACK_API_PORT_TRAFFIC_REPLY: options.restPortTrafficReply }), }, stdio: ["ignore", "ignore", "pipe"], }); @@ -123,20 +131,24 @@ async function startFakeSlackPortFixture(options?: { } } -function runFakeSlackPortTrafficCheck(fixture: FakeSlackPortFixture) { +function runPortTrafficCheck(restPort: number, websocketPort?: number) { return spawnSync( process.execPath, [ "--experimental-strip-types", FAKE_API_PORT_TRAFFIC, "127.0.0.1", - String(fixture.restPort), - String(fixture.websocketPort), + String(restPort), + ...(websocketPort === undefined ? [] : [String(websocketPort)]), ], { encoding: "utf8", timeout: 15_000 }, ); } +function runFakeSlackPortTrafficCheck(fixture: FakeSlackPortFixture) { + return runPortTrafficCheck(fixture.restPort, fixture.websocketPort); +} + type CleanupAction = { name: string; run: () => Promise; @@ -438,6 +450,28 @@ describe("messaging provider installed-runtime proofs", () => { } }, 25_000); + it.each([ + ["an HTTP error", 502, "nemoclaw_port_traffic_reply", "HTTP 502"], + ["an invalid sentinel", 200, "unexpected_reply", "not recognized"], + ] as const)( + "rejects %s from the REST port traffic probe", + async (_case, status, reply, error) => { + const fixture = await startFakeSlackPortFixture({ + restPortTrafficStatus: status, + restPortTrafficReply: reply, + }); + + try { + const trafficCheck = runFakeSlackPortTrafficCheck(fixture); + expect(trafficCheck.status).toBe(1); + expect(trafficCheck.stderr).toContain(error); + } finally { + await fixture.stop(); + } + }, + 15_000, + ); + it("rejects a fake Slack websocket that upgrades without a port traffic reply", async () => { const fixture = await startFakeSlackPortFixture({ suppressPortTrafficReply: true }); diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index b8120454f64..f5fd69c19a5 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -709,6 +709,8 @@ const FAKE_DOCKER_API_CONSUMER_JOBS = [ "openclaw-slack-pairing", ] as const; const LIVE_E2E_OWNING_FILE_JOBS = new Map([ + ["test/e2e/lib/fake-discord-gateway.cjs", ["hermes-discord", "openclaw-discord-pairing"]], + ["test/e2e/lib/fake-telegram-api.cjs", ["messaging-providers"]], ["test/e2e/lib/fake-wechat-api.mts", ["messaging-providers"]], ["test/e2e/lib/fake-slack-api.cjs", ["messaging-providers", "openclaw-slack-pairing"]], ["test/e2e/lib/fake-api-port-readiness.mts", FAKE_DOCKER_API_CONSUMER_JOBS], From 90bd8da50e8172103762917f0d0a369a08b18969 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 31 Aug 2026 20:56:02 -0700 Subject: [PATCH 14/45] test(e2e): align fake API runtime proof ownership Signed-off-by: Prekshi Vyas --- ...messaging-providers-runtime-proofs.test.ts | 193 ++++++++---------- 1 file changed, 80 insertions(+), 113 deletions(-) diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 85e53cc0638..3724c6046b3 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -11,6 +11,10 @@ import { describe, expect, it, vi } from "vitest"; import { buildProcessTokenProbe } from "../fixtures/process-token-probe.ts"; import type { CommandRunner } from "../fixtures/clients/command.ts"; import { HostCliClient } from "../fixtures/clients/host.ts"; +import { spawnObservedChild } from "../fixtures/observed-child-process.ts"; +import { startTestProgress } from "../fixtures/progress.ts"; +import { redactString } from "../fixtures/redaction.ts"; +import { superviseChild } from "../fixtures/shell/supervisor.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { buildSandboxNodeInvocation, @@ -34,15 +38,17 @@ const FAKE_API_PORT_TRAFFIC = path.resolve( import.meta.dirname, "../lib/fake-api-port-readiness.mts", ); - -async function waitFor(predicate: () => boolean, message: string): Promise { - const deadline = Date.now() + 5_000; - let matched = predicate(); - while (!matched && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 25)); - matched = predicate(); - } - expect(matched, message).toBe(true); +const FAKE_SLACK_APP_TOKEN = "xapp-fake-slack-port-test"; +const FAKE_SLACK_BOT_TOKEN = "xoxb-fake-slack-port-test"; +const STDERR_TAIL_LIMIT = 4_096; + +async function waitFor(predicate: () => boolean, message: string | (() => string)): Promise { + await vi.waitFor( + () => { + expect(predicate(), typeof message === "string" ? message : message()).toBe(true); + }, + { interval: 25, timeout: 5_000 }, + ); } function successfulShellResult(command: string[], stdout = ""): ShellProbeResult { @@ -82,48 +88,74 @@ async function startFakeSlackPortFixture(options?: { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fake-slack-ports-")); const portFile = path.join(dir, "port"); const captureFile = path.join(dir, "capture.jsonl"); - const child = spawn(process.execPath, [FAKE_SLACK_API], { - env: { - ...process.env, - FAKE_SLACK_API_HOST: "127.0.0.1", - FAKE_SLACK_API_PORT: "0", - FAKE_SLACK_API_WEBSOCKET_PORT: "0", - FAKE_SLACK_API_PORT_FILE: portFile, - FAKE_SLACK_API_CAPTURE_FILE: captureFile, - FAKE_SLACK_API_EXPECTED_BOT_TOKEN: "xoxb-fake-slack-port-test", - FAKE_SLACK_API_EXPECTED_APP_TOKEN: "xapp-fake-slack-port-test", - ...(options?.suppressPortTrafficReply - ? { FAKE_SLACK_API_SUPPRESS_PORT_TRAFFIC_REPLY: "1" } - : {}), - ...(options?.restPortTrafficStatus === undefined - ? {} - : { FAKE_SLACK_API_PORT_TRAFFIC_STATUS: String(options.restPortTrafficStatus) }), - ...(options?.restPortTrafficReply === undefined - ? {} - : { FAKE_SLACK_API_PORT_TRAFFIC_REPLY: options.restPortTrafficReply }), + const progress = startTestProgress( + "fake Slack port fixture", + ["start fake Slack listeners", "exercise REST and WebSocket traffic"], + { logLine: () => undefined }, + ); + const controller = new AbortController(); + const child = spawnObservedChild(process.execPath, [FAKE_SLACK_API], { + activityLabel: "command: fake-slack-port-fixture", + progress, + spawn: { + detached: true, + env: { + PATH: process.env.PATH ?? "", + FAKE_SLACK_API_HOST: "127.0.0.1", + FAKE_SLACK_API_PORT: "0", + FAKE_SLACK_API_WEBSOCKET_PORT: "0", + FAKE_SLACK_API_PORT_FILE: portFile, + FAKE_SLACK_API_CAPTURE_FILE: captureFile, + FAKE_SLACK_API_EXPECTED_BOT_TOKEN: FAKE_SLACK_BOT_TOKEN, + FAKE_SLACK_API_EXPECTED_APP_TOKEN: FAKE_SLACK_APP_TOKEN, + ...(options?.suppressPortTrafficReply + ? { FAKE_SLACK_API_SUPPRESS_PORT_TRAFFIC_REPLY: "1" } + : {}), + ...(options?.restPortTrafficStatus === undefined + ? {} + : { FAKE_SLACK_API_PORT_TRAFFIC_STATUS: String(options.restPortTrafficStatus) }), + ...(options?.restPortTrafficReply === undefined + ? {} + : { FAKE_SLACK_API_PORT_TRAFFIC_REPLY: options.restPortTrafficReply }), + }, + stdio: ["ignore", "ignore", "pipe"], }, - stdio: ["ignore", "ignore", "pipe"], }); - let stderr = ""; - child.stderr?.setEncoding("utf8"); - child.stderr?.on("data", (chunk: string) => { - stderr += chunk; + let stderrTail = ""; + const supervised = superviseChild(child, { + timeoutMs: 30_000, + killGraceMs: 1_000, + signal: controller.signal, + onStderr: (chunk) => { + stderrTail = `${stderrTail}${chunk}`.slice(-STDERR_TAIL_LIMIT); + }, }); - const stop = async () => { - child.kill("SIGTERM"); - await new Promise((resolve) => - child.exitCode !== null ? resolve() : child.once("exit", () => resolve()), - ); - fs.rmSync(dir, { recursive: true, force: true }); + let stopPromise: Promise | undefined; + const stop = () => { + stopPromise ??= (async () => { + controller.abort(); + const result = await supervised; + progress.stop(result.spawnError || result.timedOut ? "failed" : "passed"); + fs.rmSync(dir, { recursive: true, force: true }); + })(); + return stopPromise; }; try { - await waitFor(() => fs.existsSync(portFile), `fake Slack listeners did not start: ${stderr}`); + await waitFor( + () => fs.existsSync(portFile), + () => + `fake Slack listeners did not start: ${redactString(stderrTail, [ + FAKE_SLACK_APP_TOKEN, + FAKE_SLACK_BOT_TOKEN, + ])}`, + ); const listening = readFakeSlackCapture(captureFile).filter( (entry) => entry.event === "listening", ); const restPort = Number(fs.readFileSync(portFile, "utf8").trim()); const websocketPort = Number(listening.find((entry) => entry.kind === "websocket")?.port ?? 0); + progress.phase("exercise REST and WebSocket traffic"); return { restPort, websocketPort, captureFile, stop }; } catch (error) { await stop(); @@ -149,11 +181,6 @@ function runFakeSlackPortTrafficCheck(fixture: FakeSlackPortFixture) { return runPortTrafficCheck(fixture.restPort, fixture.websocketPort); } -type CleanupAction = { - name: string; - run: () => Promise; -}; - function successfulCommand(stdout = "") { return { artifacts: { result: "", stderr: "", stdout: "" }, @@ -166,11 +193,7 @@ function successfulCommand(stdout = "") { }; } -function fakeDockerHost(publishedAddress = "127.0.0.1"): { - calls: string[][]; - host: HostCliClient; -} { - const calls: string[][] = []; +function fakeDockerHost(publishedAddress = "127.0.0.1"): HostCliClient { const host = { command: async (command: string, args: string[]) => { switch (command) { @@ -178,7 +201,6 @@ function fakeDockerHost(publishedAddress = "127.0.0.1"): { return successfulCommand(); default: expect(command).toBe("docker"); - calls.push([...args]); args .filter((argument) => args[0] === "run" && argument.endsWith(":/tmp/fake")) .forEach((fakeApiMount) => { @@ -193,11 +215,7 @@ function fakeDockerHost(publishedAddress = "127.0.0.1"): { } }, } as unknown as HostCliClient; - return { calls, host }; -} - -async function runCleanup(actions: CleanupAction[]): Promise { - for (let index = actions.length - 1; index >= 0; index -= 1) await actions[index]!.run(); + return host; } describe("messaging provider installed-runtime proofs", () => { @@ -322,66 +340,13 @@ describe("messaging provider installed-runtime proofs", () => { } }); - it.each([ - ["discord-gateway", ["127.0.0.1::8080"], ["8080/tcp"]], - ["slack", ["127.0.0.1::8080", "127.0.0.1::8081"], ["8080/tcp", "8081/tcp"]], - ] as const)( - "configures the fake %s API with an internal backend and loopback-only proxy ports", - async (kind, expectedPublications, expectedPortQueries) => { - const { calls, host } = fakeDockerHost(); - const cleanup: CleanupAction[] = []; - - try { - const api = await startFakeDockerApi(host, (name, run) => cleanup.push({ name, run }), { - kind, - imageScript: `fake-${kind}-api.cjs`, - containerPrefix: `fake-${kind}`, - portEnv: "FAKE_API_PORT", - portFileEnv: "FAKE_API_PORT_FILE", - captureFileEnv: "FAKE_API_CAPTURE_FILE", - expectedEnv: {}, - redactionValues: [], - env: {}, - }); - const networkCreate = calls.find((args) => args[0] === "network" && args[1] === "create"); - const runCalls = calls.filter((args) => args[0] === "run"); - expect(networkCreate).toBeDefined(); - expect(runCalls).toHaveLength(2); - const createdNetwork = networkCreate!; - const [apiRun, proxyRun] = runCalls as [string[], string[]]; - const network = createdNetwork.at(-1); - const apiContainer = apiRun[apiRun.indexOf("--name") + 1]; - const proxyContainer = proxyRun[proxyRun.indexOf("--name") + 1]; - const publications = proxyRun.flatMap((argument, index) => - argument === "-p" ? [proxyRun[index + 1]] : [], - ); - - expect(createdNetwork.slice(0, -1)).toEqual(["network", "create", "--internal"]); - expect(apiRun).toEqual(expect.arrayContaining(["--network", network])); - expect(apiRun).not.toContain("-p"); - expect(apiRun).not.toContain("bridge"); - expect(publications).toEqual(expectedPublications); - expect(proxyRun).toEqual(expect.arrayContaining(["--network", "bridge"])); - expect(calls).toContainEqual(["network", "connect", network, proxyContainer]); - expect( - calls.filter((args) => args[0] === "port").map((args) => [args[1], args[2]]), - ).toEqual(expectedPortQueries.map((port) => [proxyContainer, port])); - expect(proxyContainer).not.toBe(apiContainer); - expect(api.port).toBe("32100"); - expect(api.alternatePort).toBe(kind === "slack" ? "32101" : undefined); - } finally { - await runCleanup(cleanup); - } - }, - ); - it("rejects a fake API proxy port that Docker publishes beyond loopback", async () => { - const { host } = fakeDockerHost("0.0.0.0"); - const cleanup: CleanupAction[] = []; + const host = fakeDockerHost("0.0.0.0"); + const cleanup: Array<() => Promise> = []; try { await expect( - startFakeDockerApi(host, (name, run) => cleanup.push({ name, run }), { + startFakeDockerApi(host, (_name, run) => cleanup.push(run), { kind: "discord-gateway", imageScript: "fake-discord-gateway-api.cjs", containerPrefix: "fake-discord-gateway", @@ -394,7 +359,9 @@ describe("messaging provider installed-runtime proofs", () => { }), ).rejects.toThrow(/did not bind only to 127\.0\.0\.1/u); } finally { - await runCleanup(cleanup); + await cleanup + .reverse() + .reduce((previous, action) => previous.then(action), Promise.resolve()); } }); From 7d544dd7289a36a88a923ea51515d7014418c29f Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 31 Aug 2026 23:22:04 -0700 Subject: [PATCH 15/45] test(e2e): preserve runtime proof behavior boundaries Signed-off-by: Prekshi Vyas --- test/e2e/live/messaging-providers-helpers.ts | 2 +- test/e2e/support/messaging-providers-runtime-proofs.test.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 7044dd8954c..64b22bab17b 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -70,7 +70,7 @@ for (const port of ports) { } `; -// Leave ample headroom beneath OpenShell's strict per-argument ceiling. +// Keep each encoded chunk below OpenShell's 32 KiB argument limit. const SANDBOX_SOURCE_CHUNK_BYTES = 16_384; const SANDBOX_SHELL_BOOTSTRAP = `set -eu; printf '%s' "$@" | base64 -d | sh`; diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 3724c6046b3..c21227e4f62 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -477,8 +477,6 @@ describe("messaging provider installed-runtime proofs", () => { fs.writeFileSync(path.join(selfProc, "cmdline"), `${invocation.join("\0")}\0`); expect(script).not.toContain(token); - expect(script).not.toContain("grep"); - expect(script).toContain('case "$nemoclaw_process_probe_cmdline" in'); expect(invocation.every((argument) => !argument.includes(token))).toBe(true); const [command, ...args] = invocation; From e735e7c6f62acf6c5f9e8610a85f361487c36564 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 31 Aug 2026 23:43:56 -0700 Subject: [PATCH 16/45] test(e2e): bound process-token runtime proof Signed-off-by: Prekshi Vyas --- test/e2e/support/messaging-providers-runtime-proofs.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index c21227e4f62..e56335032e4 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -506,7 +506,7 @@ describe("messaging provider installed-runtime proofs", () => { } finally { fs.rmSync(dir, { recursive: true, force: true }); } - }); + }, 20_000); it("reconstructs multi-argument Node source byte-for-byte below the OpenShell limit", () => { const source = [ From dbd011c5371d5ae6a07a15b3ea22ef5458cc09cd Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 01:14:45 -0700 Subject: [PATCH 17/45] test(e2e): cover fake API readiness diagnostics Signed-off-by: Prekshi Vyas --- ...messaging-providers-runtime-proofs.test.ts | 252 +++++++++++++++--- 1 file changed, 209 insertions(+), 43 deletions(-) diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index e56335032e4..0b7649db905 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -34,6 +34,7 @@ import { parseInstalledWechatProof } from "../live/messaging-providers-wechat-ru const FAKE_TELEGRAM_API = path.resolve(import.meta.dirname, "../lib/fake-telegram-api.cjs"); const FAKE_SLACK_API = path.resolve(import.meta.dirname, "../lib/fake-slack-api.cjs"); const FAKE_WECHAT_API = path.resolve(import.meta.dirname, "../lib/fake-wechat-api.mts"); +const FAKE_DISCORD_GATEWAY = path.resolve(import.meta.dirname, "../lib/fake-discord-gateway.cjs"); const FAKE_API_PORT_TRAFFIC = path.resolve( import.meta.dirname, "../lib/fake-api-port-readiness.mts", @@ -71,6 +72,13 @@ type FakeSlackPortFixture = { stop: () => Promise; }; +type FakePortFixture = { + restPort: number; + websocketPort?: number; + captureFile: string; + stop: () => Promise; +}; + function readFakeSlackCapture(captureFile: string): Array> { return fs .readFileSync(captureFile, "utf8") @@ -80,47 +88,39 @@ function readFakeSlackCapture(captureFile: string): Array JSON.parse(line)); } -async function startFakeSlackPortFixture(options?: { - suppressPortTrafficReply?: boolean; - restPortTrafficStatus?: number; - restPortTrafficReply?: string; -}): Promise { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fake-slack-ports-")); +async function startFakePortFixture(options: { + label: string; + script: string; + nodeArgs?: string[]; + env: (portFile: string, captureFile: string) => NodeJS.ProcessEnv; + redactionValues?: string[]; + websocketPort?: (captureFile: string) => number; +}): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fake-api-ports-")); const portFile = path.join(dir, "port"); const captureFile = path.join(dir, "capture.jsonl"); const progress = startTestProgress( - "fake Slack port fixture", - ["start fake Slack listeners", "exercise REST and WebSocket traffic"], + options.label, + ["start fake API listeners", "exercise port traffic"], { logLine: () => undefined }, ); const controller = new AbortController(); - const child = spawnObservedChild(process.execPath, [FAKE_SLACK_API], { - activityLabel: "command: fake-slack-port-fixture", - progress, - spawn: { - detached: true, - env: { - PATH: process.env.PATH ?? "", - FAKE_SLACK_API_HOST: "127.0.0.1", - FAKE_SLACK_API_PORT: "0", - FAKE_SLACK_API_WEBSOCKET_PORT: "0", - FAKE_SLACK_API_PORT_FILE: portFile, - FAKE_SLACK_API_CAPTURE_FILE: captureFile, - FAKE_SLACK_API_EXPECTED_BOT_TOKEN: FAKE_SLACK_BOT_TOKEN, - FAKE_SLACK_API_EXPECTED_APP_TOKEN: FAKE_SLACK_APP_TOKEN, - ...(options?.suppressPortTrafficReply - ? { FAKE_SLACK_API_SUPPRESS_PORT_TRAFFIC_REPLY: "1" } - : {}), - ...(options?.restPortTrafficStatus === undefined - ? {} - : { FAKE_SLACK_API_PORT_TRAFFIC_STATUS: String(options.restPortTrafficStatus) }), - ...(options?.restPortTrafficReply === undefined - ? {} - : { FAKE_SLACK_API_PORT_TRAFFIC_REPLY: options.restPortTrafficReply }), + const child = spawnObservedChild( + process.execPath, + [...(options.nodeArgs ?? []), options.script], + { + activityLabel: `command: ${options.label}`, + progress, + spawn: { + detached: true, + env: { + PATH: process.env.PATH ?? "", + ...options.env(portFile, captureFile), + }, + stdio: ["ignore", "ignore", "pipe"], }, - stdio: ["ignore", "ignore", "pipe"], }, - }); + ); let stderrTail = ""; const supervised = superviseChild(child, { timeoutMs: 30_000, @@ -145,17 +145,11 @@ async function startFakeSlackPortFixture(options?: { await waitFor( () => fs.existsSync(portFile), () => - `fake Slack listeners did not start: ${redactString(stderrTail, [ - FAKE_SLACK_APP_TOKEN, - FAKE_SLACK_BOT_TOKEN, - ])}`, - ); - const listening = readFakeSlackCapture(captureFile).filter( - (entry) => entry.event === "listening", + `${options.label} did not start: ${redactString(stderrTail, options.redactionValues ?? [])}`, ); - const restPort = Number(fs.readFileSync(portFile, "utf8").trim()); - const websocketPort = Number(listening.find((entry) => entry.kind === "websocket")?.port ?? 0); - progress.phase("exercise REST and WebSocket traffic"); + const restPort = parseRuntimeProofPort(fs.readFileSync(portFile, "utf8").trim()); + const websocketPort = options.websocketPort?.(captureFile); + progress.phase("exercise port traffic"); return { restPort, websocketPort, captureFile, stop }; } catch (error) { await stop(); @@ -163,6 +157,43 @@ async function startFakeSlackPortFixture(options?: { } } +async function startFakeSlackPortFixture(options?: { + suppressPortTrafficReply?: boolean; + restPortTrafficStatus?: number; + restPortTrafficReply?: string; +}): Promise { + const fixture = await startFakePortFixture({ + label: "fake Slack port fixture", + script: FAKE_SLACK_API, + redactionValues: [FAKE_SLACK_APP_TOKEN, FAKE_SLACK_BOT_TOKEN], + env: (portFile, captureFile) => ({ + FAKE_SLACK_API_HOST: "127.0.0.1", + FAKE_SLACK_API_PORT: "0", + FAKE_SLACK_API_WEBSOCKET_PORT: "0", + FAKE_SLACK_API_PORT_FILE: portFile, + FAKE_SLACK_API_CAPTURE_FILE: captureFile, + FAKE_SLACK_API_EXPECTED_BOT_TOKEN: FAKE_SLACK_BOT_TOKEN, + FAKE_SLACK_API_EXPECTED_APP_TOKEN: FAKE_SLACK_APP_TOKEN, + ...(options?.suppressPortTrafficReply + ? { FAKE_SLACK_API_SUPPRESS_PORT_TRAFFIC_REPLY: "1" } + : {}), + ...(options?.restPortTrafficStatus === undefined + ? {} + : { FAKE_SLACK_API_PORT_TRAFFIC_STATUS: String(options.restPortTrafficStatus) }), + ...(options?.restPortTrafficReply === undefined + ? {} + : { FAKE_SLACK_API_PORT_TRAFFIC_REPLY: options.restPortTrafficReply }), + }), + websocketPort: (captureFile) => + Number( + readFakeSlackCapture(captureFile).find((entry) => entry.kind === "websocket")?.port ?? 0, + ), + }); + const websocketPort = fixture.websocketPort; + expect(websocketPort).toBeGreaterThan(0); + return { ...fixture, websocketPort: websocketPort as number }; +} + function runPortTrafficCheck(restPort: number, websocketPort?: number) { return spawnSync( process.execPath, @@ -222,6 +253,11 @@ describe("messaging provider installed-runtime proofs", () => { it("collects API diagnostics when the fake container does not become ready", async () => { vi.useFakeTimers(); const artifactNames: string[] = []; + const invocations: Array<{ + artifactName: string; + command: string; + args: readonly string[]; + }> = []; const cleanupTasks: Array<() => Promise> = []; let container = ""; const runner: CommandRunner = { @@ -229,6 +265,7 @@ describe("messaging provider installed-runtime proofs", () => { const invocation = [command.command, ...command.args]; const artifactName = options?.artifactName ?? ""; artifactNames.push(...(artifactName ? [artifactName] : [])); + invocations.push({ artifactName, command: command.command, args: command.args }); switch (artifactName) { case "start-fake-slack-api": container = invocation[invocation.indexOf("--name") + 1] ?? ""; @@ -265,6 +302,22 @@ describe("messaging provider installed-runtime proofs", () => { "failure-fake-slack-api-api-inspect", "failure-fake-slack-api-api-logs", ]); + expect( + invocations.filter(({ artifactName }) => + artifactName.startsWith("failure-fake-slack-api-"), + ), + ).toEqual([ + { + artifactName: "failure-fake-slack-api-api-inspect", + command: "docker", + args: ["inspect", "--format", "{{json .State}}", container], + }, + { + artifactName: "failure-fake-slack-api-api-logs", + command: "docker", + args: ["logs", "--tail", "200", container], + }, + ]); } finally { vi.useRealTimers(); await cleanupTasks @@ -280,20 +333,32 @@ describe("messaging provider installed-runtime proofs", () => { it("collects diagnostics when published fake API proxy ports do not carry traffic", async () => { const artifactNames: string[] = []; + const invocations: Array<{ + artifactName: string; + command: string; + args: readonly string[]; + }> = []; const cleanupTasks: Array<() => Promise> = []; + let apiContainer = ""; + let proxyContainer = ""; const runner: CommandRunner = { async run(command, options) { const invocation = [command.command, ...command.args]; const artifactName = options?.artifactName ?? ""; artifactNames.push(...(artifactName ? [artifactName] : [])); + invocations.push({ artifactName, command: command.command, args: command.args }); switch (artifactName) { case "start-fake-slack-api": { + apiContainer = invocation[invocation.indexOf("--name") + 1] ?? ""; const mountSuffix = ":/tmp/fake"; const mount = invocation.find((argument) => argument.endsWith(mountSuffix)); expect(mount).toBeDefined(); fs.writeFileSync(path.join(mount!.slice(0, -mountSuffix.length), "port"), "8080\n"); return successfulShellResult(invocation); } + case "start-fake-slack-api-proxy": + proxyContainer = invocation[invocation.indexOf("--name") + 1] ?? ""; + return successfulShellResult(invocation); case "port-fake-slack-api": return successfulShellResult(invocation, "127.0.0.1:41080\n"); case "port-fake-slack-websocket-api": @@ -333,6 +398,34 @@ describe("messaging provider installed-runtime proofs", () => { "failure-fake-slack-api-api-inspect", "failure-fake-slack-api-api-logs", ]); + expect(apiContainer).not.toBe(""); + expect(proxyContainer).not.toBe(""); + expect( + invocations.filter(({ artifactName }) => + artifactName.startsWith("failure-fake-slack-api-"), + ), + ).toEqual([ + { + artifactName: "failure-fake-slack-api-proxy-inspect", + command: "docker", + args: ["inspect", "--format", "{{json .State}}", proxyContainer], + }, + { + artifactName: "failure-fake-slack-api-proxy-logs", + command: "docker", + args: ["logs", "--tail", "200", proxyContainer], + }, + { + artifactName: "failure-fake-slack-api-api-inspect", + command: "docker", + args: ["inspect", "--format", "{{json .State}}", apiContainer], + }, + { + artifactName: "failure-fake-slack-api-api-logs", + command: "docker", + args: ["logs", "--tail", "200", apiContainer], + }, + ]); } finally { await cleanupTasks .reverse() @@ -417,6 +510,79 @@ describe("messaging provider installed-runtime proofs", () => { } }, 25_000); + it.each([ + [ + "Discord", + FAKE_DISCORD_GATEWAY, + [] as string[], + (portFile: string, captureFile: string) => ({ + 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: "fake-discord-readiness-token", + }), + ], + [ + "Telegram", + FAKE_TELEGRAM_API, + [] as string[], + (portFile: string, captureFile: string) => ({ + FAKE_TELEGRAM_API_HOST: "127.0.0.1", + FAKE_TELEGRAM_API_PORT: "0", + FAKE_TELEGRAM_API_PORT_FILE: portFile, + FAKE_TELEGRAM_API_CAPTURE_FILE: captureFile, + FAKE_TELEGRAM_API_EXPECTED_TOKEN: "fake-telegram-readiness-token", + }), + ], + [ + "WeChat", + FAKE_WECHAT_API, + ["--experimental-strip-types"], + (portFile: string, captureFile: string) => ({ + FAKE_WECHAT_API_HOST: "127.0.0.1", + FAKE_WECHAT_API_PORT: "0", + FAKE_WECHAT_API_PORT_FILE: portFile, + FAKE_WECHAT_API_CAPTURE_FILE: captureFile, + FAKE_WECHAT_API_EXPECTED_TOKEN: "fake-wechat-readiness-token", + FAKE_WECHAT_API_EXPECTED_TARGET: "readiness-user@im.wechat", + FAKE_WECHAT_API_EXPECTED_TEXT: "readiness proof", + }), + ], + ])( + "accepts the real %s fake API REST readiness reply", + async (provider, script, nodeArgs, env) => { + const fixture = await startFakePortFixture({ + label: `fake ${provider} port fixture`, + script, + nodeArgs, + env, + }); + + try { + const trafficCheck = runPortTrafficCheck(fixture.restPort); + expect(trafficCheck.status, trafficCheck.stderr).toBe(0); + } finally { + await fixture.stop(); + } + }, + 15_000, + ); + + it("rejects an invalid reply through the generic REST-only traffic probe", async () => { + const fixture = await startFakeSlackPortFixture({ + restPortTrafficReply: "unexpected_reply", + }); + + try { + const trafficCheck = runPortTrafficCheck(fixture.restPort); + expect(trafficCheck.status).toBe(1); + expect(trafficCheck.stderr).toContain("REST port traffic reply was not recognized"); + } finally { + await fixture.stop(); + } + }, 15_000); + it.each([ ["an HTTP error", 502, "nemoclaw_port_traffic_reply", "HTTP 502"], ["an invalid sentinel", 200, "unexpected_reply", "not recognized"], From ed71acfd58cb90c3190382b02605a91371f7a175 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 02:00:47 -0700 Subject: [PATCH 18/45] test(e2e): preserve Slack rebuild policy bindings Signed-off-by: Prekshi Vyas --- .../fixtures/hermes-discord-policy-binding.ts | 71 +++++++++++++------ test/e2e/live/messaging-providers-helpers.ts | 42 ++++++++--- test/e2e/live/messaging-providers.test.ts | 19 +++-- .../hermes-discord-policy-binding.test.ts | 51 +++++++++++++ 4 files changed, 143 insertions(+), 40 deletions(-) diff --git a/test/e2e/fixtures/hermes-discord-policy-binding.ts b/test/e2e/fixtures/hermes-discord-policy-binding.ts index 930eaec3a14..89f86bbdf06 100644 --- a/test/e2e/fixtures/hermes-discord-policy-binding.ts +++ b/test/e2e/fixtures/hermes-discord-policy-binding.ts @@ -20,12 +20,16 @@ const policyBoundary = ( ) as typeof policyBoundaryModule; const { parseOpenShellPolicy } = policyBoundary; -export function bindHermesDiscordPolicyEndpoint( +export type PolicyEndpointBinding = { + providerName: string; + host: string; + port: number; + protocol: string; +}; + +export function bindPolicyEndpoints( policyFile: string, - providerName: string, - host: string, - port: number, - protocol: string, + bindings: readonly PolicyEndpointBinding[], ): void { const source = fs.readFileSync(policyFile, "utf8"); const policy = parseOpenShellPolicy(source).policy; @@ -34,32 +38,55 @@ export function bindHermesDiscordPolicyEndpoint( const candidateEndpoints = (entry as { endpoints?: unknown }).endpoints; return Array.isArray(candidateEndpoints) ? candidateEndpoints : []; }); - const endpoint = endpoints.find((candidate) => { - if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) { - return false; - } - const value = candidate as { host?: unknown; port?: unknown; protocol?: unknown }; - return ( - value.host === host && - value.port === port && - value.protocol === protocol - ); - }) as Record | undefined; - if (!endpoint) throw new Error("fake Discord endpoint is missing from the base policy"); - endpoint.credential_binding = { provider: providerName }; + for (const binding of bindings) { + const endpoint = endpoints.find((candidate) => { + if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) { + return false; + } + const value = candidate as { host?: unknown; port?: unknown; protocol?: unknown }; + return ( + value.host === binding.host && + value.port === binding.port && + value.protocol === binding.protocol + ); + }) as Record | undefined; + if (!endpoint) throw new Error("fake messaging endpoint is missing from the base policy"); + + endpoint.credential_binding = { provider: binding.providerName }; + } + fs.writeFileSync(policyFile, YAML.stringify(policy)); fs.chmodSync(policyFile, 0o600); } +export function bindHermesDiscordPolicyEndpoint( + policyFile: string, + providerName: string, + host: string, + port: number, + protocol: string, +): void { + bindPolicyEndpoints(policyFile, [{ providerName, host, port, protocol }]); +} + function main(): void { - const [policyFile, providerName, host, rawPort, protocol] = process.argv.slice(2); - if (!policyFile || !providerName || !host || !rawPort || !protocol) { + const [policyFile, ...rawBindings] = process.argv.slice(2); + if (!policyFile || rawBindings.length === 0 || rawBindings.length % 4 !== 0) { throw new Error( - "usage: hermes-discord-policy-binding ", + "usage: hermes-discord-policy-binding [ ]...", ); } - bindHermesDiscordPolicyEndpoint(policyFile, providerName, host, Number(rawPort), protocol); + const bindings: PolicyEndpointBinding[] = []; + for (let index = 0; index < rawBindings.length; index += 4) { + bindings.push({ + providerName: rawBindings[index]!, + host: rawBindings[index + 1]!, + port: Number(rawBindings[index + 2]), + protocol: rawBindings[index + 3]!, + }); + } + bindPolicyEndpoints(policyFile, bindings); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 64b22bab17b..f841228960c 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -960,33 +960,59 @@ export async function applyRestRewritePolicy( expectExitZero(result, `apply ${api.kind} fake REST policy`); if (!providerName) return; + await bindRestRewritePolicyCredentials( + host, + [{ providerName, port: api.port }], + api.kind, + env, + redactionValues, + ); +} + +export async function bindRestRewritePolicyCredentials( + host: HostCliClient, + bindings: readonly { providerName: string; port: string }[], + artifactKind: string, + env: NodeJS.ProcessEnv, + redactionValues: string[], +): Promise { + if (bindings.length === 0) throw new Error("at least one REST credential binding is required"); + const binding = await runHost( host, "bash", [ "-lc", String.raw`set -eu +openshell="$1" +sandbox="$2" +helper="$3" +shift 3 policy_file="$(mktemp)" trap 'rm -f "$policy_file"' EXIT -"$1" policy get --base "$2" >"$policy_file" -node --import tsx "$5" "$policy_file" "$3" host.openshell.internal "$4" rest -"$1" policy set --policy "$policy_file" --wait "$2"`, - `bind-fake-${api.kind}-rest-policy`, +"$openshell" policy get --base "$sandbox" >"$policy_file" +node --import tsx "$helper" "$policy_file" "$@" +"$openshell" policy set --policy "$policy_file" --wait "$sandbox"`, + `bind-fake-${artifactKind}-rest-policy`, host.openshellCommandPath, SANDBOX_NAME, - providerName, - api.port, path.join(REPO_ROOT, "test/e2e/fixtures/hermes-discord-policy-binding.ts"), + ...bindings.flatMap(({ providerName, port }) => [ + providerName, + "host.openshell.internal", + port, + "rest", + ]), ], { - artifactName: `apply-${api.kind}-rest-policy-credential-binding`, + artifactName: `apply-${artifactKind}-rest-policy-credential-binding`, cwd: REPO_ROOT, env, redactionValues, timeoutMs: 120_000, }, ); - expectExitZero(binding, `bind ${api.kind} fake REST policy credential`); + expectExitZero(binding, `bind ${artifactKind} fake REST policy credentials`); } export function lastJsonLine( diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index 6012ea41fe2..ddad20fddcf 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -18,6 +18,7 @@ import { accountBool, accountString, applyRestRewritePolicy, + bindRestRewritePolicyCredentials, CLI_ENTRYPOINT, channelAccount, channelEnabled, @@ -884,13 +885,7 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); env: state.env, redactionValues, }); - await applyRestRewritePolicy( - host, - fakeSlack, - state.env, - redactionValues, - `${SANDBOX_NAME}-slack-bridge`, - ); + await applyRestRewritePolicy(host, fakeSlack, state.env, redactionValues); expect( fakeSlack.alternatePort, "fake Slack API must publish an independent app-token port", @@ -899,12 +894,16 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); ...fakeSlack, port: fakeSlack.alternatePort!, }; - await applyRestRewritePolicy( + await applyRestRewritePolicy(host, fakeSlackApp, state.env, redactionValues); + await bindRestRewritePolicyCredentials( host, - fakeSlackApp, + [ + { providerName: `${SANDBOX_NAME}-slack-bridge`, port: fakeSlack.port }, + { providerName: `${SANDBOX_NAME}-slack-app`, port: fakeSlackApp.port }, + ], + "slack", state.env, redactionValues, - `${SANDBOX_NAME}-slack-app`, ); const slackBotPlaceholder = await sandboxOutput( diff --git a/test/e2e/support/hermes-discord-policy-binding.test.ts b/test/e2e/support/hermes-discord-policy-binding.test.ts index 9b61d85ffa8..e80011c52a2 100644 --- a/test/e2e/support/hermes-discord-policy-binding.test.ts +++ b/test/e2e/support/hermes-discord-policy-binding.test.ts @@ -152,4 +152,55 @@ describe("Hermes Discord E2E policy binding", () => { provider: "e2e-hermes-discord-discord-bridge", }); }); + + it("binds two Slack ports in one replacement policy", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-messaging-policy-")); + tempDirs.push(tempDir); + const policyFile = path.join(tempDir, "policy.yaml"); + fs.writeFileSync( + policyFile, + [ + "version: 1", + "network_policies:", + " slack:", + " endpoints:", + " - host: host.openshell.internal", + " port: 43117", + " protocol: rest", + " - host: host.openshell.internal", + " port: 43118", + " protocol: rest", + "", + ].join("\n"), + ); + + const result = spawnSync( + process.execPath, + [ + "--disable-warning=DEP0205", + "--import", + "tsx", + HELPER, + policyFile, + "e2e-msg-slack-bridge", + "host.openshell.internal", + "43117", + "rest", + "e2e-msg-slack-app", + "host.openshell.internal", + "43118", + "rest", + ], + { encoding: "utf8", killSignal: "SIGKILL", timeout: 15_000 }, + ); + const endpoints = YAML.parse(fs.readFileSync(policyFile, "utf8")).network_policies.slack + .endpoints as Array>; + + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + expect(endpoints).toMatchObject([ + { port: 43117, credential_binding: { provider: "e2e-msg-slack-bridge" } }, + { port: 43118, credential_binding: { provider: "e2e-msg-slack-app" } }, + ]); + }); }); From 98ffcd0d5f0c110f19fc9592aa96289ca0f0efe6 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 02:14:41 -0700 Subject: [PATCH 19/45] test(e2e): isolate fake API proxy network Signed-off-by: Prekshi Vyas --- .../fixtures/hermes-discord-policy-binding.ts | 10 ---- test/e2e/live/messaging-providers-helpers.ts | 15 +----- ...messaging-providers-runtime-proofs.test.ts | 52 ++++++++++++++++++- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/test/e2e/fixtures/hermes-discord-policy-binding.ts b/test/e2e/fixtures/hermes-discord-policy-binding.ts index 89f86bbdf06..9da0fe056f3 100644 --- a/test/e2e/fixtures/hermes-discord-policy-binding.ts +++ b/test/e2e/fixtures/hermes-discord-policy-binding.ts @@ -60,16 +60,6 @@ export function bindPolicyEndpoints( fs.chmodSync(policyFile, 0o600); } -export function bindHermesDiscordPolicyEndpoint( - policyFile: string, - providerName: string, - host: string, - port: number, - protocol: string, -): void { - bindPolicyEndpoints(policyFile, [{ providerName, host, port, protocol }]); -} - function main(): void { const [policyFile, ...rawBindings] = process.argv.slice(2); if (!policyFile || rawBindings.length === 0 || rawBindings.length % 4 !== 0) { diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index f841228960c..5e31b273696 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -820,7 +820,7 @@ export async function startFakeDockerApi( "--name", proxyContainer, "--network", - "bridge", + network, ...containerPorts.flatMap((port) => ["-p", `127.0.0.1::${String(port)}`]), "--read-only", "--cap-drop", @@ -847,19 +847,6 @@ export async function startFakeDockerApi( ); expectExitZero(proxyStart, `start fake ${options.kind} API proxy`); - const proxyConnect = await runHost( - host, - "docker", - ["network", "connect", network, proxyContainer], - { - artifactName: `connect-fake-${options.kind}-api-proxy`, - env: options.env, - redactionValues: options.redactionValues, - timeoutMs: 30_000, - }, - ); - expectExitZero(proxyConnect, `connect fake ${options.kind} API proxy`); - const publishedPort = async (containerPort: number, artifactName: string): Promise => { const result = await runHost( host, diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 0b7649db905..842c652ba94 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -224,9 +224,13 @@ function successfulCommand(stdout = "") { }; } -function fakeDockerHost(publishedAddress = "127.0.0.1"): HostCliClient { +function fakeDockerHost( + publishedAddress = "127.0.0.1", + invocations: Array = [], +): HostCliClient { const host = { command: async (command: string, args: string[]) => { + invocations.push([command, ...args]); switch (command) { case "node": return successfulCommand(); @@ -250,6 +254,52 @@ function fakeDockerHost(publishedAddress = "127.0.0.1"): HostCliClient { } describe("messaging provider installed-runtime proofs", () => { + it("runs the published proxy only on the internal fake API network", async () => { + const invocations: Array = []; + const host = fakeDockerHost("127.0.0.1", invocations); + const cleanup: Array<() => Promise> = []; + + try { + await startFakeDockerApi(host, (_name, run) => cleanup.push(run), { + kind: "discord-gateway", + imageScript: "fake-discord-gateway.cjs", + containerPrefix: "fake-discord-gateway", + portEnv: "FAKE_DISCORD_GATEWAY_PORT", + portFileEnv: "FAKE_DISCORD_GATEWAY_PORT_FILE", + captureFileEnv: "FAKE_DISCORD_GATEWAY_CAPTURE_FILE", + expectedEnv: {}, + redactionValues: [], + env: {}, + }); + + const networkCreate = invocations.find( + (invocation) => invocation[0] === "docker" && invocation[1] === "network", + ); + const network = networkCreate?.at(-1); + const proxyRun = invocations.find( + (invocation) => + invocation[0] === "docker" && + invocation[1] === "run" && + invocation.some((argument) => argument.startsWith("NEMOCLAW_FAKE_API_UPSTREAM=")), + ); + + expect(network).toMatch(/^nemoclaw-fake-api-network-/u); + expect(proxyRun).toEqual(expect.arrayContaining(["--network", network])); + expect( + invocations.some( + (invocation) => + invocation[0] === "docker" && + invocation[1] === "network" && + invocation[2] === "connect", + ), + ).toBe(false); + } finally { + await cleanup + .reverse() + .reduce((previous, action) => previous.then(action), Promise.resolve()); + } + }); + it("collects API diagnostics when the fake container does not become ready", async () => { vi.useFakeTimers(); const artifactNames: string[] = []; From c984600248d10155e635cb1ca98d9e3bf8c51435 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 02:34:45 -0700 Subject: [PATCH 20/45] test(e2e): verify fake API trust boundaries Signed-off-by: Prekshi Vyas --- test/e2e/live/messaging-providers-helpers.ts | 83 +++++++++++++ .../hermes-discord-policy-binding.test.ts | 16 ++- ...messaging-providers-runtime-proofs.test.ts | 109 ++++++++---------- 3 files changed, 145 insertions(+), 63 deletions(-) diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 5e31b273696..688ed3e6155 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -391,6 +391,28 @@ export async function runHost( }); } +async function inspectDockerJson( + host: HostCliClient, + container: string, + template: string, + artifactName: string, + env: NodeJS.ProcessEnv, + redactionValues: string[], +): Promise { + const result = await runHost(host, "docker", ["inspect", "--format", template, container], { + artifactName, + env, + redactionValues, + timeoutMs: 30_000, + }); + expectExitZero(result, `inspect Docker container ${container}`); + try { + return JSON.parse(result.stdout.trim()); + } catch { + throw new Error(`Docker container ${container} returned unreadable inspection JSON`); + } +} + export async function runSandboxShell( sandbox: SandboxClient, script: string, @@ -810,6 +832,8 @@ export async function startFakeDockerApi( } }); + // The fake API receives raw expected credentials. Keep it unpublished and + // expose only this credential-free forwarding container on host loopback. const proxyStart = await runHost( host, "docker", @@ -847,6 +871,65 @@ export async function startFakeDockerApi( ); expectExitZero(proxyStart, `start fake ${options.kind} API proxy`); + const networkInspections = await Promise.all( + [ + ["api", container], + ["proxy", proxyContainer], + ].map(async ([role, name]) => ({ + role, + networks: await inspectDockerJson( + host, + name!, + "{{json .NetworkSettings.Networks}}", + `prove-fake-${options.kind}-api-${role}-internal-network`, + options.env, + options.redactionValues, + ), + })), + ); + for (const { role, networks } of networkInspections) { + if ( + typeof networks !== "object" || + networks === null || + Array.isArray(networks) || + Object.keys(networks).length !== 1 || + !Object.hasOwn(networks, network) + ) { + throw new Error(`fake ${options.kind} API ${role} has unexpected network attachments`); + } + } + + const apiPorts = await runHost(host, "docker", ["port", container], { + artifactName: `prove-fake-${options.kind}-api-internal-only`, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 30_000, + }); + expectExitZero(apiPorts, `inspect fake ${options.kind} API published ports`); + if (apiPorts.stdout.trim()) { + throw new Error(`credential-bearing fake ${options.kind} API container is host-published`); + } + + const proxyEnvironment = await inspectDockerJson( + host, + proxyContainer, + "{{json .Config.Env}}", + `prove-fake-${options.kind}-api-proxy-credential-free`, + options.env, + options.redactionValues, + ); + const credentialNames = new Set(Object.keys(options.expectedEnv)); + if ( + !Array.isArray(proxyEnvironment) || + !proxyEnvironment.every((entry): entry is string => typeof entry === "string") || + proxyEnvironment.some((entry) => credentialNames.has(entry.split("=", 1)[0] ?? "")) || + options.redactionValues.some( + (value) => value.length > 0 && proxyEnvironment.some((entry) => entry.includes(value)), + ) + ) { + throw new Error(`fake ${options.kind} API proxy received credential-bearing environment`); + } + const publishedPort = async (containerPort: number, artifactName: string): Promise => { const result = await runHost( host, diff --git a/test/e2e/support/hermes-discord-policy-binding.test.ts b/test/e2e/support/hermes-discord-policy-binding.test.ts index e80011c52a2..e5cc7dd1042 100644 --- a/test/e2e/support/hermes-discord-policy-binding.test.ts +++ b/test/e2e/support/hermes-discord-policy-binding.test.ts @@ -99,7 +99,20 @@ describe("Hermes Discord E2E policy binding", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-messaging-policy-")); tempDirs.push(tempDir); const policyFile = path.join(tempDir, "policy.yaml"); - fs.writeFileSync(policyFile, "version: 1\nnetwork_policies: {}\n"); + const original = [ + "version: 1", + "network_policies:", + " fake:", + " endpoints:", + " - host: host.docker.internal", + " port: 43117", + " protocol: rest", + " - host: host.docker.internal", + " port: 43117", + " protocol: websocket", + "", + ].join("\n"); + fs.writeFileSync(policyFile, original); const result = spawnSync( process.execPath, @@ -118,6 +131,7 @@ describe("Hermes Discord E2E policy binding", () => { expect(result.status).not.toBe(0); expect(result.stderr).toContain(""); + expect(fs.readFileSync(policyFile, "utf8")).toBe(original); }); it("binds only the requested protocol when a fake host and port are shared", () => { diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 842c652ba94..97023ebdce8 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -224,29 +224,46 @@ function successfulCommand(stdout = "") { }; } -function fakeDockerHost( - publishedAddress = "127.0.0.1", - invocations: Array = [], -): HostCliClient { +function fakeDockerHost(publishedAddress = "127.0.0.1"): HostCliClient { + let network = ""; const host = { command: async (command: string, args: string[]) => { - invocations.push([command, ...args]); switch (command) { case "node": return successfulCommand(); default: expect(command).toBe("docker"); - args - .filter((argument) => args[0] === "run" && argument.endsWith(":/tmp/fake")) - .forEach((fakeApiMount) => { - const fixtureDir = fakeApiMount.slice(0, -":/tmp/fake".length); - fs.writeFileSync(path.join(fixtureDir, "port"), "8080"); - }); - return args[0] === "port" - ? successfulCommand( - `${publishedAddress}:${args[2] === "8081/tcp" ? "32101" : "32100"}\n`, - ) - : successfulCommand(); + switch (args[0]) { + case "network": + network = args[1] === "create" ? (args.at(-1) ?? "") : network; + return successfulCommand(); + case "run": + args + .filter((argument) => argument.endsWith(":/tmp/fake")) + .forEach((fakeApiMount) => { + const fixtureDir = fakeApiMount.slice(0, -":/tmp/fake".length); + fs.writeFileSync(path.join(fixtureDir, "port"), "8080"); + }); + return successfulCommand(); + case "inspect": + return args[2] === "{{json .NetworkSettings.Networks}}" + ? successfulCommand(JSON.stringify({ [network]: {} })) + : successfulCommand( + JSON.stringify([ + "PATH=/usr/local/bin:/usr/bin:/bin", + "NEMOCLAW_FAKE_API_UPSTREAM=fake-api", + "NEMOCLAW_FAKE_API_PROXY_PORTS=8080", + ]), + ); + case "port": + return successfulCommand( + args[2] + ? `${publishedAddress}:${args[2] === "8081/tcp" ? "32101" : "32100"}\n` + : "", + ); + default: + return successfulCommand(); + } } }, } as unknown as HostCliClient; @@ -254,52 +271,6 @@ function fakeDockerHost( } describe("messaging provider installed-runtime proofs", () => { - it("runs the published proxy only on the internal fake API network", async () => { - const invocations: Array = []; - const host = fakeDockerHost("127.0.0.1", invocations); - const cleanup: Array<() => Promise> = []; - - try { - await startFakeDockerApi(host, (_name, run) => cleanup.push(run), { - kind: "discord-gateway", - imageScript: "fake-discord-gateway.cjs", - containerPrefix: "fake-discord-gateway", - portEnv: "FAKE_DISCORD_GATEWAY_PORT", - portFileEnv: "FAKE_DISCORD_GATEWAY_PORT_FILE", - captureFileEnv: "FAKE_DISCORD_GATEWAY_CAPTURE_FILE", - expectedEnv: {}, - redactionValues: [], - env: {}, - }); - - const networkCreate = invocations.find( - (invocation) => invocation[0] === "docker" && invocation[1] === "network", - ); - const network = networkCreate?.at(-1); - const proxyRun = invocations.find( - (invocation) => - invocation[0] === "docker" && - invocation[1] === "run" && - invocation.some((argument) => argument.startsWith("NEMOCLAW_FAKE_API_UPSTREAM=")), - ); - - expect(network).toMatch(/^nemoclaw-fake-api-network-/u); - expect(proxyRun).toEqual(expect.arrayContaining(["--network", network])); - expect( - invocations.some( - (invocation) => - invocation[0] === "docker" && - invocation[1] === "network" && - invocation[2] === "connect", - ), - ).toBe(false); - } finally { - await cleanup - .reverse() - .reduce((previous, action) => previous.then(action), Promise.resolve()); - } - }); - it("collects API diagnostics when the fake container does not become ready", async () => { vi.useFakeTimers(); const artifactNames: string[] = []; @@ -391,6 +362,7 @@ describe("messaging provider installed-runtime proofs", () => { const cleanupTasks: Array<() => Promise> = []; let apiContainer = ""; let proxyContainer = ""; + let network = ""; const runner: CommandRunner = { async run(command, options) { const invocation = [command.command, ...command.args]; @@ -398,6 +370,9 @@ describe("messaging provider installed-runtime proofs", () => { artifactNames.push(...(artifactName ? [artifactName] : [])); invocations.push({ artifactName, command: command.command, args: command.args }); switch (artifactName) { + case "create-fake-slack-api-network": + network = invocation.at(-1) ?? ""; + return successfulShellResult(invocation); case "start-fake-slack-api": { apiContainer = invocation[invocation.indexOf("--name") + 1] ?? ""; const mountSuffix = ":/tmp/fake"; @@ -409,6 +384,16 @@ describe("messaging provider installed-runtime proofs", () => { case "start-fake-slack-api-proxy": proxyContainer = invocation[invocation.indexOf("--name") + 1] ?? ""; return successfulShellResult(invocation); + case "prove-fake-slack-api-api-internal-network": + case "prove-fake-slack-api-proxy-internal-network": + return successfulShellResult(invocation, JSON.stringify({ [network]: {} })); + case "prove-fake-slack-api-internal-only": + return successfulShellResult(invocation); + case "prove-fake-slack-api-proxy-credential-free": + return successfulShellResult( + invocation, + '["NEMOCLAW_FAKE_API_UPSTREAM=fake-api","NEMOCLAW_FAKE_API_PROXY_PORTS=8080,8081"]', + ); case "port-fake-slack-api": return successfulShellResult(invocation, "127.0.0.1:41080\n"); case "port-fake-slack-websocket-api": From 2757424c6621ea760461656175d7e35036e87428 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 03:20:21 -0700 Subject: [PATCH 21/45] test(e2e): use owned Slack policy on rebuild Signed-off-by: Prekshi Vyas --- test/e2e/live/messaging-providers-helpers.ts | 62 -------------------- test/e2e/live/messaging-providers.test.ts | 20 +++++-- 2 files changed, 16 insertions(+), 66 deletions(-) diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 688ed3e6155..7b936331bbf 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -26,12 +26,6 @@ import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; export { CLI_ENTRYPOINT, expectExitZero, REPO_ROOT }; -export const BASE_POLICY = path.join( - REPO_ROOT, - "nemoclaw-blueprint", - "policies", - "openclaw-sandbox.yaml", -); export const FAKE_LIB_DIR = path.join(REPO_ROOT, "test", "e2e", "lib"); const FAKE_API_PORT_TRAFFIC = path.join(FAKE_LIB_DIR, "fake-api-port-readiness.mts"); export const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? `e2e-msg-${process.pid}`; @@ -521,62 +515,6 @@ export function policyTextHasHost(text: string, host: string): boolean { return text.split(/\r?\n/).some((line) => accepted.has(line.trim())); } -export async function premergeSlackPolicyIfNeeded(): Promise<() => void> { - const original = fs.readFileSync(BASE_POLICY, "utf8"); - if (policyTextHasHost(original, "api.slack.com")) { - return () => {}; - } - fs.appendFileSync( - BASE_POLICY, - ` - - # Slack - pre-merged for messaging provider E2E (#2340) - slack: - name: slack - endpoints: - - host: slack.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: api.slack.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: hooks.slack.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: wss-primary.slack.com - port: 443 - protocol: websocket - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: WEBSOCKET_TEXT, path: "/**" } - - host: wss-backup.slack.com - port: 443 - protocol: websocket - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: WEBSOCKET_TEXT, path: "/**" } - binaries: - - { path: /usr/local/bin/node } - - { path: /usr/bin/node } -`, - ); - return () => fs.writeFileSync(BASE_POLICY, original); -} - export async function readOpenClawConfig( sandbox: SandboxClient, redactionValues: string[], diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index ddad20fddcf..879bb860ad8 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -10,6 +10,8 @@ */ import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { testTimeoutOptions } from "../../helpers/timeouts"; import { expect, test } from "../fixtures/e2e-test.ts"; @@ -35,7 +37,6 @@ import { outputText, pluginEnabled, policyTextHasHost, - premergeSlackPolicyIfNeeded, REBUILD_TIMEOUT_MS, rawTokenSurfaceProbe, readOpenClawConfig, @@ -57,6 +58,13 @@ import { runInstalledWechatRuntimeProof } from "./messaging-providers-wechat-run process.env.NEMOCLAW_CLI_BIN ??= CLI_ENTRYPOINT; +const SANDBOX_BACKUP_ROOT = path.join( + os.homedir(), + ".nemoclaw", + "rebuild-backups", + SANDBOX_NAME, +); + test( "messaging providers preserve placeholder, policy, runtime, and send contracts", { @@ -97,6 +105,13 @@ test( wechatAccount: state.wechatAccount, }); + cleanup.trackDisposable(`remove rebuild backups for ${SANDBOX_NAME}`, () => { + fs.rmSync(SANDBOX_BACKUP_ROOT, { recursive: true, force: true }); + expect( + fs.existsSync(SANDBOX_BACKUP_ROOT), + `rebuild backup directory remains after cleanup: ${SANDBOX_BACKUP_ROOT}`, + ).toBe(false); + }); cleanup.trackDisposable(`delete OpenShell sandbox ${SANDBOX_NAME}`, () => sandbox.cleanupSandbox(SANDBOX_NAME, { artifactName: "cleanup-openshell-sandbox-delete-messaging-providers", @@ -112,9 +127,6 @@ test( timeoutMs: 15 * 60_000, }); - const restoreSlackPolicy = await premergeSlackPolicyIfNeeded(); - cleanup.add("restore messaging E2E Slack policy pre-merge", restoreSlackPolicy); - await runSecondaryCleanup(() => runHost(host, "node", [CLI_ENTRYPOINT, SANDBOX_NAME, "destroy", "--yes"], { artifactName: "preclean-nemoclaw-destroy-messaging-providers", From 121c42859ef9125618db75ddc164c1890f875d78 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 03:43:58 -0700 Subject: [PATCH 22/45] fix(e2e): publish proxy through bridge network Signed-off-by: Prekshi Vyas --- test/e2e/live/messaging-providers-helpers.ts | 23 ++++++++++++++++--- ...messaging-providers-runtime-proofs.test.ts | 20 ++++++++++++++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 7b936331bbf..cea9f73bac1 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -772,6 +772,9 @@ export async function startFakeDockerApi( // The fake API receives raw expected credentials. Keep it unpublished and // expose only this credential-free forwarding container on host loopback. + // Docker does not install published-port forwarding for a container whose + // only attachment is an internal network, so establish the loopback binding + // on the default bridge before adding the private API network. const proxyStart = await runHost( host, "docker", @@ -782,7 +785,7 @@ export async function startFakeDockerApi( "--name", proxyContainer, "--network", - network, + "bridge", ...containerPorts.flatMap((port) => ["-p", `127.0.0.1::${String(port)}`]), "--read-only", "--cap-drop", @@ -809,6 +812,19 @@ export async function startFakeDockerApi( ); expectExitZero(proxyStart, `start fake ${options.kind} API proxy`); + const proxyConnect = await runHost( + host, + "docker", + ["network", "connect", network, proxyContainer], + { + artifactName: `connect-fake-${options.kind}-api-proxy-internal-network`, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 30_000, + }, + ); + expectExitZero(proxyConnect, `connect fake ${options.kind} API proxy to private network`); + const networkInspections = await Promise.all( [ ["api", container], @@ -826,12 +842,13 @@ export async function startFakeDockerApi( })), ); for (const { role, networks } of networkInspections) { + const expectedNetworks = role === "api" ? [network] : ["bridge", network]; if ( typeof networks !== "object" || networks === null || Array.isArray(networks) || - Object.keys(networks).length !== 1 || - !Object.hasOwn(networks, network) + Object.keys(networks).length !== expectedNetworks.length || + expectedNetworks.some((name) => !Object.hasOwn(networks, name)) ) { throw new Error(`fake ${options.kind} API ${role} has unexpected network attachments`); } diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 97023ebdce8..21711b05c55 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -226,6 +226,7 @@ function successfulCommand(stdout = "") { function fakeDockerHost(publishedAddress = "127.0.0.1"): HostCliClient { let network = ""; + let proxyContainer = ""; const host = { command: async (command: string, args: string[]) => { switch (command) { @@ -238,6 +239,11 @@ function fakeDockerHost(publishedAddress = "127.0.0.1"): HostCliClient { network = args[1] === "create" ? (args.at(-1) ?? "") : network; return successfulCommand(); case "run": + proxyContainer = args.some((argument) => + argument.startsWith("NEMOCLAW_FAKE_API_UPSTREAM="), + ) + ? (args[args.indexOf("--name") + 1] ?? "") + : proxyContainer; args .filter((argument) => argument.endsWith(":/tmp/fake")) .forEach((fakeApiMount) => { @@ -247,7 +253,13 @@ function fakeDockerHost(publishedAddress = "127.0.0.1"): HostCliClient { return successfulCommand(); case "inspect": return args[2] === "{{json .NetworkSettings.Networks}}" - ? successfulCommand(JSON.stringify({ [network]: {} })) + ? successfulCommand( + JSON.stringify( + args.at(-1) === proxyContainer + ? { bridge: {}, [network]: {} } + : { [network]: {} }, + ), + ) : successfulCommand( JSON.stringify([ "PATH=/usr/local/bin:/usr/bin:/bin", @@ -385,8 +397,12 @@ describe("messaging provider installed-runtime proofs", () => { proxyContainer = invocation[invocation.indexOf("--name") + 1] ?? ""; return successfulShellResult(invocation); case "prove-fake-slack-api-api-internal-network": - case "prove-fake-slack-api-proxy-internal-network": return successfulShellResult(invocation, JSON.stringify({ [network]: {} })); + case "prove-fake-slack-api-proxy-internal-network": + return successfulShellResult( + invocation, + JSON.stringify({ bridge: {}, [network]: {} }), + ); case "prove-fake-slack-api-internal-only": return successfulShellResult(invocation); case "prove-fake-slack-api-proxy-credential-free": From 603c6ee736370c1a187f2dda7742b3efefd9d7ad Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 03:55:10 -0700 Subject: [PATCH 23/45] test(e2e): decouple diagnostic call order Signed-off-by: Prekshi Vyas --- ...messaging-providers-runtime-proofs.test.ts | 102 ++++++++---------- 1 file changed, 47 insertions(+), 55 deletions(-) diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 21711b05c55..5fb1075d933 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -331,26 +331,24 @@ describe("messaging provider installed-runtime proofs", () => { await vi.runAllTimersAsync(); await start; expect(container).not.toBe(""); - expect(artifactNames.filter((name) => name.startsWith("failure-fake-slack-api-"))).toEqual([ - "failure-fake-slack-api-api-inspect", - "failure-fake-slack-api-api-logs", - ]); - expect( - invocations.filter(({ artifactName }) => - artifactName.startsWith("failure-fake-slack-api-"), - ), - ).toEqual([ - { - artifactName: "failure-fake-slack-api-api-inspect", - command: "docker", - args: ["inspect", "--format", "{{json .State}}", container], - }, - { - artifactName: "failure-fake-slack-api-api-logs", - command: "docker", - args: ["logs", "--tail", "200", container], - }, - ]); + const diagnostics = invocations.filter(({ artifactName }) => + artifactName.startsWith("failure-fake-slack-api-"), + ); + expect(diagnostics).toHaveLength(2); + expect(diagnostics).toEqual( + expect.arrayContaining([ + { + artifactName: "failure-fake-slack-api-api-inspect", + command: "docker", + args: ["inspect", "--format", "{{json .State}}", container], + }, + { + artifactName: "failure-fake-slack-api-api-logs", + command: "docker", + args: ["logs", "--tail", "200", container], + }, + ]), + ); } finally { vi.useRealTimers(); await cleanupTasks @@ -365,7 +363,6 @@ describe("messaging provider installed-runtime proofs", () => { }); it("collects diagnostics when published fake API proxy ports do not carry traffic", async () => { - const artifactNames: string[] = []; const invocations: Array<{ artifactName: string; command: string; @@ -379,7 +376,6 @@ describe("messaging provider installed-runtime proofs", () => { async run(command, options) { const invocation = [command.command, ...command.args]; const artifactName = options?.artifactName ?? ""; - artifactNames.push(...(artifactName ? [artifactName] : [])); invocations.push({ artifactName, command: command.command, args: command.args }); switch (artifactName) { case "create-fake-slack-api-network": @@ -443,40 +439,36 @@ describe("messaging provider installed-runtime proofs", () => { env: {}, }), ).rejects.toThrow(/proxy .* did not carry traffic to API container .*traffic check/u); - expect(artifactNames.filter((name) => name.startsWith("failure-fake-slack-api-"))).toEqual([ - "failure-fake-slack-api-proxy-inspect", - "failure-fake-slack-api-proxy-logs", - "failure-fake-slack-api-api-inspect", - "failure-fake-slack-api-api-logs", - ]); expect(apiContainer).not.toBe(""); expect(proxyContainer).not.toBe(""); - expect( - invocations.filter(({ artifactName }) => - artifactName.startsWith("failure-fake-slack-api-"), - ), - ).toEqual([ - { - artifactName: "failure-fake-slack-api-proxy-inspect", - command: "docker", - args: ["inspect", "--format", "{{json .State}}", proxyContainer], - }, - { - artifactName: "failure-fake-slack-api-proxy-logs", - command: "docker", - args: ["logs", "--tail", "200", proxyContainer], - }, - { - artifactName: "failure-fake-slack-api-api-inspect", - command: "docker", - args: ["inspect", "--format", "{{json .State}}", apiContainer], - }, - { - artifactName: "failure-fake-slack-api-api-logs", - command: "docker", - args: ["logs", "--tail", "200", apiContainer], - }, - ]); + const diagnostics = invocations.filter(({ artifactName }) => + artifactName.startsWith("failure-fake-slack-api-"), + ); + expect(diagnostics).toHaveLength(4); + expect(diagnostics).toEqual( + expect.arrayContaining([ + { + artifactName: "failure-fake-slack-api-proxy-inspect", + command: "docker", + args: ["inspect", "--format", "{{json .State}}", proxyContainer], + }, + { + artifactName: "failure-fake-slack-api-proxy-logs", + command: "docker", + args: ["logs", "--tail", "200", proxyContainer], + }, + { + artifactName: "failure-fake-slack-api-api-inspect", + command: "docker", + args: ["inspect", "--format", "{{json .State}}", apiContainer], + }, + { + artifactName: "failure-fake-slack-api-api-logs", + command: "docker", + args: ["logs", "--tail", "200", apiContainer], + }, + ]), + ); } finally { await cleanupTasks .reverse() @@ -601,7 +593,7 @@ describe("messaging provider installed-runtime proofs", () => { }), ], ])( - "accepts the real %s fake API REST readiness reply", + "accepts the %s fake API REST traffic reply", async (provider, script, nodeArgs, env) => { const fixture = await startFakePortFixture({ label: `fake ${provider} port fixture`, From 141aaa913cecedc0f54a00f628454b3ed1519046 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 04:11:37 -0700 Subject: [PATCH 24/45] fix(e2e): bind proxy to OpenShell bridge Signed-off-by: Prekshi Vyas --- test/e2e/live/messaging-providers-helpers.ts | 71 +++++++++-- ...messaging-providers-runtime-proofs.test.ts | 114 ++++++++++++++++-- 2 files changed, 164 insertions(+), 21 deletions(-) diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index cea9f73bac1..6b57a392d65 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -3,6 +3,7 @@ import crypto from "node:crypto"; import fs from "node:fs"; +import { isIPv4 } from "node:net"; import os from "node:os"; import path from "node:path"; @@ -36,6 +37,7 @@ export const LIVE_TIMEOUT_MS = 90 * 60_000; export const OPENSHELL_EXEC_ARGUMENT_LIMIT_BYTES = 32_768; const FAKE_API_IMAGE = "node:22-trixie-slim@sha256:db8a96a63e5264607ada2d206758876ebbed6a12be2ada7517793cbfb0c2a29c"; +const DEFAULT_OPENSHELL_DOCKER_NETWORK = "openshell-docker"; const FAKE_API_PROXY_SOURCE = String.raw` const net = require("node:net"); const upstream = process.env.NEMOCLAW_FAKE_API_UPSTREAM; @@ -407,6 +409,48 @@ async function inspectDockerJson( } } +async function inspectOpenShellBridgeAddress( + host: HostCliClient, + kind: FakeDockerApiKind, + env: NodeJS.ProcessEnv, + redactionValues: string[], +): Promise { + const networkName = + env.OPENSHELL_DOCKER_NETWORK_NAME ?? + process.env.OPENSHELL_DOCKER_NETWORK_NAME ?? + DEFAULT_OPENSHELL_DOCKER_NETWORK; + const result = await runHost(host, "docker", ["network", "inspect", networkName], { + artifactName: `inspect-fake-${kind}-openshell-network`, + env, + redactionValues, + timeoutMs: 30_000, + }); + expectExitZero(result, "inspect OpenShell Docker network"); + + let records: unknown; + try { + records = JSON.parse(result.stdout); + } catch { + throw new Error("OpenShell Docker network inspection returned invalid JSON"); + } + const record = Array.isArray(records) && records.length === 1 ? records[0] : undefined; + const gateways = + record !== null && typeof record === "object" + ? ((record as { IPAM?: { Config?: Array<{ Gateway?: unknown }> } }).IPAM?.Config?.flatMap( + ({ Gateway }) => (typeof Gateway === "string" && isIPv4(Gateway) ? [Gateway] : []), + ) ?? []) + : []; + if ( + record === null || + typeof record !== "object" || + (record as { Driver?: unknown }).Driver !== "bridge" || + gateways.length !== 1 + ) { + throw new Error("OpenShell Docker network must expose exactly one IPv4 bridge gateway"); + } + return gateways[0]!; +} + export async function runSandboxShell( sandbox: SandboxClient, script: string, @@ -647,6 +691,12 @@ export async function startFakeDockerApi( env: NodeJS.ProcessEnv; }, ): Promise { + const openshellBridgeAddress = await inspectOpenShellBridgeAddress( + host, + options.kind, + options.env, + options.redactionValues, + ); fs.mkdirSync(path.join(REPO_ROOT, ".tmp"), { recursive: true }); const dir = fs.mkdtempSync(path.join(REPO_ROOT, ".tmp", `fake-${options.kind}.`)); const portFile = path.join(dir, "port"); @@ -771,10 +821,9 @@ export async function startFakeDockerApi( }); // The fake API receives raw expected credentials. Keep it unpublished and - // expose only this credential-free forwarding container on host loopback. - // Docker does not install published-port forwarding for a container whose - // only attachment is an internal network, so establish the loopback binding - // on the default bridge before adding the private API network. + // expose only this credential-free forwarding container on the OpenShell + // bridge gateway. That is the host address behind host.openshell.internal; + // loopback-only publication is unreachable from the policy gateway. const proxyStart = await runHost( host, "docker", @@ -786,7 +835,7 @@ export async function startFakeDockerApi( proxyContainer, "--network", "bridge", - ...containerPorts.flatMap((port) => ["-p", `127.0.0.1::${String(port)}`]), + ...containerPorts.flatMap((port) => ["-p", `${openshellBridgeAddress}::${String(port)}`]), "--read-only", "--cap-drop", "ALL", @@ -898,11 +947,13 @@ export async function startFakeDockerApi( }, ); expectExitZero(result, `read fake ${options.kind} API proxy port`); - const hostPort = result.stdout.trim().match(/^127\.0\.0\.1:(\d+)$/u)?.[1]; - if (!hostPort) { - throw new Error(`fake ${options.kind} API proxy port did not bind only to 127.0.0.1`); + const published = result.stdout.trim().match(/^(\d+\.\d+\.\d+\.\d+):(\d+)$/u); + if (published?.[1] !== openshellBridgeAddress || !published[2]) { + throw new Error( + `fake ${options.kind} API proxy port did not bind only to the OpenShell bridge`, + ); } - return hostPort; + return published[2]; }; const publishedRestPort = await publishedPort(8080, `port-fake-${options.kind}-api`); @@ -914,7 +965,7 @@ export async function startFakeDockerApi( [ "--experimental-strip-types", FAKE_API_PORT_TRAFFIC, - "127.0.0.1", + openshellBridgeAddress, publishedRestPort, ...(options.kind === "slack" ? [publishedWebsocketPort] : []), ], diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 5fb1075d933..46d2862c1c6 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -42,6 +42,13 @@ const FAKE_API_PORT_TRAFFIC = path.resolve( const FAKE_SLACK_APP_TOKEN = "xapp-fake-slack-port-test"; const FAKE_SLACK_BOT_TOKEN = "xoxb-fake-slack-port-test"; const STDERR_TAIL_LIMIT = 4_096; +const OPENSHELL_BRIDGE_ADDRESS = "172.18.0.1"; +const OPENSHELL_NETWORK_INSPECT = JSON.stringify([ + { + Driver: "bridge", + IPAM: { Config: [{ Subnet: "172.18.0.0/16", Gateway: OPENSHELL_BRIDGE_ADDRESS }] }, + }, +]); async function waitFor(predicate: () => boolean, message: string | (() => string)): Promise { await vi.waitFor( @@ -224,11 +231,16 @@ function successfulCommand(stdout = "") { }; } -function fakeDockerHost(publishedAddress = "127.0.0.1"): HostCliClient { +function fakeDockerHost( + publishedAddress = OPENSHELL_BRIDGE_ADDRESS, + calls: string[][] = [], + networkInspect = OPENSHELL_NETWORK_INSPECT, +): HostCliClient { let network = ""; let proxyContainer = ""; const host = { command: async (command: string, args: string[]) => { + calls.push([command, ...args]); switch (command) { case "node": return successfulCommand(); @@ -236,8 +248,15 @@ function fakeDockerHost(publishedAddress = "127.0.0.1"): HostCliClient { expect(command).toBe("docker"); switch (args[0]) { case "network": - network = args[1] === "create" ? (args.at(-1) ?? "") : network; - return successfulCommand(); + switch (args[1]) { + case "inspect": + return successfulCommand(networkInspect); + case "create": + network = args.at(-1) ?? ""; + return successfulCommand(); + default: + return successfulCommand(); + } case "run": proxyContainer = args.some((argument) => argument.startsWith("NEMOCLAW_FAKE_API_UPSTREAM="), @@ -300,6 +319,8 @@ describe("messaging provider installed-runtime proofs", () => { artifactNames.push(...(artifactName ? [artifactName] : [])); invocations.push({ artifactName, command: command.command, args: command.args }); switch (artifactName) { + case "inspect-fake-slack-openshell-network": + return successfulShellResult(invocation, OPENSHELL_NETWORK_INSPECT); case "start-fake-slack-api": container = invocation[invocation.indexOf("--name") + 1] ?? ""; break; @@ -378,6 +399,8 @@ describe("messaging provider installed-runtime proofs", () => { const artifactName = options?.artifactName ?? ""; invocations.push({ artifactName, command: command.command, args: command.args }); switch (artifactName) { + case "inspect-fake-slack-openshell-network": + return successfulShellResult(invocation, OPENSHELL_NETWORK_INSPECT); case "create-fake-slack-api-network": network = invocation.at(-1) ?? ""; return successfulShellResult(invocation); @@ -395,10 +418,7 @@ describe("messaging provider installed-runtime proofs", () => { case "prove-fake-slack-api-api-internal-network": return successfulShellResult(invocation, JSON.stringify({ [network]: {} })); case "prove-fake-slack-api-proxy-internal-network": - return successfulShellResult( - invocation, - JSON.stringify({ bridge: {}, [network]: {} }), - ); + return successfulShellResult(invocation, JSON.stringify({ bridge: {}, [network]: {} })); case "prove-fake-slack-api-internal-only": return successfulShellResult(invocation); case "prove-fake-slack-api-proxy-credential-free": @@ -407,9 +427,9 @@ describe("messaging provider installed-runtime proofs", () => { '["NEMOCLAW_FAKE_API_UPSTREAM=fake-api","NEMOCLAW_FAKE_API_PROXY_PORTS=8080,8081"]', ); case "port-fake-slack-api": - return successfulShellResult(invocation, "127.0.0.1:41080\n"); + return successfulShellResult(invocation, `${OPENSHELL_BRIDGE_ADDRESS}:41080\n`); case "port-fake-slack-websocket-api": - return successfulShellResult(invocation, "127.0.0.1:41081\n"); + return successfulShellResult(invocation, `${OPENSHELL_BRIDGE_ADDRESS}:41081\n`); case "prove-fake-slack-api-proxy-traffic": return { ...successfulShellResult(invocation), @@ -476,7 +496,49 @@ describe("messaging provider installed-runtime proofs", () => { } }); - it("rejects a fake API proxy port that Docker publishes beyond loopback", async () => { + it("binds and probes the credential-free proxy through the OpenShell bridge", async () => { + const calls: string[][] = []; + const host = fakeDockerHost(OPENSHELL_BRIDGE_ADDRESS, calls); + const cleanup: Array<() => Promise> = []; + + try { + await startFakeDockerApi(host, (_name, run) => cleanup.push(run), { + kind: "slack", + imageScript: "fake-slack-api.cjs", + containerPrefix: "fake-slack", + portEnv: "FAKE_SLACK_API_PORT", + portFileEnv: "FAKE_SLACK_API_PORT_FILE", + captureFileEnv: "FAKE_SLACK_API_CAPTURE_FILE", + expectedEnv: {}, + redactionValues: [], + env: {}, + }); + const proxyRun = calls.find( + (call) => + call[0] === "docker" && + call[1] === "run" && + call.some((argument) => argument.startsWith("NEMOCLAW_FAKE_API_UPSTREAM=")), + ); + expect(proxyRun).toBeDefined(); + expect( + proxyRun!.flatMap((argument, index) => (argument === "-p" ? [proxyRun![index + 1]] : [])), + ).toEqual([`${OPENSHELL_BRIDGE_ADDRESS}::8080`, `${OPENSHELL_BRIDGE_ADDRESS}::8081`]); + expect(calls).toContainEqual([ + "node", + "--experimental-strip-types", + FAKE_API_PORT_TRAFFIC, + OPENSHELL_BRIDGE_ADDRESS, + "32100", + "32101", + ]); + } finally { + await cleanup + .reverse() + .reduce((previous, action) => previous.then(action), Promise.resolve()); + } + }); + + it("rejects a fake API proxy port that Docker publishes beyond the OpenShell bridge", async () => { const host = fakeDockerHost("0.0.0.0"); const cleanup: Array<() => Promise> = []; @@ -493,7 +555,7 @@ describe("messaging provider installed-runtime proofs", () => { redactionValues: [], env: {}, }), - ).rejects.toThrow(/did not bind only to 127\.0\.0\.1/u); + ).rejects.toThrow(/did not bind only to the OpenShell bridge/u); } finally { await cleanup .reverse() @@ -501,6 +563,36 @@ describe("messaging provider installed-runtime proofs", () => { } }); + it("rejects an ambiguous configured OpenShell bridge before creating resources", async () => { + const calls: string[][] = []; + const networkInspect = JSON.stringify([ + { + Driver: "bridge", + IPAM: { + Config: [{ Gateway: OPENSHELL_BRIDGE_ADDRESS }, { Gateway: "172.19.0.1" }], + }, + }, + ]); + const host = fakeDockerHost(OPENSHELL_BRIDGE_ADDRESS, calls, networkInspect); + const cleanup: Array<() => Promise> = []; + + await expect( + startFakeDockerApi(host, (_name, run) => cleanup.push(run), { + kind: "discord-gateway", + imageScript: "fake-discord-gateway-api.cjs", + containerPrefix: "fake-discord-gateway", + portEnv: "FAKE_API_PORT", + portFileEnv: "FAKE_API_PORT_FILE", + captureFileEnv: "FAKE_API_CAPTURE_FILE", + expectedEnv: {}, + redactionValues: [], + env: { OPENSHELL_DOCKER_NETWORK_NAME: "configured-openshell-network" }, + }), + ).rejects.toThrow(/exactly one IPv4 bridge gateway/u); + expect(calls).toEqual([["docker", "network", "inspect", "configured-openshell-network"]]); + expect(cleanup).toHaveLength(0); + }); + it("uses a synthetic WeChat token even when the host exports one", () => { const previousToken = process.env.WECHAT_BOT_TOKEN; process.env.WECHAT_BOT_TOKEN = "host-wechat-token-must-not-reach-the-fake-api"; From d1fbe5b3f97f6aa3fc8b3d7b0b0710951acc43ca Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 04:36:04 -0700 Subject: [PATCH 25/45] test(e2e): own proxy traffic proof at runtime Signed-off-by: Prekshi Vyas --- test/e2e/live/messaging-providers.test.ts | 51 +++++++++++-------- ...messaging-providers-runtime-proofs.test.ts | 42 --------------- 2 files changed, 31 insertions(+), 62 deletions(-) diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index 879bb860ad8..5f1db01430c 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -58,12 +58,7 @@ import { runInstalledWechatRuntimeProof } from "./messaging-providers-wechat-run process.env.NEMOCLAW_CLI_BIN ??= CLI_ENTRYPOINT; -const SANDBOX_BACKUP_ROOT = path.join( - os.homedir(), - ".nemoclaw", - "rebuild-backups", - SANDBOX_NAME, -); +const SANDBOX_BACKUP_ROOT = path.join(os.homedir(), ".nemoclaw", "rebuild-backups", SANDBOX_NAME); test( "messaging providers preserve placeholder, policy, runtime, and send contracts", @@ -518,12 +513,14 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w ); const config = await readOpenClawConfig(sandbox, redactionValues); - ([ - ["M6a", "telegram", "telegram"], - ["M6b", "discord", "discord"], - ["M6c", "slack", "slack"], - ["M6d", "whatsapp", "whatsapp"], - ] as const).forEach(([assertionId, channel, plugin]) => { + ( + [ + ["M6a", "telegram", "telegram"], + ["M6b", "discord", "discord"], + ["M6c", "slack", "slack"], + ["M6d", "whatsapp", "whatsapp"], + ] as const + ).forEach(([assertionId, channel, plugin]) => { check(channelEnabled(config, channel), `${assertionId}: channels.${channel}.enabled is true`); check( pluginEnabled(config, plugin), @@ -590,8 +587,8 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w check( Boolean( whatsappHealth && - typeof whatsappHealth === "object" && - (whatsappHealth as Record).enabled === false, + typeof whatsappHealth === "object" && + (whatsappHealth as Record).enabled === false, ), "M-WA8a: WhatsApp health monitor is disabled for unpaired QR session", ); @@ -648,12 +645,14 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w const parsedRuntime = JSON.parse(runtimeChannels) as { chat?: Record; }; - ([ - ["M6e", "telegram", "default"], - ["M6f", "discord", "default"], - ["M6g", "slack", "default"], - ["M6i", "openclaw-weixin", state.wechatAccount], - ] as const).forEach(([assertionId, channel, accountId]) => { + ( + [ + ["M6e", "telegram", "default"], + ["M6f", "discord", "default"], + ["M6g", "slack", "default"], + ["M6i", "openclaw-weixin", state.wechatAccount], + ] as const + ).forEach(([assertionId, channel, accountId]) => { const entry = parsedRuntime.chat?.[channel]; check( entry?.installed === true && @@ -897,6 +896,18 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); env: state.env, redactionValues, }); + const slackRestTrafficProof = lastJsonLine( + fakeSlack.captureFile, + (row) => row.event === "request" && row.path === "/__nemoclaw_e2e_port_traffic", + ); + const slackWebsocketTrafficProof = lastJsonLine( + fakeSlack.captureFile, + (row) => row.event === "websocket-port-traffic-reply" && row.path === "/socket-mode", + ); + check( + slackRestTrafficProof !== undefined && slackWebsocketTrafficProof !== undefined, + "M-S14: bridge-published proxy carried real REST and WebSocket traffic to fake Slack", + ); await applyRestRewritePolicy(host, fakeSlack, state.env, redactionValues); expect( fakeSlack.alternatePort, diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 46d2862c1c6..fb0518940eb 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -496,48 +496,6 @@ describe("messaging provider installed-runtime proofs", () => { } }); - it("binds and probes the credential-free proxy through the OpenShell bridge", async () => { - const calls: string[][] = []; - const host = fakeDockerHost(OPENSHELL_BRIDGE_ADDRESS, calls); - const cleanup: Array<() => Promise> = []; - - try { - await startFakeDockerApi(host, (_name, run) => cleanup.push(run), { - kind: "slack", - imageScript: "fake-slack-api.cjs", - containerPrefix: "fake-slack", - portEnv: "FAKE_SLACK_API_PORT", - portFileEnv: "FAKE_SLACK_API_PORT_FILE", - captureFileEnv: "FAKE_SLACK_API_CAPTURE_FILE", - expectedEnv: {}, - redactionValues: [], - env: {}, - }); - const proxyRun = calls.find( - (call) => - call[0] === "docker" && - call[1] === "run" && - call.some((argument) => argument.startsWith("NEMOCLAW_FAKE_API_UPSTREAM=")), - ); - expect(proxyRun).toBeDefined(); - expect( - proxyRun!.flatMap((argument, index) => (argument === "-p" ? [proxyRun![index + 1]] : [])), - ).toEqual([`${OPENSHELL_BRIDGE_ADDRESS}::8080`, `${OPENSHELL_BRIDGE_ADDRESS}::8081`]); - expect(calls).toContainEqual([ - "node", - "--experimental-strip-types", - FAKE_API_PORT_TRAFFIC, - OPENSHELL_BRIDGE_ADDRESS, - "32100", - "32101", - ]); - } finally { - await cleanup - .reverse() - .reduce((previous, action) => previous.then(action), Promise.resolve()); - } - }); - it("rejects a fake API proxy port that Docker publishes beyond the OpenShell bridge", async () => { const host = fakeDockerHost("0.0.0.0"); const cleanup: Array<() => Promise> = []; From b4b19c0a205a593fbf4ed82e421a857549f94f04 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 05:06:14 -0700 Subject: [PATCH 26/45] test(e2e): resolve WeChat runtime inventory Signed-off-by: Prekshi Vyas --- ...essaging-providers-wechat-runtime-proof.ts | 44 +++++++++++++++++-- ...messaging-providers-runtime-proofs.test.ts | 39 +++++++++++++++- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/test/e2e/live/messaging-providers-wechat-runtime-proof.ts b/test/e2e/live/messaging-providers-wechat-runtime-proof.ts index 24932dbc210..19ad5f5d73c 100644 --- a/test/e2e/live/messaging-providers-wechat-runtime-proof.ts +++ b/test/e2e/live/messaging-providers-wechat-runtime-proof.ts @@ -17,12 +17,47 @@ export type InstalledWechatRuntimeProof = { pluginVersion: string; }; +export const WECHAT_INSTALLED_PLUGIN_DISCOVERY_SOURCE = String.raw` +function resolveInstalledWechatPluginRoot(inventoryText) { + let inventory; + try { + inventory = JSON.parse(inventoryText); + } catch { + throw new Error("OpenClaw plugin inventory is not valid JSON"); + } + const plugins = Array.isArray(inventory?.plugins) ? inventory.plugins : []; + const matches = plugins.filter( + (plugin) => + plugin && + plugin.id === "openclaw-weixin" && + plugin.enabled === true && + plugin.status === "loaded" && + typeof plugin.rootDir === "string" && + plugin.rootDir.length > 0, + ); + if (matches.length !== 1) { + throw new Error("OpenClaw plugin inventory did not identify one loaded openclaw-weixin root"); + } + const configuredRoot = matches[0].rootDir; + if (!path.isAbsolute(configuredRoot)) { + throw new Error("OpenClaw plugin inventory returned a relative openclaw-weixin root"); + } + try { + return fs.realpathSync(configuredRoot); + } catch { + throw new Error("OpenClaw plugin inventory returned a missing openclaw-weixin root"); + } +} +`; + export const WECHAT_INSTALLED_RUNTIME_PROOF_SOURCE = String.raw` import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +${WECHAT_INSTALLED_PLUGIN_DISCOVERY_SOURCE} + function invariant(condition, message) { if (!condition) throw new Error(message); } @@ -60,9 +95,12 @@ function resolveOpenClawRoot() { } const stateDir = process.env.OPENCLAW_STATE_DIR || "/sandbox/.openclaw"; -const extensionRoot = path.join(stateDir, "extensions", "openclaw-weixin"); -invariant(fs.existsSync(extensionRoot), "installed openclaw-weixin extension is missing"); -const pluginRoot = fs.realpathSync(extensionRoot); +const pluginInventory = execFileSync("openclaw", ["plugins", "list", "--json"], { + encoding: "utf8", + maxBuffer: 4 * 1024 * 1024, + timeout: 45_000, +}); +const pluginRoot = resolveInstalledWechatPluginRoot(pluginInventory); const pluginMetadata = JSON.parse(fs.readFileSync(path.join(pluginRoot, "package.json"), "utf8")); invariant( pluginMetadata.name === "@tencent-weixin/openclaw-weixin", diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index fb0518940eb..c075426d249 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -29,7 +29,10 @@ import { parseInstalledSlackProof, SLACK_MANAGED_NPM_PROJECT_DISCOVERY_SOURCE, } from "../live/messaging-providers-slack-runtime-proof.ts"; -import { parseInstalledWechatProof } from "../live/messaging-providers-wechat-runtime-proof.ts"; +import { + parseInstalledWechatProof, + WECHAT_INSTALLED_PLUGIN_DISCOVERY_SOURCE, +} from "../live/messaging-providers-wechat-runtime-proof.ts"; const FAKE_TELEGRAM_API = path.resolve(import.meta.dirname, "../lib/fake-telegram-api.cjs"); const FAKE_SLACK_API = path.resolve(import.meta.dirname, "../lib/fake-slack-api.cjs"); @@ -936,6 +939,40 @@ describe("messaging provider installed-runtime proofs", () => { ); }); + it("uses OpenClaw's loaded plugin inventory for the WeChat runtime root", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-wechat-plugin-root-")); + const packageRoot = path.join(dir, "packages", "openclaw-weixin"); + fs.mkdirSync(packageRoot, { recursive: true }); + try { + const inventory = JSON.stringify({ + plugins: [ + { + id: "openclaw-weixin", + enabled: true, + status: "loaded", + rootDir: packageRoot, + installPath: "/sandbox/.openclaw/extensions/openclaw-weixin", + }, + ], + }); + const source = [ + 'import fs from "node:fs";', + 'import path from "node:path";', + WECHAT_INSTALLED_PLUGIN_DISCOVERY_SOURCE, + `process.stdout.write(resolveInstalledWechatPluginRoot(${JSON.stringify(inventory)}));`, + ].join("\n"); + const result = spawnSync(process.execPath, ["--input-type=module", "-"], { + encoding: "utf8", + input: source, + }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe(fs.realpathSync(packageRoot)); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("redacts Telegram tokens from fake API captures", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fake-telegram-redaction-")); const portFile = path.join(dir, "port"); From db5a9c6793accb0bd9167e9421becdf03940ad39 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 05:20:19 -0700 Subject: [PATCH 27/45] refactor(e2e): neutralize policy binding owner Signed-off-by: Prekshi Vyas --- ...rd-policy-boundary-build.ts => policy-boundary-build.ts} | 2 +- ...scord-policy-binding.ts => policy-credential-binding.ts} | 2 +- test/e2e/live/hermes-discord.test.ts | 2 +- test/e2e/live/messaging-providers-helpers.ts | 2 +- test/e2e/live/openclaw-pairing-helpers.ts | 2 +- test/e2e/mock-parity.json | 2 +- ...cy-binding.test.ts => policy-credential-binding.test.ts} | 6 +++--- test/helpers/vitest-watch-triggers.ts | 2 +- test/repository/vitest-watch-triggers.test.ts | 2 +- 9 files changed, 11 insertions(+), 11 deletions(-) rename test/e2e/fixtures/{hermes-discord-policy-boundary-build.ts => policy-boundary-build.ts} (91%) rename test/e2e/fixtures/{hermes-discord-policy-binding.ts => policy-credential-binding.ts} (96%) rename test/e2e/support/{hermes-discord-policy-binding.test.ts => policy-credential-binding.test.ts} (97%) diff --git a/test/e2e/fixtures/hermes-discord-policy-boundary-build.ts b/test/e2e/fixtures/policy-boundary-build.ts similarity index 91% rename from test/e2e/fixtures/hermes-discord-policy-boundary-build.ts rename to test/e2e/fixtures/policy-boundary-build.ts index 086187dbd5a..e0e22f5adda 100644 --- a/test/e2e/fixtures/hermes-discord-policy-boundary-build.ts +++ b/test/e2e/fixtures/policy-boundary-build.ts @@ -19,7 +19,7 @@ export async function requireSuccessfulPolicyBoundaryBuild( ): Promise { if (result.status === 0) return; - const diagnosticPath = await createArtifactSink("hermes-discord-policy-binding").writeText( + const diagnosticPath = await createArtifactSink("policy-credential-binding").writeText( "policy-boundary-build.log", [result.stderr, result.stdout, result.error?.message].filter(Boolean).join("\n"), ); diff --git a/test/e2e/fixtures/hermes-discord-policy-binding.ts b/test/e2e/fixtures/policy-credential-binding.ts similarity index 96% rename from test/e2e/fixtures/hermes-discord-policy-binding.ts rename to test/e2e/fixtures/policy-credential-binding.ts index 9da0fe056f3..638c2963730 100644 --- a/test/e2e/fixtures/hermes-discord-policy-binding.ts +++ b/test/e2e/fixtures/policy-credential-binding.ts @@ -64,7 +64,7 @@ function main(): void { const [policyFile, ...rawBindings] = process.argv.slice(2); if (!policyFile || rawBindings.length === 0 || rawBindings.length % 4 !== 0) { throw new Error( - "usage: hermes-discord-policy-binding [ ]...", + "usage: policy-credential-binding [ ]...", ); } const bindings: PolicyEndpointBinding[] = []; diff --git a/test/e2e/live/hermes-discord.test.ts b/test/e2e/live/hermes-discord.test.ts index 209f73b53fa..45d5f95886c 100644 --- a/test/e2e/live/hermes-discord.test.ts +++ b/test/e2e/live/hermes-discord.test.ts @@ -189,7 +189,7 @@ node --import tsx "$6" "$policy_file" "$3" "$4" "$5" websocket `${options.sandboxName}-discord-bridge`, FAKE_DISCORD_HOST, String(options.api.port), - path.join(REPO_ROOT, "test/e2e/fixtures/hermes-discord-policy-binding.ts"), + path.join(REPO_ROOT, "test/e2e/fixtures/policy-credential-binding.ts"), ], { artifactName: "bind-hermes-fake-discord-gateway-credential", diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 6b57a392d65..faf92c78b0f 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -1072,7 +1072,7 @@ node --import tsx "$helper" "$policy_file" "$@" `bind-fake-${artifactKind}-rest-policy`, host.openshellCommandPath, SANDBOX_NAME, - path.join(REPO_ROOT, "test/e2e/fixtures/hermes-discord-policy-binding.ts"), + path.join(REPO_ROOT, "test/e2e/fixtures/policy-credential-binding.ts"), ...bindings.flatMap(({ providerName, port }) => [ providerName, "host.openshell.internal", diff --git a/test/e2e/live/openclaw-pairing-helpers.ts b/test/e2e/live/openclaw-pairing-helpers.ts index 6f722aee0a4..5020e8c6f0d 100644 --- a/test/e2e/live/openclaw-pairing-helpers.ts +++ b/test/e2e/live/openclaw-pairing-helpers.ts @@ -232,7 +232,7 @@ node --import tsx "$7" "$policy_file" "$3" "$4" "$5" "$6" policyHost, String(options.api.port), options.protocol, - path.join(REPO_ROOT, "test/e2e/fixtures/hermes-discord-policy-binding.ts"), + path.join(REPO_ROOT, "test/e2e/fixtures/policy-credential-binding.ts"), ], { artifactName: `${options.artifactName}-credential-binding`, diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 59040319b22..9e09cc5662b 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -473,7 +473,7 @@ "src/lib/messaging/channels/discord/credential-injection.test.ts", "src/lib/onboard/messaging-policy-presets.test.ts", "test/channels/channels-add-preset.test.ts", - "test/e2e/support/hermes-discord-policy-binding.test.ts", + "test/e2e/support/policy-credential-binding.test.ts", "test/onboarding/onboard-preset-diff.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts" diff --git a/test/e2e/support/hermes-discord-policy-binding.test.ts b/test/e2e/support/policy-credential-binding.test.ts similarity index 97% rename from test/e2e/support/hermes-discord-policy-binding.test.ts rename to test/e2e/support/policy-credential-binding.test.ts index e5cc7dd1042..b43663997b5 100644 --- a/test/e2e/support/hermes-discord-policy-binding.test.ts +++ b/test/e2e/support/policy-credential-binding.test.ts @@ -9,9 +9,9 @@ import path from "node:path"; import { afterEach, beforeAll, describe, expect, it } from "vitest"; import YAML from "yaml"; -import { requireSuccessfulPolicyBoundaryBuild } from "../fixtures/hermes-discord-policy-boundary-build.ts"; +import { requireSuccessfulPolicyBoundaryBuild } from "../fixtures/policy-boundary-build.ts"; -const HELPER = path.resolve(import.meta.dirname, "../fixtures/hermes-discord-policy-binding.ts"); +const HELPER = path.resolve(import.meta.dirname, "../fixtures/policy-credential-binding.ts"); const TYPESCRIPT = path.resolve("node_modules/typescript/bin/tsc"); const POLICY_BOUNDARY_CONFIG = path.resolve("nemoclaw/tsconfig.shared.json"); const tempDirs: string[] = []; @@ -34,7 +34,7 @@ function runBinding(policyFile: string, protocol = "websocket") { ); } -describe("Hermes Discord E2E policy binding", () => { +describe("E2E policy credential binding", () => { beforeAll(async () => { const result = spawnSync(process.execPath, [TYPESCRIPT, "-p", POLICY_BOUNDARY_CONFIG], { encoding: "utf8", diff --git a/test/helpers/vitest-watch-triggers.ts b/test/helpers/vitest-watch-triggers.ts index 6689594782a..4dbebb80bf2 100644 --- a/test/helpers/vitest-watch-triggers.ts +++ b/test/helpers/vitest-watch-triggers.ts @@ -189,7 +189,7 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [ { pattern: /(?:^|\/)nemoclaw\/(?:src\/shared\/openshell-policy-boundary\.cts|tsconfig\.shared\.json)$/, - testsToRun: runTests("test/e2e/support/hermes-discord-policy-binding.test.ts"), + testsToRun: runTests("test/e2e/support/policy-credential-binding.test.ts"), }, { pattern: /(?:^|\/)scripts\/setup-jetson\.sh$/, diff --git a/test/repository/vitest-watch-triggers.test.ts b/test/repository/vitest-watch-triggers.test.ts index 705872331e8..33f903dd4ac 100644 --- a/test/repository/vitest-watch-triggers.test.ts +++ b/test/repository/vitest-watch-triggers.test.ts @@ -176,7 +176,7 @@ describe("Vitest opaque-input watch triggers", () => { "maps each policy compiler input to its spawned fixture contract [%s] (#10016)", (inputPath) => { expect(triggeredBy(inputPath)).toEqual([ - "test/e2e/support/hermes-discord-policy-binding.test.ts", + "test/e2e/support/policy-credential-binding.test.ts", ]); }, ); From a486db6cd0783ed6797c4c40d2c04ceec5f8b70f Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 05:39:06 -0700 Subject: [PATCH 28/45] test(e2e): declare pairing helper owners Signed-off-by: Prekshi Vyas --- test/e2e/mock-parity.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 9e09cc5662b..08209070a55 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -615,10 +615,12 @@ }, { "live": "test/e2e/live/openclaw-discord-pairing.test.ts", + "liveSources": ["test/e2e/live/openclaw-pairing-helpers.ts"], "fast": [ "src/lib/messaging/channels/discord/credential-injection.test.ts", "src/lib/onboard/messaging-policy-presets.test.ts", "test/e2e/support/openclaw-discord-pairing-helpers.test.ts", + "test/e2e/support/policy-credential-binding.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts" ] @@ -653,10 +655,12 @@ }, { "live": "test/e2e/live/openclaw-slack-pairing.test.ts", + "liveSources": ["test/e2e/live/openclaw-pairing-helpers.ts"], "fast": [ "src/lib/onboard/sandbox-create-plan.test.ts", "test/e2e/support/openclaw-discord-pairing-helpers.test.ts", "test/e2e/support/messaging-providers-runtime-proofs.test.ts", + "test/e2e/support/policy-credential-binding.test.ts", "test/onboarding/onboard-messaging.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts" From ed4a691177015876f23117100b19f9bfbdcff4c6 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 06:03:58 -0700 Subject: [PATCH 29/45] fix(e2e): isolate credential forwarding proxies Signed-off-by: Prekshi Vyas --- test/e2e/live/hermes-discord.test.ts | 91 ++-------- test/e2e/live/messaging-providers-helpers.ts | 151 ++++++++-------- test/e2e/live/messaging-providers.test.ts | 68 ++++--- .../e2e/live/openclaw-discord-pairing.test.ts | 23 ++- test/e2e/live/openclaw-pairing-helpers.ts | 61 ------- test/e2e/live/openclaw-slack-pairing.test.ts | 37 ++-- ...messaging-providers-runtime-proofs.test.ts | 168 +++++++++++++++++- 7 files changed, 336 insertions(+), 263 deletions(-) diff --git a/test/e2e/live/hermes-discord.test.ts b/test/e2e/live/hermes-discord.test.ts index 45d5f95886c..53dcffe2424 100644 --- a/test/e2e/live/hermes-discord.test.ts +++ b/test/e2e/live/hermes-discord.test.ts @@ -16,6 +16,7 @@ import { buildProcessTokenProbe } from "../fixtures/process-token-probe.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { hermesDiscordHttpProxyWebSocketUrl } from "./hermes-discord-proxy.ts"; import { + applyFakeApiPolicy, assertDiscordGatewayCapture, type FakeDockerApi, startFakeDockerApi, @@ -133,75 +134,6 @@ async function startHermesFakeDiscordGateway( }); } -async function applyHermesFakeDiscordPolicy(options: { - host: HostCliClient; - sandboxName: string; - api: FakeDockerApi; - env: NodeJS.ProcessEnv; - redactions: string[]; -}): Promise { - const result = await options.host.command( - options.host.openshellCommandPath, - [ - "policy", - "update", - options.sandboxName, - "--add-endpoint", - `${FAKE_DISCORD_HOST}:${options.api.port}:read-write:websocket:enforce:websocket-credential-rewrite,allowed-ip=10.0.0.0/8,allowed-ip=172.16.0.0/12,allowed-ip=192.168.0.0/16`, - "--add-allow", - `${FAKE_DISCORD_HOST}:${options.api.port}:GET:/**`, - "--add-allow", - `${FAKE_DISCORD_HOST}:${options.api.port}:WEBSOCKET_TEXT:/**`, - "--binary", - "/usr/local/bin/node", - "--binary", - "/usr/bin/node", - "--binary", - "/usr/local/bin/python3", - "--binary", - "/usr/bin/python3", - "--binary", - "/opt/hermes/.venv/bin/python", - "--wait", - ], - { - artifactName: "apply-hermes-fake-discord-gateway-policy", - env: options.env, - redactionValues: options.redactions, - timeoutMs: 120_000, - }, - ); - expectExitZero(result, "apply Hermes fake Discord Gateway policy"); - - const binding = await options.host.command( - "bash", - [ - "-lc", - String.raw`set -eu -policy_file="$(mktemp)" -trap 'rm -f "$policy_file"' EXIT -"$1" policy get --base "$2" >"$policy_file" -node --import tsx "$6" "$policy_file" "$3" "$4" "$5" websocket -"$1" policy set --policy "$policy_file" --wait "$2"`, - "bind-hermes-fake-discord-policy", - options.host.openshellCommandPath, - options.sandboxName, - `${options.sandboxName}-discord-bridge`, - FAKE_DISCORD_HOST, - String(options.api.port), - path.join(REPO_ROOT, "test/e2e/fixtures/policy-credential-binding.ts"), - ], - { - artifactName: "bind-hermes-fake-discord-gateway-credential", - cwd: REPO_ROOT, - env: options.env, - redactionValues: options.redactions, - timeoutMs: 120_000, - }, - ); - expectExitZero(binding, "bind Hermes fake Discord Gateway credential"); -} - const HERMES_DISCORD_PYTHON_GATEWAY_PROOF = String.raw` import asyncio import inspect @@ -612,12 +544,27 @@ PY`, DISCORD_TOKEN, redactionValues, ); - await applyHermesFakeDiscordPolicy({ + await applyFakeApiPolicy({ host, sandboxName: SANDBOX_NAME, - api: fakeGateway, + policyHost: FAKE_DISCORD_HOST, + endpoints: [ + { + port: fakeGateway.port, + protocol: "websocket", + providerName: `${SANDBOX_NAME}-discord-bridge`, + }, + ], + binaries: [ + "/usr/local/bin/node", + "/usr/bin/node", + "/usr/local/bin/python3", + "/usr/bin/python3", + "/opt/hermes/.venv/bin/python", + ], + artifactName: "apply-hermes-fake-discord-gateway-policy", env, - redactions: redactionValues, + redactionValues, }); const nativeGateway = await runHermesPythonDiscordGatewayProof( diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index faf92c78b0f..4475eec8bb5 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -152,6 +152,23 @@ export type FakeDockerApi = { container: string; }; +export type FakeApiPolicyEndpoint = { + port: string; + protocol: "rest" | "websocket"; + providerName: string; +}; + +const FAKE_API_POLICY_PROTOCOL = { + rest: { + credentialRewrite: "request-body-credential-rewrite", + allowMethods: ["GET", "POST"], + }, + websocket: { + credentialRewrite: "websocket-credential-rewrite", + allowMethods: ["GET", "WEBSOCKET_TEXT"], + }, +} as const; + export function outputText(result: CommandOutput): string { return [result.stdout, result.stderr].filter(Boolean).join("\n"); } @@ -820,10 +837,10 @@ export async function startFakeDockerApi( } }); - // The fake API receives raw expected credentials. Keep it unpublished and - // expose only this credential-free forwarding container on the OpenShell - // bridge gateway. That is the host address behind host.openshell.internal; - // loopback-only publication is unreachable from the policy gateway. + // The fake API receives raw expected credentials. Keep both it and its TCP + // forwarder on the internal network, and publish only the forwarder on the + // OpenShell bridge gateway. The forwarder carries rewritten credential + // traffic, so it must not retain default-bridge egress. const proxyStart = await runHost( host, "docker", @@ -834,7 +851,7 @@ export async function startFakeDockerApi( "--name", proxyContainer, "--network", - "bridge", + network, ...containerPorts.flatMap((port) => ["-p", `${openshellBridgeAddress}::${String(port)}`]), "--read-only", "--cap-drop", @@ -861,19 +878,6 @@ export async function startFakeDockerApi( ); expectExitZero(proxyStart, `start fake ${options.kind} API proxy`); - const proxyConnect = await runHost( - host, - "docker", - ["network", "connect", network, proxyContainer], - { - artifactName: `connect-fake-${options.kind}-api-proxy-internal-network`, - env: options.env, - redactionValues: options.redactionValues, - timeoutMs: 30_000, - }, - ); - expectExitZero(proxyConnect, `connect fake ${options.kind} API proxy to private network`); - const networkInspections = await Promise.all( [ ["api", container], @@ -891,7 +895,7 @@ export async function startFakeDockerApi( })), ); for (const { role, networks } of networkInspections) { - const expectedNetworks = role === "api" ? [network] : ["bridge", network]; + const expectedNetworks = [network]; if ( typeof networks !== "object" || networks === null || @@ -918,7 +922,7 @@ export async function startFakeDockerApi( host, proxyContainer, "{{json .Config.Env}}", - `prove-fake-${options.kind}-api-proxy-credential-free`, + `prove-fake-${options.kind}-api-proxy-credential-environment-free`, options.env, options.redactionValues, ); @@ -1000,62 +1004,51 @@ export async function startFakeDockerApi( }; } -export async function applyRestRewritePolicy( - host: HostCliClient, - api: FakeDockerApi, - env: NodeJS.ProcessEnv, - redactionValues: string[], - providerName?: string, -): Promise { - const result = await runHost( - host, - "openshell", - [ - "policy", - "update", - SANDBOX_NAME, +export async function applyFakeApiPolicy(options: { + host: HostCliClient; + sandboxName: string; + policyHost: string; + endpoints: readonly FakeApiPolicyEndpoint[]; + binaries: readonly string[]; + artifactName: string; + env: NodeJS.ProcessEnv; + redactionValues: string[]; +}): Promise { + if (options.endpoints.length === 0) throw new Error("at least one fake API endpoint is required"); + if (options.binaries.length === 0) throw new Error("at least one fake API binary is required"); + + const args = ["policy", "update", options.sandboxName]; + for (const endpoint of options.endpoints) { + if (!/^[1-9][0-9]*$/u.test(endpoint.port)) { + throw new Error(`invalid fake API policy port ${JSON.stringify(endpoint.port)}`); + } + const policyProtocol = FAKE_API_POLICY_PROTOCOL[endpoint.protocol]; + args.push( "--add-endpoint", - `host.openshell.internal:${api.port}:read-write:rest:enforce:request-body-credential-rewrite,allowed-ip=10.0.0.0/8,allowed-ip=172.16.0.0/12,allowed-ip=192.168.0.0/16`, - "--add-allow", - `host.openshell.internal:${api.port}:GET:/**`, - "--add-allow", - `host.openshell.internal:${api.port}:POST:/**`, - "--binary", - "/usr/local/bin/node", - "--binary", - "/usr/bin/node", - "--wait", - ], + `${options.policyHost}:${endpoint.port}:read-write:${endpoint.protocol}:enforce:${policyProtocol.credentialRewrite},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 policyProtocol.allowMethods) { + args.push("--add-allow", `${options.policyHost}:${endpoint.port}:${method}:/**`); + } + } + for (const binary of options.binaries) args.push("--binary", binary); + args.push("--wait"); + + const result = await runHost( + options.host, + options.host.openshellCommandPath, + args, { - artifactName: `apply-${api.kind}-rest-policy`, - env, - redactionValues, + artifactName: options.artifactName, + env: options.env, + redactionValues: options.redactionValues, timeoutMs: 120_000, }, ); - expectExitZero(result, `apply ${api.kind} fake REST policy`); - if (!providerName) return; - - await bindRestRewritePolicyCredentials( - host, - [{ providerName, port: api.port }], - api.kind, - env, - redactionValues, - ); -} - -export async function bindRestRewritePolicyCredentials( - host: HostCliClient, - bindings: readonly { providerName: string; port: string }[], - artifactKind: string, - env: NodeJS.ProcessEnv, - redactionValues: string[], -): Promise { - if (bindings.length === 0) throw new Error("at least one REST credential binding is required"); + expectExitZero(result, options.artifactName); const binding = await runHost( - host, + options.host, "bash", [ "-lc", @@ -1069,26 +1062,26 @@ trap 'rm -f "$policy_file"' EXIT "$openshell" policy get --base "$sandbox" >"$policy_file" node --import tsx "$helper" "$policy_file" "$@" "$openshell" policy set --policy "$policy_file" --wait "$sandbox"`, - `bind-fake-${artifactKind}-rest-policy`, - host.openshellCommandPath, - SANDBOX_NAME, + `bind-${options.artifactName}`, + options.host.openshellCommandPath, + options.sandboxName, path.join(REPO_ROOT, "test/e2e/fixtures/policy-credential-binding.ts"), - ...bindings.flatMap(({ providerName, port }) => [ + ...options.endpoints.flatMap(({ providerName, port, protocol }) => [ providerName, - "host.openshell.internal", + options.policyHost, port, - "rest", + protocol, ]), ], { - artifactName: `apply-${artifactKind}-rest-policy-credential-binding`, + artifactName: `${options.artifactName}-credential-binding`, cwd: REPO_ROOT, - env, - redactionValues, + env: options.env, + redactionValues: options.redactionValues, timeoutMs: 120_000, }, ); - expectExitZero(binding, `bind ${artifactKind} fake REST policy credentials`); + expectExitZero(binding, `${options.artifactName} credential binding`); } export function lastJsonLine( diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index 5f1db01430c..83442cd1cb1 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -19,8 +19,7 @@ import { assertStockManagedImageReceipt } from "../fixtures/managed-image-receip import { accountBool, accountString, - applyRestRewritePolicy, - bindRestRewritePolicyCredentials, + applyFakeApiPolicy, CLI_ENTRYPOINT, channelAccount, channelEnabled, @@ -908,7 +907,6 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); slackRestTrafficProof !== undefined && slackWebsocketTrafficProof !== undefined, "M-S14: bridge-published proxy carried real REST and WebSocket traffic to fake Slack", ); - await applyRestRewritePolicy(host, fakeSlack, state.env, redactionValues); expect( fakeSlack.alternatePort, "fake Slack API must publish an independent app-token port", @@ -917,17 +915,27 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); ...fakeSlack, port: fakeSlack.alternatePort!, }; - await applyRestRewritePolicy(host, fakeSlackApp, state.env, redactionValues); - await bindRestRewritePolicyCredentials( + await applyFakeApiPolicy({ host, - [ - { providerName: `${SANDBOX_NAME}-slack-bridge`, port: fakeSlack.port }, - { providerName: `${SANDBOX_NAME}-slack-app`, port: fakeSlackApp.port }, + sandboxName: SANDBOX_NAME, + policyHost: "host.openshell.internal", + endpoints: [ + { + port: fakeSlack.port, + protocol: "rest", + providerName: `${SANDBOX_NAME}-slack-bridge`, + }, + { + port: fakeSlackApp.port, + protocol: "rest", + providerName: `${SANDBOX_NAME}-slack-app`, + }, ], - "slack", - state.env, + binaries: ["/usr/local/bin/node", "/usr/bin/node"], + artifactName: "apply-slack-fake-api-policy", + env: state.env, redactionValues, - ); + }); const slackBotPlaceholder = await sandboxOutput( sandbox, @@ -1060,13 +1068,22 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); env: state.env, redactionValues, }); - await applyRestRewritePolicy( + await applyFakeApiPolicy({ host, - fakeTelegram, - state.env, + sandboxName: SANDBOX_NAME, + policyHost: "host.openshell.internal", + endpoints: [ + { + port: fakeTelegram.port, + protocol: "rest", + providerName: `${SANDBOX_NAME}-telegram-bridge`, + }, + ], + binaries: ["/usr/local/bin/node", "/usr/bin/node"], + artifactName: "apply-telegram-fake-api-policy", + env: state.env, redactionValues, - `${SANDBOX_NAME}-telegram-bridge`, - ); + }); const telegramMockTarget = "42424242"; const telegramMockText = "NemoClaw OpenClaw Telegram plugin mock E2E"; const installedTelegramProof = await runInstalledTelegramRuntimeProof( @@ -1117,13 +1134,22 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); env: state.env, redactionValues, }); - await applyRestRewritePolicy( + await applyFakeApiPolicy({ host, - fakeWechat, - state.env, + sandboxName: SANDBOX_NAME, + policyHost: "host.openshell.internal", + endpoints: [ + { + port: fakeWechat.port, + protocol: "rest", + providerName: `${SANDBOX_NAME}-wechat-bridge`, + }, + ], + binaries: ["/usr/local/bin/node", "/usr/bin/node"], + artifactName: "apply-wechat-fake-api-policy", + env: state.env, redactionValues, - `${SANDBOX_NAME}-wechat-bridge`, - ); + }); const installedWechatProof = await runInstalledWechatRuntimeProof( sandbox, fakeWechat, diff --git a/test/e2e/live/openclaw-discord-pairing.test.ts b/test/e2e/live/openclaw-discord-pairing.test.ts index f2b27ee4179..70db5952d28 100644 --- a/test/e2e/live/openclaw-discord-pairing.test.ts +++ b/test/e2e/live/openclaw-discord-pairing.test.ts @@ -2,9 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { expect, test } from "../fixtures/e2e-test.ts"; -import { assertDiscordGatewayCapture } from "./messaging-providers-helpers.ts"; import { - applyFakePolicy, + applyFakeApiPolicy, + assertDiscordGatewayCapture, +} from "./messaging-providers-helpers.ts"; +import { approveAndAssertPairing, assertOpenClawStateRoot, cleanupPairingSandbox, @@ -132,15 +134,20 @@ test("OpenClaw Discord pairing request is shared with connect-shell approval", { progress.phase("route Discord Gateway traffic through the managed policy"); const fakeGateway = await startFakeDiscordGateway(host, cleanup, env, DISCORD_TOKEN, redactions); - await applyFakePolicy({ + await applyFakeApiPolicy({ host, sandboxName: SANDBOX_NAME, - api: fakeGateway, - protocol: "websocket", - rewrite: "websocket-credential-rewrite", - providerName: `${SANDBOX_NAME}-discord-bridge`, + policyHost: "host.openshell.internal", + endpoints: [ + { + port: fakeGateway.port, + protocol: "websocket", + providerName: `${SANDBOX_NAME}-discord-bridge`, + }, + ], + binaries: ["/usr/local/bin/node", "/usr/bin/node"], env, - redactions, + redactionValues: redactions, artifactName: "apply-discord-gateway-policy", }); const gatewayProof = await runDiscordGatewayProof({ diff --git a/test/e2e/live/openclaw-pairing-helpers.ts b/test/e2e/live/openclaw-pairing-helpers.ts index 5020e8c6f0d..934eb286862 100644 --- a/test/e2e/live/openclaw-pairing-helpers.ts +++ b/test/e2e/live/openclaw-pairing-helpers.ts @@ -184,67 +184,6 @@ export async function startFakeSlackApi( }); } -export async function applyFakePolicy(options: { - host: HostCliClient; - sandboxName: string; - api: FakeDockerApi; - protocol: "rest" | "websocket"; - rewrite: "request-body-credential-rewrite" | "websocket-credential-rewrite"; - providerName: string; - env: NodeJS.ProcessEnv; - redactions: string[]; - artifactName: string; -}): Promise { - const policyHost = "host.openshell.internal"; - const methods = options.protocol === "rest" ? ["GET", "POST"] : ["GET", "WEBSOCKET_TEXT"]; - const args = [ - "policy", - "update", - options.sandboxName, - "--add-endpoint", - `${policyHost}:${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", `${policyHost}:${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); - - const binding = await options.host.command( - "bash", - [ - "-lc", - String.raw`set -eu -policy_file="$(mktemp)" -trap 'rm -f "$policy_file"' EXIT -"$1" policy get --base "$2" >"$policy_file" -node --import tsx "$7" "$policy_file" "$3" "$4" "$5" "$6" -"$1" policy set --policy "$policy_file" --wait "$2"`, - `bind-fake-${options.protocol}-policy`, - options.host.openshellCommandPath, - options.sandboxName, - options.providerName, - policyHost, - String(options.api.port), - options.protocol, - path.join(REPO_ROOT, "test/e2e/fixtures/policy-credential-binding.ts"), - ], - { - artifactName: `${options.artifactName}-credential-binding`, - cwd: REPO_ROOT, - env: options.env, - redactionValues: options.redactions, - timeoutMs: 120_000, - }, - ); - expectExitZero(binding, `${options.artifactName} credential binding`); -} - export async function assertOpenClawStateRoot( sandbox: SandboxClient, sandboxName: string, diff --git a/test/e2e/live/openclaw-slack-pairing.test.ts b/test/e2e/live/openclaw-slack-pairing.test.ts index 42a0ba7d00d..fe30f6ba831 100644 --- a/test/e2e/live/openclaw-slack-pairing.test.ts +++ b/test/e2e/live/openclaw-slack-pairing.test.ts @@ -5,7 +5,6 @@ import fs from "node:fs"; import { expect, test } from "../fixtures/e2e-test.ts"; import { - applyFakePolicy, approveAndAssertPairing, assertOpenClawStateRoot, assertSlackPresetPolicySemantics, @@ -18,6 +17,7 @@ import { startFakeSlackApi, writePairingArtifacts, } from "./openclaw-pairing-helpers.ts"; +import { applyFakeApiPolicy } from "./messaging-providers-helpers.ts"; import { dockerInfo, expectExitZero, @@ -177,27 +177,26 @@ test("OpenClaw Slack Socket Mode pairing request is shared with connect-shell ap ...fakeSlack, port: fakeSlack.alternatePort!, }; - await applyFakePolicy({ + await applyFakeApiPolicy({ host, sandboxName: SANDBOX_NAME, - api: fakeSlack, - protocol: "rest", - rewrite: "request-body-credential-rewrite", - providerName: `${SANDBOX_NAME}-slack-bridge`, - env, - redactions, - artifactName: "apply-slack-rest-policy", - }); - await applyFakePolicy({ - host, - sandboxName: SANDBOX_NAME, - api: fakeSlackWebSocket, - protocol: "websocket", - rewrite: "websocket-credential-rewrite", - providerName: `${SANDBOX_NAME}-slack-app`, + policyHost: "host.openshell.internal", + endpoints: [ + { + port: fakeSlack.port, + protocol: "rest", + providerName: `${SANDBOX_NAME}-slack-bridge`, + }, + { + port: fakeSlackWebSocket.port, + protocol: "websocket", + providerName: `${SANDBOX_NAME}-slack-app`, + }, + ], + binaries: ["/usr/local/bin/node", "/usr/bin/node"], env, - redactions, - artifactName: "apply-slack-websocket-policy", + redactionValues: redactions, + artifactName: "apply-slack-fake-api-policy", }); progress.phase("issue a Slack pairing request"); diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index c075426d249..41a0fe081c3 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -17,6 +17,7 @@ import { redactString } from "../fixtures/redaction.ts"; import { superviseChild } from "../fixtures/shell/supervisor.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { + applyFakeApiPolicy, buildSandboxNodeInvocation, buildSandboxShellInvocation, isNvidiaEndpointRateLimitFailure, @@ -238,6 +239,7 @@ function fakeDockerHost( publishedAddress = OPENSHELL_BRIDGE_ADDRESS, calls: string[][] = [], networkInspect = OPENSHELL_NETWORK_INSPECT, + proxyOnDefaultBridge = false, ): HostCliClient { let network = ""; let proxyContainer = ""; @@ -278,7 +280,10 @@ function fakeDockerHost( ? successfulCommand( JSON.stringify( args.at(-1) === proxyContainer - ? { bridge: {}, [network]: {} } + ? { + ...(proxyOnDefaultBridge ? { bridge: {} } : {}), + [network]: {}, + } : { [network]: {} }, ), ) @@ -305,6 +310,93 @@ function fakeDockerHost( } describe("messaging provider installed-runtime proofs", () => { + it("applies mixed fake API endpoints and bindings through one policy transition", async () => { + const calls: Array<{ + command: string; + args: string[]; + artifactName: string; + cwd?: string; + }> = []; + const runner: CommandRunner = { + async run(command, options) { + calls.push({ + command: command.command, + args: [...command.args], + artifactName: options?.artifactName ?? "", + ...(options?.cwd ? { cwd: options.cwd } : {}), + }); + return successfulShellResult([command.command, ...command.args]); + }, + }; + const host = new HostCliClient(runner, { openshellPath: "/opt/openshell" }); + + await applyFakeApiPolicy({ + host, + sandboxName: "e2e-slack-policy-owner", + policyHost: "host.openshell.internal", + endpoints: [ + { + port: "43117", + protocol: "rest", + providerName: "e2e-slack-policy-owner-slack-bridge", + }, + { + port: "43118", + protocol: "websocket", + providerName: "e2e-slack-policy-owner-slack-app", + }, + ], + binaries: ["/usr/local/bin/node", "/usr/bin/node"], + artifactName: "apply-slack-owner-policy", + env: {}, + redactionValues: [], + }); + + expect(calls).toHaveLength(2); + expect(calls[0]).toEqual({ + command: "/opt/openshell", + args: [ + "policy", + "update", + "e2e-slack-policy-owner", + "--add-endpoint", + "host.openshell.internal:43117:read-write:rest:enforce:request-body-credential-rewrite,allowed-ip=10.0.0.0/8,allowed-ip=172.16.0.0/12,allowed-ip=192.168.0.0/16", + "--add-allow", + "host.openshell.internal:43117:GET:/**", + "--add-allow", + "host.openshell.internal:43117:POST:/**", + "--add-endpoint", + "host.openshell.internal:43118:read-write:websocket:enforce:websocket-credential-rewrite,allowed-ip=10.0.0.0/8,allowed-ip=172.16.0.0/12,allowed-ip=192.168.0.0/16", + "--add-allow", + "host.openshell.internal:43118:GET:/**", + "--add-allow", + "host.openshell.internal:43118:WEBSOCKET_TEXT:/**", + "--binary", + "/usr/local/bin/node", + "--binary", + "/usr/bin/node", + "--wait", + ], + artifactName: "apply-slack-owner-policy", + }); + expect(calls[1]?.command).toBe("bash"); + expect(calls[1]?.artifactName).toBe("apply-slack-owner-policy-credential-binding"); + expect(calls[1]?.cwd).toBe(path.resolve(".")); + expect(calls[1]?.args.slice(3)).toEqual([ + "/opt/openshell", + "e2e-slack-policy-owner", + path.resolve("test/e2e/fixtures/policy-credential-binding.ts"), + "e2e-slack-policy-owner-slack-bridge", + "host.openshell.internal", + "43117", + "rest", + "e2e-slack-policy-owner-slack-app", + "host.openshell.internal", + "43118", + "websocket", + ]); + }); + it("collects API diagnostics when the fake container does not become ready", async () => { vi.useFakeTimers(); const artifactNames: string[] = []; @@ -421,10 +513,10 @@ describe("messaging provider installed-runtime proofs", () => { case "prove-fake-slack-api-api-internal-network": return successfulShellResult(invocation, JSON.stringify({ [network]: {} })); case "prove-fake-slack-api-proxy-internal-network": - return successfulShellResult(invocation, JSON.stringify({ bridge: {}, [network]: {} })); + return successfulShellResult(invocation, JSON.stringify({ [network]: {} })); case "prove-fake-slack-api-internal-only": return successfulShellResult(invocation); - case "prove-fake-slack-api-proxy-credential-free": + case "prove-fake-slack-api-proxy-credential-environment-free": return successfulShellResult( invocation, '["NEMOCLAW_FAKE_API_UPSTREAM=fake-api","NEMOCLAW_FAKE_API_PROXY_PORTS=8080,8081"]', @@ -524,6 +616,76 @@ describe("messaging provider installed-runtime proofs", () => { } }); + it("publishes an internal-only proxy without default-bridge egress", async () => { + const calls: string[][] = []; + const host = fakeDockerHost(OPENSHELL_BRIDGE_ADDRESS, calls); + const cleanup: Array<() => Promise> = []; + + try { + await startFakeDockerApi(host, (_name, run) => cleanup.push(run), { + kind: "discord-gateway", + imageScript: "fake-discord-gateway-api.cjs", + containerPrefix: "fake-discord-gateway", + portEnv: "FAKE_API_PORT", + portFileEnv: "FAKE_API_PORT_FILE", + captureFileEnv: "FAKE_API_CAPTURE_FILE", + expectedEnv: {}, + redactionValues: [], + env: {}, + }); + const networkName = calls.find( + ([command, action, operation]) => + command === "docker" && action === "network" && operation === "create", + )?.at(-1); + const proxyRun = calls.find( + ([command, action, ...args]) => + command === "docker" && + action === "run" && + args.some((argument) => argument.startsWith("NEMOCLAW_FAKE_API_UPSTREAM=")), + ); + expect(networkName).toMatch(/^nemoclaw-fake-api-network-/u); + expect(proxyRun).toBeDefined(); + const proxyArgs = proxyRun!; + expect(proxyArgs[proxyArgs.indexOf("--network") + 1]).toBe(networkName); + expect(proxyArgs).not.toContain("bridge"); + expect(proxyArgs).toContain(`${OPENSHELL_BRIDGE_ADDRESS}::8080`); + } finally { + await cleanup + .reverse() + .reduce((previous, action) => previous.then(action), Promise.resolve()); + } + }); + + it("rejects a proxy that retains default-bridge egress", async () => { + const host = fakeDockerHost( + OPENSHELL_BRIDGE_ADDRESS, + [], + OPENSHELL_NETWORK_INSPECT, + true, + ); + const cleanup: Array<() => Promise> = []; + + try { + await expect( + startFakeDockerApi(host, (_name, run) => cleanup.push(run), { + kind: "discord-gateway", + imageScript: "fake-discord-gateway-api.cjs", + containerPrefix: "fake-discord-gateway", + portEnv: "FAKE_API_PORT", + portFileEnv: "FAKE_API_PORT_FILE", + captureFileEnv: "FAKE_API_CAPTURE_FILE", + expectedEnv: {}, + redactionValues: [], + env: {}, + }), + ).rejects.toThrow(/proxy has unexpected network attachments/u); + } finally { + await cleanup + .reverse() + .reduce((previous, action) => previous.then(action), Promise.resolve()); + } + }); + it("rejects an ambiguous configured OpenShell bridge before creating resources", async () => { const calls: string[][] = []; const networkInspect = JSON.stringify([ From 5b30a53a0a7f8d28fa60046d2036b7648d4f4614 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 06:15:39 -0700 Subject: [PATCH 30/45] test(e2e): clarify proxy publication contract Signed-off-by: Prekshi Vyas --- test/e2e/support/messaging-providers-runtime-proofs.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 41a0fe081c3..d8431e93f1e 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -616,7 +616,7 @@ describe("messaging provider installed-runtime proofs", () => { } }); - it("publishes an internal-only proxy without default-bridge egress", async () => { + it("publishes a proxy only on the OpenShell bridge without default-bridge egress", async () => { const calls: string[][] = []; const host = fakeDockerHost(OPENSHELL_BRIDGE_ADDRESS, calls); const cleanup: Array<() => Promise> = []; From 6cbbd33792c6967d71c9159f00d28e3d8d2ea418 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 06:26:43 -0700 Subject: [PATCH 31/45] refactor(e2e): share raw token surface probes Signed-off-by: Prekshi Vyas --- test/e2e/live/hermes-discord.test.ts | 35 ++++--------------- test/e2e/live/messaging-providers-helpers.ts | 12 +++++-- test/e2e/live/messaging-providers.test.ts | 1 + ...messaging-providers-runtime-proofs.test.ts | 35 +++++++++++++++++++ 4 files changed, 53 insertions(+), 30 deletions(-) diff --git a/test/e2e/live/hermes-discord.test.ts b/test/e2e/live/hermes-discord.test.ts index 53dcffe2424..ef47e398657 100644 --- a/test/e2e/live/hermes-discord.test.ts +++ b/test/e2e/live/hermes-discord.test.ts @@ -12,13 +12,13 @@ import type { HostCliClient, SandboxClient } from "../fixtures/clients/index.ts" import { sandboxAccessEnv, validateSandboxName } from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; import { REPO_ROOT } from "../fixtures/paths.ts"; -import { buildProcessTokenProbe } from "../fixtures/process-token-probe.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { hermesDiscordHttpProxyWebSocketUrl } from "./hermes-discord-proxy.ts"; import { applyFakeApiPolicy, assertDiscordGatewayCapture, type FakeDockerApi, + rawTokenSurfaceProbe, startFakeDockerApi, } from "./messaging-providers-helpers.ts"; import { @@ -299,27 +299,6 @@ async function assertRawTokenAbsentFromFiles( expect(probe.stdout.trim()).toBe("OK"); } -async function rawTokenSurfaceProbe( - sandbox: SandboxClient, - token: string, - surface: "env" | "process" | "filesystem", - artifactName: string, - redactionValues: string[], -): Promise { - const tokenB64 = Buffer.from(token, "utf8").toString("base64"); - const script = - surface === "env" - ? `token="$(printf %s ${shellQuote(tokenB64)} | base64 -d)"\nif env 2>/dev/null | grep -Fq "$token"; then echo FOUND_TOKEN; elif env 2>/dev/null | grep -q '^DISCORD_PROXY='; then echo FOUND_DISCORD_PROXY; else echo ABSENT; fi` - : surface === "process" - ? buildProcessTokenProbe(token) - : `token="$(printf %s ${shellQuote(tokenB64)} | base64 -d)"\nhit="$(grep -rFlm1 -F "$token" /sandbox /home /etc /tmp /var 2>/dev/null | head -1 || true)"\nif [ -n "$hit" ]; then printf 'FOUND_TOKEN %s\\n' "$hit"; else echo ABSENT; fi`; - return sandboxShWithArgs(sandbox, SANDBOX_NAME, script, [], { - artifactName, - redactionValues, - timeoutMs: surface === "filesystem" ? 120_000 : 60_000, - }); -} - test("hermes-discord: Hermes Discord schema, credential isolation, and native gateway rewrite", { timeout: HERMES_DISCORD_TEST_TIMEOUT_MS, meta: { @@ -602,33 +581,33 @@ PY`, const envSurface = await rawTokenSurfaceProbe( sandbox, + SANDBOX_NAME, DISCORD_TOKEN, "env", "phase-5-raw-token-env-probe", redactionValues, ); - expectExitZero(envSurface, "sandbox environment token isolation"); - expect(envSurface.stdout.trim()).toBe("ABSENT"); + expect(envSurface).toBe("ABSENT"); const processSurface = await rawTokenSurfaceProbe( sandbox, + SANDBOX_NAME, DISCORD_TOKEN, "process", "phase-5-raw-token-process-probe", redactionValues, ); - expectExitZero(processSurface, "sandbox process token isolation"); - expect(processSurface.stdout.trim()).toBe("ABSENT"); + expect(processSurface).toBe("ABSENT"); const filesystemSurface = await rawTokenSurfaceProbe( sandbox, + SANDBOX_NAME, DISCORD_TOKEN, "filesystem", "phase-5-raw-token-filesystem-probe", redactionValues, ); - expectExitZero(filesystemSurface, "sandbox filesystem token isolation"); - expect(filesystemSurface.stdout.trim()).toBe("ABSENT"); + expect(filesystemSurface).toBe("ABSENT"); const discordApi = await sandboxNode( sandbox, diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 4475eec8bb5..bee5138ef19 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -474,10 +474,11 @@ export async function runSandboxShell( options: { artifactName: string; redactionValues: string[]; + sandboxName?: string; timeoutMs?: number; }, ): Promise { - return sandbox.exec(SANDBOX_NAME, buildSandboxShellInvocation(script), { + return sandbox.exec(options.sandboxName ?? SANDBOX_NAME, buildSandboxShellInvocation(script), { artifactName: options.artifactName, env: sandboxAccessEnv(), redactionValues: options.redactionValues, @@ -634,6 +635,7 @@ export async function sandboxOutput( export async function rawTokenSurfaceProbe( sandbox: SandboxClient, + sandboxName: string, token: string, surface: "env" | "process" | "filesystem", artifactName: string, @@ -649,7 +651,13 @@ if env 2>/dev/null | grep -Fq "$token"; then echo FOUND; else echo ABSENT; fi` : `token="$(printf '%s' ${shellQuote(tokenB64)} | base64 -d)" match="$(grep -rIlm1 -F "$token" /sandbox /home /etc /tmp /var 2>/dev/null | head -1 || true)" if [ -n "$match" ]; then printf '%s\n' "$match"; else echo ABSENT; fi`; - return sandboxOutput(sandbox, probe, artifactName, redactionValues); + const result = await runSandboxShell(sandbox, probe, { + artifactName, + redactionValues, + sandboxName, + }); + expectExitZero(result, artifactName); + return result.stdout.trim(); } async function collectFakeApiStartupDiagnostics( diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index 83442cd1cb1..b2d2dfa2d0e 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -464,6 +464,7 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w for (const surface of ["env", "process", "filesystem"] as const) { const probe = await rawTokenSurfaceProbe( sandbox, + SANDBOX_NAME, token, surface, `${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${surface}-leak-probe`, diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index d8431e93f1e..a3b19523272 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -11,6 +11,7 @@ import { describe, expect, it, vi } from "vitest"; import { buildProcessTokenProbe } from "../fixtures/process-token-probe.ts"; import type { CommandRunner } from "../fixtures/clients/command.ts"; import { HostCliClient } from "../fixtures/clients/host.ts"; +import type { SandboxClient } from "../fixtures/clients/sandbox.ts"; import { spawnObservedChild } from "../fixtures/observed-child-process.ts"; import { startTestProgress } from "../fixtures/progress.ts"; import { redactString } from "../fixtures/redaction.ts"; @@ -24,6 +25,7 @@ import { messagingEnv, OPENSHELL_EXEC_ARGUMENT_LIMIT_BYTES, parseRuntimeProofPort, + rawTokenSurfaceProbe, startFakeDockerApi, } from "../live/messaging-providers-helpers.ts"; import { @@ -310,6 +312,39 @@ function fakeDockerHost( } describe("messaging provider installed-runtime proofs", () => { + it("runs the shared raw-token probe against the caller's sandbox", async () => { + const token = "raw-token-probe-must-not-enter-argv"; + let observedSandbox = ""; + let observedInvocation: string[] = []; + let observedArtifact = ""; + const sandbox = { + async exec( + sandboxName: string, + invocation: string[], + options: { artifactName?: string }, + ) { + observedSandbox = sandboxName; + observedInvocation = invocation; + observedArtifact = options.artifactName ?? ""; + return successfulShellResult(["openshell", "sandbox", "exec"], "ABSENT\n"); + }, + } as unknown as SandboxClient; + + const output = await rawTokenSurfaceProbe( + sandbox, + "e2e-hermes-discord", + token, + "env", + "hermes-raw-token-env", + [token], + ); + + expect(output).toBe("ABSENT"); + expect(observedSandbox).toBe("e2e-hermes-discord"); + expect(observedArtifact).toBe("hermes-raw-token-env"); + expect(JSON.stringify(observedInvocation)).not.toContain(token); + }); + it("applies mixed fake API endpoints and bindings through one policy transition", async () => { const calls: Array<{ command: string; From 68bf2239af0f108acafd7e7f6bbf373128982189 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 06:40:43 -0700 Subject: [PATCH 32/45] fix(e2e): migrate Hermes fake gateway host Signed-off-by: Prekshi Vyas --- test/e2e/live/hermes-discord.test.ts | 23 ++----------------- .../hermes-discord-proxy-request.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/test/e2e/live/hermes-discord.test.ts b/test/e2e/live/hermes-discord.test.ts index ef47e398657..4485b7ec4a6 100644 --- a/test/e2e/live/hermes-discord.test.ts +++ b/test/e2e/live/hermes-discord.test.ts @@ -41,7 +41,7 @@ const DISCORD_SERVER_IDS = process.env.DISCORD_SERVER_IDS ?? "149159099275359059 const DISCORD_ALLOWED_IDS = process.env.DISCORD_ALLOWED_IDS ?? "1005536447329222676"; const DISCORD_REQUIRE_MENTION = process.env.DISCORD_REQUIRE_MENTION ?? "0"; const HERMES_HEALTH_URL = "http://localhost:8642/health"; -const FAKE_DISCORD_HOST = "host.docker.internal"; +const FAKE_DISCORD_HOST = "host.openshell.internal"; const HERMES_DISCORD_HTTP_PROXY_GATEWAY_TEMPLATE = hermesDiscordHttpProxyWebSocketUrl( "{host}", "{port}", @@ -199,7 +199,7 @@ results = [] async def main(): port = int(os.environ["FAKE_DISCORD_GATEWAY_CLIENT_PORT"]) - host = os.environ.get("FAKE_DISCORD_GATEWAY_CLIENT_HOST", "host.docker.internal") + host = os.environ.get("FAKE_DISCORD_GATEWAY_CLIENT_HOST", "host.openshell.internal") token = read_env_token() client = discord.Client(intents=discord.Intents.none()) setup = getattr(client, "_async_setup_hook", None) @@ -282,23 +282,6 @@ async function runHermesPythonDiscordGatewayProof( ); } -async function assertRawTokenAbsentFromFiles( - sandbox: SandboxClient, - token: string, - redactionValues: string[], -): Promise { - const tokenB64 = Buffer.from(token, "utf8").toString("base64"); - const probe = await sandboxShWithArgs( - sandbox, - SANDBOX_NAME, - `token="$(printf %s ${shellQuote(tokenB64)} | base64 -d)"\nif grep -Fq "$token" /sandbox/.hermes/config.yaml /sandbox/.hermes/.env 2>/dev/null; then echo LEAK; else echo OK; fi`, - [], - { artifactName: "raw-discord-token-config-env-probe", redactionValues }, - ); - expectExitZero(probe, "raw Discord token config probe"); - expect(probe.stdout.trim()).toBe("OK"); -} - test("hermes-discord: Hermes Discord schema, credential isolation, and native gateway rewrite", { timeout: HERMES_DISCORD_TEST_TIMEOUT_MS, meta: { @@ -577,8 +560,6 @@ PY`, assertDiscordGatewayCapture(fakeGateway.captureFile, DISCORD_TOKEN); progress.phase("verify Discord token isolation and REST boundary"); - await assertRawTokenAbsentFromFiles(sandbox, DISCORD_TOKEN, redactionValues); - const envSurface = await rawTokenSurfaceProbe( sandbox, SANDBOX_NAME, diff --git a/test/e2e/support/hermes-discord-proxy-request.test.ts b/test/e2e/support/hermes-discord-proxy-request.test.ts index 0c477283ed6..010b22af7de 100644 --- a/test/e2e/support/hermes-discord-proxy-request.test.ts +++ b/test/e2e/support/hermes-discord-proxy-request.test.ts @@ -7,10 +7,10 @@ import { hermesDiscordHttpProxyWebSocketUrl } from "../live/hermes-discord-proxy describe("Hermes Discord proxy request", () => { it("uses HTTP absolute-form for the native WebSocket upgrade through OpenShell", () => { - const gateway = new URL(hermesDiscordHttpProxyWebSocketUrl("host.docker.internal", 32_768)); + const gateway = new URL(hermesDiscordHttpProxyWebSocketUrl("host.openshell.internal", 32_768)); expect(gateway.protocol).toBe("http:"); - expect(gateway.host).toBe("host.docker.internal:32768"); + expect(gateway.host).toBe("host.openshell.internal:32768"); expect(gateway.pathname).toBe("/gateway"); }); }); From 72457a20b7d7c4c9117540fa68d9483ca410c828 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 06:59:19 -0700 Subject: [PATCH 33/45] refactor(e2e): share secondary cleanup owner Signed-off-by: Prekshi Vyas --- test/e2e/fixtures/policy-credential-binding.ts | 10 +++------- test/e2e/live/phase6-messaging-helpers.ts | 15 +++++---------- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/test/e2e/fixtures/policy-credential-binding.ts b/test/e2e/fixtures/policy-credential-binding.ts index 638c2963730..87341fddca3 100644 --- a/test/e2e/fixtures/policy-credential-binding.ts +++ b/test/e2e/fixtures/policy-credential-binding.ts @@ -6,13 +6,9 @@ import { pathToFileURL } from "node:url"; import YAML from "yaml"; -// Import the tsc-compiled output, not the .cts source: a standalone `node -// --import tsx` child process (this fixture's execution mode) hits a Node/tsx -// loader conflict on newer Node versions where Node's own native .cts -// handling intercepts the file before tsx's transform runs, producing -// "Cannot use import statement outside a module". Every other consumer in -// this repo already imports the compiled .cjs for the same reason. -// sourceOfTruth: nemoclaw/src/shared/openshell-policy-boundary.cts +// This standalone `node --import tsx` fixture cannot import the `.cts` source. +// Native `.cts` handling bypasses tsx and rejects the TypeScript import syntax. +// Use the compiled module. Source: nemoclaw/src/shared/openshell-policy-boundary.cts import * as policyBoundaryModule from "../../../nemoclaw/dist/shared/openshell-policy-boundary.cjs"; const policyBoundary = ( diff --git a/test/e2e/live/phase6-messaging-helpers.ts b/test/e2e/live/phase6-messaging-helpers.ts index 4bfd20ace28..d250e463344 100644 --- a/test/e2e/live/phase6-messaging-helpers.ts +++ b/test/e2e/live/phase6-messaging-helpers.ts @@ -22,9 +22,12 @@ import { import { expect } from "../fixtures/e2e-test.ts"; import { CLI_ENTRYPOINT, REPO_ROOT } from "../fixtures/paths.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; -import { isNvidiaEndpointRateLimitFailure } from "./messaging-providers-helpers.ts"; +import { + isNvidiaEndpointRateLimitFailure, + runSecondaryCleanup, +} from "./messaging-providers-helpers.ts"; -export { expectExitZero, REPO_ROOT, resultText }; +export { expectExitZero, REPO_ROOT, resultText, runSecondaryCleanup }; export const CLI = process.env.NEMOCLAW_CLI_BIN ?? CLI_ENTRYPOINT; @@ -69,14 +72,6 @@ export function redactionValues(apiKey: string | undefined): string[] { return [apiKey].filter((value): value is string => typeof value === "string" && value.length > 0); } -export async function runSecondaryCleanup(run: () => Promise): Promise { - try { - await run(); - } catch { - // Cleanup and diagnostics must not hide primary test failures. - } -} - export async function precleanSandbox( host: HostCliClient, sandboxName: string, From 4e9b1baf637537a9be8a0d052f8abda3afa4fbf4 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 07:09:50 -0700 Subject: [PATCH 34/45] docs(e2e): clarify messaging token precedence Signed-off-by: Prekshi Vyas --- test/e2e/live/messaging-providers.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index b2d2dfa2d0e..fbfe5b5fdb3 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -2,11 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 /** - * - * Preserves the high-value provider/config/redaction contracts: fake tokens by - * default, _REAL secrets opt in to real sends, provider placeholders must not - * leak into sandbox-visible surfaces, and installed OpenClaw channel runtime - * exports must drive the hermetic Slack and Telegram send proofs. + * Fake tokens are the default. `_REAL` Telegram, Discord, and Slack tokens take + * precedence; non-empty standard token variables for those providers are also + * used. Provider placeholders must not leak into sandbox-visible surfaces, and + * installed OpenClaw channel runtime exports must drive the hermetic Slack and + * Telegram send proofs. */ import fs from "node:fs"; From 492606af05b244150b19b8352d90a4a06fedcbe1 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 07:43:26 -0700 Subject: [PATCH 35/45] test(e2e): assert fake API policy behavior Signed-off-by: Prekshi Vyas --- test/e2e/fixtures/policy-boundary-build.ts | 2 +- test/e2e/mock-parity.json | 1 + test/e2e/support/fake-api-policy-consumer.ts | 94 ++++++++++++++++ ...messaging-providers-runtime-proofs.test.ts | 104 ++++-------------- 4 files changed, 119 insertions(+), 82 deletions(-) create mode 100644 test/e2e/support/fake-api-policy-consumer.ts diff --git a/test/e2e/fixtures/policy-boundary-build.ts b/test/e2e/fixtures/policy-boundary-build.ts index e0e22f5adda..f80dc7997bd 100644 --- a/test/e2e/fixtures/policy-boundary-build.ts +++ b/test/e2e/fixtures/policy-boundary-build.ts @@ -24,6 +24,6 @@ export async function requireSuccessfulPolicyBoundaryBuild( [result.stderr, result.stdout, result.error?.message].filter(Boolean).join("\n"), ); throw new Error( - `Policy boundary build ${failureReason(result)}; see redacted diagnostic artifact: ${diagnosticPath}`, + `Policy boundary build ${failureReason(result)}; see diagnostic artifact: ${diagnosticPath}`, ); } diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 08209070a55..523928529ff 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -469,6 +469,7 @@ }, { "live": "test/e2e/live/hermes-discord.test.ts", + "liveSources": ["test/e2e/live/messaging-providers-helpers.ts"], "fast": [ "src/lib/messaging/channels/discord/credential-injection.test.ts", "src/lib/onboard/messaging-policy-presets.test.ts", diff --git a/test/e2e/support/fake-api-policy-consumer.ts b/test/e2e/support/fake-api-policy-consumer.ts new file mode 100644 index 00000000000..60dbb1bb420 --- /dev/null +++ b/test/e2e/support/fake-api-policy-consumer.ts @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { CommandRunner } from "../fixtures/clients/command.ts"; +import type { ShellProbeResult, TrustedShellCommand } from "../fixtures/shell-probe.ts"; + +export type SyntheticPolicyEndpoint = { + host: string; + port: number; + protocol: string; + request_body_credential_rewrite?: boolean; + websocket_credential_rewrite?: boolean; + credential_binding?: { provider: string }; +}; + +function commandResult(command: TrustedShellCommand): ShellProbeResult { + return { + artifacts: { result: "", stderr: "", stdout: "" }, + command: [command.command, ...command.args], + exitCode: 0, + signal: null, + stderr: "", + stdout: "", + timedOut: false, + }; +} + +function consumeEndpoint(spec: string): SyntheticPolicyEndpoint { + const match = /^([^:]+):([1-9][0-9]*):read-write:(rest|websocket):enforce:([^,]+)(?:,.*)?$/u.exec( + spec, + ); + if (!match) { + throw new Error(`synthetic policy consumer rejected endpoint ${JSON.stringify(spec)}`); + } + const [, host, rawPort, protocol, rewrite] = match; + if (rewrite !== "request-body-credential-rewrite" && rewrite !== "websocket-credential-rewrite") { + throw new Error(`synthetic policy consumer rejected credential rewrite ${String(rewrite)}`); + } + return { + host: host!, + port: Number(rawPort), + protocol: protocol!, + ...(rewrite === "request-body-credential-rewrite" + ? { request_body_credential_rewrite: true } + : { websocket_credential_rewrite: true }), + }; +} + +export class SyntheticFakeApiPolicyConsumer implements CommandRunner { + endpoints: SyntheticPolicyEndpoint[] = []; + + constructor(private readonly openshellPath: string) {} + + async run(command: TrustedShellCommand): Promise { + switch (command.command) { + case this.openshellPath: + if (command.args[0] !== "policy" || command.args[1] !== "update" || !command.args[2]) { + throw new Error("synthetic policy consumer expected a policy update"); + } + this.endpoints = command.args + .flatMap((value, index) => + value === "--add-endpoint" ? [command.args[index + 1] ?? ""] : [], + ) + .map(consumeEndpoint); + if (this.endpoints.length === 0) { + throw new Error("synthetic policy consumer received no endpoints"); + } + break; + case "bash": { + const rawBindings = command.args.slice(-(this.endpoints.length * 4)); + if (rawBindings.length !== this.endpoints.length * 4) { + throw new Error("synthetic policy consumer received incomplete credential bindings"); + } + Array.from({ length: this.endpoints.length }).forEach((_, index) => { + const [provider, host, rawPort, protocol] = rawBindings.slice(index * 4, index * 4 + 4); + const endpoint = this.endpoints.find( + (candidate) => + candidate.host === host && + candidate.port === Number(rawPort) && + candidate.protocol === protocol, + ); + if (!provider || !endpoint) { + throw new Error("synthetic policy consumer could not bind an endpoint"); + } + endpoint.credential_binding = { provider }; + }); + break; + } + default: + throw new Error(`synthetic policy consumer rejected command ${command.command}`); + } + return commandResult(command); + } +} diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index a3b19523272..c9e8e8bb8b7 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -17,6 +17,7 @@ import { startTestProgress } from "../fixtures/progress.ts"; import { redactString } from "../fixtures/redaction.ts"; import { superviseChild } from "../fixtures/shell/supervisor.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; +import { SyntheticFakeApiPolicyConsumer } from "./fake-api-policy-consumer.ts"; import { applyFakeApiPolicy, buildSandboxNodeInvocation, @@ -345,25 +346,9 @@ describe("messaging provider installed-runtime proofs", () => { expect(JSON.stringify(observedInvocation)).not.toContain(token); }); - it("applies mixed fake API endpoints and bindings through one policy transition", async () => { - const calls: Array<{ - command: string; - args: string[]; - artifactName: string; - cwd?: string; - }> = []; - const runner: CommandRunner = { - async run(command, options) { - calls.push({ - command: command.command, - args: [...command.args], - artifactName: options?.artifactName ?? "", - ...(options?.cwd ? { cwd: options.cwd } : {}), - }); - return successfulShellResult([command.command, ...command.args]); - }, - }; - const host = new HostCliClient(runner, { openshellPath: "/opt/openshell" }); + it("produces mixed REST and WebSocket policy state through a synthetic consumer", async () => { + const consumer = new SyntheticFakeApiPolicyConsumer("/opt/openshell"); + const host = new HostCliClient(consumer, { openshellPath: "/opt/openshell" }); await applyFakeApiPolicy({ host, @@ -387,48 +372,21 @@ describe("messaging provider installed-runtime proofs", () => { redactionValues: [], }); - expect(calls).toHaveLength(2); - expect(calls[0]).toEqual({ - command: "/opt/openshell", - args: [ - "policy", - "update", - "e2e-slack-policy-owner", - "--add-endpoint", - "host.openshell.internal:43117:read-write:rest:enforce:request-body-credential-rewrite,allowed-ip=10.0.0.0/8,allowed-ip=172.16.0.0/12,allowed-ip=192.168.0.0/16", - "--add-allow", - "host.openshell.internal:43117:GET:/**", - "--add-allow", - "host.openshell.internal:43117:POST:/**", - "--add-endpoint", - "host.openshell.internal:43118:read-write:websocket:enforce:websocket-credential-rewrite,allowed-ip=10.0.0.0/8,allowed-ip=172.16.0.0/12,allowed-ip=192.168.0.0/16", - "--add-allow", - "host.openshell.internal:43118:GET:/**", - "--add-allow", - "host.openshell.internal:43118:WEBSOCKET_TEXT:/**", - "--binary", - "/usr/local/bin/node", - "--binary", - "/usr/bin/node", - "--wait", - ], - artifactName: "apply-slack-owner-policy", - }); - expect(calls[1]?.command).toBe("bash"); - expect(calls[1]?.artifactName).toBe("apply-slack-owner-policy-credential-binding"); - expect(calls[1]?.cwd).toBe(path.resolve(".")); - expect(calls[1]?.args.slice(3)).toEqual([ - "/opt/openshell", - "e2e-slack-policy-owner", - path.resolve("test/e2e/fixtures/policy-credential-binding.ts"), - "e2e-slack-policy-owner-slack-bridge", - "host.openshell.internal", - "43117", - "rest", - "e2e-slack-policy-owner-slack-app", - "host.openshell.internal", - "43118", - "websocket", + expect(consumer.endpoints).toEqual([ + { + host: "host.openshell.internal", + port: 43_117, + protocol: "rest", + request_body_credential_rewrite: true, + credential_binding: { provider: "e2e-slack-policy-owner-slack-bridge" }, + }, + { + host: "host.openshell.internal", + port: 43_118, + protocol: "websocket", + websocket_credential_rewrite: true, + credential_binding: { provider: "e2e-slack-policy-owner-slack-app" }, + }, ]); }); @@ -651,13 +609,12 @@ describe("messaging provider installed-runtime proofs", () => { } }); - it("publishes a proxy only on the OpenShell bridge without default-bridge egress", async () => { - const calls: string[][] = []; - const host = fakeDockerHost(OPENSHELL_BRIDGE_ADDRESS, calls); + it("accepts an isolated proxy published only on the OpenShell bridge", async () => { + const host = fakeDockerHost(); const cleanup: Array<() => Promise> = []; try { - await startFakeDockerApi(host, (_name, run) => cleanup.push(run), { + const api = await startFakeDockerApi(host, (_name, run) => cleanup.push(run), { kind: "discord-gateway", imageScript: "fake-discord-gateway-api.cjs", containerPrefix: "fake-discord-gateway", @@ -668,22 +625,7 @@ describe("messaging provider installed-runtime proofs", () => { redactionValues: [], env: {}, }); - const networkName = calls.find( - ([command, action, operation]) => - command === "docker" && action === "network" && operation === "create", - )?.at(-1); - const proxyRun = calls.find( - ([command, action, ...args]) => - command === "docker" && - action === "run" && - args.some((argument) => argument.startsWith("NEMOCLAW_FAKE_API_UPSTREAM=")), - ); - expect(networkName).toMatch(/^nemoclaw-fake-api-network-/u); - expect(proxyRun).toBeDefined(); - const proxyArgs = proxyRun!; - expect(proxyArgs[proxyArgs.indexOf("--network") + 1]).toBe(networkName); - expect(proxyArgs).not.toContain("bridge"); - expect(proxyArgs).toContain(`${OPENSHELL_BRIDGE_ADDRESS}::8080`); + expect(api.port).toBe("32100"); } finally { await cleanup .reverse() From 3903ba4b67d3270d4472f4ad049e5a501786e18b Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 08:01:34 -0700 Subject: [PATCH 36/45] test(e2e): map shared messaging cleanup owner Signed-off-by: Prekshi Vyas --- test/e2e/mock-parity.json | 27 +++++++++++++++---- ...messaging-providers-runtime-proofs.test.ts | 11 ++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 523928529ff..2d6de12252f 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -469,11 +469,15 @@ }, { "live": "test/e2e/live/hermes-discord.test.ts", - "liveSources": ["test/e2e/live/messaging-providers-helpers.ts"], + "liveSources": [ + "test/e2e/live/messaging-providers-helpers.ts", + "test/e2e/live/phase6-messaging-helpers.ts" + ], "fast": [ "src/lib/messaging/channels/discord/credential-injection.test.ts", "src/lib/onboard/messaging-policy-presets.test.ts", "test/channels/channels-add-preset.test.ts", + "test/e2e/support/messaging-providers-runtime-proofs.test.ts", "test/e2e/support/policy-credential-binding.test.ts", "test/onboarding/onboard-preset-diff.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", @@ -616,10 +620,14 @@ }, { "live": "test/e2e/live/openclaw-discord-pairing.test.ts", - "liveSources": ["test/e2e/live/openclaw-pairing-helpers.ts"], + "liveSources": [ + "test/e2e/live/openclaw-pairing-helpers.ts", + "test/e2e/live/phase6-messaging-helpers.ts" + ], "fast": [ "src/lib/messaging/channels/discord/credential-injection.test.ts", "src/lib/onboard/messaging-policy-presets.test.ts", + "test/e2e/support/messaging-providers-runtime-proofs.test.ts", "test/e2e/support/openclaw-discord-pairing-helpers.test.ts", "test/e2e/support/policy-credential-binding.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", @@ -656,7 +664,10 @@ }, { "live": "test/e2e/live/openclaw-slack-pairing.test.ts", - "liveSources": ["test/e2e/live/openclaw-pairing-helpers.ts"], + "liveSources": [ + "test/e2e/live/openclaw-pairing-helpers.ts", + "test/e2e/live/phase6-messaging-helpers.ts" + ], "fast": [ "src/lib/onboard/sandbox-create-plan.test.ts", "test/e2e/support/openclaw-discord-pairing-helpers.test.ts", @@ -766,7 +777,9 @@ }, { "live": "test/e2e/live/telegram-injection.test.ts", + "liveSources": ["test/e2e/live/phase6-messaging-helpers.ts"], "fast": [ + "test/e2e/support/messaging-providers-runtime-proofs.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts" ] @@ -788,10 +801,12 @@ "test/e2e/live/channels-stop-start-config-state.ts", "test/e2e/live/channels-stop-start-googlechat-proof.ts", "test/e2e/live/channels-stop-start-helpers.ts", - "test/e2e/live/channels-stop-start-plan-state.ts" + "test/e2e/live/channels-stop-start-plan-state.ts", + "test/e2e/live/phase6-messaging-helpers.ts" ], "fast": [ "test/channels/channels-add-bridge-lifecycle.test.ts", + "test/e2e/support/messaging-providers-runtime-proofs.test.ts", "test/e2e/support/channels-stop-start-cleanup.test.ts", "test/e2e/support/channels-stop-start-config-state.test.ts", "test/e2e/support/channels-stop-start-googlechat.test.ts", @@ -826,9 +841,11 @@ "liveSources": [ "test/e2e/live/hermes-slack-credential-transport.ts", "test/e2e/live/hermes-slack-e2e-helpers.ts", - "test/e2e/live/hermes-slack-proof.ts" + "test/e2e/live/hermes-slack-proof.ts", + "test/e2e/live/phase6-messaging-helpers.ts" ], "fast": [ + "test/e2e/support/messaging-providers-runtime-proofs.test.ts", "test/e2e/support/hermes-slack-cleanup.test.ts", "test/e2e/support/hermes-slack-credential-transport.test.ts", "test/e2e/support/hermes-slack-proof.test.ts", diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index c9e8e8bb8b7..302cae3cecf 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -27,8 +27,10 @@ import { OPENSHELL_EXEC_ARGUMENT_LIMIT_BYTES, parseRuntimeProofPort, rawTokenSurfaceProbe, + runSecondaryCleanup, startFakeDockerApi, } from "../live/messaging-providers-helpers.ts"; +import { runSecondaryCleanup as runPhase6SecondaryCleanup } from "../live/phase6-messaging-helpers.ts"; import { parseInstalledSlackProof, SLACK_MANAGED_NPM_PROJECT_DISCOVERY_SOURCE, @@ -346,6 +348,15 @@ describe("messaging provider installed-runtime proofs", () => { expect(JSON.stringify(observedInvocation)).not.toContain(token); }); + it("shares secondary cleanup ownership across phase 6 messaging consumers", async () => { + expect(runPhase6SecondaryCleanup).toBe(runSecondaryCleanup); + await expect( + runPhase6SecondaryCleanup(async () => { + throw new Error("secondary cleanup failure"); + }), + ).resolves.toBeUndefined(); + }); + it("produces mixed REST and WebSocket policy state through a synthetic consumer", async () => { const consumer = new SyntheticFakeApiPolicyConsumer("/opt/openshell"); const host = new HostCliClient(consumer, { openshellPath: "/opt/openshell" }); From 15ad4d3e697c8a19262ca064db25364ebec96832 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 08:22:51 -0700 Subject: [PATCH 37/45] test(e2e): reduce policy proof scaffolding Signed-off-by: Prekshi Vyas --- test/e2e/support/fake-api-policy-consumer.ts | 94 ----------- ...messaging-providers-runtime-proofs.test.ts | 151 ++++++++++++------ 2 files changed, 103 insertions(+), 142 deletions(-) delete mode 100644 test/e2e/support/fake-api-policy-consumer.ts diff --git a/test/e2e/support/fake-api-policy-consumer.ts b/test/e2e/support/fake-api-policy-consumer.ts deleted file mode 100644 index 60dbb1bb420..00000000000 --- a/test/e2e/support/fake-api-policy-consumer.ts +++ /dev/null @@ -1,94 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { CommandRunner } from "../fixtures/clients/command.ts"; -import type { ShellProbeResult, TrustedShellCommand } from "../fixtures/shell-probe.ts"; - -export type SyntheticPolicyEndpoint = { - host: string; - port: number; - protocol: string; - request_body_credential_rewrite?: boolean; - websocket_credential_rewrite?: boolean; - credential_binding?: { provider: string }; -}; - -function commandResult(command: TrustedShellCommand): ShellProbeResult { - return { - artifacts: { result: "", stderr: "", stdout: "" }, - command: [command.command, ...command.args], - exitCode: 0, - signal: null, - stderr: "", - stdout: "", - timedOut: false, - }; -} - -function consumeEndpoint(spec: string): SyntheticPolicyEndpoint { - const match = /^([^:]+):([1-9][0-9]*):read-write:(rest|websocket):enforce:([^,]+)(?:,.*)?$/u.exec( - spec, - ); - if (!match) { - throw new Error(`synthetic policy consumer rejected endpoint ${JSON.stringify(spec)}`); - } - const [, host, rawPort, protocol, rewrite] = match; - if (rewrite !== "request-body-credential-rewrite" && rewrite !== "websocket-credential-rewrite") { - throw new Error(`synthetic policy consumer rejected credential rewrite ${String(rewrite)}`); - } - return { - host: host!, - port: Number(rawPort), - protocol: protocol!, - ...(rewrite === "request-body-credential-rewrite" - ? { request_body_credential_rewrite: true } - : { websocket_credential_rewrite: true }), - }; -} - -export class SyntheticFakeApiPolicyConsumer implements CommandRunner { - endpoints: SyntheticPolicyEndpoint[] = []; - - constructor(private readonly openshellPath: string) {} - - async run(command: TrustedShellCommand): Promise { - switch (command.command) { - case this.openshellPath: - if (command.args[0] !== "policy" || command.args[1] !== "update" || !command.args[2]) { - throw new Error("synthetic policy consumer expected a policy update"); - } - this.endpoints = command.args - .flatMap((value, index) => - value === "--add-endpoint" ? [command.args[index + 1] ?? ""] : [], - ) - .map(consumeEndpoint); - if (this.endpoints.length === 0) { - throw new Error("synthetic policy consumer received no endpoints"); - } - break; - case "bash": { - const rawBindings = command.args.slice(-(this.endpoints.length * 4)); - if (rawBindings.length !== this.endpoints.length * 4) { - throw new Error("synthetic policy consumer received incomplete credential bindings"); - } - Array.from({ length: this.endpoints.length }).forEach((_, index) => { - const [provider, host, rawPort, protocol] = rawBindings.slice(index * 4, index * 4 + 4); - const endpoint = this.endpoints.find( - (candidate) => - candidate.host === host && - candidate.port === Number(rawPort) && - candidate.protocol === protocol, - ); - if (!provider || !endpoint) { - throw new Error("synthetic policy consumer could not bind an endpoint"); - } - endpoint.credential_binding = { provider }; - }); - break; - } - default: - throw new Error(`synthetic policy consumer rejected command ${command.command}`); - } - return commandResult(command); - } -} diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 302cae3cecf..307a298447e 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -7,17 +7,18 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import YAML from "yaml"; import { buildProcessTokenProbe } from "../fixtures/process-token-probe.ts"; import type { CommandRunner } from "../fixtures/clients/command.ts"; import { HostCliClient } from "../fixtures/clients/host.ts"; import type { SandboxClient } from "../fixtures/clients/sandbox.ts"; import { spawnObservedChild } from "../fixtures/observed-child-process.ts"; +import { bindPolicyEndpoints } from "../fixtures/policy-credential-binding.ts"; import { startTestProgress } from "../fixtures/progress.ts"; import { redactString } from "../fixtures/redaction.ts"; import { superviseChild } from "../fixtures/shell/supervisor.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; -import { SyntheticFakeApiPolicyConsumer } from "./fake-api-policy-consumer.ts"; import { applyFakeApiPolicy, buildSandboxNodeInvocation, @@ -321,11 +322,7 @@ describe("messaging provider installed-runtime proofs", () => { let observedInvocation: string[] = []; let observedArtifact = ""; const sandbox = { - async exec( - sandboxName: string, - invocation: string[], - options: { artifactName?: string }, - ) { + async exec(sandboxName: string, invocation: string[], options: { artifactName?: string }) { observedSandbox = sandboxName; observedInvocation = invocation; observedArtifact = options.artifactName ?? ""; @@ -349,7 +346,11 @@ describe("messaging provider installed-runtime proofs", () => { }); it("shares secondary cleanup ownership across phase 6 messaging consumers", async () => { - expect(runPhase6SecondaryCleanup).toBe(runSecondaryCleanup); + await expect( + runSecondaryCleanup(async () => { + throw new Error("secondary cleanup failure"); + }), + ).resolves.toBeUndefined(); await expect( runPhase6SecondaryCleanup(async () => { throw new Error("secondary cleanup failure"); @@ -357,48 +358,107 @@ describe("messaging provider installed-runtime proofs", () => { ).resolves.toBeUndefined(); }); - it("produces mixed REST and WebSocket policy state through a synthetic consumer", async () => { - const consumer = new SyntheticFakeApiPolicyConsumer("/opt/openshell"); - const host = new HostCliClient(consumer, { openshellPath: "/opt/openshell" }); + it("produces mixed REST and WebSocket policy state through a minimal consumer", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fake-api-policy-")); + const policyFile = path.join(dir, "policy.yaml"); + const commands: Array<{ command: string; args: string[] }> = []; + const consumer: CommandRunner = { + async run(command) { + const invocation = [command.command, ...command.args]; + commands.push({ command: command.command, args: [...command.args] }); + return successfulShellResult(invocation); + }, + }; + + try { + const host = new HostCliClient(consumer, { openshellPath: "/opt/openshell" }); + await applyFakeApiPolicy({ + host, + sandboxName: "e2e-slack-policy-owner", + policyHost: "host.openshell.internal", + endpoints: [ + { + port: "43117", + protocol: "rest", + providerName: "e2e-slack-policy-owner-slack-bridge", + }, + { + port: "43118", + protocol: "websocket", + providerName: "e2e-slack-policy-owner-slack-app", + }, + ], + binaries: ["/usr/local/bin/node", "/usr/bin/node"], + artifactName: "apply-slack-owner-policy", + env: {}, + redactionValues: [], + }); - await applyFakeApiPolicy({ - host, - sandboxName: "e2e-slack-policy-owner", - policyHost: "host.openshell.internal", - endpoints: [ + const updateArgs = commands.find(({ command }) => command === "/opt/openshell")?.args ?? []; + const endpointSpecs = updateArgs.flatMap((value, index) => + value === "--add-endpoint" ? [updateArgs[index + 1] ?? ""] : [], + ); + const syntheticEndpoints = endpointSpecs.map((spec) => { + const match = + /^([^:]+):([1-9][0-9]*):read-write:(rest|websocket):enforce:(request-body-credential-rewrite|websocket-credential-rewrite)/u.exec( + spec, + ); + expect(match, "synthetic policy consumer accepted the endpoint").not.toBeNull(); + const [, host, rawPort, protocol, rewrite] = match!; + return { + host, + port: Number(rawPort), + protocol, + ...(rewrite === "request-body-credential-rewrite" + ? { request_body_credential_rewrite: true } + : { websocket_credential_rewrite: true }), + }; + }); + fs.writeFileSync( + policyFile, + YAML.stringify({ + version: 1, + network_policies: { synthetic: { endpoints: syntheticEndpoints } }, + }), + ); + const bindingArgs = commands.find(({ command }) => command === "bash")?.args ?? []; + const rawBindings = bindingArgs.slice(-(syntheticEndpoints.length * 4)); + bindPolicyEndpoints( + policyFile, + Array.from({ length: syntheticEndpoints.length }, (_, index) => { + const [providerName, host, rawPort, protocol] = rawBindings.slice( + index * 4, + index * 4 + 4, + ); + return { + providerName: providerName!, + host: host!, + port: Number(rawPort), + protocol: protocol!, + }; + }), + ); + const endpoints = YAML.parse(fs.readFileSync(policyFile, "utf8")).network_policies.synthetic + .endpoints; + expect(endpoints).toEqual([ { - port: "43117", + host: "host.openshell.internal", + port: 43_117, protocol: "rest", - providerName: "e2e-slack-policy-owner-slack-bridge", + request_body_credential_rewrite: true, + credential_binding: { provider: "e2e-slack-policy-owner-slack-bridge" }, }, { - port: "43118", + host: "host.openshell.internal", + port: 43_118, protocol: "websocket", - providerName: "e2e-slack-policy-owner-slack-app", + websocket_credential_rewrite: true, + credential_binding: { provider: "e2e-slack-policy-owner-slack-app" }, }, - ], - binaries: ["/usr/local/bin/node", "/usr/bin/node"], - artifactName: "apply-slack-owner-policy", - env: {}, - redactionValues: [], - }); - - expect(consumer.endpoints).toEqual([ - { - host: "host.openshell.internal", - port: 43_117, - protocol: "rest", - request_body_credential_rewrite: true, - credential_binding: { provider: "e2e-slack-policy-owner-slack-bridge" }, - }, - { - host: "host.openshell.internal", - port: 43_118, - protocol: "websocket", - websocket_credential_rewrite: true, - credential_binding: { provider: "e2e-slack-policy-owner-slack-app" }, - }, - ]); + ]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } }); it("collects API diagnostics when the fake container does not become ready", async () => { @@ -645,12 +705,7 @@ describe("messaging provider installed-runtime proofs", () => { }); it("rejects a proxy that retains default-bridge egress", async () => { - const host = fakeDockerHost( - OPENSHELL_BRIDGE_ADDRESS, - [], - OPENSHELL_NETWORK_INSPECT, - true, - ); + const host = fakeDockerHost(OPENSHELL_BRIDGE_ADDRESS, [], OPENSHELL_NETWORK_INSPECT, true); const cleanup: Array<() => Promise> = []; try { From 0031d8e1bdf183e5cdda7bb66f244279a144ee2a Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 08:45:31 -0700 Subject: [PATCH 38/45] test(e2e): clarify cleanup failure contract Signed-off-by: Prekshi Vyas --- test/e2e/live/messaging-providers-helpers.ts | 7 ++++--- test/e2e/live/messaging-providers.test.ts | 11 ++++++----- .../messaging-providers-runtime-proofs.test.ts | 8 +------- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index bee5138ef19..cf5935e6e7d 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -845,9 +845,10 @@ export async function startFakeDockerApi( } }); - // The fake API receives raw expected credentials. Keep both it and its TCP - // forwarder on the internal network, and publish only the forwarder on the - // OpenShell bridge gateway. The forwarder carries rewritten credential + // Docker does not expose a usable `-p` mapping for the credential-bearing API + // on this internal network, which is the regression this helper covers. Keep + // both it and its TCP forwarder on that network, and publish only the forwarder + // on the OpenShell bridge gateway. The forwarder carries rewritten credential // traffic, so it must not retain default-bridge egress. const proxyStart = await runHost( host, diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index fbfe5b5fdb3..dcc61c2b2e3 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -2,11 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 /** - * Fake tokens are the default. `_REAL` Telegram, Discord, and Slack tokens take - * precedence; non-empty standard token variables for those providers are also - * used. Provider placeholders must not leak into sandbox-visible surfaces, and - * installed OpenClaw channel runtime exports must drive the hermetic Slack and - * Telegram send proofs. + * Fake tokens are the default. `TELEGRAM_BOT_TOKEN_REAL`, + * `DISCORD_BOT_TOKEN_REAL`, `SLACK_BOT_TOKEN_REAL`, and `SLACK_APP_TOKEN_REAL` + * override the corresponding non-empty standard token variable; each standard + * variable overrides the fake default. Provider placeholders must not leak into + * sandbox-visible surfaces, and installed OpenClaw channel runtime exports must + * drive the hermetic Slack and Telegram send proofs. */ import fs from "node:fs"; diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 307a298447e..97a33ccd12c 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -31,7 +31,6 @@ import { runSecondaryCleanup, startFakeDockerApi, } from "../live/messaging-providers-helpers.ts"; -import { runSecondaryCleanup as runPhase6SecondaryCleanup } from "../live/phase6-messaging-helpers.ts"; import { parseInstalledSlackProof, SLACK_MANAGED_NPM_PROJECT_DISCOVERY_SOURCE, @@ -345,17 +344,12 @@ describe("messaging provider installed-runtime proofs", () => { expect(JSON.stringify(observedInvocation)).not.toContain(token); }); - it("shares secondary cleanup ownership across phase 6 messaging consumers", async () => { + it("does not propagate a secondary cleanup failure", async () => { await expect( runSecondaryCleanup(async () => { throw new Error("secondary cleanup failure"); }), ).resolves.toBeUndefined(); - await expect( - runPhase6SecondaryCleanup(async () => { - throw new Error("secondary cleanup failure"); - }), - ).resolves.toBeUndefined(); }); it("produces mixed REST and WebSocket policy state through a minimal consumer", async () => { From 150f7d876c3d92e1d08dad54ea33c61802fbf1b8 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 09:16:29 -0700 Subject: [PATCH 39/45] fix(e2e): keep fake API credentials out of argv Signed-off-by: Prekshi Vyas --- test/e2e/lib/fake-discord-gateway.cjs | 7 ++- test/e2e/lib/fake-slack-api.cjs | 9 ++- test/e2e/lib/fake-telegram-api.cjs | 7 ++- test/e2e/lib/fake-wechat-api.mts | 7 ++- test/e2e/live/hermes-discord.test.ts | 2 +- test/e2e/live/messaging-providers-helpers.ts | 39 +++++++++--- test/e2e/live/messaging-providers.test.ts | 8 ++- test/e2e/live/openclaw-pairing-helpers.ts | 10 +-- test/e2e/live/phase6-messaging-helpers.ts | 10 --- ...messaging-providers-runtime-proofs.test.ts | 61 ++++++++++++++++--- 10 files changed, 123 insertions(+), 37 deletions(-) diff --git a/test/e2e/lib/fake-discord-gateway.cjs b/test/e2e/lib/fake-discord-gateway.cjs index 0dfbe146e9a..014b089d293 100755 --- a/test/e2e/lib/fake-discord-gateway.cjs +++ b/test/e2e/lib/fake-discord-gateway.cjs @@ -8,11 +8,16 @@ const crypto = require("crypto"); const fs = require("fs"); const net = require("net"); +function readExpectedCredential(name) { + const file = process.env[`${name}_FILE`] || ""; + return file ? fs.readFileSync(file, "utf8") : process.env[name] || ""; +} + const host = process.env.FAKE_DISCORD_GATEWAY_HOST || "0.0.0.0"; const port = Number(process.env.FAKE_DISCORD_GATEWAY_PORT || "0"); const portFile = process.env.FAKE_DISCORD_GATEWAY_PORT_FILE || ""; const captureFile = process.env.FAKE_DISCORD_GATEWAY_CAPTURE_FILE || ""; -const expectedToken = process.env.FAKE_DISCORD_GATEWAY_EXPECTED_TOKEN || ""; +const expectedToken = readExpectedCredential("FAKE_DISCORD_GATEWAY_EXPECTED_TOKEN"); const PORT_TRAFFIC_REQUEST_LINE = "GET /__nemoclaw_e2e_port_traffic HTTP/1.1"; const PORT_TRAFFIC_REPLY = JSON.stringify({ type: "nemoclaw_port_traffic_reply" }); diff --git a/test/e2e/lib/fake-slack-api.cjs b/test/e2e/lib/fake-slack-api.cjs index 5a1cc2ab89b..29c82eafa89 100755 --- a/test/e2e/lib/fake-slack-api.cjs +++ b/test/e2e/lib/fake-slack-api.cjs @@ -8,6 +8,11 @@ const fs = require("fs"); const crypto = require("crypto"); const http = require("http"); +function readExpectedCredential(name) { + const file = process.env[`${name}_FILE`] || ""; + return file ? fs.readFileSync(file, "utf8") : process.env[name] || ""; +} + const host = process.env.FAKE_SLACK_API_HOST || "0.0.0.0"; const rawPort = process.env.FAKE_SLACK_API_PORT || "0"; const port = Number(rawPort); @@ -15,8 +20,8 @@ const rawWebsocketPort = process.env.FAKE_SLACK_API_WEBSOCKET_PORT || "0"; const websocketPort = Number(rawWebsocketPort); const portFile = process.env.FAKE_SLACK_API_PORT_FILE || ""; const captureFile = process.env.FAKE_SLACK_API_CAPTURE_FILE || ""; -const expectedBotToken = process.env.FAKE_SLACK_API_EXPECTED_BOT_TOKEN || ""; -const expectedAppToken = process.env.FAKE_SLACK_API_EXPECTED_APP_TOKEN || ""; +const expectedBotToken = readExpectedCredential("FAKE_SLACK_API_EXPECTED_BOT_TOKEN"); +const expectedAppToken = readExpectedCredential("FAKE_SLACK_API_EXPECTED_APP_TOKEN"); const socketUserId = process.env.FAKE_SLACK_API_SOCKET_USER_ID || "U3730E2E"; const socketChannelId = process.env.FAKE_SLACK_API_SOCKET_CHANNEL_ID || "D3730E2E"; const socketTeamId = process.env.FAKE_SLACK_API_SOCKET_TEAM_ID || "T3730E2E"; diff --git a/test/e2e/lib/fake-telegram-api.cjs b/test/e2e/lib/fake-telegram-api.cjs index 2c34fa65a42..5ec1c5a973e 100755 --- a/test/e2e/lib/fake-telegram-api.cjs +++ b/test/e2e/lib/fake-telegram-api.cjs @@ -7,12 +7,17 @@ const fs = require("fs"); const http = require("http"); +function readExpectedCredential(name) { + const file = process.env[`${name}_FILE`] || ""; + return file ? fs.readFileSync(file, "utf8") : process.env[name] || ""; +} + const host = process.env.FAKE_TELEGRAM_API_HOST || "0.0.0.0"; const rawPort = process.env.FAKE_TELEGRAM_API_PORT || "0"; const port = Number(rawPort); const portFile = process.env.FAKE_TELEGRAM_API_PORT_FILE || ""; const captureFile = process.env.FAKE_TELEGRAM_API_CAPTURE_FILE || ""; -const expectedToken = process.env.FAKE_TELEGRAM_API_EXPECTED_TOKEN || ""; +const expectedToken = readExpectedCredential("FAKE_TELEGRAM_API_EXPECTED_TOKEN"); const MAX_BODY_BYTES = 1024 * 1024; const PORT_TRAFFIC_PATH = "/__nemoclaw_e2e_port_traffic"; const PORT_TRAFFIC_REPLY = "nemoclaw_port_traffic_reply"; diff --git a/test/e2e/lib/fake-wechat-api.mts b/test/e2e/lib/fake-wechat-api.mts index 2fac65c502a..0fca396814d 100755 --- a/test/e2e/lib/fake-wechat-api.mts +++ b/test/e2e/lib/fake-wechat-api.mts @@ -5,12 +5,17 @@ import fs from "node:fs"; import http from "node:http"; +function readExpectedCredential(name: string): string { + const file = process.env[`${name}_FILE`] ?? ""; + return file ? fs.readFileSync(file, "utf8") : (process.env[name] ?? ""); +} + const host = process.env.FAKE_WECHAT_API_HOST || "0.0.0.0"; const rawPort = process.env.FAKE_WECHAT_API_PORT || "0"; const port = Number(rawPort); const portFile = process.env.FAKE_WECHAT_API_PORT_FILE || ""; const captureFile = process.env.FAKE_WECHAT_API_CAPTURE_FILE || ""; -const expectedToken = process.env.FAKE_WECHAT_API_EXPECTED_TOKEN || ""; +const expectedToken = readExpectedCredential("FAKE_WECHAT_API_EXPECTED_TOKEN"); const expectedTarget = process.env.FAKE_WECHAT_API_EXPECTED_TARGET || ""; const expectedText = process.env.FAKE_WECHAT_API_EXPECTED_TEXT || ""; const MAX_BODY_BYTES = 1024 * 1024; diff --git a/test/e2e/live/hermes-discord.test.ts b/test/e2e/live/hermes-discord.test.ts index 4485b7ec4a6..f51ab9a3d0d 100644 --- a/test/e2e/live/hermes-discord.test.ts +++ b/test/e2e/live/hermes-discord.test.ts @@ -128,7 +128,7 @@ async function startHermesFakeDiscordGateway( portEnv: "FAKE_DISCORD_GATEWAY_PORT", portFileEnv: "FAKE_DISCORD_GATEWAY_PORT_FILE", captureFileEnv: "FAKE_DISCORD_GATEWAY_CAPTURE_FILE", - expectedEnv: { FAKE_DISCORD_GATEWAY_EXPECTED_TOKEN: token }, + credentialEnv: { FAKE_DISCORD_GATEWAY_EXPECTED_TOKEN: token }, env, redactionValues, }); diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index cf5935e6e7d..bb634e2d429 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -711,7 +711,8 @@ export async function startFakeDockerApi( portEnv: string; portFileEnv: string; captureFileEnv: string; - expectedEnv: Record; + credentialEnv: Record; + fixtureEnv?: Record; redactionValues: string[]; env: NodeJS.ProcessEnv; }, @@ -724,13 +725,32 @@ export async function startFakeDockerApi( ); fs.mkdirSync(path.join(REPO_ROOT, ".tmp"), { recursive: true }); const dir = fs.mkdtempSync(path.join(REPO_ROOT, ".tmp", `fake-${options.kind}.`)); - const portFile = path.join(dir, "port"); - const captureFile = path.join(dir, "capture.jsonl"); + const fixtureDir = path.join(dir, "fixture"); + const credentialDir = path.join(dir, "credentials"); + const portFile = path.join(fixtureDir, "port"); + const captureFile = path.join(fixtureDir, "capture.jsonl"); const container = uniqueContainerName(options.containerPrefix); const proxyContainer = uniqueContainerName(`${options.containerPrefix}-proxy`); const network = uniqueContainerName("nemoclaw-fake-api-network"); const containerPorts = options.kind === "slack" ? [8080, 8081] : [8080]; - fs.writeFileSync(captureFile, ""); + let credentialMounts: Array<{ envName: string; hostPath: string; containerPath: string }>; + try { + fs.mkdirSync(fixtureDir, { mode: 0o700 }); + fs.mkdirSync(credentialDir, { mode: 0o700 }); + fs.writeFileSync(captureFile, ""); + credentialMounts = Object.entries(options.credentialEnv).map(([key, value], index) => { + if (!/^[A-Z][A-Z0-9_]*$/u.test(key) || value.length === 0) { + throw new Error(`invalid fake ${options.kind} API credential input`); + } + const hostPath = path.join(credentialDir, String(index)); + const containerPath = `/run/nemoclaw-fake-api-credentials/${String(index)}`; + fs.writeFileSync(hostPath, value, { mode: 0o600 }); + return { envName: `${key}_FILE`, hostPath, containerPath }; + }); + } catch (error) { + fs.rmSync(dir, { recursive: true, force: true }); + throw error; + } const networkCreate = await runHost( host, @@ -777,12 +797,15 @@ export async function startFakeDockerApi( `${options.captureFileEnv}=/tmp/fake/capture.jsonl`, ]; if (options.kind === "slack") dockerArgs.push("-e", "FAKE_SLACK_API_WEBSOCKET_PORT=8081"); - for (const [key, value] of Object.entries(options.expectedEnv)) { + for (const [key, value] of Object.entries(options.fixtureEnv ?? {})) { dockerArgs.push("-e", `${key}=${value}`); } + for (const { envName, hostPath, containerPath } of credentialMounts) { + dockerArgs.push("-e", `${envName}=${containerPath}`, "-v", `${hostPath}:${containerPath}:ro`); + } dockerArgs.push( "-v", - `${dir}:/tmp/fake`, + `${fixtureDir}:/tmp/fake`, "-v", `${FAKE_LIB_DIR}:/opt/nemoclaw-e2e:ro`, FAKE_API_IMAGE, @@ -935,7 +958,9 @@ export async function startFakeDockerApi( options.env, options.redactionValues, ); - const credentialNames = new Set(Object.keys(options.expectedEnv)); + const credentialNames = new Set( + Object.keys(options.credentialEnv).flatMap((name) => [name, `${name}_FILE`]), + ); if ( !Array.isArray(proxyEnvironment) || !proxyEnvironment.every((entry): entry is string => typeof entry === "string") || diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index dcc61c2b2e3..720bb606b1f 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -890,7 +890,7 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); portEnv: "FAKE_SLACK_API_PORT", portFileEnv: "FAKE_SLACK_API_PORT_FILE", captureFileEnv: "FAKE_SLACK_API_CAPTURE_FILE", - expectedEnv: { + credentialEnv: { FAKE_SLACK_API_EXPECTED_BOT_TOKEN: state.tokens.slackBot, FAKE_SLACK_API_EXPECTED_APP_TOKEN: state.tokens.slackApp, }, @@ -1064,7 +1064,7 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); portEnv: "FAKE_TELEGRAM_API_PORT", portFileEnv: "FAKE_TELEGRAM_API_PORT_FILE", captureFileEnv: "FAKE_TELEGRAM_API_CAPTURE_FILE", - expectedEnv: { + credentialEnv: { FAKE_TELEGRAM_API_EXPECTED_TOKEN: state.tokens.telegram, }, env: state.env, @@ -1128,8 +1128,10 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); portEnv: "FAKE_WECHAT_API_PORT", portFileEnv: "FAKE_WECHAT_API_PORT_FILE", captureFileEnv: "FAKE_WECHAT_API_CAPTURE_FILE", - expectedEnv: { + credentialEnv: { FAKE_WECHAT_API_EXPECTED_TOKEN: state.tokens.wechat, + }, + fixtureEnv: { FAKE_WECHAT_API_EXPECTED_TARGET: wechatMockTarget, FAKE_WECHAT_API_EXPECTED_TEXT: wechatMockText, }, diff --git a/test/e2e/live/openclaw-pairing-helpers.ts b/test/e2e/live/openclaw-pairing-helpers.ts index 934eb286862..73f5df703ea 100644 --- a/test/e2e/live/openclaw-pairing-helpers.ts +++ b/test/e2e/live/openclaw-pairing-helpers.ts @@ -16,9 +16,9 @@ import { startFakeDockerApi, } from "./messaging-providers-helpers.ts"; import { - cleanupSandbox, expectExitZero, phase6Env, + precleanSandbox, resultText, runSecondaryCleanup, sandboxSh, @@ -79,7 +79,7 @@ export async function cleanupPairingSandbox( redactions: string[], prefix: string, ): Promise { - await cleanupSandbox(host, sandboxName, env, redactions, prefix); + await precleanSandbox(host, sandboxName, env, redactions, prefix); await runSecondaryCleanup(() => host.command("openshell", ["gateway", "destroy", "-g", "nemoclaw"], { artifactName: `${prefix}-openshell-gateway-destroy`, @@ -153,7 +153,7 @@ export async function startFakeDiscordGateway( portEnv: "FAKE_DISCORD_GATEWAY_PORT", portFileEnv: "FAKE_DISCORD_GATEWAY_PORT_FILE", captureFileEnv: "FAKE_DISCORD_GATEWAY_CAPTURE_FILE", - expectedEnv: { FAKE_DISCORD_GATEWAY_EXPECTED_TOKEN: token }, + credentialEnv: { FAKE_DISCORD_GATEWAY_EXPECTED_TOKEN: token }, env, redactionValues: redactions, }); @@ -174,9 +174,11 @@ export async function startFakeSlackApi( portEnv: "FAKE_SLACK_API_PORT", portFileEnv: "FAKE_SLACK_API_PORT_FILE", captureFileEnv: "FAKE_SLACK_API_CAPTURE_FILE", - expectedEnv: { + credentialEnv: { FAKE_SLACK_API_EXPECTED_BOT_TOKEN: botToken, FAKE_SLACK_API_EXPECTED_APP_TOKEN: appToken, + }, + fixtureEnv: { FAKE_SLACK_API_SOCKET_USER_ID: PAIRING_USER.slack, }, env, diff --git a/test/e2e/live/phase6-messaging-helpers.ts b/test/e2e/live/phase6-messaging-helpers.ts index d250e463344..aecc98719b1 100644 --- a/test/e2e/live/phase6-messaging-helpers.ts +++ b/test/e2e/live/phase6-messaging-helpers.ts @@ -97,16 +97,6 @@ export async function precleanSandbox( ); } -export async function cleanupSandbox( - host: HostCliClient, - sandboxName: string, - env: NodeJS.ProcessEnv, - redactions: string[], - prefix: string, -): Promise { - await precleanSandbox(host, sandboxName, env, redactions, prefix); -} - export function trackSandboxCleanup( cleanup: CleanupRegistry, host: HostCliClient, diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 97a33ccd12c..da017ad2dcf 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -493,7 +493,7 @@ describe("messaging provider installed-runtime proofs", () => { portEnv: "FAKE_SLACK_API_PORT", portFileEnv: "FAKE_SLACK_API_PORT_FILE", captureFileEnv: "FAKE_SLACK_API_CAPTURE_FILE", - expectedEnv: { + credentialEnv: { FAKE_SLACK_API_EXPECTED_BOT_TOKEN: "xoxb-fake-slack-not-ready-test", FAKE_SLACK_API_EXPECTED_APP_TOKEN: "xapp-fake-slack-not-ready-test", }, @@ -604,7 +604,7 @@ describe("messaging provider installed-runtime proofs", () => { portEnv: "FAKE_SLACK_API_PORT", portFileEnv: "FAKE_SLACK_API_PORT_FILE", captureFileEnv: "FAKE_SLACK_API_CAPTURE_FILE", - expectedEnv: { + credentialEnv: { FAKE_SLACK_API_EXPECTED_BOT_TOKEN: "xoxb-fake-slack-network-test", FAKE_SLACK_API_EXPECTED_APP_TOKEN: "xapp-fake-slack-network-test", }, @@ -662,7 +662,7 @@ describe("messaging provider installed-runtime proofs", () => { portEnv: "FAKE_API_PORT", portFileEnv: "FAKE_API_PORT_FILE", captureFileEnv: "FAKE_API_CAPTURE_FILE", - expectedEnv: {}, + credentialEnv: {}, redactionValues: [], env: {}, }), @@ -686,7 +686,7 @@ describe("messaging provider installed-runtime proofs", () => { portEnv: "FAKE_API_PORT", portFileEnv: "FAKE_API_PORT_FILE", captureFileEnv: "FAKE_API_CAPTURE_FILE", - expectedEnv: {}, + credentialEnv: {}, redactionValues: [], env: {}, }); @@ -698,6 +698,51 @@ describe("messaging provider installed-runtime proofs", () => { } }); + it("mounts fake API credentials without placing their values in Docker arguments", async () => { + const calls: string[][] = []; + const host = fakeDockerHost(OPENSHELL_BRIDGE_ADDRESS, calls); + const cleanup: Array<() => Promise> = []; + const sentinel = "123456:credential-must-not-enter-docker-argv"; + let credentialFile = ""; + + try { + await startFakeDockerApi(host, (_name, run) => cleanup.push(run), { + kind: "telegram", + imageScript: "fake-telegram-api.cjs", + containerPrefix: "fake-telegram-credential-file", + portEnv: "FAKE_TELEGRAM_API_PORT", + portFileEnv: "FAKE_TELEGRAM_API_PORT_FILE", + captureFileEnv: "FAKE_TELEGRAM_API_CAPTURE_FILE", + credentialEnv: { FAKE_TELEGRAM_API_EXPECTED_TOKEN: sentinel }, + redactionValues: [sentinel], + env: {}, + }); + + const apiRun = calls.find( + (invocation) => + invocation[0] === "docker" && + invocation[1] === "run" && + !invocation.some((argument) => argument.startsWith("NEMOCLAW_FAKE_API_UPSTREAM=")), + ); + expect(apiRun).toBeDefined(); + expect(JSON.stringify(apiRun)).not.toContain(sentinel); + expect(apiRun).toContain( + "FAKE_TELEGRAM_API_EXPECTED_TOKEN_FILE=/run/nemoclaw-fake-api-credentials/0", + ); + const mountSuffix = ":/run/nemoclaw-fake-api-credentials/0:ro"; + const credentialMount = apiRun?.find((argument) => argument.endsWith(mountSuffix)); + expect(credentialMount).toBeDefined(); + credentialFile = credentialMount!.slice(0, -mountSuffix.length); + expect(fs.statSync(credentialFile).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(credentialFile, "utf8")).toBe(sentinel); + } finally { + await cleanup + .reverse() + .reduce((previous, action) => previous.then(action), Promise.resolve()); + } + expect(fs.existsSync(credentialFile)).toBe(false); + }); + it("rejects a proxy that retains default-bridge egress", async () => { const host = fakeDockerHost(OPENSHELL_BRIDGE_ADDRESS, [], OPENSHELL_NETWORK_INSPECT, true); const cleanup: Array<() => Promise> = []; @@ -711,7 +756,7 @@ describe("messaging provider installed-runtime proofs", () => { portEnv: "FAKE_API_PORT", portFileEnv: "FAKE_API_PORT_FILE", captureFileEnv: "FAKE_API_CAPTURE_FILE", - expectedEnv: {}, + credentialEnv: {}, redactionValues: [], env: {}, }), @@ -744,7 +789,7 @@ describe("messaging provider installed-runtime proofs", () => { portEnv: "FAKE_API_PORT", portFileEnv: "FAKE_API_PORT_FILE", captureFileEnv: "FAKE_API_CAPTURE_FILE", - expectedEnv: {}, + credentialEnv: {}, redactionValues: [], env: { OPENSHELL_DOCKER_NETWORK_NAME: "configured-openshell-network" }, }), @@ -1176,7 +1221,9 @@ describe("messaging provider installed-runtime proofs", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fake-telegram-redaction-")); const portFile = path.join(dir, "port"); const captureFile = path.join(dir, "capture.jsonl"); + const credentialFile = path.join(dir, "expected-token"); const token = "123456:SUPER-SECRET-TELEGRAM-TOKEN"; + fs.writeFileSync(credentialFile, token, { mode: 0o600 }); const child = spawn(process.execPath, [FAKE_TELEGRAM_API], { env: { ...process.env, @@ -1184,7 +1231,7 @@ describe("messaging provider installed-runtime proofs", () => { FAKE_TELEGRAM_API_PORT: "0", FAKE_TELEGRAM_API_PORT_FILE: portFile, FAKE_TELEGRAM_API_CAPTURE_FILE: captureFile, - FAKE_TELEGRAM_API_EXPECTED_TOKEN: token, + FAKE_TELEGRAM_API_EXPECTED_TOKEN_FILE: credentialFile, }, stdio: ["ignore", "ignore", "pipe"], }); From 1fc389927e61666be7a13fdf3c85cafe5f87da8e Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 10:06:49 -0700 Subject: [PATCH 40/45] fix(e2e): publish fake API proxy from bridge Signed-off-by: Prekshi Vyas --- test/e2e/live/messaging-providers-helpers.ts | 40 +++++++++------ ...messaging-providers-runtime-proofs.test.ts | 49 ++++++++++++++++--- 2 files changed, 67 insertions(+), 22 deletions(-) diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index bb634e2d429..aadb1979539 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -426,12 +426,12 @@ async function inspectDockerJson( } } -async function inspectOpenShellBridgeAddress( +async function inspectOpenShellBridge( host: HostCliClient, kind: FakeDockerApiKind, env: NodeJS.ProcessEnv, redactionValues: string[], -): Promise { +): Promise<{ address: string; networkName: string }> { const networkName = env.OPENSHELL_DOCKER_NETWORK_NAME ?? process.env.OPENSHELL_DOCKER_NETWORK_NAME ?? @@ -465,7 +465,7 @@ async function inspectOpenShellBridgeAddress( ) { throw new Error("OpenShell Docker network must expose exactly one IPv4 bridge gateway"); } - return gateways[0]!; + return { address: gateways[0]!, networkName }; } export async function runSandboxShell( @@ -717,12 +717,8 @@ export async function startFakeDockerApi( env: NodeJS.ProcessEnv; }, ): Promise { - const openshellBridgeAddress = await inspectOpenShellBridgeAddress( - host, - options.kind, - options.env, - options.redactionValues, - ); + const { address: openshellBridgeAddress, networkName: openshellNetwork } = + await inspectOpenShellBridge(host, options.kind, options.env, options.redactionValues); fs.mkdirSync(path.join(REPO_ROOT, ".tmp"), { recursive: true }); const dir = fs.mkdtempSync(path.join(REPO_ROOT, ".tmp", `fake-${options.kind}.`)); const fixtureDir = path.join(dir, "fixture"); @@ -868,11 +864,10 @@ export async function startFakeDockerApi( } }); - // Docker does not expose a usable `-p` mapping for the credential-bearing API - // on this internal network, which is the regression this helper covers. Keep - // both it and its TCP forwarder on that network, and publish only the forwarder - // on the OpenShell bridge gateway. The forwarder carries rewritten credential - // traffic, so it must not retain default-bridge egress. + // Docker ignores `-p` mappings when a container starts on an internal network. + // Start the credential-free proxy on the OpenShell bridge so its ports can bind + // only to that bridge's gateway, then attach it to the private API network. + // The credential-bearing API remains attached only to the private network. const proxyStart = await runHost( host, "docker", @@ -883,7 +878,7 @@ export async function startFakeDockerApi( "--name", proxyContainer, "--network", - network, + openshellNetwork, ...containerPorts.flatMap((port) => ["-p", `${openshellBridgeAddress}::${String(port)}`]), "--read-only", "--cap-drop", @@ -910,6 +905,19 @@ export async function startFakeDockerApi( ); expectExitZero(proxyStart, `start fake ${options.kind} API proxy`); + const connectProxy = await runHost( + host, + "docker", + ["network", "connect", network, proxyContainer], + { + artifactName: `connect-fake-${options.kind}-api-proxy-internal-network`, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 30_000, + }, + ); + expectExitZero(connectProxy, `connect fake ${options.kind} API proxy to private network`); + const networkInspections = await Promise.all( [ ["api", container], @@ -927,7 +935,7 @@ export async function startFakeDockerApi( })), ); for (const { role, networks } of networkInspections) { - const expectedNetworks = [network]; + const expectedNetworks = role === "api" ? [network] : [openshellNetwork, network]; if ( typeof networks !== "object" || networks === null || diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index da017ad2dcf..f15cb7e43c6 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -52,6 +52,7 @@ const FAKE_SLACK_APP_TOKEN = "xapp-fake-slack-port-test"; const FAKE_SLACK_BOT_TOKEN = "xoxb-fake-slack-port-test"; const STDERR_TAIL_LIMIT = 4_096; const OPENSHELL_BRIDGE_ADDRESS = "172.18.0.1"; +const OPENSHELL_NETWORK_NAME = "openshell-docker"; const OPENSHELL_NETWORK_INSPECT = JSON.stringify([ { Driver: "bridge", @@ -248,6 +249,8 @@ function fakeDockerHost( ): HostCliClient { let network = ""; let proxyContainer = ""; + let proxyPrimaryNetwork = ""; + let proxyConnectedToInternal = false; const host = { command: async (command: string, args: string[]) => { calls.push([command, ...args]); @@ -264,15 +267,22 @@ function fakeDockerHost( case "create": network = args.at(-1) ?? ""; return successfulCommand(); + case "connect": + proxyConnectedToInternal = args[2] === network && args[3] === proxyContainer; + return successfulCommand(); default: return successfulCommand(); } case "run": - proxyContainer = args.some((argument) => + const startsProxy = args.some((argument) => argument.startsWith("NEMOCLAW_FAKE_API_UPSTREAM="), - ) + ); + proxyContainer = startsProxy ? (args[args.indexOf("--name") + 1] ?? "") : proxyContainer; + proxyPrimaryNetwork = startsProxy + ? (args[args.indexOf("--network") + 1] ?? "") + : proxyPrimaryNetwork; args .filter((argument) => argument.endsWith(":/tmp/fake")) .forEach((fakeApiMount) => { @@ -287,7 +297,8 @@ function fakeDockerHost( args.at(-1) === proxyContainer ? { ...(proxyOnDefaultBridge ? { bridge: {} } : {}), - [network]: {}, + [proxyPrimaryNetwork]: {}, + ...(proxyConnectedToInternal ? { [network]: {} } : {}), } : { [network]: {} }, ), @@ -571,7 +582,10 @@ describe("messaging provider installed-runtime proofs", () => { case "prove-fake-slack-api-api-internal-network": return successfulShellResult(invocation, JSON.stringify({ [network]: {} })); case "prove-fake-slack-api-proxy-internal-network": - return successfulShellResult(invocation, JSON.stringify({ [network]: {} })); + return successfulShellResult( + invocation, + JSON.stringify({ [OPENSHELL_NETWORK_NAME]: {}, [network]: {} }), + ); case "prove-fake-slack-api-internal-only": return successfulShellResult(invocation); case "prove-fake-slack-api-proxy-credential-environment-free": @@ -674,8 +688,9 @@ describe("messaging provider installed-runtime proofs", () => { } }); - it("accepts an isolated proxy published only on the OpenShell bridge", async () => { - const host = fakeDockerHost(); + it("publishes the proxy from the OpenShell bridge and connects it to the private API", async () => { + const calls: string[][] = []; + const host = fakeDockerHost(OPENSHELL_BRIDGE_ADDRESS, calls); const cleanup: Array<() => Promise> = []; try { @@ -691,6 +706,28 @@ describe("messaging provider installed-runtime proofs", () => { env: {}, }); expect(api.port).toBe("32100"); + const proxyRun = calls.find( + (invocation) => + invocation[0] === "docker" && + invocation[1] === "run" && + invocation.some((argument) => argument.startsWith("NEMOCLAW_FAKE_API_UPSTREAM=")), + ); + expect(proxyRun).toBeDefined(); + expect(proxyRun?.[proxyRun.indexOf("--network") + 1]).toBe(OPENSHELL_NETWORK_NAME); + const privateNetwork = calls + .find( + (invocation) => + invocation[0] === "docker" && invocation[1] === "network" && invocation[2] === "create", + ) + ?.at(-1); + const proxyContainer = proxyRun?.[proxyRun.indexOf("--name") + 1]; + expect(calls).toContainEqual([ + "docker", + "network", + "connect", + privateNetwork, + proxyContainer, + ]); } finally { await cleanup .reverse() From c5bee381dbdf8c22137f9d21f3c757a25dfe05c5 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 12:14:12 -0700 Subject: [PATCH 41/45] fix(e2e): avoid fake API gateway hairpin Signed-off-by: Prekshi Vyas --- test/e2e/live/messaging-providers-helpers.ts | 75 +++++++++---- test/e2e/live/messaging-providers.test.ts | 3 + test/e2e/live/openclaw-pairing-helpers.ts | 8 +- ...messaging-providers-runtime-proofs.test.ts | 105 +++++++++++++----- 4 files changed, 138 insertions(+), 53 deletions(-) diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index aadb1979539..1f64e447ff4 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -426,12 +426,12 @@ async function inspectDockerJson( } } -async function inspectOpenShellBridge( +async function inspectOpenShellBridgeAddress( host: HostCliClient, kind: FakeDockerApiKind, env: NodeJS.ProcessEnv, redactionValues: string[], -): Promise<{ address: string; networkName: string }> { +): Promise { const networkName = env.OPENSHELL_DOCKER_NETWORK_NAME ?? process.env.OPENSHELL_DOCKER_NETWORK_NAME ?? @@ -465,7 +465,7 @@ async function inspectOpenShellBridge( ) { throw new Error("OpenShell Docker network must expose exactly one IPv4 bridge gateway"); } - return { address: gateways[0]!, networkName }; + return gateways[0]!; } export async function runSandboxShell( @@ -717,8 +717,12 @@ export async function startFakeDockerApi( env: NodeJS.ProcessEnv; }, ): Promise { - const { address: openshellBridgeAddress, networkName: openshellNetwork } = - await inspectOpenShellBridge(host, options.kind, options.env, options.redactionValues); + const openshellBridgeAddress = await inspectOpenShellBridgeAddress( + host, + options.kind, + options.env, + options.redactionValues, + ); fs.mkdirSync(path.join(REPO_ROOT, ".tmp"), { recursive: true }); const dir = fs.mkdtempSync(path.join(REPO_ROOT, ".tmp", `fake-${options.kind}.`)); const fixtureDir = path.join(dir, "fixture"); @@ -728,6 +732,7 @@ export async function startFakeDockerApi( const container = uniqueContainerName(options.containerPrefix); const proxyContainer = uniqueContainerName(`${options.containerPrefix}-proxy`); const network = uniqueContainerName("nemoclaw-fake-api-network"); + const publicationNetwork = uniqueContainerName("nemoclaw-fake-api-publication"); const containerPorts = options.kind === "slack" ? [8080, 8081] : [8080]; let credentialMounts: Array<{ envName: string; hostPath: string; containerPath: string }>; try { @@ -777,6 +782,38 @@ export async function startFakeDockerApi( } }); + const publicationNetworkCreate = await runHost( + host, + "docker", + ["network", "create", "--driver", "bridge", publicationNetwork], + { + artifactName: `create-fake-${options.kind}-api-publication-network`, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 30_000, + }, + ); + try { + expectExitZero(publicationNetworkCreate, `create fake ${options.kind} API publication network`); + } catch (error) { + fs.rmSync(dir, { recursive: true, force: true }); + throw error; + } + cleanup(`remove ${publicationNetwork}`, async () => { + const remove = await runHost(host, "docker", ["network", "rm", publicationNetwork], { + artifactName: `cleanup-${publicationNetwork}`, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 60_000, + }); + if (remove.exitCode !== 0 && !/No such network:/iu.test(resultText(remove))) { + expectExitZero( + remove, + `remove fake ${options.kind} API publication network ${publicationNetwork}`, + ); + } + }); + const dockerArgs = [ "run", "-d", @@ -865,9 +902,10 @@ export async function startFakeDockerApi( }); // Docker ignores `-p` mappings when a container starts on an internal network. - // Start the credential-free proxy on the OpenShell bridge so its ports can bind - // only to that bridge's gateway, then attach it to the private API network. - // The credential-bearing API remains attached only to the private network. + // Start the credential-free proxy on a dedicated publication bridge so the + // OpenShell gateway can reach its bridge-bound ports without a same-network + // hairpin, then attach it to the private API network. The credential-bearing + // API remains attached only to the private network. const proxyStart = await runHost( host, "docker", @@ -878,7 +916,7 @@ export async function startFakeDockerApi( "--name", proxyContainer, "--network", - openshellNetwork, + publicationNetwork, ...containerPorts.flatMap((port) => ["-p", `${openshellBridgeAddress}::${String(port)}`]), "--read-only", "--cap-drop", @@ -935,7 +973,7 @@ export async function startFakeDockerApi( })), ); for (const { role, networks } of networkInspections) { - const expectedNetworks = role === "api" ? [network] : [openshellNetwork, network]; + const expectedNetworks = role === "api" ? [network] : [publicationNetwork, network]; if ( typeof networks !== "object" || networks === null || @@ -1076,17 +1114,12 @@ export async function applyFakeApiPolicy(options: { for (const binary of options.binaries) args.push("--binary", binary); args.push("--wait"); - const result = await runHost( - options.host, - options.host.openshellCommandPath, - args, - { - artifactName: options.artifactName, - env: options.env, - redactionValues: options.redactionValues, - timeoutMs: 120_000, - }, - ); + const result = await runHost(options.host, options.host.openshellCommandPath, args, { + artifactName: options.artifactName, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 120_000, + }); expectExitZero(result, options.artifactName); const binding = await runHost( diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index c5e5417928d..720bb606b1f 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -888,6 +888,7 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); imageScript: "fake-slack-api.cjs", containerPrefix: "nemoclaw-fake-slack", portEnv: "FAKE_SLACK_API_PORT", + portFileEnv: "FAKE_SLACK_API_PORT_FILE", captureFileEnv: "FAKE_SLACK_API_CAPTURE_FILE", credentialEnv: { FAKE_SLACK_API_EXPECTED_BOT_TOKEN: state.tokens.slackBot, @@ -1061,6 +1062,7 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); imageScript: "fake-telegram-api.cjs", containerPrefix: "nemoclaw-fake-telegram", portEnv: "FAKE_TELEGRAM_API_PORT", + portFileEnv: "FAKE_TELEGRAM_API_PORT_FILE", captureFileEnv: "FAKE_TELEGRAM_API_CAPTURE_FILE", credentialEnv: { FAKE_TELEGRAM_API_EXPECTED_TOKEN: state.tokens.telegram, @@ -1124,6 +1126,7 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); nodeArgs: ["--experimental-strip-types"], containerPrefix: "nemoclaw-fake-wechat", portEnv: "FAKE_WECHAT_API_PORT", + portFileEnv: "FAKE_WECHAT_API_PORT_FILE", captureFileEnv: "FAKE_WECHAT_API_CAPTURE_FILE", credentialEnv: { FAKE_WECHAT_API_EXPECTED_TOKEN: state.tokens.wechat, diff --git a/test/e2e/live/openclaw-pairing-helpers.ts b/test/e2e/live/openclaw-pairing-helpers.ts index 5d40e28558a..7bffc18db6d 100644 --- a/test/e2e/live/openclaw-pairing-helpers.ts +++ b/test/e2e/live/openclaw-pairing-helpers.ts @@ -151,6 +151,7 @@ export async function startFakeDiscordGateway( 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", credentialEnv: { FAKE_DISCORD_GATEWAY_EXPECTED_TOKEN: token }, env, @@ -171,6 +172,7 @@ export async function startFakeSlackApi( imageScript: "fake-slack-api.cjs", containerPrefix: "nemoclaw-fake-slack-pairing", portEnv: "FAKE_SLACK_API_PORT", + portFileEnv: "FAKE_SLACK_API_PORT_FILE", captureFileEnv: "FAKE_SLACK_API_CAPTURE_FILE", credentialEnv: { FAKE_SLACK_API_EXPECTED_BOT_TOKEN: botToken, @@ -546,11 +548,7 @@ export async function issuePairingRequest(options: { const script = options.channel === "slack" ? SLACK_PAIRING_SCRIPT : DISCORD_PAIRING_SCRIPT; const args = options.channel === "slack" - ? [ - options.fakeSlackPort ?? "", - options.fakeSlackWebSocketPort ?? "", - PAIRING_USER.slack, - ] + ? [options.fakeSlackPort ?? "", options.fakeSlackWebSocketPort ?? "", PAIRING_USER.slack] : [PAIRING_USER.discord, DISCORD_DM_CHANNEL]; return sandboxShWithArgs(options.sandbox, options.sandboxName, script, args, { artifactName: `${options.channel}-issue-pairing-request`, diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index f15cb7e43c6..80202a93d1d 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -52,7 +52,6 @@ const FAKE_SLACK_APP_TOKEN = "xapp-fake-slack-port-test"; const FAKE_SLACK_BOT_TOKEN = "xoxb-fake-slack-port-test"; const STDERR_TAIL_LIMIT = 4_096; const OPENSHELL_BRIDGE_ADDRESS = "172.18.0.1"; -const OPENSHELL_NETWORK_NAME = "openshell-docker"; const OPENSHELL_NETWORK_INSPECT = JSON.stringify([ { Driver: "bridge", @@ -247,7 +246,7 @@ function fakeDockerHost( networkInspect = OPENSHELL_NETWORK_INSPECT, proxyOnDefaultBridge = false, ): HostCliClient { - let network = ""; + let privateNetwork = ""; let proxyContainer = ""; let proxyPrimaryNetwork = ""; let proxyConnectedToInternal = false; @@ -265,10 +264,13 @@ function fakeDockerHost( case "inspect": return successfulCommand(networkInspect); case "create": - network = args.at(-1) ?? ""; + privateNetwork = args.includes("--internal") + ? (args.at(-1) ?? "") + : privateNetwork; return successfulCommand(); case "connect": - proxyConnectedToInternal = args[2] === network && args[3] === proxyContainer; + proxyConnectedToInternal = + args[2] === privateNetwork && args[3] === proxyContainer; return successfulCommand(); default: return successfulCommand(); @@ -298,9 +300,9 @@ function fakeDockerHost( ? { ...(proxyOnDefaultBridge ? { bridge: {} } : {}), [proxyPrimaryNetwork]: {}, - ...(proxyConnectedToInternal ? { [network]: {} } : {}), + ...(proxyConnectedToInternal ? { [privateNetwork]: {} } : {}), } - : { [network]: {} }, + : { [privateNetwork]: {} }, ), ) : successfulCommand( @@ -545,6 +547,9 @@ describe("messaging provider installed-runtime proofs", () => { expect(artifactNames).toContainEqual( expect.stringMatching(/^cleanup-nemoclaw-fake-api-network-/u), ); + expect(artifactNames).toContainEqual( + expect.stringMatching(/^cleanup-nemoclaw-fake-api-publication-/u), + ); }); it("collects diagnostics when published fake API proxy ports do not carry traffic", async () => { @@ -557,6 +562,7 @@ describe("messaging provider installed-runtime proofs", () => { let apiContainer = ""; let proxyContainer = ""; let network = ""; + let publicationNetwork = ""; const runner: CommandRunner = { async run(command, options) { const invocation = [command.command, ...command.args]; @@ -568,6 +574,9 @@ describe("messaging provider installed-runtime proofs", () => { case "create-fake-slack-api-network": network = invocation.at(-1) ?? ""; return successfulShellResult(invocation); + case "create-fake-slack-api-publication-network": + publicationNetwork = invocation.at(-1) ?? ""; + return successfulShellResult(invocation); case "start-fake-slack-api": { apiContainer = invocation[invocation.indexOf("--name") + 1] ?? ""; const mountSuffix = ":/tmp/fake"; @@ -584,7 +593,7 @@ describe("messaging provider installed-runtime proofs", () => { case "prove-fake-slack-api-proxy-internal-network": return successfulShellResult( invocation, - JSON.stringify({ [OPENSHELL_NETWORK_NAME]: {}, [network]: {} }), + JSON.stringify({ [publicationNetwork]: {}, [network]: {} }), ); case "prove-fake-slack-api-internal-only": return successfulShellResult(invocation); @@ -688,23 +697,31 @@ describe("messaging provider installed-runtime proofs", () => { } }); - it("publishes the proxy from the OpenShell bridge and connects it to the private API", async () => { + it("publishes through a dedicated bridge and connects the proxy to the private API", async () => { const calls: string[][] = []; const host = fakeDockerHost(OPENSHELL_BRIDGE_ADDRESS, calls); const cleanup: Array<() => Promise> = []; + const cleanupNames: string[] = []; try { - const api = await startFakeDockerApi(host, (_name, run) => cleanup.push(run), { - kind: "discord-gateway", - imageScript: "fake-discord-gateway-api.cjs", - containerPrefix: "fake-discord-gateway", - portEnv: "FAKE_API_PORT", - portFileEnv: "FAKE_API_PORT_FILE", - captureFileEnv: "FAKE_API_CAPTURE_FILE", - credentialEnv: {}, - redactionValues: [], - env: {}, - }); + const api = await startFakeDockerApi( + host, + (name, run) => { + cleanupNames.push(name); + cleanup.push(run); + }, + { + kind: "discord-gateway", + imageScript: "fake-discord-gateway-api.cjs", + containerPrefix: "fake-discord-gateway", + portEnv: "FAKE_API_PORT", + portFileEnv: "FAKE_API_PORT_FILE", + captureFileEnv: "FAKE_API_CAPTURE_FILE", + credentialEnv: {}, + redactionValues: [], + env: {}, + }, + ); expect(api.port).toBe("32100"); const proxyRun = calls.find( (invocation) => @@ -713,13 +730,27 @@ describe("messaging provider installed-runtime proofs", () => { invocation.some((argument) => argument.startsWith("NEMOCLAW_FAKE_API_UPSTREAM=")), ); expect(proxyRun).toBeDefined(); - expect(proxyRun?.[proxyRun.indexOf("--network") + 1]).toBe(OPENSHELL_NETWORK_NAME); - const privateNetwork = calls - .find( - (invocation) => - invocation[0] === "docker" && invocation[1] === "network" && invocation[2] === "create", - ) + const networkCreates = calls.filter( + (invocation) => + invocation[0] === "docker" && invocation[1] === "network" && invocation[2] === "create", + ); + expect(networkCreates).toHaveLength(2); + const privateNetwork = networkCreates + .find((invocation) => invocation.includes("--internal")) ?.at(-1); + const publicationCreate = networkCreates.find( + (invocation) => !invocation.includes("--internal"), + ); + const publicationNetwork = publicationCreate?.at(-1); + expect(publicationCreate?.slice(0, -1)).toEqual([ + "docker", + "network", + "create", + "--driver", + "bridge", + ]); + expect(proxyRun?.[proxyRun.indexOf("--network") + 1]).toBe(publicationNetwork); + expect(proxyRun).not.toContain("openshell-docker"); const proxyContainer = proxyRun?.[proxyRun.indexOf("--name") + 1]; expect(calls).toContainEqual([ "docker", @@ -728,11 +759,23 @@ describe("messaging provider installed-runtime proofs", () => { privateNetwork, proxyContainer, ]); + expect(cleanupNames).toEqual([ + expect.stringMatching(/^remove nemoclaw-fake-api-network-/u), + expect.stringMatching(/^remove nemoclaw-fake-api-publication-/u), + expect.stringMatching(/^remove fake-discord-gateway-/u), + expect.stringMatching(/^remove fake-discord-gateway-proxy-/u), + ]); } finally { await cleanup .reverse() .reduce((previous, action) => previous.then(action), Promise.resolve()); } + expect( + calls.filter( + (invocation) => + invocation[0] === "docker" && invocation[1] === "network" && invocation[2] === "rm", + ), + ).toHaveLength(2); }); it("mounts fake API credentials without placing their values in Docker arguments", async () => { @@ -770,8 +813,16 @@ describe("messaging provider installed-runtime proofs", () => { const credentialMount = apiRun?.find((argument) => argument.endsWith(mountSuffix)); expect(credentialMount).toBeDefined(); credentialFile = credentialMount!.slice(0, -mountSuffix.length); - expect(fs.statSync(credentialFile).mode & 0o777).toBe(0o600); - expect(fs.readFileSync(credentialFile, "utf8")).toBe(sentinel); + const credentialFd = fs.openSync( + credentialFile, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW, + ); + try { + expect(fs.fstatSync(credentialFd).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(credentialFd, "utf8")).toBe(sentinel); + } finally { + fs.closeSync(credentialFd); + } } finally { await cleanup .reverse() From a0df2effe83d029db3173ff988e5ba3e0bba8479 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 13:43:09 -0700 Subject: [PATCH 42/45] fix(e2e): isolate fake API credentials Signed-off-by: Prekshi Vyas --- test/e2e/live/hermes-discord.test.ts | 673 +++++++++--------- test/e2e/live/messaging-providers-helpers.ts | 66 +- test/e2e/live/messaging-providers.test.ts | 89 +-- .../e2e/live/openclaw-discord-pairing.test.ts | 306 ++++---- test/e2e/live/openclaw-slack-pairing.test.ts | 289 ++++---- ...messaging-providers-runtime-proofs.test.ts | 66 +- 6 files changed, 742 insertions(+), 747 deletions(-) diff --git a/test/e2e/live/hermes-discord.test.ts b/test/e2e/live/hermes-discord.test.ts index f51ab9a3d0d..f0e18ada82a 100644 --- a/test/e2e/live/hermes-discord.test.ts +++ b/test/e2e/live/hermes-discord.test.ts @@ -20,6 +20,7 @@ import { type FakeDockerApi, rawTokenSurfaceProbe, startFakeDockerApi, + SYNTHETIC_FAKE_API_CREDENTIALS, } from "./messaging-providers-helpers.ts"; import { runSecondaryCleanup as bestEffortLifecycleCleanup, @@ -36,7 +37,7 @@ import { const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-hermes-discord"; validateSandboxName(SANDBOX_NAME); -const DISCORD_TOKEN = process.env.DISCORD_BOT_TOKEN ?? "test-fake-discord-token-hermes-e2e"; +const DISCORD_TOKEN = SYNTHETIC_FAKE_API_CREDENTIALS.hermesDiscord; const DISCORD_SERVER_IDS = process.env.DISCORD_SERVER_IDS ?? "1491590992753590594"; const DISCORD_ALLOWED_IDS = process.env.DISCORD_ALLOWED_IDS ?? "1005536447329222676"; const DISCORD_REQUIRE_MENTION = process.env.DISCORD_REQUIRE_MENTION ?? "0"; @@ -282,151 +283,160 @@ async function runHermesPythonDiscordGatewayProof( ); } -test("hermes-discord: Hermes Discord schema, credential isolation, and native gateway rewrite", { - timeout: HERMES_DISCORD_TEST_TIMEOUT_MS, - meta: { - e2ePhases: [ - "prepare clean Hermes Discord runner", - "install Hermes Discord sandbox", - "validate Discord provider and Hermes health", - "validate Discord config and placeholders", - "exercise native Discord gateway rewrite", - "verify Discord token isolation and REST boundary", - "finalize Hermes Discord resources", - ], - }, -}, async ({ artifacts, cleanup, host, progress, sandbox, secrets }) => { - const apiKey = secrets.required("NVIDIA_INFERENCE_API_KEY"); - const env = commandEnv(apiKey); - const redactionValues = redactions(apiKey); - - await artifacts.target.declare({ - id: "hermes-discord", - boundary: - "install.sh --non-interactive Hermes sandbox + Discord config + OpenShell provider rewrite + sandbox leak probes", - sandboxName: SANDBOX_NAME, - discordServerIds: DISCORD_SERVER_IDS, - discordAllowedIds: DISCORD_ALLOWED_IDS, - discordRequireMention: DISCORD_REQUIRE_MENTION, - }); - - const gatewayCleanupOptions = { - artifactName: "cleanup-hermes-discord-openshell-gateway-destroy", - env, - redactionValues, - timeoutMs: 120_000, - }; - cleanup.trackGateway( - { - cleanupGatewayRegistration: (name: string) => - cleanupWhenOpenShellAvailable( - host, - { - artifactName: "cleanup-hermes-discord-probe-openshell-gateway", - env, - redactionValues, - timeoutMs: 30_000, - }, - () => host.cleanupGatewayRegistration(name, gatewayCleanupOptions), - ), +test( + "hermes-discord: Hermes Discord schema, credential isolation, and native gateway rewrite", + { + timeout: HERMES_DISCORD_TEST_TIMEOUT_MS, + meta: { + e2ePhases: [ + "prepare clean Hermes Discord runner", + "install Hermes Discord sandbox", + "validate Discord provider and Hermes health", + "validate Discord config and placeholders", + "exercise native Discord gateway rewrite", + "verify Discord token isolation and REST boundary", + "finalize Hermes Discord resources", + ], }, - "nemoclaw", - gatewayCleanupOptions, - ); - trackPreinstallSandboxCleanup( - cleanup, - host, - sandbox, - SANDBOX_NAME, - env, - redactionValues, - "cleanup-hermes-discord", - ); + }, + async ({ artifacts, cleanup, host, progress, sandbox, secrets }) => { + const apiKey = secrets.required("NVIDIA_INFERENCE_API_KEY"); + const env = commandEnv(apiKey); + const redactionValues = redactions(apiKey); + + await artifacts.target.declare({ + id: "hermes-discord", + boundary: + "install.sh --non-interactive Hermes sandbox + Discord config + OpenShell provider rewrite + sandbox leak probes", + sandboxName: SANDBOX_NAME, + discordServerIds: DISCORD_SERVER_IDS, + discordAllowedIds: DISCORD_ALLOWED_IDS, + discordRequireMention: DISCORD_REQUIRE_MENTION, + }); - await precleanHermesDiscord(host, SANDBOX_NAME, env, redactionValues, "preclean-hermes-discord"); + const gatewayCleanupOptions = { + artifactName: "cleanup-hermes-discord-openshell-gateway-destroy", + env, + redactionValues, + timeoutMs: 120_000, + }; + cleanup.trackGateway( + { + cleanupGatewayRegistration: (name: string) => + cleanupWhenOpenShellAvailable( + host, + { + artifactName: "cleanup-hermes-discord-probe-openshell-gateway", + env, + redactionValues, + timeoutMs: 30_000, + }, + () => host.cleanupGatewayRegistration(name, gatewayCleanupOptions), + ), + }, + "nemoclaw", + gatewayCleanupOptions, + ); + trackPreinstallSandboxCleanup( + cleanup, + host, + sandbox, + SANDBOX_NAME, + env, + redactionValues, + "cleanup-hermes-discord", + ); - const docker = await dockerInfo(host, env); - expectExitZero(docker, "Docker is running"); - expect(process.env.NEMOCLAW_NON_INTERACTIVE ?? env.NEMOCLAW_NON_INTERACTIVE).toBe("1"); - expect( - process.env.NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE ?? env.NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE, - ).toBe("1"); + await precleanHermesDiscord( + host, + SANDBOX_NAME, + env, + redactionValues, + "preclean-hermes-discord", + ); - progress.phase("install Hermes Discord sandbox"); - const install = await host.command("bash", ["install.sh", "--non-interactive"], { - artifactName: "phase-1-install-hermes-discord", - cwd: REPO_ROOT, - env, - redactionValues, - timeoutMs: 60 * 60_000, - }); - expectExitZero(install, "install.sh --non-interactive with Hermes Discord"); - - const cliProbe = await host.command( - "bash", - [ - "-lc", - 'command -v nemoclaw && command -v "$1" && "$1" --version', - "cli-probe-hermes-discord", - host.openshellCommandPath, - ], - { - artifactName: "phase-1-cli-probe", + const docker = await dockerInfo(host, env); + expectExitZero(docker, "Docker is running"); + expect(process.env.NEMOCLAW_NON_INTERACTIVE ?? env.NEMOCLAW_NON_INTERACTIVE).toBe("1"); + expect( + process.env.NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE ?? env.NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE, + ).toBe("1"); + + progress.phase("install Hermes Discord sandbox"); + const install = await host.command("bash", ["install.sh", "--non-interactive"], { + artifactName: "phase-1-install-hermes-discord", + cwd: REPO_ROOT, env, redactionValues, - timeoutMs: 30_000, - }, - ); - expectExitZero(cliProbe, "nemoclaw and openshell installed"); - expect(cliProbe.stdout).toContain("nemoclaw"); + timeoutMs: 60 * 60_000, + }); + expectExitZero(install, "install.sh --non-interactive with Hermes Discord"); - progress.phase("validate Discord provider and Hermes health"); - const list = await host.command("nemoclaw", ["list"], { - artifactName: "phase-2-nemoclaw-list", - env, - redactionValues, - timeoutMs: 60_000, - }); - expectExitZero(list, "nemoclaw list"); - expect(resultText(list)).toContain(SANDBOX_NAME); + const cliProbe = await host.command( + "bash", + [ + "-lc", + 'command -v nemoclaw && command -v "$1" && "$1" --version', + "cli-probe-hermes-discord", + host.openshellCommandPath, + ], + { + artifactName: "phase-1-cli-probe", + env, + redactionValues, + timeoutMs: 30_000, + }, + ); + expectExitZero(cliProbe, "nemoclaw and openshell installed"); + expect(cliProbe.stdout).toContain("nemoclaw"); - const provider = await host.command( - host.openshellCommandPath, - ["provider", "get", `${SANDBOX_NAME}-discord-bridge`], - { - artifactName: "phase-2-discord-provider-get", + progress.phase("validate Discord provider and Hermes health"); + const list = await host.command("nemoclaw", ["list"], { + artifactName: "phase-2-nemoclaw-list", env, redactionValues, timeoutMs: 60_000, - }, - ); - expectExitZero(provider, "Discord provider exists in gateway"); - - let health: ShellProbeResult | undefined; - for (let attempt = 1; attempt <= 15; attempt += 1) { - health = await sandboxSh(sandbox, SANDBOX_NAME, `curl -sf ${shellQuote(HERMES_HEALTH_URL)}`, { - artifactName: `phase-3-hermes-health-${attempt}`, - redactionValues, - timeoutMs: 20_000, }); - switch (health.exitCode === 0 && /"ok"/i.test(resultText(health))) { - case true: - attempt = 16; - break; - default: - await sleep(4_000); + expectExitZero(list, "nemoclaw list"); + expect(resultText(list)).toContain(SANDBOX_NAME); + + const provider = await host.command( + host.openshellCommandPath, + ["provider", "get", `${SANDBOX_NAME}-discord-bridge`], + { + artifactName: "phase-2-discord-provider-get", + env, + redactionValues, + timeoutMs: 60_000, + }, + ); + expectExitZero(provider, "Discord provider exists in gateway"); + + let health: ShellProbeResult | undefined; + for (let attempt = 1; attempt <= 15; attempt += 1) { + health = await sandboxSh(sandbox, SANDBOX_NAME, `curl -sf ${shellQuote(HERMES_HEALTH_URL)}`, { + artifactName: `phase-3-hermes-health-${attempt}`, + redactionValues, + timeoutMs: 20_000, + }); + switch (health.exitCode === 0 && /"ok"/i.test(resultText(health))) { + case true: + attempt = 16; + break; + default: + await sleep(4_000); + } } - } - expect(health, "Hermes health probe did not run").toBeTruthy(); - expect(health?.exitCode, health ? resultText(health) : "missing health result").toBe(0); - expect(resultText(health!)).toMatch(/"ok"/i); - - progress.phase("validate Discord config and placeholders"); - const expectedRequireMention = DISCORD_REQUIRE_MENTION === "0" ? "false" : "true"; - const configProbe = await sandboxShWithArgs( - sandbox, - SANDBOX_NAME, - `EXPECTED_REQUIRE_MENTION=${shellQuote(expectedRequireMention)} python3 - <<'PY' + expect(health, "Hermes health probe did not run").toBeTruthy(); + expect(health?.exitCode, health ? resultText(health) : "missing health result").toBe(0); + expect(resultText(health!)).toMatch(/"ok"/i); + + progress.phase("validate Discord config and placeholders"); + const expectedRequireMention = DISCORD_REQUIRE_MENTION === "0" ? "false" : "true"; + const configProbe = await sandboxShWithArgs( + sandbox, + SANDBOX_NAME, + `EXPECTED_REQUIRE_MENTION=${shellQuote(expectedRequireMention)} python3 - <<'PY' import os import sys, yaml with open("/sandbox/.hermes/config.yaml", "r", encoding="utf-8") as f: @@ -464,16 +474,16 @@ if errors: raise SystemExit(1) print("OK") PY`, - [], - { artifactName: "phase-4-hermes-discord-config-shape", redactionValues }, - ); - expectExitZero(configProbe, "Hermes Discord config shape"); - expect(configProbe.stdout.trim()).toBe("OK"); + [], + { artifactName: "phase-4-hermes-discord-config-shape", redactionValues }, + ); + expectExitZero(configProbe, "Hermes Discord config shape"); + expect(configProbe.stdout.trim()).toBe("OK"); - const envProbe = await sandboxShWithArgs( - sandbox, - SANDBOX_NAME, - `EXPECTED_ALLOWED_USERS=${shellQuote(normalizedCsv(DISCORD_ALLOWED_IDS))} EXPECTED_GUILD_IDS=${shellQuote(normalizedCsv(DISCORD_SERVER_IDS))} python3 - <<'PY' + const envProbe = await sandboxShWithArgs( + sandbox, + SANDBOX_NAME, + `EXPECTED_ALLOWED_USERS=${shellQuote(normalizedCsv(DISCORD_ALLOWED_IDS))} EXPECTED_GUILD_IDS=${shellQuote(normalizedCsv(DISCORD_SERVER_IDS))} python3 - <<'PY' import os from pathlib import Path text = Path("/sandbox/.hermes/.env").read_text(encoding="utf-8") @@ -492,108 +502,108 @@ if errors: raise SystemExit(1) print("OK") PY`, - [], - { artifactName: "phase-4-hermes-discord-env-shape", redactionValues }, - ); - expectExitZero(envProbe, "Hermes Discord .env shape"); - expect(envProbe.stdout.trim()).toBe("OK"); - - progress.phase("exercise native Discord gateway rewrite"); - const fakeGateway = await startHermesFakeDiscordGateway( - host, - cleanup, - env, - DISCORD_TOKEN, - redactionValues, - ); - await applyFakeApiPolicy({ - host, - sandboxName: SANDBOX_NAME, - policyHost: FAKE_DISCORD_HOST, - endpoints: [ - { - port: fakeGateway.port, - protocol: "websocket", - providerName: `${SANDBOX_NAME}-discord-bridge`, - }, - ], - binaries: [ - "/usr/local/bin/node", - "/usr/bin/node", - "/usr/local/bin/python3", - "/usr/bin/python3", - "/opt/hermes/.venv/bin/python", - ], - artifactName: "apply-hermes-fake-discord-gateway-policy", - env, - redactionValues, - }); + [], + { artifactName: "phase-4-hermes-discord-env-shape", redactionValues }, + ); + expectExitZero(envProbe, "Hermes Discord .env shape"); + expect(envProbe.stdout.trim()).toBe("OK"); - const nativeGateway = await runHermesPythonDiscordGatewayProof( - sandbox, - fakeGateway.port, - redactionValues, - ); - const gatewayCapture = await host.command( - "bash", - [ - "-lc", - 'if [ -f "$1" ]; then sed -n "1,80p" "$1"; else printf "MISSING_CAPTURE\\n"; fi', - "read-hermes-discord-gateway-capture", - fakeGateway.captureFile, - ], - { - artifactName: "hermes-discord-gateway-capture", + progress.phase("exercise native Discord gateway rewrite"); + const fakeGateway = await startHermesFakeDiscordGateway( + host, + cleanup, env, + DISCORD_TOKEN, redactionValues, - timeoutMs: 30_000, - }, - ); - expectExitZero(gatewayCapture, "Hermes Discord Gateway capture"); - expectExitZero(nativeGateway, "Hermes Python Discord Gateway protocol proof"); - expect(resultText(nativeGateway)).toContain("UPGRADE"); - expect(resultText(nativeGateway)).toContain("HELLO"); - expect(resultText(nativeGateway)).toContain("IDENTIFY_SENT_PLACEHOLDER"); - expect(resultText(nativeGateway)).toContain("READY"); - expect(resultText(nativeGateway)).toContain("HEARTBEAT_ACK"); - expect(resultText(nativeGateway)).not.toContain("IMPORT_DISCORD_FAILED"); - assertDiscordGatewayCapture(fakeGateway.captureFile, DISCORD_TOKEN); - - progress.phase("verify Discord token isolation and REST boundary"); - const envSurface = await rawTokenSurfaceProbe( - sandbox, - SANDBOX_NAME, - DISCORD_TOKEN, - "env", - "phase-5-raw-token-env-probe", - redactionValues, - ); - expect(envSurface).toBe("ABSENT"); - - const processSurface = await rawTokenSurfaceProbe( - sandbox, - SANDBOX_NAME, - DISCORD_TOKEN, - "process", - "phase-5-raw-token-process-probe", - redactionValues, - ); - expect(processSurface).toBe("ABSENT"); + ); + await applyFakeApiPolicy({ + host, + sandboxName: SANDBOX_NAME, + policyHost: FAKE_DISCORD_HOST, + endpoints: [ + { + port: fakeGateway.port, + protocol: "websocket", + providerName: `${SANDBOX_NAME}-discord-bridge`, + }, + ], + binaries: [ + "/usr/local/bin/node", + "/usr/bin/node", + "/usr/local/bin/python3", + "/usr/bin/python3", + "/opt/hermes/.venv/bin/python", + ], + artifactName: "apply-hermes-fake-discord-gateway-policy", + env, + redactionValues, + }); - const filesystemSurface = await rawTokenSurfaceProbe( - sandbox, - SANDBOX_NAME, - DISCORD_TOKEN, - "filesystem", - "phase-5-raw-token-filesystem-probe", - redactionValues, - ); - expect(filesystemSurface).toBe("ABSENT"); + const nativeGateway = await runHermesPythonDiscordGatewayProof( + sandbox, + fakeGateway.port, + redactionValues, + ); + const gatewayCapture = await host.command( + "bash", + [ + "-lc", + 'if [ -f "$1" ]; then sed -n "1,80p" "$1"; else printf "MISSING_CAPTURE\\n"; fi', + "read-hermes-discord-gateway-capture", + fakeGateway.captureFile, + ], + { + artifactName: "hermes-discord-gateway-capture", + env, + redactionValues, + timeoutMs: 30_000, + }, + ); + expectExitZero(gatewayCapture, "Hermes Discord Gateway capture"); + expectExitZero(nativeGateway, "Hermes Python Discord Gateway protocol proof"); + expect(resultText(nativeGateway)).toContain("UPGRADE"); + expect(resultText(nativeGateway)).toContain("HELLO"); + expect(resultText(nativeGateway)).toContain("IDENTIFY_SENT_PLACEHOLDER"); + expect(resultText(nativeGateway)).toContain("READY"); + expect(resultText(nativeGateway)).toContain("HEARTBEAT_ACK"); + expect(resultText(nativeGateway)).not.toContain("IMPORT_DISCORD_FAILED"); + assertDiscordGatewayCapture(fakeGateway.captureFile, DISCORD_TOKEN); + + progress.phase("verify Discord token isolation and REST boundary"); + const envSurface = await rawTokenSurfaceProbe( + sandbox, + SANDBOX_NAME, + DISCORD_TOKEN, + "env", + "phase-5-raw-token-env-probe", + redactionValues, + ); + expect(envSurface).toBe("ABSENT"); + + const processSurface = await rawTokenSurfaceProbe( + sandbox, + SANDBOX_NAME, + DISCORD_TOKEN, + "process", + "phase-5-raw-token-process-probe", + redactionValues, + ); + expect(processSurface).toBe("ABSENT"); + + const filesystemSurface = await rawTokenSurfaceProbe( + sandbox, + SANDBOX_NAME, + DISCORD_TOKEN, + "filesystem", + "phase-5-raw-token-filesystem-probe", + redactionValues, + ); + expect(filesystemSurface).toBe("ABSENT"); - const discordApi = await sandboxNode( - sandbox, - SANDBOX_NAME, - ` + const discordApi = await sandboxNode( + sandbox, + SANDBOX_NAME, + ` import https from "node:https"; const token = process.env.DISCORD_BOT_TOKEN ?? ""; if (!/^openshell:resolve:env:v[1-9][0-9]*_DISCORD_BOT_TOKEN$/.test(token)) { @@ -614,39 +624,39 @@ req.on("error", (error) => console.log(JSON.stringify({ error: error.message })) req.setTimeout(20000, () => { req.destroy(); console.log(JSON.stringify({ error: "timeout" })); }); req.end(); `, - {}, - { - artifactName: "phase-6-discord-users-me", - redactionValues, - timeoutMs: 30_000, - }, - ); - expectExitZero(discordApi, "Discord REST users/@me probe command"); - const discordApiRows = discordApi.stdout - .split(/\r?\n/) - .filter((line) => line.trim().startsWith("{")) - .map((line) => JSON.parse(line) as { statusCode?: number; error?: string }); - const discordApiResult = discordApiRows.at(-1) ?? {}; - switch (discordApiResult.error ?? "") { - case "timeout": - await artifacts.writeJson("phase-6-discord-users-me-skip.json", { - reason: "Discord API timed out, matching legacy skip behavior", - }); - break; - case "": - expect( - [200, 401].includes(discordApiResult.statusCode ?? 0), - `Unexpected Discord users/@me response (got ${discordApiResult.statusCode}): ${discordApi.stdout}`, - ).toBe(true); - break; - default: - throw new Error(`Discord API call failed: ${discordApiResult.error}`); - } - - const bridgeResidue = await sandboxShWithArgs( - sandbox, - SANDBOX_NAME, - String.raw`set +e + {}, + { + artifactName: "phase-6-discord-users-me", + redactionValues, + timeoutMs: 30_000, + }, + ); + expectExitZero(discordApi, "Discord REST users/@me probe command"); + const discordApiRows = discordApi.stdout + .split(/\r?\n/) + .filter((line) => line.trim().startsWith("{")) + .map((line) => JSON.parse(line) as { statusCode?: number; error?: string }); + const discordApiResult = discordApiRows.at(-1) ?? {}; + switch (discordApiResult.error ?? "") { + case "timeout": + await artifacts.writeJson("phase-6-discord-users-me-skip.json", { + reason: "Discord API timed out, matching legacy skip behavior", + }); + break; + case "": + expect( + [200, 401].includes(discordApiResult.statusCode ?? 0), + `Unexpected Discord users/@me response (got ${discordApiResult.statusCode}): ${discordApi.stdout}`, + ).toBe(true); + break; + default: + throw new Error(`Discord API call failed: ${discordApiResult.error}`); + } + + const bridgeResidue = await sandboxShWithArgs( + sandbox, + SANDBOX_NAME, + String.raw`set +e env_needle="$(printf "%s%s" "NEMOCLAW_DISCORD_" "FACADE_URL")" name_needle="$(printf "%s%s" "nemoclaw-discord-" "facade")" proxy_needle="$(printf "%s" "DISCORD_PROXY")" @@ -666,65 +676,66 @@ for p in /proc/[0-9]*; do case "$cmd" in *"$name_needle"*) echo PROCESS_FACADE ;; esac case "$cmd" in *"$decode_needle"*) echo PROCESS_DECODE_PROXY ;; esac done`, - [], - { artifactName: "phase-7-no-local-discord-bridge", redactionValues }, - ); - expectExitZero(bridgeResidue, "no local Discord bridge residue probe"); - expect(bridgeResidue.stdout.trim()).toBe(""); - - progress.phase("finalize Hermes Discord resources"); - await (async (): Promise => { - switch (process.env.NEMOCLAW_E2E_KEEP_SANDBOX) { - case "1": - return; - default: - } - const destroy = await host.command("nemoclaw", [SANDBOX_NAME, "destroy", "--yes"], { - artifactName: "phase-8-nemoclaw-destroy", - env, - redactionValues, - timeoutMs: 15 * 60_000, - }); - expectExitZero(destroy, "destroy Hermes Discord sandbox"); - await bestEffortLifecycleCleanup(() => - host.command(host.openshellCommandPath, ["gateway", "destroy", "-g", "nemoclaw"], { - artifactName: "phase-8-openshell-gateway-destroy", - env, - redactionValues, - timeoutMs: 120_000, - }), + [], + { artifactName: "phase-7-no-local-discord-bridge", redactionValues }, ); - const registryProbe = await host.command( - "bash", - [ - "-lc", - `registry="$HOME/.nemoclaw/sandboxes.json"; if [ -f "$registry" ] && grep -Fq ${shellQuote(`"${SANDBOX_NAME}"`)} "$registry"; then echo FOUND; exit 1; else echo ABSENT; fi`, - ], - { - artifactName: "phase-8-registry-removal-probe", - env: sandboxAccessEnv(), + expectExitZero(bridgeResidue, "no local Discord bridge residue probe"); + expect(bridgeResidue.stdout.trim()).toBe(""); + + progress.phase("finalize Hermes Discord resources"); + await (async (): Promise => { + switch (process.env.NEMOCLAW_E2E_KEEP_SANDBOX) { + case "1": + return; + default: + } + const destroy = await host.command("nemoclaw", [SANDBOX_NAME, "destroy", "--yes"], { + artifactName: "phase-8-nemoclaw-destroy", + env, redactionValues, - timeoutMs: 30_000, + timeoutMs: 15 * 60_000, + }); + expectExitZero(destroy, "destroy Hermes Discord sandbox"); + await bestEffortLifecycleCleanup(() => + host.command(host.openshellCommandPath, ["gateway", "destroy", "-g", "nemoclaw"], { + artifactName: "phase-8-openshell-gateway-destroy", + env, + redactionValues, + timeoutMs: 120_000, + }), + ); + const registryProbe = await host.command( + "bash", + [ + "-lc", + `registry="$HOME/.nemoclaw/sandboxes.json"; if [ -f "$registry" ] && grep -Fq ${shellQuote(`"${SANDBOX_NAME}"`)} "$registry"; then echo FOUND; exit 1; else echo ABSENT; fi`, + ], + { + artifactName: "phase-8-registry-removal-probe", + env: sandboxAccessEnv(), + redactionValues, + timeoutMs: 30_000, + }, + ); + expectExitZero(registryProbe, "sandbox removed from registry"); + expect(registryProbe.stdout.trim()).toBe("ABSENT"); + })(); + + await artifacts.target.complete({ + id: "hermes-discord", + assertions: { + dockerAndNonInteractivePrereqs: true, + installHermesDiscord: true, + providerRegistered: true, + hermesHealthy: true, + configSchema: true, + envPlaceholders: true, + nativePythonDiscordGatewayRewrite: true, + rawTokenAbsentFromConfigEnvProcessAndFilesystem: true, + discordRestBoundaryReachedOrSkippedOnTimeout: true, + noLocalDiscordBridgeResidue: true, + cleanupVerified: process.env.NEMOCLAW_E2E_KEEP_SANDBOX !== "1", }, - ); - expectExitZero(registryProbe, "sandbox removed from registry"); - expect(registryProbe.stdout.trim()).toBe("ABSENT"); - })(); - - await artifacts.target.complete({ - id: "hermes-discord", - assertions: { - dockerAndNonInteractivePrereqs: true, - installHermesDiscord: true, - providerRegistered: true, - hermesHealthy: true, - configSchema: true, - envPlaceholders: true, - nativePythonDiscordGatewayRewrite: true, - rawTokenAbsentFromConfigEnvProcessAndFilesystem: true, - discordRestBoundaryReachedOrSkippedOnTimeout: true, - noLocalDiscordBridgeResidue: true, - cleanupVerified: process.env.NEMOCLAW_E2E_KEEP_SANDBOX !== "1", - }, - }); -}); + }); + }, +); diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 1f64e447ff4..164996d14bd 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -86,6 +86,22 @@ export type MessagingTokens = { extraGithub: string; }; +export const SYNTHETIC_FAKE_API_CREDENTIALS = Object.freeze({ + messagingProviders: Object.freeze({ + telegram: "test-fake-telegram-token-e2e", + discord: "test-fake-discord-token-e2e", + slackBot: "xoxb-fake-slack-token-e2e", + slackApp: "xapp-fake-slack-app-token-e2e", + wechat: "test-fake-wechat-token-e2e", + }), + hermesDiscord: "test-fake-discord-token-hermes-e2e", + openClawDiscordPairing: "test-fake-discord-pairing-e2e", + openClawSlackPairing: Object.freeze({ + bot: "xoxb-fake-slack-pairing-e2e", + app: "xapp-fake-slack-pairing-e2e", + }), +}); + export type MessagingEnv = { env: NodeJS.ProcessEnv; tokens: MessagingTokens; @@ -189,10 +205,6 @@ export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -export function isFakeSlackToken(value: string): boolean { - return /^(xoxb|xapp)-(fake|test)-/.test(value); -} - export function nonEmpty(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; @@ -253,23 +265,8 @@ export function tokenValues(tokens: MessagingTokens): string[] { } export function messagingEnv(): MessagingEnv { - const telegram = - nonEmpty(process.env.TELEGRAM_BOT_TOKEN_REAL) ?? - nonEmpty(process.env.TELEGRAM_BOT_TOKEN) ?? - "test-fake-telegram-token-e2e"; - const discord = - nonEmpty(process.env.DISCORD_BOT_TOKEN_REAL) ?? - nonEmpty(process.env.DISCORD_BOT_TOKEN) ?? - "test-fake-discord-token-e2e"; - const slackBot = - nonEmpty(process.env.SLACK_BOT_TOKEN_REAL) ?? - nonEmpty(process.env.SLACK_BOT_TOKEN) ?? - "xoxb-fake-slack-token-e2e"; - const slackApp = - nonEmpty(process.env.SLACK_APP_TOKEN_REAL) ?? - nonEmpty(process.env.SLACK_APP_TOKEN) ?? - "xapp-fake-slack-app-token-e2e"; - const wechat = "test-fake-wechat-token-e2e"; + const { telegram, discord, slackBot, slackApp, wechat } = + SYNTHETIC_FAKE_API_CREDENTIALS.messagingProviders; const wechatAccount = nonEmpty(process.env.WECHAT_ACCOUNT_ID) ?? "e2e-fake-account-12345"; const slackIds = nonEmpty(process.env.SLACK_ALLOWED_USERS) ?? "U0AR85ATALW,U09E2ESLACK"; @@ -340,6 +337,8 @@ export function messagingEnv(): MessagingEnv { TELEGRAM_BOT_TOKEN_AGENT_A: tokens.extraTelegramA, TELEGRAM_BOT_TOKEN_AGENT_B: tokens.extraTelegramB, GITHUB_TOKEN: tokens.extraGithub, + NEMOCLAW_SKIP_TELEGRAM_REACHABILITY: "1", + NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION: "1", }; if (telegramAllowlistKey === "TELEGRAM_ALLOWED_IDS") { @@ -356,22 +355,6 @@ export function messagingEnv(): MessagingEnv { env.TELEGRAM_CHAT_ID = telegramIds; } - if ( - !process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY && - !nonEmpty(process.env.TELEGRAM_BOT_TOKEN_REAL) && - telegram.includes("fake") - ) { - env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY = "1"; - } - if ( - !process.env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION && - !nonEmpty(process.env.SLACK_BOT_TOKEN_REAL) && - !nonEmpty(process.env.SLACK_APP_TOKEN_REAL) && - (isFakeSlackToken(slackBot) || isFakeSlackToken(slackApp)) - ) { - env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION = "1"; - } - return { env, tokens, telegramIds, telegramAllowlistKey, slackIds, wechatAccount }; } @@ -379,7 +362,7 @@ export async function runSecondaryCleanup(run: () => Promise): Promise< try { await run(); } catch { - // Cleanup and diagnostics should not hide the primary failure. + // Ignore best-effort cleanup failures; later setup or assertions report the actionable failure. } } @@ -740,8 +723,11 @@ export async function startFakeDockerApi( fs.mkdirSync(credentialDir, { mode: 0o700 }); fs.writeFileSync(captureFile, ""); credentialMounts = Object.entries(options.credentialEnv).map(([key, value], index) => { - if (!/^[A-Z][A-Z0-9_]*$/u.test(key) || value.length === 0) { - throw new Error(`invalid fake ${options.kind} API credential input`); + if ( + !/^[A-Z][A-Z0-9_]*$/u.test(key) || + !/^(?:test-fake-|xoxb-fake-|xapp-fake-)/u.test(value) + ) { + throw new Error(`fake ${options.kind} API credentials must use synthetic values`); } const hostPath = path.join(credentialDir, String(index)); const containerPath = `/run/nemoclaw-fake-api-credentials/${String(index)}`; diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index 720bb606b1f..1d0d126f11a 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -2,12 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 /** - * Fake tokens are the default. `TELEGRAM_BOT_TOKEN_REAL`, - * `DISCORD_BOT_TOKEN_REAL`, `SLACK_BOT_TOKEN_REAL`, and `SLACK_APP_TOKEN_REAL` - * override the corresponding non-empty standard token variable; each standard - * variable overrides the fake default. Provider placeholders must not leak into - * sandbox-visible surfaces, and installed OpenClaw channel runtime exports must - * drive the hermetic Slack and Telegram send proofs. + * Synthetic tokens are mandatory because this scenario routes provider traffic + * to fake APIs. Host messaging tokens are ignored. Provider placeholders must + * not leak into sandbox-visible surfaces, and installed OpenClaw channel runtime + * exports must drive the hermetic Slack, Telegram, and WeChat send proofs. */ import fs from "node:fs"; @@ -72,7 +70,7 @@ test( "inspect providers placeholders and credential isolation", "probe Telegram and Discord policy rewrites", "exercise installed Slack, Telegram, and WeChat runtimes", - "inspect gateway health and optional live sends", + "inspect gateway health after synthetic sends", ], }, }, @@ -1223,7 +1221,7 @@ setTimeout(() => { console.log("TIMEOUT"); sock.destroy(); }, 5000); await skipNote(artifacts, skips, "S2: no Slack-related output in gateway log"); } - progress.phase("inspect gateway health and optional live sends"); + progress.phase("inspect gateway health after synthetic sends"); const doctor = await runHost(host, "node", [CLI_ENTRYPOINT, SANDBOX_NAME, "doctor", "--json"], { artifactName: "doctor-json-messaging-providers", env: state.env, @@ -1251,75 +1249,10 @@ setTimeout(() => { console.log("TIMEOUT"); sock.destroy(); }, 5000); } } - const telegramRealTarget = nonEmpty(process.env.TELEGRAM_CHAT_ID_E2E); - if (nonEmpty(process.env.TELEGRAM_BOT_TOKEN_REAL) && telegramRealTarget) { - check(telegramStatus === "200", "M18-real: Telegram getMe returned 200 with real token"); - const send = await runSandboxShell( - sandbox, - `OPENCLAW_NO_COLOR=1 openclaw message send --channel telegram --target ${shellQuote(telegramRealTarget)} --message "NemoClaw OpenClaw Telegram plugin E2E $(date -u +%Y-%m-%dT%H:%M:%SZ)" --json`, - { - artifactName: "real-telegram-send-messaging-providers", - redactionValues, - timeoutMs: 120_000, - }, - ); - check( - send.exitCode === 0, - `M19-real: Telegram openclaw message send succeeded (${outputText(send).slice(0, 200)})`, - ); - } else { - await skipNote( - artifacts, - skips, - "M18-real/M19-real: complete real Telegram credentials not available; installed runtime fake send covered M19", - ); - } - - const discordRealTarget = nonEmpty(process.env.DISCORD_CHANNEL_ID_E2E); - if (nonEmpty(process.env.DISCORD_BOT_TOKEN_REAL) && discordRealTarget) { - check(discordStatus === "200", "M20: Discord users/@me returned 200 with real token"); - const send = await runSandboxShell( - sandbox, - `OPENCLAW_NO_COLOR=1 openclaw message send --channel discord --target ${shellQuote(`channel:${discordRealTarget}`)} --message "NemoClaw OpenClaw Discord plugin E2E $(date -u +%Y-%m-%dT%H:%M:%SZ)" --json`, - { - artifactName: "real-discord-send-messaging-providers", - redactionValues, - timeoutMs: 120_000, - }, - ); - check( - send.exitCode === 0, - `M21: Discord openclaw message send succeeded (${outputText(send).slice(0, 200)})`, - ); - } else { - await skipNote( - artifacts, - skips, - "M20/M21: complete real Discord credentials not available; fake Gateway proof covered provider rewrite", - ); - } - - const slackRealTarget = nonEmpty(process.env.SLACK_CHANNEL_ID_E2E); - if (nonEmpty(process.env.SLACK_BOT_TOKEN_REAL) && slackRealTarget) { - const send = await runSandboxShell( - sandbox, - `OPENCLAW_NO_COLOR=1 openclaw message send --channel slack --target ${shellQuote(`channel:${slackRealTarget}`)} --message "NemoClaw OpenClaw Slack plugin E2E $(date -u +%Y-%m-%dT%H:%M:%SZ)" --json`, - { - artifactName: "real-slack-send-messaging-providers", - redactionValues, - timeoutMs: 120_000, - }, - ); - check( - send.exitCode === 0, - `M23: Slack openclaw message send succeeded (${outputText(send).slice(0, 200)})`, - ); - } else { - check( - slackAuthCapture?.tokenMatchesExpected === true && - slackAppCapture?.tokenMatchesExpected === true, - "M22/M23: Slack host mock accepted OpenShell-rewritten bot/app tokens", - ); - } + check( + slackAuthCapture?.tokenMatchesExpected === true && + slackAppCapture?.tokenMatchesExpected === true, + "M22/M23: Slack host mock accepted OpenShell-rewritten synthetic bot/app tokens", + ); }, ); diff --git a/test/e2e/live/openclaw-discord-pairing.test.ts b/test/e2e/live/openclaw-discord-pairing.test.ts index 70db5952d28..c3b5cf6fc3a 100644 --- a/test/e2e/live/openclaw-discord-pairing.test.ts +++ b/test/e2e/live/openclaw-discord-pairing.test.ts @@ -5,6 +5,7 @@ import { expect, test } from "../fixtures/e2e-test.ts"; import { applyFakeApiPolicy, assertDiscordGatewayCapture, + SYNTHETIC_FAKE_API_CREDENTIALS, } from "./messaging-providers-helpers.ts"; import { approveAndAssertPairing, @@ -32,162 +33,177 @@ import { } from "./phase6-messaging-helpers.ts"; const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-oc-disc-pair"; -const DISCORD_TOKEN = process.env.DISCORD_BOT_TOKEN ?? "test-fake-discord-pairing-e2e"; +const DISCORD_TOKEN = SYNTHETIC_FAKE_API_CREDENTIALS.openClawDiscordPairing; const LIVE_TIMEOUT_MS = 55 * 60_000; -test("OpenClaw Discord pairing request is shared with connect-shell approval", { - timeout: LIVE_TIMEOUT_MS, - meta: { - e2ePhases: [ - "load Discord credentials and clear pairing state", - "install the Discord-enabled OpenClaw sandbox", - "inspect Discord bridge and OpenClaw configuration", - "route Discord Gateway traffic through the managed policy", - "issue a Discord pairing request", - "approve the Discord code through connect-shell", - ], +test( + "OpenClaw Discord pairing request is shared with connect-shell approval", + { + timeout: LIVE_TIMEOUT_MS, + meta: { + e2ePhases: [ + "load Discord credentials and clear pairing state", + "install the Discord-enabled OpenClaw sandbox", + "inspect Discord bridge and OpenClaw configuration", + "route Discord Gateway traffic through the managed policy", + "issue a Discord pairing request", + "approve the Discord code through connect-shell", + ], + }, }, -}, async ({ artifacts, cleanup, host, progress, 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.target.declare({ - id: "openclaw-discord-pairing", - 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.trackGateway(host, "nemoclaw", { - artifactName: "cleanup-discord-pairing-openshell-gateway-destroy", - env, - redactionValues: redactions, - timeoutMs: 120_000, - }); - trackSandboxCleanup( - cleanup, - host, - sandbox, - 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); + async ({ artifacts, cleanup, host, progress, 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 }); - progress.phase("install the Discord-enabled OpenClaw sandbox"); - 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"); + await artifacts.target.declare({ + id: "openclaw-discord-pairing", + 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, + }); - progress.phase("inspect Discord bridge and OpenClaw configuration"); - const provider = await host.command( - "openshell", - ["provider", "get", `${SANDBOX_NAME}-discord-bridge`], - { - artifactName: "provider-get-discord-pairing", + cleanup.trackGateway(host, "nemoclaw", { + artifactName: "cleanup-discord-pairing-openshell-gateway-destroy", env, redactionValues: redactions, - timeoutMs: 60_000, - }, - ); - expectExitZero(provider, "Discord provider exists"); + timeoutMs: 120_000, + }); + trackSandboxCleanup( + cleanup, + host, + sandbox, + SANDBOX_NAME, + env, + redactions, + "cleanup-discord-pairing", + ); + await cleanupPairingSandbox(host, SANDBOX_NAME, env, redactions, "preclean-discord-pairing"); - 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({'hasToken': 'token' in account, '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 { - hasToken: boolean; - dmPolicy: string; - allowFrom: string[]; - accountProxy: string; - managedProxy: string; - }; - expect(configSummary.hasToken, "Discord config must omit the token field").toBe(false); - expect(configSummary.dmPolicy).not.toBe("allowlist"); - expect(configSummary.accountProxy, "Discord account proxy").toBe(""); - expect(configSummary.managedProxy, "OpenClaw managed proxy").toMatch(/^http:\/\//); + const docker = await dockerInfo(host, env); + expect(docker.exitCode, resultText(docker)).toBe(0); + + progress.phase("install the Discord-enabled OpenClaw sandbox"); + 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"); - await assertOpenClawStateRoot(sandbox, SANDBOX_NAME, "discord", redactions); + progress.phase("inspect Discord bridge and OpenClaw configuration"); + 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"); - progress.phase("route Discord Gateway traffic through the managed policy"); - const fakeGateway = await startFakeDiscordGateway(host, cleanup, env, DISCORD_TOKEN, redactions); - await applyFakeApiPolicy({ - host, - sandboxName: SANDBOX_NAME, - policyHost: "host.openshell.internal", - endpoints: [ + 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({'hasToken': 'token' in account, '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)}`, { - port: fakeGateway.port, - protocol: "websocket", - providerName: `${SANDBOX_NAME}-discord-bridge`, + artifactName: "discord-openclaw-config", + redactionValues: redactions, }, - ], - binaries: ["/usr/local/bin/node", "/usr/bin/node"], - env, - redactionValues: redactions, - artifactName: "apply-discord-gateway-policy", - }); - const gatewayProof = await runDiscordGatewayProof({ - sandbox, - sandboxName: SANDBOX_NAME, - port: fakeGateway.port, - redactions, - }); - expect(gatewayProof).toContain("UPGRADE"); - expect(gatewayProof).toContain("HELLO"); - expect(gatewayProof).toContain("IDENTIFY_SENT_PLACEHOLDER"); - assertDiscordGatewayCapture(fakeGateway.captureFile, DISCORD_TOKEN); - expect(gatewayProof).toContain("READY"); - expect(gatewayProof).toContain("HEARTBEAT_ACK"); + ); + expectExitZero(config, "Discord OpenClaw config"); + const configSummary = JSON.parse(config.stdout.trim()) as { + hasToken: boolean; + dmPolicy: string; + allowFrom: string[]; + accountProxy: string; + managedProxy: string; + }; + expect(configSummary.hasToken, "Discord config must omit the token field").toBe(false); + expect(configSummary.dmPolicy).not.toBe("allowlist"); + expect(configSummary.accountProxy, "Discord account proxy").toBe(""); + expect(configSummary.managedProxy, "OpenClaw managed proxy").toMatch(/^http:\/\//); + + await assertOpenClawStateRoot(sandbox, SANDBOX_NAME, "discord", redactions); + + progress.phase("route Discord Gateway traffic through the managed policy"); + const fakeGateway = await startFakeDiscordGateway( + host, + cleanup, + env, + DISCORD_TOKEN, + redactions, + ); + await applyFakeApiPolicy({ + host, + sandboxName: SANDBOX_NAME, + policyHost: "host.openshell.internal", + endpoints: [ + { + port: fakeGateway.port, + protocol: "websocket", + providerName: `${SANDBOX_NAME}-discord-bridge`, + }, + ], + binaries: ["/usr/local/bin/node", "/usr/bin/node"], + env, + redactionValues: redactions, + artifactName: "apply-discord-gateway-policy", + }); + const gatewayProof = await runDiscordGatewayProof({ + sandbox, + sandboxName: SANDBOX_NAME, + port: fakeGateway.port, + redactions, + }); + expect(gatewayProof).toContain("UPGRADE"); + expect(gatewayProof).toContain("HELLO"); + expect(gatewayProof).toContain("IDENTIFY_SENT_PLACEHOLDER"); + assertDiscordGatewayCapture(fakeGateway.captureFile, DISCORD_TOKEN); + expect(gatewayProof).toContain("READY"); + expect(gatewayProof).toContain("HEARTBEAT_ACK"); - progress.phase("issue a Discord pairing request"); - 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 }); + progress.phase("issue a Discord pairing request"); + 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 }); - progress.phase("approve the Discord code through connect-shell"); - await approveAndAssertPairing({ - sandbox, - sandboxName: SANDBOX_NAME, - channel: "discord", - code: pairing.code, - redactions, - }); -}); + progress.phase("approve the Discord code through connect-shell"); + await approveAndAssertPairing({ + sandbox, + sandboxName: SANDBOX_NAME, + channel: "discord", + code: pairing.code, + redactions, + }); + }, +); diff --git a/test/e2e/live/openclaw-slack-pairing.test.ts b/test/e2e/live/openclaw-slack-pairing.test.ts index fe30f6ba831..1b485c768d5 100644 --- a/test/e2e/live/openclaw-slack-pairing.test.ts +++ b/test/e2e/live/openclaw-slack-pairing.test.ts @@ -17,7 +17,10 @@ import { startFakeSlackApi, writePairingArtifacts, } from "./openclaw-pairing-helpers.ts"; -import { applyFakeApiPolicy } from "./messaging-providers-helpers.ts"; +import { + applyFakeApiPolicy, + SYNTHETIC_FAKE_API_CREDENTIALS, +} from "./messaging-providers-helpers.ts"; import { dockerInfo, expectExitZero, @@ -28,8 +31,8 @@ import { } from "./phase6-messaging-helpers.ts"; const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-oc-slack-pair"; -const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN ?? "xoxb-fake-slack-pairing-e2e"; -const SLACK_APP_TOKEN = process.env.SLACK_APP_TOKEN ?? "xapp-fake-slack-pairing-e2e"; +const SLACK_BOT_TOKEN = SYNTHETIC_FAKE_API_CREDENTIALS.openClawSlackPairing.bot; +const SLACK_APP_TOKEN = SYNTHETIC_FAKE_API_CREDENTIALS.openClawSlackPairing.app; const LIVE_TIMEOUT_MS = 55 * 60_000; function assertSlackCapture( @@ -73,152 +76,156 @@ function assertSlackCapture( ).toBe(true); } -test("OpenClaw Slack Socket Mode pairing request is shared with connect-shell approval", { - timeout: LIVE_TIMEOUT_MS, - meta: { - e2ePhases: [ - "load Slack credentials and clear pairing state", - "install the Slack-enabled OpenClaw sandbox", - "inspect Slack providers and preset policy", - "route Slack API and websocket traffic through managed policies", - "issue a Slack pairing request", - "approve the Slack code through connect-shell", - ], +test( + "OpenClaw Slack Socket Mode pairing request is shared with connect-shell approval", + { + timeout: LIVE_TIMEOUT_MS, + meta: { + e2ePhases: [ + "load Slack credentials and clear pairing state", + "install the Slack-enabled OpenClaw sandbox", + "inspect Slack providers and preset policy", + "route Slack API and websocket traffic through managed policies", + "issue a Slack pairing request", + "approve the Slack code through connect-shell", + ], + }, }, -}, async ({ artifacts, cleanup, host, progress, sandbox, secrets, skip }) => { - const apiKey = secrets.required("NVIDIA_INFERENCE_API_KEY"); - const env = pairingEnv({ - sandboxName: SANDBOX_NAME, - apiKey, - channel: "slack", - slackBot: SLACK_BOT_TOKEN, - slackApp: SLACK_APP_TOKEN, - }); - const redactions = pairingRedactions({ - apiKey, - slackBot: SLACK_BOT_TOKEN, - slackApp: SLACK_APP_TOKEN, - }); + async ({ artifacts, cleanup, host, progress, sandbox, secrets, skip }) => { + const apiKey = secrets.required("NVIDIA_INFERENCE_API_KEY"); + const env = pairingEnv({ + sandboxName: SANDBOX_NAME, + apiKey, + channel: "slack", + slackBot: SLACK_BOT_TOKEN, + slackApp: SLACK_APP_TOKEN, + }); + const redactions = pairingRedactions({ + apiKey, + slackBot: SLACK_BOT_TOKEN, + slackApp: SLACK_APP_TOKEN, + }); - await artifacts.target.declare({ - id: "openclaw-slack-pairing", - boundary: - "install.sh Slack OpenClaw sandbox + fake Slack REST/websocket token rewrite + runtime pairing request + connect-shell approval", - sandboxName: SANDBOX_NAME, - pairingUser: PAIRING_USER.slack, - }); + await artifacts.target.declare({ + id: "openclaw-slack-pairing", + boundary: + "install.sh Slack OpenClaw sandbox + fake Slack REST/websocket token rewrite + runtime pairing request + connect-shell approval", + sandboxName: SANDBOX_NAME, + pairingUser: PAIRING_USER.slack, + }); - cleanup.trackGateway(host, "nemoclaw", { - artifactName: "cleanup-slack-pairing-openshell-gateway-destroy", - env, - redactionValues: redactions, - timeoutMs: 120_000, - }); - trackSandboxCleanup( - cleanup, - host, - sandbox, - SANDBOX_NAME, - env, - redactions, - "cleanup-slack-pairing", - ); - await cleanupPairingSandbox(host, SANDBOX_NAME, env, redactions, "preclean-slack-pairing"); + cleanup.trackGateway(host, "nemoclaw", { + artifactName: "cleanup-slack-pairing-openshell-gateway-destroy", + env, + redactionValues: redactions, + timeoutMs: 120_000, + }); + trackSandboxCleanup( + cleanup, + host, + sandbox, + SANDBOX_NAME, + env, + redactions, + "cleanup-slack-pairing", + ); + await cleanupPairingSandbox(host, SANDBOX_NAME, env, redactions, "preclean-slack-pairing"); - const docker = await dockerInfo(host, env); - expect(docker.exitCode, resultText(docker)).toBe(0); + const docker = await dockerInfo(host, env); + expect(docker.exitCode, resultText(docker)).toBe(0); - progress.phase("install the Slack-enabled OpenClaw sandbox"); - const install = await installSandboxOrSkipOnRateLimit( - host, - env, - redactions, - "install-slack-pairing", - skip, - "NVIDIA endpoint validation was rate-limited before Slack pairing assertions ran", - ); - expectExitZero(install, "install.sh --non-interactive with Slack"); - await expectSandboxReady(host, SANDBOX_NAME, env, redactions, "sandbox-list-slack-pairing"); + progress.phase("install the Slack-enabled OpenClaw sandbox"); + const install = await installSandboxOrSkipOnRateLimit( + host, + env, + redactions, + "install-slack-pairing", + skip, + "NVIDIA endpoint validation was rate-limited before Slack pairing assertions ran", + ); + expectExitZero(install, "install.sh --non-interactive with Slack"); + await expectSandboxReady(host, SANDBOX_NAME, env, redactions, "sandbox-list-slack-pairing"); + + progress.phase("inspect Slack providers and preset policy"); + for (const providerName of [`${SANDBOX_NAME}-slack-bridge`, `${SANDBOX_NAME}-slack-app`]) { + const provider = await host.command("openshell", ["provider", "get", providerName], { + artifactName: `provider-get-${providerName}`, + env, + redactionValues: redactions, + timeoutMs: 60_000, + }); + expectExitZero(provider, `${providerName} exists`); + } - progress.phase("inspect Slack providers and preset policy"); - for (const providerName of [`${SANDBOX_NAME}-slack-bridge`, `${SANDBOX_NAME}-slack-app`]) { - const provider = await host.command("openshell", ["provider", "get", providerName], { - artifactName: `provider-get-${providerName}`, + await assertOpenClawStateRoot(sandbox, SANDBOX_NAME, "slack", redactions); + await assertSlackPresetPolicySemantics({ + host, + sandboxName: SANDBOX_NAME, env, - redactionValues: redactions, - timeoutMs: 60_000, + redactions, }); - expectExitZero(provider, `${providerName} exists`); - } - - await assertOpenClawStateRoot(sandbox, SANDBOX_NAME, "slack", redactions); - await assertSlackPresetPolicySemantics({ - host, - sandboxName: SANDBOX_NAME, - env, - redactions, - }); - progress.phase("route Slack API and websocket traffic through managed policies"); - const fakeSlack = await startFakeSlackApi( - host, - cleanup, - env, - SLACK_BOT_TOKEN, - SLACK_APP_TOKEN, - redactions, - ); - expect( - fakeSlack.alternatePort, - "fake Slack API must publish an independent app-token port", - ).toMatch(/^[1-9][0-9]*$/u); - expect(fakeSlack.alternatePort).not.toBe(fakeSlack.port); - const fakeSlackWebSocket = { - ...fakeSlack, - port: fakeSlack.alternatePort!, - }; - await applyFakeApiPolicy({ - host, - sandboxName: SANDBOX_NAME, - policyHost: "host.openshell.internal", - endpoints: [ - { - port: fakeSlack.port, - protocol: "rest", - providerName: `${SANDBOX_NAME}-slack-bridge`, - }, - { - port: fakeSlackWebSocket.port, - protocol: "websocket", - providerName: `${SANDBOX_NAME}-slack-app`, - }, - ], - binaries: ["/usr/local/bin/node", "/usr/bin/node"], - env, - redactionValues: redactions, - artifactName: "apply-slack-fake-api-policy", - }); + progress.phase("route Slack API and websocket traffic through managed policies"); + const fakeSlack = await startFakeSlackApi( + host, + cleanup, + env, + SLACK_BOT_TOKEN, + SLACK_APP_TOKEN, + redactions, + ); + expect( + fakeSlack.alternatePort, + "fake Slack API must publish an independent app-token port", + ).toMatch(/^[1-9][0-9]*$/u); + expect(fakeSlack.alternatePort).not.toBe(fakeSlack.port); + const fakeSlackWebSocket = { + ...fakeSlack, + port: fakeSlack.alternatePort!, + }; + await applyFakeApiPolicy({ + host, + sandboxName: SANDBOX_NAME, + policyHost: "host.openshell.internal", + endpoints: [ + { + port: fakeSlack.port, + protocol: "rest", + providerName: `${SANDBOX_NAME}-slack-bridge`, + }, + { + port: fakeSlackWebSocket.port, + protocol: "websocket", + providerName: `${SANDBOX_NAME}-slack-app`, + }, + ], + binaries: ["/usr/local/bin/node", "/usr/bin/node"], + env, + redactionValues: redactions, + artifactName: "apply-slack-fake-api-policy", + }); - progress.phase("issue a Slack pairing request"); - const issue = await issuePairingRequest({ - sandbox, - sandboxName: SANDBOX_NAME, - channel: "slack", - redactions, - fakeSlackPort: fakeSlack.port, - fakeSlackWebSocketPort: fakeSlackWebSocket.port, - }); - expectExitZero(issue, "Slack pairing request creation"); - const code = extractPairingCode(resultText(issue), "PAIRING_E2E_RESULT"); - assertSlackCapture([fakeSlack.captureFile], code, PAIRING_USER.slack); - await writePairingArtifacts(artifacts, "slack", { code, user: PAIRING_USER.slack }); + progress.phase("issue a Slack pairing request"); + const issue = await issuePairingRequest({ + sandbox, + sandboxName: SANDBOX_NAME, + channel: "slack", + redactions, + fakeSlackPort: fakeSlack.port, + fakeSlackWebSocketPort: fakeSlackWebSocket.port, + }); + expectExitZero(issue, "Slack pairing request creation"); + const code = extractPairingCode(resultText(issue), "PAIRING_E2E_RESULT"); + assertSlackCapture([fakeSlack.captureFile], code, PAIRING_USER.slack); + await writePairingArtifacts(artifacts, "slack", { code, user: PAIRING_USER.slack }); - progress.phase("approve the Slack code through connect-shell"); - await approveAndAssertPairing({ - sandbox, - sandboxName: SANDBOX_NAME, - channel: "slack", - code, - redactions, - }); -}); + progress.phase("approve the Slack code through connect-shell"); + await approveAndAssertPairing({ + sandbox, + sandboxName: SANDBOX_NAME, + channel: "slack", + code, + redactions, + }); + }, +); diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 80202a93d1d..8478d0276ef 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -30,6 +30,7 @@ import { rawTokenSurfaceProbe, runSecondaryCleanup, startFakeDockerApi, + SYNTHETIC_FAKE_API_CREDENTIALS, } from "../live/messaging-providers-helpers.ts"; import { parseInstalledSlackProof, @@ -782,7 +783,7 @@ describe("messaging provider installed-runtime proofs", () => { const calls: string[][] = []; const host = fakeDockerHost(OPENSHELL_BRIDGE_ADDRESS, calls); const cleanup: Array<() => Promise> = []; - const sentinel = "123456:credential-must-not-enter-docker-argv"; + const sentinel = "test-fake-credential-must-not-enter-docker-argv"; let credentialFile = ""; try { @@ -886,23 +887,64 @@ describe("messaging provider installed-runtime proofs", () => { expect(cleanup).toHaveLength(0); }); - it("uses a synthetic WeChat token even when the host exports one", () => { - const previousToken = process.env.WECHAT_BOT_TOKEN; - process.env.WECHAT_BOT_TOKEN = "host-wechat-token-must-not-reach-the-fake-api"; - + it("uses synthetic credentials for every fake messaging API despite host tokens", () => { + vi.stubEnv("TELEGRAM_BOT_TOKEN_REAL", "host-real-telegram-token"); + vi.stubEnv("TELEGRAM_BOT_TOKEN", "host-telegram-token"); + vi.stubEnv("DISCORD_BOT_TOKEN_REAL", "host-real-discord-token"); + vi.stubEnv("DISCORD_BOT_TOKEN", "host-discord-token"); + vi.stubEnv("SLACK_BOT_TOKEN_REAL", "host-real-slack-bot-token"); + vi.stubEnv("SLACK_BOT_TOKEN", "host-slack-bot-token"); + vi.stubEnv("SLACK_APP_TOKEN_REAL", "host-real-slack-app-token"); + vi.stubEnv("SLACK_APP_TOKEN", "host-slack-app-token"); + vi.stubEnv("WECHAT_BOT_TOKEN", "host-wechat-token"); try { const fixture = messagingEnv(); - expect(fixture.tokens.wechat).toBe("test-fake-wechat-token-e2e"); - expect(fixture.env.WECHAT_BOT_TOKEN).toBe("test-fake-wechat-token-e2e"); + const expected = SYNTHETIC_FAKE_API_CREDENTIALS.messagingProviders; + expect(fixture.tokens).toMatchObject(expected); + expect(fixture.env).toMatchObject({ + TELEGRAM_BOT_TOKEN: expected.telegram, + DISCORD_BOT_TOKEN: expected.discord, + SLACK_BOT_TOKEN: expected.slackBot, + SLACK_APP_TOKEN: expected.slackApp, + WECHAT_BOT_TOKEN: expected.wechat, + NEMOCLAW_SKIP_TELEGRAM_REACHABILITY: "1", + NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION: "1", + }); + expect(SYNTHETIC_FAKE_API_CREDENTIALS).toMatchObject({ + hermesDiscord: "test-fake-discord-token-hermes-e2e", + openClawDiscordPairing: "test-fake-discord-pairing-e2e", + openClawSlackPairing: { + bot: "xoxb-fake-slack-pairing-e2e", + app: "xapp-fake-slack-pairing-e2e", + }, + }); } finally { - Reflect.deleteProperty(process.env, "WECHAT_BOT_TOKEN"); - Object.assign( - process.env, - previousToken === undefined ? {} : { WECHAT_BOT_TOKEN: previousToken }, - ); + vi.unstubAllEnvs(); } }); + it("rejects a non-synthetic credential before creating fake API resources", async () => { + const calls: string[][] = []; + const host = fakeDockerHost(OPENSHELL_BRIDGE_ADDRESS, calls); + const cleanup: Array<() => Promise> = []; + + await expect( + startFakeDockerApi(host, (_name, run) => cleanup.push(run), { + kind: "telegram", + imageScript: "fake-telegram-api.cjs", + containerPrefix: "fake-telegram-real-credential", + portEnv: "FAKE_TELEGRAM_API_PORT", + portFileEnv: "FAKE_TELEGRAM_API_PORT_FILE", + captureFileEnv: "FAKE_TELEGRAM_API_CAPTURE_FILE", + credentialEnv: { FAKE_TELEGRAM_API_EXPECTED_TOKEN: "host-real-telegram-token" }, + redactionValues: [], + env: {}, + }), + ).rejects.toThrow(/credentials must use synthetic values/u); + expect(calls).toEqual([["docker", "network", "inspect", "openshell-docker"]]); + expect(cleanup).toHaveLength(0); + }); + it("publishes independent fake Slack REST and websocket ports from one fixture", async () => { const fixture = await startFakeSlackPortFixture(); From b0e17df16c2c1300adde5756f83b46fc7077f321 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 14:27:45 -0700 Subject: [PATCH 43/45] fix(e2e): fail closed on fixture checks Signed-off-by: Prekshi Vyas --- test/e2e/lib/fake-api-port-readiness.mts | 1 + test/e2e/live/messaging-providers-helpers.ts | 14 ++++--- test/e2e/mock-parity.json | 1 + .../support/fake-api-port-readiness.test.ts | 37 +++++++++++++++++++ ...messaging-providers-runtime-proofs.test.ts | 20 ++++++++-- 5 files changed, 63 insertions(+), 10 deletions(-) create mode 100644 test/e2e/support/fake-api-port-readiness.test.ts diff --git a/test/e2e/lib/fake-api-port-readiness.mts b/test/e2e/lib/fake-api-port-readiness.mts index d9a7f925d04..c69b59bcbaf 100644 --- a/test/e2e/lib/fake-api-port-readiness.mts +++ b/test/e2e/lib/fake-api-port-readiness.mts @@ -24,6 +24,7 @@ function urlHost(host: string): string { async function probeHttp(host: string, port: number): Promise { const response = await fetch(`http://${urlHost(host)}:${port}/__nemoclaw_e2e_port_traffic`, { + redirect: "error", signal: AbortSignal.timeout(ATTEMPT_TIMEOUT_MS), }); if (!response.ok) { diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 164996d14bd..6e43847c4fe 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -358,12 +358,14 @@ export function messagingEnv(): MessagingEnv { return { env, tokens, telegramIds, telegramAllowlistKey, slackIds, wechatAccount }; } -export async function runSecondaryCleanup(run: () => Promise): Promise { - try { - await run(); - } catch { - // Ignore best-effort cleanup failures; later setup or assertions report the actionable failure. - } +const SECONDARY_CLEANUP_ALREADY_ABSENT = + /\bNotFound\b|\bNot Found\b|(?:sandbox|gateway|provider)[^\n]*(?:does not exist|not found|not present)|no (?:such |active )?(?:sandbox|gateway|provider)|No gateway metadata found|Run 'nemoclaw onboard' to create one/iu; + +export async function runSecondaryCleanup(run: () => Promise): Promise { + const result = await run(); + if (result.exitCode === 0 || SECONDARY_CLEANUP_ALREADY_ABSENT.test(resultText(result))) return; + const artifact = result.artifacts.result || "redacted cleanup command artifact"; + throw new Error(`secondary cleanup failed; see ${artifact} for redacted command output`); } export async function runHost( diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 2d6de12252f..8c47473de02 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -595,6 +595,7 @@ "src/lib/onboard/messaging-policy-presets.test.ts", "src/lib/onboard/providers.test.ts", "test/channels/channels-add-bridge-lifecycle.test.ts", + "test/e2e/support/fake-api-port-readiness.test.ts", "test/e2e/support/messaging-providers-runtime-proofs.test.ts", "test/onboarding/onboard-messaging.test.ts", "test/runtime/messaging/messaging-build-applier.test.ts", diff --git a/test/e2e/support/fake-api-port-readiness.test.ts b/test/e2e/support/fake-api-port-readiness.test.ts new file mode 100644 index 00000000000..83f5d62d69f --- /dev/null +++ b/test/e2e/support/fake-api-port-readiness.test.ts @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import http from "node:http"; + +import { describe, expect, it } from "vitest"; + +import { closeServer, listenServer, writeJsonResponse } from "../fixtures/http-protocol.ts"; +import { proveFakeApiPortTraffic } from "../lib/fake-api-port-readiness.mts"; + +describe("fake API port readiness", () => { + it("rejects a REST redirect without contacting its target", async () => { + let targetRequests = 0; + const target = http.createServer((_request, response) => { + targetRequests += 1; + writeJsonResponse(response, 200, { type: "nemoclaw_port_traffic_reply" }); + }); + const targetPort = await listenServer(target, 0, "127.0.0.1"); + const redirect = http.createServer((_request, response) => { + response + .writeHead(302, { + location: `http://127.0.0.1:${targetPort}/__nemoclaw_e2e_port_traffic`, + }) + .end(); + }); + const redirectPort = await listenServer(redirect, 0, "127.0.0.1"); + + try { + await expect( + proveFakeApiPortTraffic({ host: "127.0.0.1", restPort: redirectPort }), + ).rejects.toThrow(/fake API port traffic check failed/u); + expect(targetRequests).toBe(0); + } finally { + await Promise.all([closeServer(redirect), closeServer(target)]); + } + }, 15_000); +}); diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 8478d0276ef..d09acebe623 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -358,12 +358,24 @@ describe("messaging provider installed-runtime proofs", () => { expect(JSON.stringify(observedInvocation)).not.toContain(token); }); - it("does not propagate a secondary cleanup failure", async () => { + it("accepts absent secondary cleanup results and reports other failures", async () => { + const cleanupResult = (stderr: string, artifact: string): ShellProbeResult => ({ + ...successfulShellResult(["openshell", "sandbox", "delete", "fixture"]), + exitCode: 1, + stderr, + artifacts: { stdout: "", stderr: "", result: artifact }, + }); + await expect( - runSecondaryCleanup(async () => { - throw new Error("secondary cleanup failure"); - }), + runSecondaryCleanup(async () => cleanupResult("Sandbox 'fixture' does not exist.", "")), ).resolves.toBeUndefined(); + await expect( + runSecondaryCleanup(async () => + cleanupResult("permission denied", "artifacts/cleanup-failed.result.json"), + ), + ).rejects.toThrow( + "secondary cleanup failed; see artifacts/cleanup-failed.result.json for redacted command output", + ); }); it("produces mixed REST and WebSocket policy state through a minimal consumer", async () => { From fb01ee9b0b8407e499348c33d1989f71d2e7f992 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 15:05:07 -0700 Subject: [PATCH 44/45] refactor(e2e): inline policy build diagnostics Signed-off-by: Prekshi Vyas --- test/e2e/fixtures/policy-boundary-build.ts | 29 ------------------- .../support/policy-credential-binding.test.ts | 25 ++++++++++++++-- 2 files changed, 22 insertions(+), 32 deletions(-) delete mode 100644 test/e2e/fixtures/policy-boundary-build.ts diff --git a/test/e2e/fixtures/policy-boundary-build.ts b/test/e2e/fixtures/policy-boundary-build.ts deleted file mode 100644 index f80dc7997bd..00000000000 --- a/test/e2e/fixtures/policy-boundary-build.ts +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { SpawnSyncReturns } from "node:child_process"; - -import { createArtifactSink } from "./artifacts.ts"; - -function failureReason(result: SpawnSyncReturns): string { - if (result.error) { - const code = (result.error as NodeJS.ErrnoException).code; - return code === "ETIMEDOUT" ? "timed out" : "failed to execute"; - } - if (result.signal) return `terminated by ${result.signal}`; - return `exited with status ${String(result.status)}`; -} - -export async function requireSuccessfulPolicyBoundaryBuild( - result: SpawnSyncReturns, -): Promise { - if (result.status === 0) return; - - const diagnosticPath = await createArtifactSink("policy-credential-binding").writeText( - "policy-boundary-build.log", - [result.stderr, result.stdout, result.error?.message].filter(Boolean).join("\n"), - ); - throw new Error( - `Policy boundary build ${failureReason(result)}; see diagnostic artifact: ${diagnosticPath}`, - ); -} diff --git a/test/e2e/support/policy-credential-binding.test.ts b/test/e2e/support/policy-credential-binding.test.ts index b43663997b5..fb855b4c18b 100644 --- a/test/e2e/support/policy-credential-binding.test.ts +++ b/test/e2e/support/policy-credential-binding.test.ts @@ -9,7 +9,7 @@ import path from "node:path"; import { afterEach, beforeAll, describe, expect, it } from "vitest"; import YAML from "yaml"; -import { requireSuccessfulPolicyBoundaryBuild } from "../fixtures/policy-boundary-build.ts"; +import { createArtifactSink } from "../fixtures/artifacts.ts"; const HELPER = path.resolve(import.meta.dirname, "../fixtures/policy-credential-binding.ts"); const TYPESCRIPT = path.resolve("node_modules/typescript/bin/tsc"); @@ -41,8 +41,27 @@ describe("E2E policy credential binding", () => { killSignal: "SIGKILL", timeout: 15_000, }); - await requireSuccessfulPolicyBoundaryBuild(result); - }); + switch (result.status) { + case 0: + return; + default: { + const diagnosticPath = await createArtifactSink("policy-credential-binding").writeText( + "policy-boundary-build.log", + [result.stderr, result.stdout, result.error?.message].filter(Boolean).join("\n"), + ); + const reason = result.error + ? (result.error as NodeJS.ErrnoException).code === "ETIMEDOUT" + ? "timed out" + : "failed to execute" + : result.signal + ? `terminated by ${result.signal}` + : `exited with status ${String(result.status)}`; + throw new Error( + `Policy boundary build ${reason}; see diagnostic artifact: ${diagnosticPath}`, + ); + } + } + }, 20_000); afterEach(() => { for (const tempDir of tempDirs.splice(0)) { From 956387f330d0bc46fc88f1ad3641f3c230e7fd25 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 17:04:59 -0700 Subject: [PATCH 45/45] test(e2e): clarify fake API test ownership Signed-off-by: Prekshi Vyas --- test/e2e/fixtures/polling.ts | 9 +- test/e2e/lib/fake-api-port-readiness.mts | 46 ++- test/e2e/live/messaging-providers.test.ts | 385 +++++++++++------- .../support/fake-api-port-readiness.test.ts | 18 + ...messaging-providers-runtime-proofs.test.ts | 65 ++- 5 files changed, 315 insertions(+), 208 deletions(-) diff --git a/test/e2e/fixtures/polling.ts b/test/e2e/fixtures/polling.ts index b95f197dc32..f130bab8138 100644 --- a/test/e2e/fixtures/polling.ts +++ b/test/e2e/fixtures/polling.ts @@ -23,12 +23,17 @@ export interface PollOptions { export type PollingFailureReason = "aborted" | "terminal" | "exhausted"; export class PollingError extends Error { + readonly lastAttempt?: PollAttempt; + readonly reason: PollingFailureReason; + constructor( message: string, - readonly lastAttempt?: PollAttempt, - readonly reason: PollingFailureReason = "exhausted", + lastAttempt?: PollAttempt, + reason: PollingFailureReason = "exhausted", ) { super(message); + this.lastAttempt = lastAttempt; + this.reason = reason; } } diff --git a/test/e2e/lib/fake-api-port-readiness.mts b/test/e2e/lib/fake-api-port-readiness.mts index c69b59bcbaf..ec8c832cdae 100644 --- a/test/e2e/lib/fake-api-port-readiness.mts +++ b/test/e2e/lib/fake-api-port-readiness.mts @@ -4,6 +4,8 @@ import { isIP } from "node:net"; import { pathToFileURL } from "node:url"; +import { PollingError, pollUntil } from "../fixtures/polling.ts"; + const ATTEMPT_TIMEOUT_MS = 1_000; const PORT_TRAFFIC_TIMEOUT_MS = 10_000; const RETRY_DELAY_MS = 100; @@ -108,10 +110,6 @@ function probeWebsocket(host: string, port: number): Promise { }); } -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - export async function proveFakeApiPortTraffic(options: { host: string; restPort: number; @@ -129,21 +127,31 @@ export async function proveFakeApiPortTraffic(options: { ) { throw new Error("WebSocket port must be an integer between 1 and 65535"); } - const deadline = Date.now() + PORT_TRAFFIC_TIMEOUT_MS; - let lastError: string | undefined; - do { - try { - await probeHttp(options.host, options.restPort); - if (options.websocketPort !== undefined) { - await probeWebsocket(options.host, options.websocketPort); - } - return; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - await sleep(RETRY_DELAY_MS); - } while (Date.now() < deadline); - throw new Error(`fake API port traffic check failed: ${lastError ?? "no reply"}`); + type ProbeResult = { ok: true } | { error: string; ok: false }; + try { + await pollUntil({ + artifactPrefix: "fake-api-port-traffic", + deadlineMs: PORT_TRAFFIC_TIMEOUT_MS, + delayMs: RETRY_DELAY_MS, + probe: async () => { + try { + await probeHttp(options.host, options.restPort); + if (options.websocketPort !== undefined) { + await probeWebsocket(options.host, options.websocketPort); + } + return { ok: true }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error), ok: false }; + } + }, + accept: (result) => result.ok, + }); + } catch (error) { + if (!(error instanceof PollingError)) throw error; + const lastResult = error.lastAttempt?.value; + const lastError = lastResult?.ok === false ? lastResult.error : "no reply"; + throw new Error(`fake API port traffic check failed: ${lastError}`); + } } async function main(): Promise { diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index 1d0d126f11a..5be0e6f6ca9 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -13,7 +13,7 @@ import os from "node:os"; import path from "node:path"; import { testTimeoutOptions } from "../../helpers/timeouts"; -import { expect, test } from "../fixtures/e2e-test.ts"; +import { type E2ETargetFixtures, expect, test } from "../fixtures/e2e-test.ts"; import { assertStockManagedImageReceipt } from "../fixtures/managed-image-receipt.ts"; import { accountBool, @@ -58,144 +58,174 @@ process.env.NEMOCLAW_CLI_BIN ??= CLI_ENTRYPOINT; const SANDBOX_BACKUP_ROOT = path.join(os.homedir(), ".nemoclaw", "rebuild-backups", SANDBOX_NAME); -test( - "messaging providers preserve placeholder, policy, runtime, and send contracts", - { - ...testTimeoutOptions(LIVE_TIMEOUT_MS), - meta: { - e2ePhases: [ - "load messaging credentials and clear the sandbox", - "install the all-channel OpenClaw sandbox", - "add WhatsApp and prove rebuild persistence", - "inspect providers placeholders and credential isolation", - "probe Telegram and Discord policy rewrites", - "exercise installed Slack, Telegram, and WeChat runtimes", - "inspect gateway health after synthetic sends", - ], - }, - }, - async ({ artifacts, cleanup, host, progress, sandbox, skip }) => { - if (!process.env.NVIDIA_INFERENCE_API_KEY) { - skip("NVIDIA_INFERENCE_API_KEY is required for live messaging-provider E2E"); - return; - } - if (!fs.existsSync(CLI_ENTRYPOINT)) { - throw new Error(`NemoClaw CLI entrypoint missing: ${CLI_ENTRYPOINT}`); +type MessagingScenarioFixtures = Pick< + E2ETargetFixtures, + "artifacts" | "cleanup" | "host" | "sandbox" +> & { + skip: (reason: string) => void; +}; + +async function installMessagingProviderSandbox( + { artifacts, cleanup, host, sandbox, skip }: MessagingScenarioFixtures, + beginInstall: () => void, +): Promise< + | { + state: ReturnType; + redactionValues: string[]; + skips: string[]; } + | undefined +> { + if (!process.env.NVIDIA_INFERENCE_API_KEY) { + skip("NVIDIA_INFERENCE_API_KEY is required for live messaging-provider E2E"); + return undefined; + } + if (!fs.existsSync(CLI_ENTRYPOINT)) { + throw new Error(`NemoClaw CLI entrypoint missing: ${CLI_ENTRYPOINT}`); + } - const state = messagingEnv(); - const redactionValues = tokenValues(state.tokens); - const skips: string[] = []; - await artifacts.writeJson("messaging-provider-env-summary.json", { - sandboxName: SANDBOX_NAME, - telegramTokenChars: state.tokens.telegram.length, - discordTokenChars: state.tokens.discord.length, - slackBotTokenChars: state.tokens.slackBot.length, - slackAppTokenChars: state.tokens.slackApp.length, - telegramAllowlistKey: state.telegramAllowlistKey, - telegramAllowedIdCount: countCsv(state.telegramIds), - slackAllowedUserCount: countCsv(state.slackIds), - wechatAccount: state.wechatAccount, - }); + const state = messagingEnv(); + const redactionValues = tokenValues(state.tokens); + const skips: string[] = []; + await artifacts.writeJson("messaging-provider-env-summary.json", { + sandboxName: SANDBOX_NAME, + telegramTokenChars: state.tokens.telegram.length, + discordTokenChars: state.tokens.discord.length, + slackBotTokenChars: state.tokens.slackBot.length, + slackAppTokenChars: state.tokens.slackApp.length, + telegramAllowlistKey: state.telegramAllowlistKey, + telegramAllowedIdCount: countCsv(state.telegramIds), + slackAllowedUserCount: countCsv(state.slackIds), + wechatAccount: state.wechatAccount, + }); - cleanup.trackDisposable(`remove rebuild backups for ${SANDBOX_NAME}`, () => { - fs.rmSync(SANDBOX_BACKUP_ROOT, { recursive: true, force: true }); - expect( - fs.existsSync(SANDBOX_BACKUP_ROOT), - `rebuild backup directory remains after cleanup: ${SANDBOX_BACKUP_ROOT}`, - ).toBe(false); - }); - cleanup.trackDisposable(`delete OpenShell sandbox ${SANDBOX_NAME}`, () => - sandbox.cleanupSandbox(SANDBOX_NAME, { - artifactName: "cleanup-openshell-sandbox-delete-messaging-providers", - env: state.env, - redactionValues, - timeoutMs: 120_000, - }), - ); - cleanup.trackSandbox(host, SANDBOX_NAME, { - artifactName: "cleanup-nemoclaw-destroy-messaging-providers", + cleanup.trackDisposable(`remove rebuild backups for ${SANDBOX_NAME}`, () => { + fs.rmSync(SANDBOX_BACKUP_ROOT, { recursive: true, force: true }); + expect( + fs.existsSync(SANDBOX_BACKUP_ROOT), + `rebuild backup directory remains after cleanup: ${SANDBOX_BACKUP_ROOT}`, + ).toBe(false); + }); + cleanup.trackDisposable(`delete OpenShell sandbox ${SANDBOX_NAME}`, () => + sandbox.cleanupSandbox(SANDBOX_NAME, { + artifactName: "cleanup-openshell-sandbox-delete-messaging-providers", env: state.env, redactionValues, - timeoutMs: 15 * 60_000, - }); - - await runSecondaryCleanup(() => - runHost(host, "node", [CLI_ENTRYPOINT, SANDBOX_NAME, "destroy", "--yes"], { - artifactName: "preclean-nemoclaw-destroy-messaging-providers", - env: state.env, - redactionValues, - timeoutMs: 15 * 60_000, - }), - ); - await runSecondaryCleanup(() => - runHost(host, "openshell", ["sandbox", "delete", SANDBOX_NAME], { - artifactName: "preclean-openshell-sandbox-delete-messaging-providers", - env: state.env, - redactionValues, - timeoutMs: 120_000, - }), - ); - await runSecondaryCleanup(() => - runHost(host, "openshell", ["gateway", "destroy", "-g", "nemoclaw"], { - artifactName: "preclean-openshell-gateway-destroy-messaging-providers", - env: state.env, - redactionValues, - timeoutMs: 120_000, - }), - ); + timeoutMs: 120_000, + }), + ); + cleanup.trackSandbox(host, SANDBOX_NAME, { + artifactName: "cleanup-nemoclaw-destroy-messaging-providers", + env: state.env, + redactionValues, + timeoutMs: 15 * 60_000, + }); - const dockerInfo = await runHost(host, "docker", ["info"], { - artifactName: "prereq-docker-info-messaging-providers", + await runSecondaryCleanup(() => + runHost(host, "node", [CLI_ENTRYPOINT, SANDBOX_NAME, "destroy", "--yes"], { + artifactName: "preclean-nemoclaw-destroy-messaging-providers", env: state.env, redactionValues, - timeoutMs: 30_000, - }); - expectExitZero(dockerInfo, "Docker must be running"); - - progress.phase("install the all-channel OpenClaw sandbox"); - const install = await runHost(host, "bash", ["install.sh", "--non-interactive"], { - artifactName: "install-messaging-providers", + timeoutMs: 15 * 60_000, + }), + ); + await runSecondaryCleanup(() => + runHost(host, "openshell", ["sandbox", "delete", SANDBOX_NAME], { + artifactName: "preclean-openshell-sandbox-delete-messaging-providers", env: state.env, redactionValues, - timeoutMs: INSTALL_TIMEOUT_MS, - }); - if (install.exitCode !== 0 && isNvidiaEndpointRateLimitFailure(outputText(install))) { - await artifacts.writeJson("messaging-provider-early-skip.json", { - reason: - "NVIDIA endpoint validation was rate-limited before messaging-provider assertions ran", - exitCode: install.exitCode, - artifact: install.artifacts.result, - }); - skip("NVIDIA endpoint validation was rate-limited before messaging-provider assertions ran"); - return; - } - expectExitZero(install, "M0: install.sh completed"); - assertStockManagedImageReceipt({ - environment: state.env, - expectedAgent: "openclaw", - sandboxName: SANDBOX_NAME, - }); - - const openshellVersion = await runHost(host, "openshell", ["--version"], { - artifactName: "openshell-version-messaging-providers", + timeoutMs: 120_000, + }), + ); + await runSecondaryCleanup(() => + runHost(host, "openshell", ["gateway", "destroy", "-g", "nemoclaw"], { + artifactName: "preclean-openshell-gateway-destroy-messaging-providers", env: state.env, redactionValues, - timeoutMs: 60_000, - }); - expectExitZero(openshellVersion, "openshell installed"); + timeoutMs: 120_000, + }), + ); - const sandboxList = await runHost(host, "openshell", ["sandbox", "list"], { - artifactName: "sandbox-list-messaging-providers", - env: state.env, - redactionValues, - timeoutMs: 60_000, + const dockerInfo = await runHost(host, "docker", ["info"], { + artifactName: "prereq-docker-info-messaging-providers", + env: state.env, + redactionValues, + timeoutMs: 30_000, + }); + expectExitZero(dockerInfo, "Docker must be running"); + + beginInstall(); + const install = await runHost(host, "bash", ["install.sh", "--non-interactive"], { + artifactName: "install-messaging-providers", + env: state.env, + redactionValues, + timeoutMs: INSTALL_TIMEOUT_MS, + }); + if (install.exitCode !== 0 && isNvidiaEndpointRateLimitFailure(outputText(install))) { + await artifacts.writeJson("messaging-provider-early-skip.json", { + reason: + "NVIDIA endpoint validation was rate-limited before messaging-provider assertions ran", + exitCode: install.exitCode, + artifact: install.artifacts.result, }); - expectExitZero(sandboxList, "openshell sandbox list"); - const sandboxRow = stripAnsi(sandboxList.stdout) - .split(/\r?\n/) - .find((line) => line.includes(SANDBOX_NAME)); - check(Boolean(sandboxRow && /\bReady\b/.test(sandboxRow)), "M0b: sandbox is Ready"); + skip("NVIDIA endpoint validation was rate-limited before messaging-provider assertions ran"); + return undefined; + } + expectExitZero(install, "M0: install.sh completed"); + assertStockManagedImageReceipt({ + environment: state.env, + expectedAgent: "openclaw", + sandboxName: SANDBOX_NAME, + }); + + const openshellVersion = await runHost(host, "openshell", ["--version"], { + artifactName: "openshell-version-messaging-providers", + env: state.env, + redactionValues, + timeoutMs: 60_000, + }); + expectExitZero(openshellVersion, "openshell installed"); + + const sandboxList = await runHost(host, "openshell", ["sandbox", "list"], { + artifactName: "sandbox-list-messaging-providers", + env: state.env, + redactionValues, + timeoutMs: 60_000, + }); + expectExitZero(sandboxList, "openshell sandbox list"); + const sandboxRow = stripAnsi(sandboxList.stdout) + .split(/\r?\n/) + .find((line) => line.includes(SANDBOX_NAME)); + check(Boolean(sandboxRow && /\bReady\b/.test(sandboxRow)), "M0b: sandbox is Ready"); + return { state, redactionValues, skips }; +} + +test( + "preserves WhatsApp policy and account state across rebuild", + { + ...testTimeoutOptions(LIVE_TIMEOUT_MS), + meta: { + e2ePhases: [ + "load messaging credentials and clear the sandbox", + "install the all-channel OpenClaw sandbox", + "add WhatsApp and prove rebuild persistence", + "inspect WhatsApp account state after rebuild", + ], + }, + }, + async ({ artifacts, cleanup, host, progress, sandbox, skip }) => { + const installed = await installMessagingProviderSandbox( + { + artifacts, + cleanup, + host, + sandbox, + skip, + }, + () => progress.phase("install the all-channel OpenClaw sandbox"), + ); + if (!installed) return; + const { state, redactionValues } = installed; progress.phase("add WhatsApp and prove rebuild persistence"); const whatsappAdd = await runHost( @@ -346,6 +376,86 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w "M-WA5a: unrelated host policy edit survived messaging rebuild", ); + progress.phase("inspect WhatsApp account state after rebuild"); + const config = await readOpenClawConfig(sandbox, redactionValues); + check(channelEnabled(config, "whatsapp"), "M-WA6: channels.whatsapp.enabled is true"); + check(pluginEnabled(config, "whatsapp"), "M-WA7: plugins.entries.whatsapp.enabled is true"); + const whatsappAccount = channelAccount(config, "whatsapp"); + check(accountBool(whatsappAccount, "enabled") === true, "M-WA8: WhatsApp account is enabled"); + const whatsappHealth = whatsappAccount.healthMonitor; + check( + Boolean( + whatsappHealth && + typeof whatsappHealth === "object" && + (whatsappHealth as Record).enabled === false, + ), + "M-WA8a: WhatsApp health monitor is disabled for unpaired QR session", + ); + check( + !JSON.stringify(whatsappAccount).match( + /token|secret|auth|session|openshell:resolve:env:WHATSAPP/i, + ), + "M-WA9: WhatsApp config has no token/auth/session provider placeholders", + ); + + const runtimeChannelsResult = await runSandboxShell( + sandbox, + "timeout 45 openclaw channels list --all --json --no-color", + { + artifactName: "openclaw-channels-list-whatsapp-rebuild", + redactionValues, + }, + ); + expectExitZero(runtimeChannelsResult, "OpenClaw channels list after WhatsApp rebuild"); + const runtimeChannels = runtimeChannelsResult.stdout.trim(); + if (!runtimeChannels) { + throw new Error( + `OpenClaw channels list did not emit WhatsApp state:\n${ + runtimeChannelsResult.stderr.trim() || "stderr was empty" + }`, + ); + } + const parsedRuntime = JSON.parse(runtimeChannels) as { + chat?: Record; + }; + const whatsappRuntime = parsedRuntime.chat?.whatsapp; + check( + whatsappRuntime?.installed === true && + (whatsappRuntime.origin === "available" || whatsappRuntime.origin === "configured"), + "M-WA10: OpenClaw channels list reports WhatsApp plugin installed", + ); + }, +); + +test( + "keeps managed messaging credentials out of the sandbox across runtime sends", + { + ...testTimeoutOptions(LIVE_TIMEOUT_MS), + meta: { + e2ePhases: [ + "load messaging credentials and clear the sandbox", + "install the all-channel OpenClaw sandbox", + "inspect providers placeholders and credential isolation", + "probe Telegram and Discord policy rewrites", + "exercise installed Slack, Telegram, and WeChat runtimes", + "inspect gateway health after synthetic sends", + ], + }, + }, + async ({ artifacts, cleanup, host, progress, sandbox, skip }) => { + const installed = await installMessagingProviderSandbox( + { + artifacts, + cleanup, + host, + sandbox, + skip, + }, + () => progress.phase("install the all-channel OpenClaw sandbox"), + ); + if (!installed) return; + const { state, redactionValues, skips } = installed; + progress.phase("inspect providers placeholders and credential isolation"); const providerList = await runHost(host, "openshell", ["provider", "list"], { artifactName: "provider-list-messaging-providers", @@ -517,7 +627,6 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w ["M6a", "telegram", "telegram"], ["M6b", "discord", "discord"], ["M6c", "slack", "slack"], - ["M6d", "whatsapp", "whatsapp"], ] as const ).forEach(([assertionId, channel, plugin]) => { check(channelEnabled(config, channel), `${assertionId}: channels.${channel}.enabled is true`); @@ -530,7 +639,6 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w const telegramAccount = channelAccount(config, "telegram"); const discordAccount = channelAccount(config, "discord"); const slackAccount = channelAccount(config, "slack"); - const whatsappAccount = channelAccount(config, "whatsapp"); const wechatAccount = channelAccount(config, "openclaw-weixin", state.wechatAccount); check( @@ -581,22 +689,6 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w "M11h: Slack wildcard channel mention allowlist contains expected users", ); - check(accountBool(whatsappAccount, "enabled") === true, "M-WA8: WhatsApp account is enabled"); - const whatsappHealth = whatsappAccount.healthMonitor; - check( - Boolean( - whatsappHealth && - typeof whatsappHealth === "object" && - (whatsappHealth as Record).enabled === false, - ), - "M-WA8a: WhatsApp health monitor is disabled for unpaired QR session", - ); - check( - !JSON.stringify(whatsappAccount).match( - /token|secret|auth|session|openshell:resolve:env:WHATSAPP/i, - ), - "M-WA9: WhatsApp config has no token/auth/session provider placeholders", - ); check( accountBool(wechatAccount, "enabled") === true, "M-W8: WeChat configured account is enabled", @@ -661,13 +753,6 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w `${assertionId}: OpenClaw channels list reports ${channel} installed/configured`, ); }); - const whatsappRuntime = parsedRuntime.chat?.whatsapp; - check( - whatsappRuntime?.installed === true && - (whatsappRuntime.origin === "available" || whatsappRuntime.origin === "configured"), - "M6h: OpenClaw channels list reports WhatsApp plugin installed", - ); - progress.phase("probe Telegram and Discord policy rewrites"); // Probe the allowed Telegram bot API path (/bot/**). The bare root // path is blocked by the Telegram egress policy by design (asserted by M14), diff --git a/test/e2e/support/fake-api-port-readiness.test.ts b/test/e2e/support/fake-api-port-readiness.test.ts index 83f5d62d69f..7ef792cf604 100644 --- a/test/e2e/support/fake-api-port-readiness.test.ts +++ b/test/e2e/support/fake-api-port-readiness.test.ts @@ -9,6 +9,24 @@ import { closeServer, listenServer, writeJsonResponse } from "../fixtures/http-p import { proveFakeApiPortTraffic } from "../lib/fake-api-port-readiness.mts"; describe("fake API port readiness", () => { + it("retries an invalid REST reply until the expected traffic reply arrives", async () => { + let requests = 0; + const server = http.createServer((_request, response) => { + requests += 1; + writeJsonResponse(response, 200, { + type: requests < 3 ? "not-ready" : "nemoclaw_port_traffic_reply", + }); + }); + const port = await listenServer(server, 0, "127.0.0.1"); + + try { + await proveFakeApiPortTraffic({ host: "127.0.0.1", restPort: port }); + expect(requests).toBe(3); + } finally { + await closeServer(server); + } + }); + it("rejects a REST redirect without contacting its target", async () => { let targetRequests = 0; const target = http.createServer((_request, response) => { diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index d09acebe623..768e415af07 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -710,11 +710,12 @@ describe("messaging provider installed-runtime proofs", () => { } }); - it("publishes through a dedicated bridge and connects the proxy to the private API", async () => { + it("keeps the API private while the credential-free proxy carries bridge traffic", async () => { const calls: string[][] = []; const host = fakeDockerHost(OPENSHELL_BRIDGE_ADDRESS, calls); const cleanup: Array<() => Promise> = []; const cleanupNames: string[] = []; + const sentinel = "test-fake-docker-state-token"; try { const api = await startFakeDockerApi( @@ -730,8 +731,8 @@ describe("messaging provider installed-runtime proofs", () => { portEnv: "FAKE_API_PORT", portFileEnv: "FAKE_API_PORT_FILE", captureFileEnv: "FAKE_API_CAPTURE_FILE", - credentialEnv: {}, - redactionValues: [], + credentialEnv: { FAKE_API_EXPECTED_TOKEN: sentinel }, + redactionValues: [sentinel], env: {}, }, ); @@ -742,36 +743,32 @@ describe("messaging provider installed-runtime proofs", () => { invocation[1] === "run" && invocation.some((argument) => argument.startsWith("NEMOCLAW_FAKE_API_UPSTREAM=")), ); - expect(proxyRun).toBeDefined(); - const networkCreates = calls.filter( - (invocation) => - invocation[0] === "docker" && invocation[1] === "network" && invocation[2] === "create", + const proxyContainer = proxyRun?.[proxyRun.indexOf("--name") + 1] ?? ""; + expect(proxyContainer).not.toBe(""); + const inspect = async (container: string, template: string) => { + const result = await host.command("docker", ["inspect", "--format", template, container]); + expect(result.exitCode).toBe(0); + return JSON.parse(result.stdout) as unknown; + }; + const apiNetworks = Object.keys( + (await inspect(api.container, "{{json .NetworkSettings.Networks}}")) as object, ); - expect(networkCreates).toHaveLength(2); - const privateNetwork = networkCreates - .find((invocation) => invocation.includes("--internal")) - ?.at(-1); - const publicationCreate = networkCreates.find( - (invocation) => !invocation.includes("--internal"), + const proxyNetworks = Object.keys( + (await inspect(proxyContainer, "{{json .NetworkSettings.Networks}}")) as object, ); - const publicationNetwork = publicationCreate?.at(-1); - expect(publicationCreate?.slice(0, -1)).toEqual([ - "docker", - "network", - "create", - "--driver", - "bridge", - ]); - expect(proxyRun?.[proxyRun.indexOf("--network") + 1]).toBe(publicationNetwork); - expect(proxyRun).not.toContain("openshell-docker"); - const proxyContainer = proxyRun?.[proxyRun.indexOf("--name") + 1]; - expect(calls).toContainEqual([ - "docker", - "network", - "connect", - privateNetwork, - proxyContainer, - ]); + expect(apiNetworks).toHaveLength(1); + expect(proxyNetworks).toHaveLength(2); + expect(proxyNetworks).toContain(apiNetworks[0]); + expect(proxyNetworks).not.toContain("bridge"); + expect((await host.command("docker", ["port", api.container])).stdout.trim()).toBe(""); + expect( + (await host.command("docker", ["port", proxyContainer, "8080/tcp"])).stdout.trim(), + ).toBe(`${OPENSHELL_BRIDGE_ADDRESS}:32100`); + const proxyEnvironment = (await inspect(proxyContainer, "{{json .Config.Env}}")) as string[]; + expect(proxyEnvironment.some((entry) => entry.includes("FAKE_API_EXPECTED_TOKEN"))).toBe( + false, + ); + expect(proxyEnvironment.some((entry) => entry.includes(sentinel))).toBe(false); expect(cleanupNames).toEqual([ expect.stringMatching(/^remove nemoclaw-fake-api-network-/u), expect.stringMatching(/^remove nemoclaw-fake-api-publication-/u), @@ -783,12 +780,6 @@ describe("messaging provider installed-runtime proofs", () => { .reverse() .reduce((previous, action) => previous.then(action), Promise.resolve()); } - expect( - calls.filter( - (invocation) => - invocation[0] === "docker" && invocation[1] === "network" && invocation[2] === "rm", - ), - ).toHaveLength(2); }); it("mounts fake API credentials without placing their values in Docker arguments", async () => {