Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions test/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -713,11 +713,15 @@ Live modules import `fixtures/e2e-test.ts`; selected integration modules import
It also follows shared E2E runtime helpers. Run child processes through
`ShellProbe` or an existing audited progress-aware boundary; new direct async
process boundaries fail the check. Synchronous calls require both a positive
timeout shorter than the first heartbeat and `killSignal: "SIGKILL"`. Keep child
contents in redacted artifacts and report only timestamp-based output activity
to the console. Pass the fixture-provided frozen, canonical `progress`
capability unchanged to an audited subprocess boundary; do not replace it with
a custom, copied, or no-op adapter.
timeout shorter than the first heartbeat and `killSignal: "SIGKILL"`.
Keep child contents in redacted artifacts and report only timestamp-based output
activity during command execution. A terminal failure assertion may include a
bounded, failure-only preview after canonical fixture redaction and all
scenario-specific sensitive values have been registered for explicit
redaction; cover the bound and redaction behavior with deterministic tests.
Success paths must not emit response contents. Pass the fixture-provided frozen,
canonical `progress` capability unchanged to an audited subprocess boundary; do
not replace it with a custom, copied, or no-op adapter.

## Push and Manual PR E2E

Expand Down
42 changes: 36 additions & 6 deletions test/e2e/fixtures/shell-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface ShellProbeRunOptions {
redactionValues?: string[];
/** Retain at most the last N bytes from each output stream. */
captureLimitBytes?: number;
/** After redaction, retain at most the first N UTF-8 bytes from each output stream. */
postRedactionCaptureLimitBytes?: number;
/** Timestamp-only output observer; chunk contents never cross this boundary. */
onOutput?: (event: ShellProbeOutputEvent) => void;
}
Expand Down Expand Up @@ -98,6 +100,12 @@ interface TextCapture {
};
}

function validateCaptureLimitBytes(value: number | undefined, optionName: string): void {
if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) {
throw new Error(`${optionName} must be a positive safe integer`);
}
}

function createTextCapture(limitBytes: number | undefined): TextCapture {
if (limitBytes === undefined) {
let text = "";
Expand All @@ -110,9 +118,7 @@ function createTextCapture(limitBytes: number | undefined): TextCapture {
},
};
}
if (!Number.isSafeInteger(limitBytes) || limitBytes <= 0) {
throw new Error("captureLimitBytes must be a positive safe integer");
}
validateCaptureLimitBytes(limitBytes, "captureLimitBytes");

let droppedBytes = 0;
let tail = Buffer.alloc(0);
Expand Down Expand Up @@ -152,6 +158,24 @@ function redactTruncatedSecretPrefix(text: string, redactionValues: string[]): s
return fragmentLength > 0 ? `[REDACTED]${text.slice(fragmentLength)}` : text;
}

function truncateRedactedUtf8Prefix(text: string, limitBytes: number | undefined): string {
if (limitBytes === undefined) return text;
const bytes = Buffer.from(text.replaceAll("\uFFFD", "?"), "utf8");
if (bytes.length <= limitBytes) return bytes.toString("utf8");
let end = limitBytes;
let sequenceStart = end - 1;
while (sequenceStart >= 0 && (bytes[sequenceStart]! & 0xc0) === 0x80) {
sequenceStart -= 1;
}
if (sequenceStart >= 0) {
const first = bytes[sequenceStart]!;
const sequenceLength =
(first & 0x80) === 0 ? 1 : (first & 0xe0) === 0xc0 ? 2 : (first & 0xf0) === 0xe0 ? 3 : 4;
if (sequenceStart + sequenceLength > end) end = sequenceStart;
}
return bytes.subarray(0, end).toString("utf8");
}

