diff --git a/test/automation/pull-requests/pr-risk-plan.test.ts b/test/automation/pull-requests/pr-risk-plan.test.ts index f14d527c964..0d1cfa43976 100644 --- a/test/automation/pull-requests/pr-risk-plan.test.ts +++ b/test/automation/pull-requests/pr-risk-plan.test.ts @@ -369,6 +369,40 @@ describe("deterministic PR risk plan", () => { expect(result.requiredJobs).toEqual([]); }); + it.each([ + "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("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.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 trusted prebuild helper changes to the EXDEV job (#10517)", () => { const changedFile = "test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts"; diff --git a/test/e2e/fixtures/hermes-discord-policy-binding.ts b/test/e2e/fixtures/hermes-discord-policy-binding.ts deleted file mode 100644 index 36eaabe3449..00000000000 --- a/test/e2e/fixtures/hermes-discord-policy-binding.ts +++ /dev/null @@ -1,125 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import fs from "node:fs"; -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 -import * as policyBoundaryModule from "../../../nemoclaw/dist/shared/openshell-policy-boundary.cjs"; - -const policyBoundary = ( - "default" in policyBoundaryModule ? policyBoundaryModule.default : policyBoundaryModule -) as typeof policyBoundaryModule; -const { parseOpenShellPolicy } = policyBoundary; - -function findHermesDiscordPolicyEndpoint( - policy: { network_policies?: Record }, - host: string, - port: number, - protocol: string, -): { endpoint: Record; policyEntry: Record } { - for (const candidate of Object.values(policy.network_policies ?? {})) { - if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) continue; - const policyEntry = candidate as Record; - if (!Array.isArray(policyEntry.endpoints)) continue; - const endpoint = policyEntry.endpoints.find((entry) => { - if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return false; - const value = entry as { host?: unknown; port?: unknown; protocol?: unknown }; - return value.host === host && value.port === port && value.protocol === protocol; - }); - if (typeof endpoint === "object" && endpoint !== null && !Array.isArray(endpoint)) { - return { endpoint: endpoint as Record, policyEntry }; - } - } - throw new Error("fake Discord endpoint is missing from the base policy"); -} - -function readPolicy(policyFile: string) { - const source = fs.readFileSync(policyFile, "utf8"); - return parseOpenShellPolicy(source).policy; -} - -export function bindHermesDiscordPolicyEndpoint( - policyFile: string, - providerName: string, - host: string, - port: number, - protocol: string, -): void { - const policy = readPolicy(policyFile); - const { endpoint } = findHermesDiscordPolicyEndpoint(policy, host, port, protocol); - endpoint.credential_binding = { provider: providerName }; - fs.writeFileSync(policyFile, YAML.stringify(policy)); - fs.chmodSync(policyFile, 0o600); -} - -export function assertHermesDiscordPolicyEndpointBinaries( - policyFile: string, - host: string, - port: number, - protocol: string, - expectedBinaries: string[], -): void { - const { policyEntry } = findHermesDiscordPolicyEndpoint( - readPolicy(policyFile), - host, - port, - protocol, - ); - if (!Array.isArray(policyEntry.binaries)) { - throw new Error("fake Discord endpoint policy has no binary restrictions"); - } - const binaries = policyEntry.binaries.map((candidate) => { - if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) { - throw new Error("fake Discord endpoint policy has an invalid binary restriction"); - } - const binaryPath = (candidate as { path?: unknown }).path; - if (typeof binaryPath !== "string") { - throw new Error("fake Discord endpoint policy has an invalid binary path"); - } - return binaryPath; - }); - if (JSON.stringify(binaries) !== JSON.stringify(expectedBinaries)) { - throw new Error( - `fake Discord endpoint policy binaries ${JSON.stringify(binaries)} did not match ${JSON.stringify(expectedBinaries)}`, - ); - } -} - -function main(): void { - const args = process.argv.slice(2); - if (args[0] === "--assert-binaries") { - const [, policyFile, host, rawPort, protocol, ...expectedBinaries] = args; - if (!policyFile || !host || !rawPort || !protocol || expectedBinaries.length === 0) { - throw new Error( - "usage: hermes-discord-policy-binding --assert-binaries ...", - ); - } - assertHermesDiscordPolicyEndpointBinaries( - policyFile, - host, - Number(rawPort), - protocol, - expectedBinaries, - ); - return; - } - - const [policyFile, providerName, host, rawPort, protocol] = args; - if (!policyFile || !providerName || !host || !rawPort || !protocol) { - throw new Error( - "usage: hermes-discord-policy-binding ", - ); - } - bindHermesDiscordPolicyEndpoint(policyFile, providerName, host, Number(rawPort), protocol); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); diff --git a/test/e2e/fixtures/hermes-discord-policy-boundary-build.ts b/test/e2e/fixtures/hermes-discord-policy-boundary-build.ts deleted file mode 100644 index 086187dbd5a..00000000000 --- a/test/e2e/fixtures/hermes-discord-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("hermes-discord-policy-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 redacted diagnostic artifact: ${diagnosticPath}`, - ); -} diff --git a/test/e2e/fixtures/policy-credential-binding.ts b/test/e2e/fixtures/policy-credential-binding.ts new file mode 100644 index 00000000000..87341fddca3 --- /dev/null +++ b/test/e2e/fixtures/policy-credential-binding.ts @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import { pathToFileURL } from "node:url"; + +import YAML from "yaml"; + +// 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 = ( + "default" in policyBoundaryModule ? policyBoundaryModule.default : policyBoundaryModule +) as typeof policyBoundaryModule; +const { parseOpenShellPolicy } = policyBoundary; + +export type PolicyEndpointBinding = { + providerName: string; + host: string; + port: number; + protocol: string; +}; + +export function bindPolicyEndpoints( + policyFile: string, + bindings: readonly PolicyEndpointBinding[], +): void { + const source = fs.readFileSync(policyFile, "utf8"); + const policy = parseOpenShellPolicy(source).policy; + const endpoints = Object.values(policy.network_policies ?? {}).flatMap((entry) => { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return []; + const candidateEndpoints = (entry as { endpoints?: unknown }).endpoints; + return Array.isArray(candidateEndpoints) ? candidateEndpoints : []; + }); + + 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); +} + +function main(): void { + const [policyFile, ...rawBindings] = process.argv.slice(2); + if (!policyFile || rawBindings.length === 0 || rawBindings.length % 4 !== 0) { + throw new Error( + "usage: policy-credential-binding [ ]...", + ); + } + 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/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 new file mode 100644 index 00000000000..ec8c832cdae --- /dev/null +++ b/test/e2e/lib/fake-api-port-readiness.mts @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +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; +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`); + const port = Number(raw); + if (port > 65_535) throw new Error(`${name} must be at most 65535`); + return port; +} + +function urlHost(host: string): string { + return isIP(host) === 6 ? `[${host}]` : host; +} + +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) { + await response.body?.cancel(); + throw new Error(`REST port traffic returned HTTP ${response.status}`); + } + const reader = response.body?.getReader(); + 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) 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"); + } +} + +function probeWebsocket(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(`ws://${urlHost(host)}:${port}/socket-mode`); + let settled = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + socket.close(); + } catch { + // Preserve the traffic-check result when the native client is not open. + } + if (error) reject(error); + else resolve(); + }; + 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.addEventListener("message", (event) => { + if (typeof event.data !== "string") { + finish(new Error("WebSocket port traffic reply was not text")); + return; + } + 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; + } + } catch { + finish(new Error("WebSocket port traffic reply was not valid JSON")); + return; + } + 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")); + }); + }); +} + +export async function proveFakeApiPortTraffic(options: { + host: string; + restPort: number; + websocketPort?: number; +}): Promise { + 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"); + } + 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"); + } + 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 { + const [host = "", restPortRaw = "", websocketPortRaw] = process.argv.slice(2); + await proveFakeApiPortTraffic({ + host, + restPort: requirePort(restPortRaw, "REST port"), + ...(websocketPortRaw === undefined + ? {} + : { websocketPort: requirePort(websocketPortRaw, "WebSocket port") }), + }); + process.stdout.write("fake API proxy ports carry traffic\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/lib/fake-discord-gateway.cjs b/test/e2e/lib/fake-discord-gateway.cjs index 5e93290ab44..014b089d293 100755 --- a/test/e2e/lib/fake-discord-gateway.cjs +++ b/test/e2e/lib/fake-discord-gateway.cjs @@ -8,11 +8,18 @@ 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" }); if (require.main === module && !expectedToken) { console.error("FAKE_DISCORD_GATEWAY_EXPECTED_TOKEN is required"); @@ -173,6 +180,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 0935ff8f0d7..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,16 +20,23 @@ 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"; +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); } @@ -41,7 +53,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); } @@ -231,6 +250,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", @@ -298,6 +323,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/lib/fake-telegram-api.cjs b/test/e2e/lib/fake-telegram-api.cjs index a4388e28241..5ec1c5a973e 100755 --- a/test/e2e/lib/fake-telegram-api.cjs +++ b/test/e2e/lib/fake-telegram-api.cjs @@ -7,16 +7,25 @@ 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"; 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 +115,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..0fca396814d 100755 --- a/test/e2e/lib/fake-wechat-api.mts +++ b/test/e2e/lib/fake-wechat-api.mts @@ -5,15 +5,22 @@ 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; +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 +68,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/live/hermes-discord.test.ts b/test/e2e/live/hermes-discord.test.ts index 40669ebd50c..f0e18ada82a 100644 --- a/test/e2e/live/hermes-discord.test.ts +++ b/test/e2e/live/hermes-discord.test.ts @@ -6,18 +6,21 @@ import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { HERMES_DISCORD_TEST_TIMEOUT_MS } from "../../../tools/e2e/hermes-timeout-contract.mts"; +import type { CleanupRegistry } from "../fixtures/cleanup.ts"; import { cleanupWhenOpenShellAvailable } from "../fixtures/cleanup-resources.ts"; 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, + SYNTHETIC_FAKE_API_CREDENTIALS, } from "./messaging-providers-helpers.ts"; import { runSecondaryCleanup as bestEffortLifecycleCleanup, @@ -34,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"; @@ -112,67 +115,24 @@ async function precleanHermesDiscord( ); } -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", - "/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" -"$1" policy get --base "$2" >"$policy_file" -node --import tsx "$6" --assert-binaries "$policy_file" "$4" "$5" websocket /opt/hermes/.venv/bin/python`, - "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/hermes-discord-policy-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"); +async function startHermesFakeDiscordGateway( + host: HostCliClient, + cleanup: CleanupRegistry, + env: NodeJS.ProcessEnv, + token: string, + redactionValues: string[], +): Promise { + return startFakeDockerApi(host, cleanup.trackDisposable.bind(cleanup), { + kind: "discord-gateway", + imageScript: "fake-discord-gateway.cjs", + containerPrefix: "nemoclaw-fake-discord-hermes", + 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, + redactionValues, + }); } const HERMES_DISCORD_PYTHON_GATEWAY_PROOF = String.raw` @@ -323,189 +283,160 @@ 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"); -} - -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: { - 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", - ], +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, - }); + 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), - ), - }, - "nemoclaw", - gatewayCleanupOptions, - ); - trackPreinstallSandboxCleanup( - cleanup, - host, - sandbox, - SANDBOX_NAME, - env, - redactionValues, - "cleanup-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", + ); - await precleanHermesDiscord(host, SANDBOX_NAME, env, redactionValues, "preclean-hermes-discord"); + await precleanHermesDiscord( + host, + SANDBOX_NAME, + env, + redactionValues, + "preclean-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"); + 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: 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", + 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: @@ -543,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") @@ -571,102 +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"); + [], + { 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 startFakeDockerApi( - host, - cleanup.trackDisposable.bind(cleanup), - { - kind: "discord-gateway", - imageScript: "fake-discord-gateway.cjs", - containerPrefix: "nemoclaw-fake-discord-hermes", - portEnv: "FAKE_DISCORD_GATEWAY_PORT", - captureFileEnv: "FAKE_DISCORD_GATEWAY_CAPTURE_FILE", - expectedEnv: { FAKE_DISCORD_GATEWAY_EXPECTED_TOKEN: DISCORD_TOKEN }, + progress.phase("exercise native Discord gateway rewrite"); + const fakeGateway = await startHermesFakeDiscordGateway( + host, + cleanup, env, + DISCORD_TOKEN, redactionValues, - }, - ); - await applyHermesFakeDiscordPolicy({ - host, - sandboxName: SANDBOX_NAME, - api: fakeGateway, - env, - redactions: redactionValues, - }); - - 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", + ); + 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, - 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"); - await assertRawTokenAbsentFromFiles(sandbox, DISCORD_TOKEN, redactionValues); - - const envSurface = await rawTokenSurfaceProbe( - sandbox, - DISCORD_TOKEN, - "env", - "phase-5-raw-token-env-probe", - redactionValues, - ); - expectExitZero(envSurface, "sandbox environment token isolation"); - expect(envSurface.stdout.trim()).toBe("ABSENT"); - - const processSurface = await rawTokenSurfaceProbe( - sandbox, - DISCORD_TOKEN, - "process", - "phase-5-raw-token-process-probe", - redactionValues, - ); - expectExitZero(processSurface, "sandbox process token isolation"); - expect(processSurface.stdout.trim()).toBe("ABSENT"); + }); - const filesystemSurface = await rawTokenSurfaceProbe( - sandbox, - DISCORD_TOKEN, - "filesystem", - "phase-5-raw-token-filesystem-probe", - redactionValues, - ); - expectExitZero(filesystemSurface, "sandbox filesystem token isolation"); - expect(filesystemSurface.stdout.trim()).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)) { @@ -687,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")" @@ -739,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 3de9fe71507..6e43847c4fe 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -27,13 +27,8 @@ 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}`; export const INSTALL_TIMEOUT_MS = 45 * 60_000; export const REBUILD_TIMEOUT_MS = 25 * 60_000; @@ -43,52 +38,21 @@ 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"; -export const FAKE_API_PROXY_READINESS_PORT = 8079; -export const FAKE_API_PROXY_SOURCE = String.raw` +const FAKE_API_PROXY_SOURCE = String.raw` const net = require("node:net"); const upstream = process.env.NEMOCLAW_FAKE_API_UPSTREAM; -const listenAddress = process.env.NEMOCLAW_FAKE_API_PROXY_LISTEN_ADDRESS || "0.0.0.0"; -const readinessPort = Number(process.env.NEMOCLAW_FAKE_API_PROXY_READINESS_PORT); -const portMappings = (process.env.NEMOCLAW_FAKE_API_PROXY_PORTS || "") +const ports = (process.env.NEMOCLAW_FAKE_API_PROXY_PORTS || "") .split(",") .filter(Boolean) - .map((mapping) => mapping.split(":").map(Number)); - -if ( - !upstream || - !net.isIPv4(listenAddress) || - !Number.isInteger(readinessPort) || - readinessPort < 1 || - readinessPort > 65535 || - portMappings.length === 0 || - portMappings.some( - (mapping) => - mapping.length !== 2 || - mapping.some((port) => !Number.isInteger(port) || port < 1 || port > 65535), - ) -) { - process.exit(2); -} + .map(Number); -function upstreamAcceptsConnection(port) { - return new Promise((resolve) => { - const socket = net.connect(port, upstream); - let settled = false; - const finish = (ready) => { - if (settled) return; - settled = true; - socket.destroy(); - resolve(ready); - }; - socket.setTimeout(500, () => finish(false)); - socket.once("connect", () => finish(true)); - socket.once("error", () => finish(false)); - }); +if (!upstream || ports.length === 0 || ports.some((port) => !Number.isInteger(port))) { + process.exit(2); } -const proxyServers = portMappings.map(([listenPort, upstreamPort]) => { +for (const port of ports) { const server = net.createServer((client) => { - const backend = net.connect(upstreamPort, upstream); + const backend = net.connect(port, upstream); const close = () => { client.destroy(); backend.destroy(); @@ -97,75 +61,12 @@ const proxyServers = portMappings.map(([listenPort, upstreamPort]) => { backend.on("error", close); client.pipe(backend).pipe(client); }); - return { listenPort, server }; -}); - -const readinessServer = net.createServer(async (client) => { - client.on("error", () => client.destroy()); - const ready = ( - await Promise.all(portMappings.map(([, upstreamPort]) => upstreamAcceptsConnection(upstreamPort))) - ).every(Boolean); - ready ? client.end("ready\n") : client.destroy(); -}); - -Promise.all( - proxyServers.map( - ({ listenPort, server }) => - new Promise((resolve) => { - server.once("error", () => process.exit(3)); - server.listen(listenPort, listenAddress, resolve); - }), - ), -) - .then(() => { - readinessServer.once("error", () => process.exit(3)); - readinessServer.listen(readinessPort, listenAddress); - }) - .catch(() => process.exit(3)); -`; -export const FAKE_API_PROXY_READINESS_SOURCE = String.raw` -const net = require("node:net"); -const host = process.argv[1]; -const port = Number(process.argv[2]); - -if (!net.isIPv4(host) || !Number.isInteger(port) || port < 1 || port > 65535) { - process.exit(2); + server.on("error", () => process.exit(3)); + server.listen(port, "0.0.0.0"); } - -function reachesUpstream() { - return new Promise((resolve) => { - const socket = net.connect({ host, port }); - let response = ""; - let settled = false; - const finish = (ready) => { - if (settled) return; - settled = true; - socket.destroy(); - resolve(ready); - }; - socket.setEncoding("utf8"); - socket.setTimeout(750, () => finish(false)); - socket.on("data", (chunk) => { - response += chunk; - if (response.includes("ready\n")) finish(true); - }); - socket.once("end", () => finish(response.includes("ready\n"))); - socket.once("error", () => finish(false)); - }); -} - -(async () => { - const deadline = Date.now() + 10000; - do { - if (await reachesUpstream()) return; - await new Promise((resolve) => setTimeout(resolve, 100)); - } while (Date.now() < deadline); - console.error("proxy did not connect to the upstream API before the readiness deadline"); - process.exit(3); -})().catch(() => process.exit(4)); `; -// Keep each source chunk at or below half of OpenShell's 32,768-byte argument limit. +// 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`; @@ -185,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; @@ -235,15 +152,39 @@ export function assertDiscordGatewayCapture(captureFile: string, expectedToken: expect(identify?.tokenLooksPlaceholder, "Discord placeholder leaked").toBe(false); } -export type FakeDockerApiKind = "slack" | "telegram" | "wechat" | "discord-gateway"; +export type FakeDockerApiKind = + | "slack" + | "telegram" + | "wechat" + | "discord-gateway" + | "discord-message"; export type FakeDockerApi = { kind: FakeDockerApiKind; port: string; alternatePort?: string; + dir: string; captureFile: string; + 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"); } @@ -264,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; @@ -328,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"; @@ -415,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") { @@ -431,31 +355,17 @@ 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 }; } -export async function runSecondaryCleanup(run: () => Promise): Promise { - try { - await run(); - } catch { - // Cleanup and diagnostics should not hide the primary 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( @@ -479,16 +389,81 @@ 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`); + } +} + +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, 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, @@ -587,62 +562,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[], @@ -701,6 +620,7 @@ export async function sandboxOutput( export async function rawTokenSurfaceProbe( sandbox: SandboxClient, + sandboxName: string, token: string, surface: "env" | "process" | "filesystem", artifactName: string, @@ -716,237 +636,52 @@ 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); -} - -async function captureFakeApiContainerDiagnostics( - host: HostCliClient, - kind: FakeDockerApiKind, - component: "api" | "api-proxy", - container: string, - env: NodeJS.ProcessEnv, - redactionValues: string[], -): Promise { - await runSecondaryCleanup(async () => { - await runHost(host, "docker", ["inspect", "--format", "{{json .State}}", container], { - artifactName: `diagnose-fake-${kind}-${component}-state`, - env, - redactionValues, - timeoutMs: 30_000, - }); + const result = await runSandboxShell(sandbox, probe, { + artifactName, + redactionValues, + sandboxName, }); - await runSecondaryCleanup(async () => { - await runHost(host, "docker", ["logs", "--tail", "100", container], { - artifactName: `diagnose-fake-${kind}-${component}-logs`, - env, - redactionValues, - timeoutMs: 30_000, - }); - }); -} - -async function requireFakeApiProxyReady( - host: HostCliClient, - options: { - kind: FakeDockerApiKind; - proxyContainer: string; - bridgeAddress: string; - readinessPort: string; - captureDiagnostics: () => Promise; - env: NodeJS.ProcessEnv; - redactionValues: string[]; - }, -): Promise { - const running = await runHost( - host, - "docker", - ["inspect", "--format", "{{.State.Running}}", options.proxyContainer], - { - artifactName: `inspect-fake-${options.kind}-api-proxy-readiness`, - env: options.env, - redactionValues: options.redactionValues, - timeoutMs: 30_000, - }, - ); - const ready = - running.exitCode === 0 && running.stdout.trim() === "true" - ? await runHost( - host, - "node", - ["-e", FAKE_API_PROXY_READINESS_SOURCE, options.bridgeAddress, options.readinessPort], - { - artifactName: `probe-fake-${options.kind}-api-proxy-readiness`, - env: options.env, - redactionValues: options.redactionValues, - timeoutMs: 15_000, - }, - ) - : undefined; - if (ready?.exitCode === 0) return; - - await options.captureDiagnostics(); - throw new Error( - `fake ${options.kind} API proxy ${options.proxyContainer} did not become ready; attempted to capture redacted proxy and API diagnostics`, - ); -} - -type DockerContainerInspect = { - Config?: { - Env?: unknown; - }; - Name?: unknown; - HostConfig?: { - CapDrop?: unknown; - PidsLimit?: unknown; - ReadonlyRootfs?: unknown; - SecurityOpt?: unknown; - }; - NetworkSettings?: { - Networks?: unknown; - Ports?: unknown; - }; -}; - -function containerNetworks(record: DockerContainerInspect): string[] { - const networks = record.NetworkSettings?.Networks; - return networks !== null && typeof networks === "object" ? Object.keys(networks).sort() : []; -} - -function publishedPortBindings(record: DockerContainerInspect): Array<{ - containerPort: string; - hostAddress: string; - hostPort: string; -}> { - const ports = record.NetworkSettings?.Ports; - if (ports === null || typeof ports !== "object") return []; - return Object.entries(ports).flatMap(([containerPort, bindings]) => - Array.isArray(bindings) - ? bindings.flatMap((binding) => { - if (binding === null || typeof binding !== "object") return []; - const { HostIp: hostAddress, HostPort: hostPort } = binding as { - HostIp?: unknown; - HostPort?: unknown; - }; - return typeof hostAddress === "string" && typeof hostPort === "string" - ? [{ containerPort, hostAddress, hostPort }] - : []; - }) - : [], - ); -} - -function stringValues(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === "string") - : []; -} - -function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every((item) => typeof item === "string"); -} - -const CREDENTIAL_ENVIRONMENT_NAME = - /(?:^|_)(?:API_?KEY|CREDENTIALS?|PASSWORDS?|SECRETS?|TOKENS?)(?:_|$)/u; - -function environmentName(entry: string): string { - return entry.split("=", 1)[0]!; -} - -function environmentContainsCredential(entries: string[], redactionValues: string[]): boolean { - return entries.some( - (entry) => - CREDENTIAL_ENVIRONMENT_NAME.test(environmentName(entry)) || - redactionValues.some((value) => value.length > 0 && entry.includes(value)), - ); + expectExitZero(result, artifactName); + return result.stdout.trim(); } -async function requireFakeApiDockerTopology( +async function collectFakeApiStartupDiagnostics( host: HostCliClient, options: { - kind: FakeDockerApiKind; - apiContainer: string; - proxyContainer: string; - network: string; - openshellBridgeAddress: string; - proxyPorts: readonly number[]; + kind: string; + proxy?: string; + container: string; env: NodeJS.ProcessEnv; redactionValues: string[]; }, ): Promise { - const containerInspect = await runHost( - host, - "docker", - ["inspect", options.apiContainer, options.proxyContainer], - { - artifactName: `inspect-fake-${options.kind}-api-topology`, - env: options.env, - redactionValues: options.redactionValues, - timeoutMs: 30_000, - }, - ); - expectExitZero(containerInspect, `inspect fake ${options.kind} API topology`); - const networkInspect = await runHost(host, "docker", ["network", "inspect", options.network], { - artifactName: `inspect-fake-${options.kind}-api-network`, - env: options.env, - redactionValues: options.redactionValues, - timeoutMs: 30_000, - }); - expectExitZero(networkInspect, `inspect fake ${options.kind} API network`); - - let containers: unknown; - let networks: unknown; - try { - containers = JSON.parse(containerInspect.stdout); - networks = JSON.parse(networkInspect.stdout); - } catch { - throw new Error(`fake ${options.kind} API Docker topology inspection returned invalid JSON`); - } - const records = Array.isArray(containers) ? (containers as DockerContainerInspect[]) : []; - const api = records.find((record) => record.Name === `/${options.apiContainer}`); - const proxy = records.find((record) => record.Name === `/${options.proxyContainer}`); - const networkRecord = Array.isArray(networks) && networks.length === 1 ? networks[0] : undefined; - const apiNetworks = api === undefined ? [] : containerNetworks(api); - const proxyNetworks = proxy === undefined ? [] : containerNetworks(proxy); - const apiBindings = api === undefined ? [] : publishedPortBindings(api); - const proxyBindings = proxy === undefined ? [] : publishedPortBindings(proxy); - const expectedContainerPorts = options.proxyPorts.map((port) => `${String(port)}/tcp`).sort(); - const observedContainerPorts = proxyBindings.map(({ containerPort }) => containerPort).sort(); - const proxySecurityOptions = stringValues(proxy?.HostConfig?.SecurityOpt); - const proxyCapabilityDrops = stringValues(proxy?.HostConfig?.CapDrop); - const inspectedProxyEnvironment = proxy?.Config?.Env; - const proxyEnvironmentValid = isStringArray(inspectedProxyEnvironment); - const proxyEnvironment = proxyEnvironmentValid ? inspectedProxyEnvironment : []; - const networkDriver = - networkRecord !== null && - typeof networkRecord === "object" && - typeof (networkRecord as { Driver?: unknown }).Driver === "string" - ? (networkRecord as { Driver: string }).Driver - : undefined; - const networkInternal = - networkRecord !== null && - typeof networkRecord === "object" && - (networkRecord as { Internal?: unknown }).Internal === true; - if ( - api === undefined || - proxy === undefined || - networkDriver !== "bridge" || - !networkInternal || - JSON.stringify(apiNetworks) !== JSON.stringify([options.network]) || - JSON.stringify(proxyNetworks) !== JSON.stringify(["bridge", options.network].sort()) || - apiBindings.length !== 0 || - JSON.stringify(observedContainerPorts) !== JSON.stringify(expectedContainerPorts) || - proxyBindings.some( - ({ hostAddress, hostPort }) => - hostAddress !== options.openshellBridgeAddress || !/^\d+$/u.test(hostPort), - ) || - proxy?.HostConfig?.ReadonlyRootfs !== true || - !proxyCapabilityDrops.includes("ALL") || - !proxySecurityOptions.includes("no-new-privileges") || - !proxyEnvironmentValid || - environmentContainsCredential(proxyEnvironment, options.redactionValues) || - proxy?.HostConfig?.PidsLimit !== 32 - ) { - throw new Error(`fake ${options.kind} API Docker topology did not preserve isolation`); + 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`, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 30_000, + }); + } catch { + // Preserve the port traffic 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 port traffic failure when a diagnostic command cannot start. + } } } @@ -959,95 +694,118 @@ export async function startFakeDockerApi( nodeArgs?: readonly string[]; containerPrefix: string; portEnv: string; + portFileEnv: string; captureFileEnv: string; - expectedEnv: Record; + credentialEnv: Record; + fixtureEnv?: Record; redactionValues: string[]; 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 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 publicationNetwork = uniqueContainerName("nemoclaw-fake-api-publication"); const containerPorts = options.kind === "slack" ? [8080, 8081] : [8080]; - const proxyPorts = [FAKE_API_PROXY_READINESS_PORT, ...containerPorts]; - fs.writeFileSync(captureFile, ""); - cleanup(`remove ${dir}`, async () => { - await fs.promises.rm(dir, { recursive: true, force: true }); - }); + 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) || + !/^(?: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)}`; + fs.writeFileSync(hostPath, value, { mode: 0o600 }); + return { envName: `${key}_FILE`, hostPath, containerPath }; + }); + } catch (error) { + fs.rmSync(dir, { recursive: true, force: true }); + throw error; + } - const openshellNetwork = - options.env.OPENSHELL_DOCKER_NETWORK_NAME ?? - process.env.OPENSHELL_DOCKER_NETWORK_NAME ?? - DEFAULT_OPENSHELL_DOCKER_NETWORK; - const openshellNetworkInspect = await runHost( + const networkCreate = await runHost( host, "docker", - ["network", "inspect", openshellNetwork], + ["network", "create", "--internal", network], { - artifactName: `inspect-fake-${options.kind}-openshell-network`, + artifactName: `create-fake-${options.kind}-api-network`, env: options.env, redactionValues: options.redactionValues, timeoutMs: 30_000, }, ); - expectExitZero(openshellNetworkInspect, "inspect OpenShell Docker network"); - let openshellNetworkRecords: unknown; try { - openshellNetworkRecords = JSON.parse(openshellNetworkInspect.stdout); - } catch { - throw new Error("OpenShell Docker network inspection returned invalid JSON"); - } - const openshellBridgeAddresses = - Array.isArray(openshellNetworkRecords) && openshellNetworkRecords.length === 1 - ? (( - openshellNetworkRecords[0] as { - Driver?: unknown; - IPAM?: { Config?: Array<{ Gateway?: unknown }> }; - } - ).IPAM?.Config?.flatMap((entry) => - typeof entry.Gateway === "string" && isIPv4(entry.Gateway) ? [entry.Gateway] : [], - ) ?? []) - : []; - const openshellBridgeAddress = - openshellBridgeAddresses.length === 1 ? openshellBridgeAddresses[0] : undefined; - if ( - (openshellNetworkRecords as Array<{ Driver?: unknown }> | undefined)?.[0]?.Driver !== - "bridge" || - typeof openshellBridgeAddress !== "string" - ) { - throw new Error("OpenShell Docker network must expose exactly one IPv4 bridge gateway"); + 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 networkCreate = await runHost( + const publicationNetworkCreate = await runHost( host, "docker", - ["network", "create", "--internal", network], + ["network", "create", "--driver", "bridge", publicationNetwork], { - artifactName: `create-fake-${options.kind}-api-network`, + artifactName: `create-fake-${options.kind}-api-publication-network`, env: options.env, redactionValues: options.redactionValues, timeoutMs: 30_000, }, ); - expectExitZero(networkCreate, `create fake ${options.kind} API network`); - cleanup(`remove ${network}`, async () => { - const remove = await runHost(host, "docker", ["network", "rm", network], { - artifactName: `cleanup-${network}`, + 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 network ${network}`); + expectExitZero( + remove, + `remove fake ${options.kind} API publication network ${publicationNetwork}`, + ); } }); const dockerArgs = [ "run", "-d", + "--rm", "--name", container, "--network", @@ -1055,15 +813,20 @@ export async function startFakeDockerApi( "-e", `${options.portEnv}=8080`, "-e", + `${options.portFileEnv}=/tmp/fake/port`, + "-e", `${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, @@ -1072,29 +835,19 @@ export async function startFakeDockerApi( `/opt/nemoclaw-e2e/${options.imageScript}`, ); - let apiDiagnosticsCaptured = false; - const captureApiDiagnostics = async (): Promise => { - if (apiDiagnosticsCaptured) return; - apiDiagnosticsCaptured = true; - await captureFakeApiContainerDiagnostics( - host, - options.kind, - "api", - container, - options.env, - options.redactionValues, - ); - }; cleanup(`remove ${container}`, async () => { - await captureApiDiagnostics(); - const remove = await runHost(host, "docker", ["rm", "-f", container], { - artifactName: `cleanup-${container}`, - 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 container ${container}`); + try { + const remove = await runHost(host, "docker", ["rm", "-f", container], { + artifactName: `cleanup-${container}`, + 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 container ${container}`); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); } }); @@ -1106,21 +859,25 @@ export async function startFakeDockerApi( }); expectExitZero(start, `start fake ${options.kind} API`); - let proxyDiagnosticsCaptured = false; - const captureProxyDiagnostics = async (): Promise => { - if (proxyDiagnosticsCaptured) return; - proxyDiagnosticsCaptured = true; - await captureFakeApiContainerDiagnostics( - host, - options.kind, - "api-proxy", - proxyContainer, - options.env, - options.redactionValues, - ); - }; + let apiReady = false; + for (let attempt = 0; attempt < 100; attempt += 1) { + if (fs.existsSync(portFile) && fs.statSync(portFile).size > 0) { + apiReady = true; + break; + } + await sleep(100); + } + 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 () => { - await captureProxyDiagnostics(); const remove = await runHost(host, "docker", ["rm", "-f", proxyContainer], { artifactName: `cleanup-${proxyContainer}`, env: options.env, @@ -1132,17 +889,23 @@ export async function startFakeDockerApi( } }); + // Docker ignores `-p` mappings when a container starts on an internal 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", [ "run", "-d", + "--rm", "--name", proxyContainer, "--network", - "bridge", - ...proxyPorts.flatMap((port) => ["-p", `${openshellBridgeAddress}::${String(port)}`]), + publicationNetwork, + ...containerPorts.flatMap((port) => ["-p", `${openshellBridgeAddress}::${String(port)}`]), "--read-only", "--cap-drop", "ALL", @@ -1153,9 +916,7 @@ export async function startFakeDockerApi( "-e", `NEMOCLAW_FAKE_API_UPSTREAM=${container}`, "-e", - `NEMOCLAW_FAKE_API_PROXY_PORTS=${containerPorts.map((port) => `${String(port)}:${String(port)}`).join(",")}`, - "-e", - `NEMOCLAW_FAKE_API_PROXY_READINESS_PORT=${String(FAKE_API_PROXY_READINESS_PORT)}`, + `NEMOCLAW_FAKE_API_PROXY_PORTS=${containerPorts.join(",")}`, FAKE_API_IMAGE, "node", "-e", @@ -1170,29 +931,80 @@ export async function startFakeDockerApi( ); expectExitZero(proxyStart, `start fake ${options.kind} API proxy`); - const proxyConnect = await runHost( + const connectProxy = await runHost( host, "docker", ["network", "connect", network, proxyContainer], { - artifactName: `connect-fake-${options.kind}-api-proxy`, + 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`); + expectExitZero(connectProxy, `connect fake ${options.kind} API proxy to private network`); - await requireFakeApiDockerTopology(host, { - kind: options.kind, - apiContainer: container, - proxyContainer, - network, - openshellBridgeAddress, - proxyPorts, + 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) { + const expectedNetworks = role === "api" ? [network] : [publicationNetwork, network]; + if ( + typeof networks !== "object" || + networks === null || + Array.isArray(networks) || + 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`); + } + } + + 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-environment-free`, + options.env, + options.redactionValues, + ); + 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") || + 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( @@ -1219,94 +1031,120 @@ export async function startFakeDockerApi( const publishedRestPort = await publishedPort(8080, `port-fake-${options.kind}-api`); const publishedWebsocketPort = options.kind === "slack" ? await publishedPort(8081, "port-fake-slack-websocket-api") : ""; - const publishedReadinessPort = await publishedPort( - FAKE_API_PROXY_READINESS_PORT, - `port-fake-${options.kind}-api-proxy-readiness`, - ); - await requireFakeApiProxyReady(host, { - kind: options.kind, - proxyContainer, - bridgeAddress: openshellBridgeAddress, - readinessPort: publishedReadinessPort, - captureDiagnostics: async () => { - await captureProxyDiagnostics(); - await captureApiDiagnostics(); + const traffic = await runHost( + host, + "node", + [ + "--experimental-strip-types", + FAKE_API_PORT_TRAFFIC, + openshellBridgeAddress, + publishedRestPort, + ...(options.kind === "slack" ? [publishedWebsocketPort] : []), + ], + { + artifactName: `prove-fake-${options.kind}-api-proxy-traffic`, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 15_000, }, - env: options.env, - redactionValues: options.redactionValues, - }); + ); + if (traffic.exitCode !== 0) { + await collectFakeApiStartupDiagnostics(host, { + kind: options.kind, + proxy: proxyContainer, + container, + env: options.env, + redactionValues: options.redactionValues, + }); + throw new Error( + `fake ${options.kind} API proxy ${proxyContainer} did not carry traffic to API container ${container}: ` + + `traffic check failed with exit ${traffic.exitCode ?? "unknown"}`, + ); + } return { kind: options.kind, port: publishedRestPort, ...(options.kind === "slack" ? { alternatePort: publishedWebsocketPort } : {}), + dir, captureFile, + container, }; } -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", - ], - { - artifactName: `apply-${api.kind}-rest-policy`, - env, - redactionValues, - timeoutMs: 120_000, - }, - ); - expectExitZero(result, `apply ${api.kind} fake REST policy`); - if (!providerName) return; + `${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: options.artifactName, + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 120_000, + }); + expectExitZero(result, options.artifactName); const binding = await runHost( - host, + options.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`, - host.openshellCommandPath, - SANDBOX_NAME, - providerName, - api.port, - path.join(REPO_ROOT, "test/e2e/fixtures/hermes-discord-policy-binding.ts"), +"$openshell" policy get --base "$sandbox" >"$policy_file" +node --import tsx "$helper" "$policy_file" "$@" +"$openshell" policy set --policy "$policy_file" --wait "$sandbox"`, + `bind-${options.artifactName}`, + options.host.openshellCommandPath, + options.sandboxName, + path.join(REPO_ROOT, "test/e2e/fixtures/policy-credential-binding.ts"), + ...options.endpoints.flatMap(({ providerName, port, protocol }) => [ + providerName, + options.policyHost, + port, + protocol, + ]), ], { - artifactName: `apply-${api.kind}-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 ${api.kind} fake REST policy credential`); + expectExitZero(binding, `${options.artifactName} credential binding`); } export function lastJsonLine( 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/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index 4cf25126f5e..5be0e6f6ca9 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -2,22 +2,23 @@ // 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. + * 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"; +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, accountString, - applyRestRewritePolicy, + applyFakeApiPolicy, CLI_ENTRYPOINT, channelAccount, channelEnabled, @@ -34,7 +35,6 @@ import { outputText, pluginEnabled, policyTextHasHost, - premergeSlackPolicyIfNeeded, REBUILD_TIMEOUT_MS, rawTokenSurfaceProbe, readOpenClawConfig, @@ -56,140 +56,176 @@ import { runInstalledWechatRuntimeProof } from "./messaging-providers-wechat-run process.env.NEMOCLAW_CLI_BIN ??= CLI_ENTRYPOINT; -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 and optional live 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}`); - } +const SANDBOX_BACKUP_ROOT = path.join(os.homedir(), ".nemoclaw", "rebuild-backups", SANDBOX_NAME); - 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, - }); +type MessagingScenarioFixtures = Pick< + E2ETargetFixtures, + "artifacts" | "cleanup" | "host" | "sandbox" +> & { + skip: (reason: string) => void; +}; - 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", - env: state.env, - redactionValues, - timeoutMs: 15 * 60_000, - }); - - const restoreSlackPolicy = await premergeSlackPolicyIfNeeded(); - cleanup.add("restore messaging E2E Slack policy pre-merge", restoreSlackPolicy); +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}`); + } - 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, - }), - ); + 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 dockerInfo = await runHost(host, "docker", ["info"], { - artifactName: "prereq-docker-info-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: 30_000, - }); - expectExitZero(dockerInfo, "Docker must be running"); + timeoutMs: 120_000, + }), + ); + cleanup.trackSandbox(host, SANDBOX_NAME, { + artifactName: "cleanup-nemoclaw-destroy-messaging-providers", + env: state.env, + redactionValues, + timeoutMs: 15 * 60_000, + }); - progress.phase("install the all-channel OpenClaw sandbox"); - const install = await runHost(host, "bash", ["install.sh", "--non-interactive"], { - artifactName: "install-messaging-providers", + await runSecondaryCleanup(() => + runHost(host, "node", [CLI_ENTRYPOINT, SANDBOX_NAME, "destroy", "--yes"], { + artifactName: "preclean-nemoclaw-destroy-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: 15 * 60_000, + }), + ); + await runSecondaryCleanup(() => + runHost(host, "openshell", ["sandbox", "delete", SANDBOX_NAME], { + artifactName: "preclean-openshell-sandbox-delete-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", + 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, + timeoutMs: 120_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( @@ -340,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", @@ -457,6 +573,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`, @@ -505,12 +622,13 @@ 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"], + ] as const + ).forEach(([assertionId, channel, plugin]) => { check(channelEnabled(config, channel), `${assertionId}: channels.${channel}.enabled is true`); check( pluginEnabled(config, plugin), @@ -521,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( @@ -572,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", @@ -635,12 +736,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 && @@ -650,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), @@ -875,20 +971,26 @@ 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", - expectedEnv: { + credentialEnv: { FAKE_SLACK_API_EXPECTED_BOT_TOKEN: state.tokens.slackBot, FAKE_SLACK_API_EXPECTED_APP_TOKEN: state.tokens.slackApp, }, env: state.env, redactionValues, }); - await applyRestRewritePolicy( - host, - fakeSlack, - state.env, - redactionValues, - `${SANDBOX_NAME}-slack-bridge`, + 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", ); expect( fakeSlack.alternatePort, @@ -898,13 +1000,27 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); ...fakeSlack, port: fakeSlack.alternatePort!, }; - await applyRestRewritePolicy( + await applyFakeApiPolicy({ host, - fakeSlackApp, - state.env, + 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`, + }, + ], + binaries: ["/usr/local/bin/node", "/usr/bin/node"], + artifactName: "apply-slack-fake-api-policy", + env: state.env, redactionValues, - `${SANDBOX_NAME}-slack-app`, - ); + }); const slackBotPlaceholder = await sandboxOutput( sandbox, @@ -1029,20 +1145,30 @@ 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", - expectedEnv: { + credentialEnv: { FAKE_TELEGRAM_API_EXPECTED_TOKEN: state.tokens.telegram, }, 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( @@ -1083,22 +1209,34 @@ 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", - expectedEnv: { + credentialEnv: { FAKE_WECHAT_API_EXPECTED_TOKEN: state.tokens.wechat, + }, + fixtureEnv: { FAKE_WECHAT_API_EXPECTED_TARGET: wechatMockTarget, FAKE_WECHAT_API_EXPECTED_TEXT: wechatMockText, }, 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, @@ -1168,7 +1306,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, @@ -1196,75 +1334,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 f2b27ee4179..c3b5cf6fc3a 100644 --- a/test/e2e/live/openclaw-discord-pairing.test.ts +++ b/test/e2e/live/openclaw-discord-pairing.test.ts @@ -2,9 +2,12 @@ // 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, + SYNTHETIC_FAKE_API_CREDENTIALS, +} from "./messaging-providers-helpers.ts"; +import { approveAndAssertPairing, assertOpenClawStateRoot, cleanupPairingSandbox, @@ -30,157 +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 }); + 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, - }); + 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); + 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"); - 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"); + const docker = await dockerInfo(host, env); + expect(docker.exitCode, resultText(docker)).toBe(0); - 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", + progress.phase("install the Discord-enabled OpenClaw sandbox"); + const install = await installSandboxOrSkipOnRateLimit( + host, env, - redactionValues: redactions, - timeoutMs: 60_000, - }, - ); - expectExitZero(provider, "Discord provider exists"); + 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"); + + 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"); - 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 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:\/\//); - await assertOpenClawStateRoot(sandbox, SANDBOX_NAME, "discord", redactions); + 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 applyFakePolicy({ - host, - sandboxName: SANDBOX_NAME, - api: fakeGateway, - protocol: "websocket", - rewrite: "websocket-credential-rewrite", - providerName: `${SANDBOX_NAME}-discord-bridge`, - env, - 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("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-pairing-helpers.ts b/test/e2e/live/openclaw-pairing-helpers.ts index 53374faeb28..7bffc18db6d 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`, @@ -151,8 +151,9 @@ 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", - expectedEnv: { FAKE_DISCORD_GATEWAY_EXPECTED_TOKEN: token }, + credentialEnv: { FAKE_DISCORD_GATEWAY_EXPECTED_TOKEN: token }, env, redactionValues: redactions, }); @@ -171,10 +172,13 @@ 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", - 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, @@ -182,67 +186,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/hermes-discord-policy-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, @@ -605,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/live/openclaw-slack-pairing.test.ts b/test/e2e/live/openclaw-slack-pairing.test.ts index ad02c20ac69..1b485c768d5 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,10 @@ import { startFakeSlackApi, writePairingArtifacts, } from "./openclaw-pairing-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,156 +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 fakeSlackRest = await startFakeSlackApi( - host, - cleanup, - env, - SLACK_BOT_TOKEN, - SLACK_APP_TOKEN, - redactions, - ); - const fakeSlackWebSocket = await startFakeSlackApi( - host, - cleanup, - env, - SLACK_BOT_TOKEN, - SLACK_APP_TOKEN, - redactions, - ); - await applyFakePolicy({ - host, - sandboxName: SANDBOX_NAME, - api: fakeSlackRest, - 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`, - env, - redactions, - artifactName: "apply-slack-websocket-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: fakeSlackRest.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, - ); - 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/live/phase6-messaging-helpers.ts b/test/e2e/live/phase6-messaging-helpers.ts index 4bfd20ace28..aecc98719b1 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, @@ -102,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/mock-parity.json b/test/e2e/mock-parity.json index 264e8391214..8c47473de02 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -16,9 +16,7 @@ "src/lib/onboard/command.test.ts", "src/lib/onboard/docker-driver-gateway-env.test.ts", "src/lib/onboard/experimental/portable-host-preparation.test.ts", - "src/lib/onboard/sandbox-prebuild.test.ts", - "test/agents/hermes/hermes-start.test.ts", - "test/e2e/support/portable-profile-rootless-runtime-workflow.test.ts" + "src/lib/onboard/sandbox-prebuild.test.ts" ] }, { @@ -471,12 +469,16 @@ }, { "live": "test/e2e/live/hermes-discord.test.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/hermes-discord-policy-binding.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", "test/e2e/support/e2e-clients.test.ts" @@ -547,7 +549,6 @@ "live": "test/e2e/live/mcp-bridge.test.ts", "liveSources": [ "test/e2e/live/mcp-bridge-cleanup.ts", - "test/e2e/live/mcp-bridge-onboard-env.ts", "test/e2e/live/mcp-bridge-reliability.ts", "test/e2e/live/mcp-bridge-sandbox.ts", "test/e2e/live/openshell-allowed-ips-rebinding.ts", @@ -594,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", @@ -619,12 +621,16 @@ }, { "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/openclaw-discord-pairing-helpers.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", "test/e2e/support/e2e-clients.test.ts" ] @@ -659,11 +665,15 @@ }, { "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", "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" @@ -768,7 +778,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" ] @@ -790,10 +802,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", @@ -828,9 +842,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/fake-api-port-readiness.test.ts b/test/e2e/support/fake-api-port-readiness.test.ts new file mode 100644 index 00000000000..7ef792cf604 --- /dev/null +++ b/test/e2e/support/fake-api-port-readiness.test.ts @@ -0,0 +1,55 @@ +// 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("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) => { + 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/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"); }); }); diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 8ebd6554bd6..768e415af07 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -1,39 +1,57 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { execFile, spawn, spawnSync } from "node:child_process"; -import { once } from "node:events"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; -import net, { type AddressInfo } from "node:net"; import os from "node:os"; import path from "node:path"; -import { promisify } from "node:util"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import YAML from "yaml"; import { buildProcessTokenProbe } from "../fixtures/process-token-probe.ts"; -import type { HostCliClient } from "../fixtures/clients/host.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 { + applyFakeApiPolicy, buildSandboxNodeInvocation, buildSandboxShellInvocation, - FAKE_API_PROXY_READINESS_PORT, - FAKE_API_PROXY_READINESS_SOURCE, - FAKE_API_PROXY_SOURCE, isNvidiaEndpointRateLimitFailure, messagingEnv, OPENSHELL_EXEC_ARGUMENT_LIMIT_BYTES, parseRuntimeProofPort, + rawTokenSurfaceProbe, + runSecondaryCleanup, startFakeDockerApi, + SYNTHETIC_FAKE_API_CREDENTIALS, } from "../live/messaging-providers-helpers.ts"; 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"); 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", +); +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([ { @@ -41,660 +59,1050 @@ const OPENSHELL_NETWORK_INSPECT = JSON.stringify([ IPAM: { Config: [{ Subnet: "172.18.0.0/16", Gateway: OPENSHELL_BRIDGE_ADDRESS }] }, }, ]); -const execFileAsync = promisify(execFile); - -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); -} -type CleanupAction = { - name: string; - run: () => Promise; -}; +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 successfulCommand(stdout = "") { +function successfulShellResult(command: string[], stdout = ""): ShellProbeResult { return { - artifacts: { result: "", stderr: "", stdout: "" }, - command: [], + command, + durationMs: 0, exitCode: 0, signal: null, - stderr: "", - stdout, timedOut: false, + stdout, + stderr: "", + artifacts: { stdout: "", stderr: "", result: "" }, }; } -function failedCommand(stderr: string) { - return { ...successfulCommand(), exitCode: 1, stderr }; -} - -function optionValues(args: string[], option: string): string[] { - return args.flatMap((argument, index) => (argument === option ? [args[index + 1]!] : [])); -} - -function optionValue(args: string[], option: string): string { - return optionValues(args, option)[0]!; -} - -function fakePublishedPorts(containerPorts: readonly number[], hostAddress: string) { - return Object.fromEntries( - containerPorts.map((containerPort) => { - return [ - String(containerPort) + "/tcp", - [{ HostIp: hostAddress, HostPort: fakeHostPort(containerPort) }], - ]; - }), - ); -} +type FakeSlackPortFixture = { + restPort: number; + websocketPort: number; + captureFile: string; + stop: () => Promise; +}; -function fakeHostPort(containerPort: number): string { - return containerPort === 8081 ? "32101" : containerPort === 8080 ? "32100" : "32079"; -} +type FakePortFixture = { + restPort: number; + websocketPort?: number; + captureFile: string; + stop: () => Promise; +}; -function publishedPortsFromRun(run: string[], publishedAddress?: string) { - return Object.fromEntries( - optionValues(run, "-p").map((publication) => { - const [commandAddress, containerPort] = publication.split("::"); - return [ - `${containerPort}/tcp`, - [ - { - HostIp: publishedAddress ?? commandAddress, - HostPort: fakeHostPort(Number(containerPort)), - }, - ], - ]; - }), - ); +function readFakeSlackCapture(captureFile: string): Array> { + return fs + .readFileSync(captureFile, "utf8") + .trim() + .split(/\r?\n/u) + .filter(Boolean) + .map((line) => JSON.parse(line)); } -type MissingProxyControl = - | "--cap-drop" - | "--pids-limit" - | "--read-only" - | "--security-opt" - | "internal-network"; - -function fakeDockerInspect( - calls: string[][], - options: { - apiPublished: boolean; - missingProxyControl?: MissingProxyControl; - proxyEnvironment: readonly string[]; - publishedAddress?: string; - }, -): string { - const [apiRun, proxyRun] = calls.filter((call) => call[0] === "run") as [string[], string[]]; - const apiContainer = optionValue(apiRun, "--name"); - const proxyContainer = optionValue(proxyRun, "--name"); - const apiNetwork = optionValue(apiRun, "--network"); - const connectedProxyNetworks = calls - .filter((call) => call[0] === "network" && call[1] === "connect" && call[3] === proxyContainer) - .map((call) => call[2]!); - const proxyNetworks = [optionValue(proxyRun, "--network"), ...connectedProxyNetworks].filter( - (network) => options.missingProxyControl !== "internal-network" || network !== apiNetwork, +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( + options.label, + ["start fake API listeners", "exercise port traffic"], + { logLine: () => undefined }, ); - return JSON.stringify([ + const controller = new AbortController(); + const child = spawnObservedChild( + process.execPath, + [...(options.nodeArgs ?? []), options.script], { - Name: "/" + apiContainer, - HostConfig: {}, - NetworkSettings: { - Networks: { [apiNetwork]: {} }, - Ports: { - ...publishedPortsFromRun(apiRun), - ...(options.apiPublished ? fakePublishedPorts([8080], "0.0.0.0") : {}), + activityLabel: `command: ${options.label}`, + progress, + spawn: { + detached: true, + env: { + PATH: process.env.PATH ?? "", + ...options.env(portFile, captureFile), }, + stdio: ["ignore", "ignore", "pipe"], }, }, - { - Config: { - Env: [...optionValues(proxyRun, "-e"), ...options.proxyEnvironment], - }, - Name: "/" + proxyContainer, - HostConfig: { - CapDrop: - options.missingProxyControl === "--cap-drop" ? [] : optionValues(proxyRun, "--cap-drop"), - PidsLimit: - options.missingProxyControl === "--pids-limit" - ? undefined - : Number(optionValue(proxyRun, "--pids-limit")), - ReadonlyRootfs: - options.missingProxyControl !== "--read-only" && proxyRun.includes("--read-only"), - SecurityOpt: - options.missingProxyControl === "--security-opt" - ? [] - : optionValues(proxyRun, "--security-opt"), - }, - NetworkSettings: { - Networks: Object.fromEntries(proxyNetworks.map((network) => [network, {}])), - Ports: publishedPortsFromRun(proxyRun, options.publishedAddress), - }, + ); + let stderrTail = ""; + const supervised = superviseChild(child, { + timeoutMs: 30_000, + killGraceMs: 1_000, + signal: controller.signal, + onStderr: (chunk) => { + stderrTail = `${stderrTail}${chunk}`.slice(-STDERR_TAIL_LIMIT); }, - ]); + }); + 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), + () => + `${options.label} did not start: ${redactString(stderrTail, options.redactionValues ?? [])}`, + ); + 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(); + throw error; + } } -function fakeDockerHost( - options: { - apiPublished?: boolean; - missingProxyControl?: MissingProxyControl; - proxyEnvironment?: readonly string[]; - publishedAddress?: string; - networkInspect?: string; - proxyRunning?: boolean; - proxyReady?: boolean; - } = {}, -): { - artifacts: Map; - calls: string[][]; - commands: Array<{ command: string; args: string[] }>; - host: HostCliClient; - resources: () => { containers: string[]; networks: string[] }; - setProxyRunning: (running: boolean) => void; -} { - const artifacts = new Map(); - const calls: string[][] = []; - const commands: Array<{ command: string; args: string[] }> = []; - const containers = new Set(); - const networks = new Set(); - const networkInspect = options.networkInspect ?? OPENSHELL_NETWORK_INSPECT; - let proxyRunning = options.proxyRunning !== false; - const dockerCommand = (args: string[]) => { - const executedArgs = [...args]; - calls.push(executedArgs); - switch (executedArgs[0]) { - case "network": - switch (executedArgs[1]) { - case "inspect": { - const createdNetwork = calls.find( - (call) => - call[0] === "network" && call[1] === "create" && call.at(-1) === executedArgs[2], - ); - return successfulCommand( - executedArgs[2] === "openshell-docker" - ? networkInspect - : JSON.stringify([ - { Driver: "bridge", Internal: createdNetwork?.includes("--internal") === true }, - ]), - ); - } - case "create": - networks.add(executedArgs.at(-1)!); - return successfulCommand(); - case "rm": - networks.delete(executedArgs[2]!); - return successfulCommand(); - default: - return successfulCommand(); - } - case "run": - containers.add(optionValue(executedArgs, "--name")); - return successfulCommand(); - case "rm": - containers.delete(executedArgs.at(-1)!); - return successfulCommand(); - case "inspect": { - const container = executedArgs.at(-1)!; - const component = container.includes("-proxy-") ? "proxy" : "api"; - return !containers.has(container) - ? failedCommand(`No such container: ${container}`) - : executedArgs[1] !== "--format" - ? successfulCommand( - fakeDockerInspect(calls, { - apiPublished: options.apiPublished === true, - missingProxyControl: options.missingProxyControl, - proxyEnvironment: options.proxyEnvironment ?? [], - publishedAddress: options.publishedAddress, - }), - ) - : executedArgs[2] === "{{json .State}}" - ? successfulCommand( - `${JSON.stringify({ Running: proxyRunning, source: component })}\n`, - ) - : successfulCommand(`${String(proxyRunning)}\n`); - } - case "logs": { - const container = executedArgs.at(-1)!; - return !containers.has(container) - ? failedCommand(`No such container: ${container}`) - : successfulCommand( - `${container.includes("-proxy-") ? "proxy" : "api"} diagnostic logs\n`, - ); - } - case "port": { - const containerPort = Number(executedArgs[2]!.replace(/\/tcp$/u, "")); - const proxyRun = calls.filter((call) => call[0] === "run").at(-1)!; - const publication = optionValues(proxyRun, "-p").find((value) => - value.endsWith(`::${String(containerPort)}`), - ); - const commandAddress = publication?.split("::")[0]; - return publication === undefined - ? failedCommand(`No public port for ${String(containerPort)}/tcp`) - : successfulCommand( - `${options.publishedAddress ?? commandAddress}:${fakeHostPort(containerPort)}\n`, - ); - } - default: - return successfulCommand(); - } - }; - const host = { - command: async ( - command: string, - args: string[], - commandOptions?: { artifactName?: string }, - ) => { - commands.push({ command, args: [...args] }); - expect(["docker", "node"]).toContain(command); - const result = - command === "node" - ? options.proxyReady === false - ? failedCommand("proxy could not reach the upstream API") - : successfulCommand() - : dockerCommand(args); - const artifactNames = - commandOptions?.artifactName === undefined ? [] : [commandOptions.artifactName]; - for (const artifactName of artifactNames) { - artifacts.set(artifactName, [result.stdout, result.stderr].filter(Boolean).join("\n")); - } - return result; - }, - } as unknown as HostCliClient; - return { - artifacts, - calls, - commands, - host, - resources: () => ({ - containers: [...containers].sort(), - networks: [...networks].sort(), +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 }), }), - setProxyRunning: (running) => { - proxyRunning = running; - const autoRemovedContainers = calls - .filter( - (args) => - !running && - args[0] === "run" && - args.includes("--rm") && - optionValue(args, "--name").includes("-proxy-"), - ) - .map((args) => optionValue(args, "--name")); - for (const container of autoRemovedContainers) containers.delete(container); - }, - }; + 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 expectFakeDiscordDiagnosticArtifacts(artifacts: Map): void { - expect(artifacts.get("diagnose-fake-discord-gateway-api-proxy-state")).toContain( - '"source":"proxy"', +function runPortTrafficCheck(restPort: number, websocketPort?: number) { + return spawnSync( + process.execPath, + [ + "--experimental-strip-types", + FAKE_API_PORT_TRAFFIC, + "127.0.0.1", + String(restPort), + ...(websocketPort === undefined ? [] : [String(websocketPort)]), + ], + { encoding: "utf8", timeout: 15_000 }, ); - expect(artifacts.get("diagnose-fake-discord-gateway-api-proxy-logs")).toContain( - "proxy diagnostic logs", - ); - expect(artifacts.get("diagnose-fake-discord-gateway-api-state")).toContain('"source":"api"'); - expect(artifacts.get("diagnose-fake-discord-gateway-api-logs")).toContain("api diagnostic logs"); } -function startFakeDiscordApi(host: HostCliClient, cleanup: CleanupAction[]) { - return startFakeDockerApi(host, (name, run) => cleanup.push({ name, run }), { - kind: "discord-gateway", - imageScript: "fake-discord-gateway.cjs", - containerPrefix: "fake-discord-gateway", - portEnv: "FAKE_DISCORD_GATEWAY_PORT", - captureFileEnv: "FAKE_DISCORD_GATEWAY_CAPTURE_FILE", - expectedEnv: { FAKE_DISCORD_GATEWAY_EXPECTED_TOKEN: "fixture-discord-token" }, - redactionValues: ["fixture-discord-token"], - env: {}, - }); +function runFakeSlackPortTrafficCheck(fixture: FakeSlackPortFixture) { + return runPortTrafficCheck(fixture.restPort, fixture.websocketPort); } -async function runCleanup(actions: CleanupAction[]): Promise { - for (let index = actions.length - 1; index >= 0; index -= 1) await actions[index]!.run(); +function successfulCommand(stdout = "") { + return { + artifacts: { result: "", stderr: "", stdout: "" }, + command: [], + exitCode: 0, + signal: null, + stderr: "", + stdout, + timedOut: false, + }; } -async function listenServer(server: net.Server, host: string): Promise { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, host, resolve); - }); - return (server.address() as AddressInfo).port; +function fakeDockerHost( + publishedAddress = OPENSHELL_BRIDGE_ADDRESS, + calls: string[][] = [], + networkInspect = OPENSHELL_NETWORK_INSPECT, + proxyOnDefaultBridge = false, +): HostCliClient { + let privateNetwork = ""; + let proxyContainer = ""; + let proxyPrimaryNetwork = ""; + let proxyConnectedToInternal = false; + const host = { + command: async (command: string, args: string[]) => { + calls.push([command, ...args]); + switch (command) { + case "node": + return successfulCommand(); + default: + expect(command).toBe("docker"); + switch (args[0]) { + case "network": + switch (args[1]) { + case "inspect": + return successfulCommand(networkInspect); + case "create": + privateNetwork = args.includes("--internal") + ? (args.at(-1) ?? "") + : privateNetwork; + return successfulCommand(); + case "connect": + proxyConnectedToInternal = + args[2] === privateNetwork && args[3] === proxyContainer; + return successfulCommand(); + default: + return successfulCommand(); + } + case "run": + 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) => { + 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( + args.at(-1) === proxyContainer + ? { + ...(proxyOnDefaultBridge ? { bridge: {} } : {}), + [proxyPrimaryNetwork]: {}, + ...(proxyConnectedToInternal ? { [privateNetwork]: {} } : {}), + } + : { [privateNetwork]: {} }, + ), + ) + : 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; + return host; } -async function closeServer(server: net.Server): Promise { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); +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); }); -} -async function tcpRequest(host: string, port: number, payload: string): Promise { - return new Promise((resolve, reject) => { - const socket = net.connect({ host, port }); - let response = ""; - socket.setEncoding("utf8"); - socket.setTimeout(5_000, () => socket.destroy(new Error("proxy request timed out"))); - socket.once("connect", () => socket.write(payload)); - socket.on("data", (chunk) => { - response += chunk; + 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 }, }); - socket.once("end", () => resolve(response)); - socket.once("error", reject); + + await expect( + 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", + ); }); -} -describe("messaging provider installed-runtime proofs", () => { - it("rejects Docker state that publishes the credential-bearing fake API", async () => { - const { host } = fakeDockerHost({ apiPublished: true }); - const cleanup: CleanupAction[] = []; + 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 { - await expect(startFakeDiscordApi(host, cleanup)).rejects.toThrow( - /Docker topology did not preserve isolation/u, + 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: [], + }); + + 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([ + { + 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 { - await runCleanup(cleanup); + fs.rmSync(dir, { recursive: true, force: true }); } }); - it.each([ - ["credential value", "PROXY_FIXTURE_VALUE=fixture-discord-token"], - ["credential name", "FAKE_DISCORD_GATEWAY_EXPECTED_TOKEN=not-a-redaction-value"], - ])("rejects Docker state that exposes a %s through the fake API proxy", async (_case, leak) => { - const { host } = fakeDockerHost({ proxyEnvironment: [leak] }); - const cleanup: CleanupAction[] = []; + 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 = { + 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 "inspect-fake-slack-openshell-network": + return successfulShellResult(invocation, OPENSHELL_NETWORK_INSPECT); + case "start-fake-slack-api": + container = invocation[invocation.indexOf("--name") + 1] ?? ""; + break; + default: + break; + } + return successfulShellResult(invocation); + }, + }; try { - await expect(startFakeDiscordApi(host, cleanup)).rejects.toThrow( - /Docker topology did not preserve isolation/u, + 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", + 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", + }, + redactionValues: [], + env: {}, + }), + ).rejects.toThrow(/fake slack API container .* did not become ready/u); + + await vi.runAllTimersAsync(); + await start; + expect(container).not.toBe(""); + 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 { - await runCleanup(cleanup); + 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), + ); + expect(artifactNames).toContainEqual( + expect.stringMatching(/^cleanup-nemoclaw-fake-api-publication-/u), + ); }); - it.each([ - { label: "capability drop", control: "--cap-drop" }, - { label: "PID limit", control: "--pids-limit" }, - { label: "read-only root", control: "--read-only" }, - { label: "security option", control: "--security-opt" }, - { label: "internal-network attachment", control: "internal-network" }, - ] as const)("rejects Docker state missing the proxy $label", async ({ control }) => { - const { host } = fakeDockerHost({ missingProxyControl: control }); - const cleanup: CleanupAction[] = []; + it("collects diagnostics when published fake API proxy ports do not carry traffic", async () => { + const invocations: Array<{ + artifactName: string; + command: string; + args: readonly string[]; + }> = []; + const cleanupTasks: Array<() => Promise> = []; + let apiContainer = ""; + let proxyContainer = ""; + let network = ""; + let publicationNetwork = ""; + const runner: CommandRunner = { + async run(command, options) { + const invocation = [command.command, ...command.args]; + 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); + 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"; + 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 "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({ [publicationNetwork]: {}, [network]: {} }), + ); + case "prove-fake-slack-api-internal-only": + return successfulShellResult(invocation); + 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"]', + ); + case "port-fake-slack-api": + return successfulShellResult(invocation, `${OPENSHELL_BRIDGE_ADDRESS}:41080\n`); + case "port-fake-slack-websocket-api": + return successfulShellResult(invocation, `${OPENSHELL_BRIDGE_ADDRESS}: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); + } + }, + }; try { - await expect(startFakeDiscordApi(host, cleanup)).rejects.toThrow( - /Docker topology did not preserve isolation/u, + await expect( + 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", + credentialEnv: { + FAKE_SLACK_API_EXPECTED_BOT_TOKEN: "xoxb-fake-slack-network-test", + FAKE_SLACK_API_EXPECTED_APP_TOKEN: "xapp-fake-slack-network-test", + }, + redactionValues: [], + env: {}, + }), + ).rejects.toThrow(/proxy .* did not carry traffic to API container .*traffic check/u); + expect(apiContainer).not.toBe(""); + expect(proxyContainer).not.toBe(""); + 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 runCleanup(cleanup); + await cleanupTasks + .reverse() + .reduce((previous, cleanupTask) => previous.then(cleanupTask), Promise.resolve()); } }); - it("returns distinct Slack REST and websocket ports from accepted Docker state", async () => { - const { host } = fakeDockerHost(); - const cleanup: CleanupAction[] = []; + 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> = []; try { - const api = await startFakeDockerApi(host, (name, run) => cleanup.push({ name, run }), { - kind: "slack", - imageScript: "fake-slack-api.cjs", - containerPrefix: "fake-slack", - portEnv: "FAKE_SLACK_API_PORT", - captureFileEnv: "FAKE_SLACK_API_CAPTURE_FILE", - expectedEnv: {}, - redactionValues: [], - env: {}, - }); + 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", + credentialEnv: {}, + redactionValues: [], + env: {}, + }), + ).rejects.toThrow(/did not bind only to the OpenShell bridge/u); + } finally { + await cleanup + .reverse() + .reduce((previous, action) => previous.then(action), Promise.resolve()); + } + }); + + 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( + 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: { FAKE_API_EXPECTED_TOKEN: sentinel }, + redactionValues: [sentinel], + env: {}, + }, + ); expect(api.port).toBe("32100"); - expect(api.alternatePort).toBe("32101"); + const proxyRun = calls.find( + (invocation) => + invocation[0] === "docker" && + invocation[1] === "run" && + invocation.some((argument) => argument.startsWith("NEMOCLAW_FAKE_API_UPSTREAM=")), + ); + 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, + ); + const proxyNetworks = Object.keys( + (await inspect(proxyContainer, "{{json .NetworkSettings.Networks}}")) as object, + ); + 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), + expect.stringMatching(/^remove fake-discord-gateway-/u), + expect.stringMatching(/^remove fake-discord-gateway-proxy-/u), + ]); } finally { - await runCleanup(cleanup); + await cleanup + .reverse() + .reduce((previous, action) => previous.then(action), Promise.resolve()); } }); - it("relays both ports without inheriting host credentials and proves upstream readiness", async () => { - const upstreamAddress = "127.0.0.1"; - const proxyAddress = "127.0.0.1"; - const inheritedCredentialName = "NEMOCLAW_FAKE_API_PROXY_TEST_CREDENTIAL"; - const previousCredential = process.env[inheritedCredentialName]; - const restoreCredential = - previousCredential === undefined - ? () => delete process.env[inheritedCredentialName] - : () => { - process.env[inheritedCredentialName] = previousCredential; - }; - process.env[inheritedCredentialName] = "must-not-reach-the-proxy-child"; - const requests: string[] = []; - const upstreamServers = ["discord", "slack"].map((label) => - net.createServer((socket) => { - socket.setEncoding("utf8"); - socket.on("data", (payload) => { - requests.push(`${label}:${payload}`); - socket.end(`${label}:${payload}`); - }); - }), - ); - const upstreamPorts = await Promise.all( - upstreamServers.map((server) => listenServer(server, upstreamAddress)), - ); - const portReservations = [net.createServer(), net.createServer(), net.createServer()]; - const [readinessPort, ...proxyPorts] = await Promise.all( - portReservations.map((server) => listenServer(server, proxyAddress)), - ); - await Promise.all(portReservations.map(closeServer)); - const credentialGuardSource = `Object.hasOwn(process.env, ${JSON.stringify(inheritedCredentialName)}) && (() => { throw new Error("proxy child inherited host credential"); })();\n`; - const proxy = spawn(process.execPath, ["-e", credentialGuardSource + FAKE_API_PROXY_SOURCE], { - env: { - NEMOCLAW_FAKE_API_UPSTREAM: upstreamAddress, - NEMOCLAW_FAKE_API_PROXY_LISTEN_ADDRESS: proxyAddress, - NEMOCLAW_FAKE_API_PROXY_PORTS: proxyPorts - .map((port, index) => `${String(port)}:${String(upstreamPorts[index])}`) - .join(","), - NEMOCLAW_FAKE_API_PROXY_READINESS_PORT: String(readinessPort), - }, - stdio: ["ignore", "ignore", "pipe"], - }); - let proxyStderr = ""; - proxy.stderr.setEncoding("utf8"); - proxy.stderr.on("data", (chunk) => { - proxyStderr += chunk; - }); + 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 = "test-fake-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); + const credentialFd = fs.openSync( + credentialFile, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW, + ); try { - await execFileAsync( - process.execPath, - ["-e", FAKE_API_PROXY_READINESS_SOURCE, proxyAddress, String(readinessPort)], - { timeout: 10_000 }, - ); - } catch (error) { - throw new Error( - `proxy readiness probe failed: ${error instanceof Error ? error.message : String(error)}; proxy stderr: ${proxyStderr}`, - ); + expect(fs.fstatSync(credentialFd).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(credentialFd, "utf8")).toBe(sentinel); + } finally { + fs.closeSync(credentialFd); } - const responses = await Promise.all([ - tcpRequest(proxyAddress, proxyPorts[0]!, "gateway"), - tcpRequest(proxyAddress, proxyPorts[1]!, "websocket"), - ]); - expect(responses, proxyStderr).toEqual(["discord:gateway", "slack:websocket"]); - expect(requests.sort()).toEqual(["discord:gateway", "slack:websocket"]); } finally { - restoreCredential(); - proxy.kill("SIGTERM"); - await (proxy.exitCode === null - ? once(proxy, "exit").then(() => undefined) - : Promise.resolve()); - await Promise.all(upstreamServers.map(closeServer)); + await cleanup + .reverse() + .reduce((previous, action) => previous.then(action), Promise.resolve()); } + expect(fs.existsSync(credentialFile)).toBe(false); }); - it("rejects a fake API proxy port that Docker publishes beyond the OpenShell bridge", async () => { - const { host } = fakeDockerHost({ publishedAddress: "0.0.0.0" }); - const cleanup: CleanupAction[] = []; + 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(startFakeDiscordApi(host, cleanup)).rejects.toThrow( - /Docker topology did not preserve isolation/u, - ); + 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", + credentialEnv: {}, + redactionValues: [], + env: {}, + }), + ).rejects.toThrow(/proxy has unexpected network attachments/u); } finally { - await runCleanup(cleanup); + await cleanup + .reverse() + .reduce((previous, action) => previous.then(action), Promise.resolve()); } }); - it("rejects ambiguous OpenShell IPv4 gateways and cleans its temporary directory", async () => { + it("rejects an ambiguous configured OpenShell bridge before creating resources", async () => { + const calls: string[][] = []; const networkInspect = JSON.stringify([ { Driver: "bridge", IPAM: { - Config: [ - { Subnet: "172.18.0.0/16", Gateway: OPENSHELL_BRIDGE_ADDRESS }, - { Subnet: "172.19.0.0/16", Gateway: "172.19.0.1" }, - ], + Config: [{ Gateway: OPENSHELL_BRIDGE_ADDRESS }, { Gateway: "172.19.0.1" }], }, }, ]); - const { calls, host } = fakeDockerHost({ networkInspect }); - const cleanup: CleanupAction[] = []; - let fixtureDir: string | undefined; - - try { - await expect(startFakeDiscordApi(host, cleanup)).rejects.toThrow( - /exactly one IPv4 bridge gateway/u, - ); - expect(calls).toEqual([["network", "inspect", "openshell-docker"]]); - expect(cleanup).toHaveLength(1); - fixtureDir = cleanup[0]!.name.replace(/^remove /u, ""); - expect(fs.existsSync(fixtureDir)).toBe(true); - } finally { - await runCleanup(cleanup); - } - expect(fixtureDir).toBeDefined(); - expect(fs.existsSync(fixtureDir!)).toBe(false); + 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", + credentialEnv: {}, + 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("fails with proxy diagnostics when the detached proxy stops before readiness", async () => { - const { artifacts, host, resources } = fakeDockerHost({ proxyRunning: false }); - const cleanup: CleanupAction[] = []; - let failure: unknown; - + 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 { - await startFakeDiscordApi(host, cleanup); - } catch (error) { - failure = error; + const fixture = messagingEnv(); + 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 { - await runCleanup(cleanup); + vi.unstubAllEnvs(); } + }); - expect(failure).toBeInstanceOf(Error); - expect((failure as Error).message).toContain( - "attempted to capture redacted proxy and API diagnostics", - ); - expectFakeDiscordDiagnosticArtifacts(artifacts); - expect(resources()).toEqual({ containers: [], networks: [] }); + 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("fails with API and proxy diagnostics when the proxy cannot reach the API", async () => { - const { artifacts, host, resources } = fakeDockerHost({ proxyReady: false }); - const cleanup: CleanupAction[] = []; - let failure: unknown; + it("publishes independent fake Slack REST and websocket ports from one fixture", async () => { + const fixture = await startFakeSlackPortFixture(); try { - await startFakeDiscordApi(host, cleanup); - } catch (error) { - failure = error; + expect(fixture.restPort).not.toBe(fixture.websocketPort); + 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 trafficCheck = runFakeSlackPortTrafficCheck(fixture); + expect(trafficCheck.status, trafficCheck.stderr).toBe(0); + const traffic = readFakeSlackCapture(fixture.captureFile); + expect(traffic).toContainEqual( + expect.objectContaining({ event: "request", path: "/__nemoclaw_e2e_port_traffic" }), + ); + 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).toContainEqual( + expect.objectContaining({ event: "websocket-port-traffic-reply", path: "/socket-mode" }), + ); } finally { - await runCleanup(cleanup); + await fixture.stop(); } + }, 25_000); - expect(failure).toBeInstanceOf(Error); - expect((failure as Error).message).toContain( - "attempted to capture redacted proxy and API diagnostics", - ); - expectFakeDiscordDiagnosticArtifacts(artifacts); - expect(resources()).toEqual({ containers: [], networks: [] }); - }); + 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 %s fake API REST traffic reply", + async (provider, script, nodeArgs, env) => { + const fixture = await startFakePortFixture({ + label: `fake ${provider} port fixture`, + script, + nodeArgs, + env, + }); - it("captures proxy diagnostics before cleanup after a post-readiness stop", async () => { - const { artifacts, host, resources, setProxyRunning } = fakeDockerHost(); - const cleanup: CleanupAction[] = []; + 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 { - await startFakeDiscordApi(host, cleanup); - setProxyRunning(false); + const trafficCheck = runPortTrafficCheck(fixture.restPort); + expect(trafficCheck.status).toBe(1); + expect(trafficCheck.stderr).toContain("REST port traffic reply was not recognized"); } finally { - await runCleanup(cleanup); + await fixture.stop(); } + }, 15_000); - expectFakeDiscordDiagnosticArtifacts(artifacts); - expect(resources()).toEqual({ containers: [], networks: [] }); - }); - - 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.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 fixture = messagingEnv(); - expect(fixture.tokens.wechat).toBe("test-fake-wechat-token-e2e"); - expect(fixture.env.WECHAT_BOT_TOKEN).toBe("test-fake-wechat-token-e2e"); - } finally { - Reflect.deleteProperty(process.env, "WECHAT_BOT_TOKEN"); - Object.assign( - process.env, - previousToken === undefined ? {} : { WECHAT_BOT_TOKEN: previousToken }, - ); - } - }); + try { + const trafficCheck = runFakeSlackPortTrafficCheck(fixture); + expect(trafficCheck.status).toBe(1); + expect(trafficCheck.stderr).toContain(error); + } finally { + await fixture.stop(); + } + }, + 15_000, + ); - 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; - }); + it("rejects a fake Slack websocket that upgrades without a port traffic reply", async () => { + 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"); - 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()), + const trafficCheck = runFakeSlackPortTrafficCheck(fixture); + expect(trafficCheck.status).toBe(1); + expect(trafficCheck.stderr).toContain("WebSocket port traffic timed out"); + const traffic = readFakeSlackCapture(fixture.captureFile); + expect(traffic).toContainEqual( + expect.objectContaining({ event: "websocket-upgrade", path: "/socket-mode" }), ); - fs.rmSync(dir, { recursive: true, force: true }); + 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(); } - }, 10_000); + }, 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-")); @@ -708,8 +1116,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; @@ -910,11 +1316,47 @@ 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"); 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, @@ -922,7 +1364,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"], }); diff --git a/test/e2e/support/hermes-discord-policy-binding.test.ts b/test/e2e/support/policy-credential-binding.test.ts similarity index 66% rename from test/e2e/support/hermes-discord-policy-binding.test.ts rename to test/e2e/support/policy-credential-binding.test.ts index 05b98436dee..fb855b4c18b 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 { createArtifactSink } from "../fixtures/artifacts.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,34 +34,34 @@ function runBinding(policyFile: string, protocol = "websocket") { ); } -function runBinaryAssertion(policyFile: string) { - return spawnSync( - process.execPath, - [ - "--disable-warning=DEP0205", - "--import", - "tsx", - HELPER, - "--assert-binaries", - policyFile, - "host.docker.internal", - "43117", - "websocket", - "/opt/hermes/.venv/bin/python", - ], - { encoding: "utf8", killSignal: "SIGKILL", timeout: 15_000 }, - ); -} - -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", 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)) { @@ -118,7 +118,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, @@ -137,6 +150,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", () => { @@ -172,8 +186,8 @@ describe("Hermes Discord E2E policy binding", () => { }); }); - it("rejects generic Python binaries in the Hermes fake Discord endpoint policy", () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-discord-policy-")); + 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( @@ -181,49 +195,45 @@ describe("Hermes Discord E2E policy binding", () => { [ "version: 1", "network_policies:", - " discord_gateway:", + " slack:", " endpoints:", - " - host: host.docker.internal", + " - host: host.openshell.internal", " port: 43117", - " protocol: websocket", - " binaries:", - " - path: /opt/hermes/.venv/bin/python", - " - path: /usr/bin/python3", - " - path: /usr/local/bin/python3", + " protocol: rest", + " - host: host.openshell.internal", + " port: 43118", + " protocol: rest", "", ].join("\n"), ); - const result = runBinaryAssertion(policyFile); - - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("/usr/bin/python3"); - expect(result.stderr).toContain("/usr/local/bin/python3"); - }); - - it("accepts only the Hermes venv interpreter for the fake Discord endpoint", () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-discord-policy-")); - tempDirs.push(tempDir); - const policyFile = path.join(tempDir, "policy.yaml"); - fs.writeFileSync( - policyFile, + const result = spawnSync( + process.execPath, [ - "version: 1", - "network_policies:", - " discord_gateway:", - " endpoints:", - " - host: host.docker.internal", - " port: 43117", - " protocol: websocket", - " binaries:", - " - path: /opt/hermes/.venv/bin/python", - "", - ].join("\n"), + "--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 result = runBinaryAssertion(policyFile); + 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" } }, + ]); }); }); 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", ]); }, ); diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index c9f250ea2fa..1e21f398863 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -698,8 +698,19 @@ 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-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], + ["test/e2e/live/messaging-providers-helpers.ts", FAKE_DOCKER_API_CONSUMER_JOBS], [ "test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts", ["openclaw-plugin-runtime-exdev"], @@ -716,7 +727,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);