export class ShellProbe {
private readonly artifacts: ArtifactSink;
private readonly progress: ChildProcessProgress;
Expand All @@ -174,6 +198,10 @@ export class ShellProbe {
const args = [...trustedCommand.args];
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
validateCaptureLimitBytes(
options.postRedactionCaptureLimitBytes,
"postRedactionCaptureLimitBytes",
);
const redactionValues = options.redactionValues ?? [];
const enforcedValues = [
...new Set(redactionValues.filter((value) => value && value.length > 0)),
Expand All @@ -192,9 +220,11 @@ export class ShellProbe {
const boundarySafeText =
droppedBytes > 0 ? redactTruncatedSecretPrefix(text, enforcedValues) : text;
const redacted = redactProbeText(boundarySafeText);
return droppedBytes > 0
? `[shell-probe omitted ${droppedBytes} earlier bytes; showing up to the last ${limitBytes} bytes]\n${redacted}`
: redacted;
const rendered =
droppedBytes > 0
? `[shell-probe omitted ${droppedBytes} earlier bytes; showing up to the last ${limitBytes} bytes]\n${redacted}`
: redacted;
return truncateRedactedUtf8Prefix(rendered, options.postRedactionCaptureLimitBytes);
};
const redactedCommand = [command, ...args].map(redactProbeText);
const activityName = safeArtifactBase(redactProbeText(options.artifactName ?? command));
Expand Down
140 changes: 140 additions & 0 deletions test/e2e/live/mcp-bridge-hermes-http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { shellQuote } from "../../../src/lib/core/shell-quote";
import { redactString } from "../fixtures/redaction.ts";

export const HERMES_MCP_HTTP_STATUS_MARKER = "NEMOCLAW_HERMES_MCP_HTTP_STATUS=";
export const HERMES_MCP_RESULT_TOKEN_MARKER = "NEMOCLAW_HERMES_MCP_RESULT_TOKEN=";
export const HERMES_MCP_FAILURE_CAPTURE_BYTES = 4_096;
export const HERMES_MCP_FAILURE_PREVIEW_CHARS = 1_024;
export const HERMES_MCP_RESPONSE_FILE_BYTES = 65_536;
export const HERMES_MCP_OVERSIZE_BODY_MARKER =
"<response body omitted: exceeds diagnostic file bound>";

interface HermesMcpCommandResult {
exitCode: number | null;
signal?: NodeJS.Signals | null;
stdout: string;
stderr: string;
}

interface McpRequestObservation {
rpcMethod?: string;
auth?: string;
path?: string;
}

const FAILURE_BODY_EMITTER = [
"import os, pathlib, sys",
"path = pathlib.Path(sys.argv[1])",
"limit = int(sys.argv[2])",
`oversize = ${JSON.stringify(HERMES_MCP_OVERSIZE_BODY_MARKER)}.encode('utf-8')`,
"secret = os.environ.get('API_SERVER_KEY', '').encode('utf-8')",
"with path.open('rb') as stream:",
" raw = stream.read(limit + 1)",
"if len(raw) > limit or len(secret) > limit:",
" sys.stdout.buffer.write(oversize)",
"else:",
" replacement = b'[REDACTED]' if len(secret) >= 10 else b'*' * len(secret)",
" sys.stdout.buffer.write(raw.replace(secret, replacement) if secret else raw)",
].join("\n");

export function buildHermesMcpChatProbeScript(payload: string, resultToken: string): string {
return [
"set -eu",
"umask 077",
'response_file="$(mktemp /tmp/nemoclaw-hermes-mcp-chat.XXXXXX)"',
"trap 'rm -f \"$response_file\"' EXIT",
`set -- -sS --max-time 180 --max-filesize ${HERMES_MCP_RESPONSE_FILE_BYTES} -o "$response_file" -w '%{http_code}' http://localhost:8642/v1/chat/completions -H 'Content-Type: application/json'`,
'if [ -n "${API_SERVER_KEY:-}" ]; then set -- "$@" -H "Authorization: Bearer ${API_SERVER_KEY}"; fi',
`emit_failure_body() { /usr/bin/python3 -I -S -c ${shellQuote(FAILURE_BODY_EMITTER)} "$response_file" ${HERMES_MCP_RESPONSE_FILE_BYTES}; }`,
"set +e",
`status="$(curl "$@" --data-binary ${shellQuote(payload)})"`,
"curl_rc=$?",
"set -e",
`printf '\\n${HERMES_MCP_HTTP_STATUS_MARKER}%s\\n' "$status" >&2`,
'if [ "$curl_rc" -ne 0 ]; then exit "$curl_rc"; fi',
`case "$status" in 2??) if grep -Fq -- ${shellQuote(resultToken)} "$response_file"; then printf '${HERMES_MCP_RESULT_TOKEN_MARKER}present\\n' >&2; else printf '${HERMES_MCP_RESULT_TOKEN_MARKER}missing\\n' >&2; fi ;; *) emit_failure_body ;; esac`,
].join("\n");
}

function sanitizedPreview(text: string, explicitValues: Iterable<string>): string {
const sanitized = redactString(text, explicitValues)
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, "")
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, " ")
.trim();
if (!sanitized) return "<empty>";
const characters = [...sanitized];
if (characters.length <= HERMES_MCP_FAILURE_PREVIEW_CHARS) return sanitized;
const suffix = "… [truncated]";
return `${characters
.slice(0, HERMES_MCP_FAILURE_PREVIEW_CHARS - [...suffix].length)
.join("")}${suffix}`;
}

function parseHttpStatus(stderr: string): number | null {
const escapedMarker = HERMES_MCP_HTTP_STATUS_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const matches = [...stderr.matchAll(new RegExp(`^${escapedMarker}([0-9]{3})$`, "gmu"))];
if (matches.length !== 1) return null;
return Number(matches[0]?.[1]);
}

function hasResultToken(stderr: string): boolean {
const marker = `${HERMES_MCP_RESULT_TOKEN_MARKER}present`;
return stderr.split(/\r?\n/u).filter((line) => line === marker).length === 1;
}

export function assertHermesMcpHttpResponse(
result: HermesMcpCommandResult,
explicitRedactionValues: Iterable<string>,
): void {
const status = parseHttpStatus(result.stderr);
if (result.exitCode !== 0) {
const transport = result.signal
? `signal=${result.signal}`
: `exit=${result.exitCode ?? "unknown"}`;
const detail = sanitizedPreview([result.stdout, result.stderr].filter(Boolean).join("\n"), [
...explicitRedactionValues,
]);
throw new Error(`hermes real MCP tool call transport failed (${transport}): ${detail}`);
}
if (status === null) {
throw new Error("hermes real MCP tool call did not report one bounded HTTP status");
}
if (status < 200 || status >= 300) {
const body = sanitizedPreview(result.stdout, explicitRedactionValues);
throw new Error(
`hermes real MCP tool call failed: HTTP ${status}; sanitized response body: ${body}`,
);
}
if (!hasResultToken(result.stderr)) {
throw new Error("hermes real MCP tool call response did not contain the fixture result token");
}
if (result.stdout !== "") {
throw new Error("hermes real MCP tool call success path emitted response contents");
}
}

export function assertAuthenticatedMcpToolCallOutcome(options: {
requests: readonly McpRequestObservation[];
callsBefore: number;
expectedSecret: string;
}): void {
const calls = options.requests.filter((request) => request.rpcMethod === "tools/call");
if (calls.length !== options.callsBefore + 1) {
throw new Error(
`real MCP tool call must issue exactly one tools/call request (observed delta=${calls.length - options.callsBefore})`,
);
}
const call = calls.at(-1);
if (call?.path !== "/mcp") {
throw new Error("real MCP tool call did not reach the managed /mcp endpoint");
}
if (call.auth !== `Bearer ${options.expectedSecret}`) {
throw new Error("real MCP tool call did not use the expected resolved bearer credential");
}
if (call.auth.includes("openshell:resolve:env")) {
throw new Error("real MCP tool call forwarded an unresolved credential placeholder");
}
}
31 changes: 30 additions & 1 deletion test/e2e/live/mcp-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ import {
type McpAdapter,
removeMcpBridgeWithOneConcurrencyRetry,
} from "./mcp-bridge-cleanup.ts";
import {
assertAuthenticatedMcpToolCallOutcome,
assertHermesMcpHttpResponse,
buildHermesMcpChatProbeScript,
HERMES_MCP_FAILURE_CAPTURE_BYTES,
} from "./mcp-bridge-hermes-http.ts";
import {
assertHermesConfig,
assertHermesInspectionRejectsUnmanagedFields,
Expand Down Expand Up @@ -537,6 +543,17 @@ async function assertRealAdapterToolCall(
messages: [{ role: "user", content: prompt }],
max_tokens: 256,
});
// ShellProbe applies these values before command output can reach progress or
// artifacts. The HTTP assertion applies the same redactions again before a
// bounded failure preview reaches Vitest.
const hermesRedactionValues = [
HOST_SECRET,
ROTATED_HOST_SECRET,
COMPATIBLE_KEY,
TOOL_CHALLENGE,
prompt,
hermesPayload,
];
const command =
options.agent === "openclaw"
? `nemoclaw-start mcporter call fake.fake_echo --args ${JSON.stringify(JSON.stringify({ challenge: TOOL_CHALLENGE }))} --output json`
Expand All @@ -545,7 +562,7 @@ async function assertRealAdapterToolCall(
"set -a",
"[ ! -f /sandbox/.hermes/.env ] || . /sandbox/.hermes/.env",
"set +a",
`if [ -n "\${API_SERVER_KEY:-}" ]; then curl -fsS --max-time 180 http://localhost:8642/v1/chat/completions -H 'Content-Type: application/json' -H "Authorization: Bearer \${API_SERVER_KEY}" --data-binary ${shellQuote(hermesPayload)}; else curl -fsS --max-time 180 http://localhost:8642/v1/chat/completions -H 'Content-Type: application/json' --data-binary ${shellQuote(hermesPayload)}; fi`,
buildHermesMcpChatProbeScript(hermesPayload, options.resultToken),
].join("\n")
: `nemoclaw-start dcode -n ${JSON.stringify(prompt)}`;
const result = await sandbox.execShell(
Expand All @@ -554,9 +571,21 @@ async function assertRealAdapterToolCall(
{
artifactName: options.artifactName,
env: buildAvailabilityProbeEnv(),
postRedactionCaptureLimitBytes:
options.agent === "hermes" ? HERMES_MCP_FAILURE_CAPTURE_BYTES : undefined,
redactionValues: options.agent === "hermes" ? hermesRedactionValues : [],
timeoutMs: 5 * 60_000,
},
);
if (options.agent === "hermes") {
assertHermesMcpHttpResponse(result, hermesRedactionValues);
assertAuthenticatedMcpToolCallOutcome({
requests: fakeMcp.requests,
callsBefore: before,
expectedSecret: options.expectedSecret ?? HOST_SECRET,
});
return;
}
expectExitZero(result, `${options.agent} real MCP tool call`);
expect(resultText(result)).toContain(options.resultToken);
const calls = fakeMcp.requests.filter((request) => request.rpcMethod === "tools/call");
Expand Down
42 changes: 42 additions & 0 deletions test/e2e/support/e2e-redaction-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,4 +435,46 @@ describe("fixture redaction entry point", () => {
await fs.rm(rootDir, { recursive: true, force: true });
}
});

it.each([
0,
-1,
1.5,
Number.POSITIVE_INFINITY,
Number.MAX_SAFE_INTEGER + 1,
])("rejects invalid post-redaction capture limit %s before spawning a child or writing artifacts", async (postRedactionCaptureLimitBytes) => {
const rootDir = await fs.mkdtemp(
path.join(os.tmpdir(), "nemoclaw-e2e-invalid-post-redaction-capture-"),
);
try {
const artifactRoot = path.join(rootDir, "e2e-artifacts/live/invalid-post-redaction-capture");
const spawnMarker = path.join(rootDir, "spawned.txt");
const artifacts = new ArtifactSink(artifactRoot);
await artifacts.ensureRoot();
const probe = new ShellProbe({
artifacts,
progress: supportProgress(),
redact: (text, extra) => redactString(text, extra),
signal: new AbortController().signal,
});

await expect(
probe.run(
trustedShellCommand({
command: "bash",
args: ["-lc", 'printf spawned >"$SPAWN_MARKER"'],
reason: "prove invalid post-redaction limits fail before child execution",
}),
{
env: { SPAWN_MARKER: spawnMarker },
postRedactionCaptureLimitBytes,
},
),
).rejects.toThrow("postRedactionCaptureLimitBytes must be a positive safe integer");
await expect(fs.access(spawnMarker)).rejects.toThrow();
await expect(fs.readdir(artifactRoot)).resolves.toEqual([]);
} finally {
await fs.rm(rootDir, { recursive: true, force: true });
}
});
});
Loading
Loading