Skip to content
Merged
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
2 changes: 1 addition & 1 deletion agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ PY
# file has to also rewrite the Dockerfile-committed hash, which reviewers gate).
# Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,validate-env-secret-boundary.py,finalize-tirith-marker.py}`.
ARG NEMOCLAW_HERMES_WRAPPER_SHA256=ec8b0e4d6254175929d5a02240acd6af25e94774a30b36ba5c6f5a69c32fb9d7
ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=d7371a84099b204346e0bfecfacb0086fdab690e5fadcf5e12417b6038927d19
ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=3d9c099bfd06912fa0a4688e113555654e0952224c4226ad5f8c68b59c34895b
ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819
# hadolint ignore=DL4006
RUN printf '%s %s\n' \
Expand Down
5 changes: 5 additions & 0 deletions ci/source-shape-test-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,11 @@
"test": "requires classifier review and integrity evidence when the OpenClaw build pin changes",
"category": "security"
},
{
"file": "test/hermes-final-image-layout.test.ts",
"test": "keeps security entrypoint hashes synchronized with the copied files",
"category": "security"
},
{
"file": "test/hermes-runtime-config-guard-topology.test.ts",
"test": "restores exact locked posture after root-separated repair and later failure (#7033)",
Expand Down
4 changes: 3 additions & 1 deletion test/e2e/live/issue-6194-tui-expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,9 @@ send -i $termSpawn -- "\\t"
after 200
expect_exact_or_exit $termSpawn $sandbox openshell_sandbox_listed 66 67
send -i $termSpawn -- "\\r"
expect_exact_or_exit $termSpawn {Name:} openshell_sandbox_detail 68 69
# Ratatui may update the "Name:" label as separate terminal diffs. Wait for a
# stable detail-panel heading, then still bind the view to the exact sandbox.
expect_exact_or_exit $termSpawn {Filesystem Access} openshell_sandbox_detail 68 69
expect_exact_or_exit $termSpawn $sandbox openshell_sandbox_detail_name 70 71
# OpenShell documents 'r' as the Network Rules focus key in sandbox detail.
send -i $termSpawn -- "r"
Expand Down
43 changes: 43 additions & 0 deletions test/e2e/live/mcp-bridge-onboard-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts";

const EXACT_MAIN_OVERLAY_KEYS = new Set([
"PATH",
"NEMOCLAW_OPENSHELL_BIN",
"NEMOCLAW_OPENSHELL_GATEWAY_BIN",
"NEMOCLAW_OPENSHELL_SANDBOX_BIN",
]);

export function buildMcpBridgeOnboardEnv(options: {
agent: "openclaw" | "hermes" | "langchain-deepagents-code";
baseEnv?: NodeJS.ProcessEnv;
compatibleKey: string;
compatibleModel: string;
endpointUrl: string;
envOverlay?: NodeJS.ProcessEnv;
sandboxName: string;
}): NodeJS.ProcessEnv {
const envOverlay = options.envOverlay ?? {};
for (const key of Object.keys(envOverlay)) {
if (!EXACT_MAIN_OVERLAY_KEYS.has(key)) {
throw new Error(`MCP exact-main onboarding does not allow env overlay key '${key}'`);
}
}

return {
...buildAvailabilityProbeEnv(options.baseEnv),
COMPATIBLE_API_KEY: options.compatibleKey,
NVIDIA_INFERENCE_API_KEY: options.compatibleKey,
NEMOCLAW_AGENT: options.agent,
NEMOCLAW_ENDPOINT_URL: options.endpointUrl,
NEMOCLAW_MODEL: options.compatibleModel,
NEMOCLAW_COMPAT_MODEL: options.compatibleModel,
NEMOCLAW_PREFERRED_API: "openai-completions",
NEMOCLAW_PROVIDER: "custom",
NEMOCLAW_SANDBOX_NAME: options.sandboxName,
NEMOCLAW_RECREATE_SANDBOX: "1",
...envOverlay,
};
}
30 changes: 16 additions & 14 deletions test/e2e/live/mcp-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
assertHermesInspectionRejectsUnmanagedFields,
assertHermesRemovalSurvivesGatewayRestart,
} from "./mcp-bridge-hermes-lifecycle.ts";
import { buildMcpBridgeOnboardEnv } from "./mcp-bridge-onboard-env.ts";
import { retryAfterHermesRestartTransportFailure } from "./mcp-bridge-reliability.ts";
import {
buildMcpDnsRebindingProbeScript,
Expand Down Expand Up @@ -117,7 +118,12 @@ async function onboardAgent(
host: HostCliClient,
cleanup: CleanupRegistry,
endpointUrl: string,
options: { agent: McpAgent; sandboxName: string; artifactName: string },
options: {
agent: McpAgent;
sandboxName: string;
artifactName: string;
envOverlay?: NodeJS.ProcessEnv;
},
): Promise<void> {
cleanup.trackSandbox(host, options.sandboxName, {
artifactName: "cleanup-destroy-sandbox",
Expand All @@ -131,19 +137,14 @@ async function onboardAgent(
["onboard", "--non-interactive", "--yes", "--yes-i-accept-third-party-software"],
{
artifactName: options.artifactName,
env: {
...buildAvailabilityProbeEnv(),
COMPATIBLE_API_KEY: COMPATIBLE_KEY,
NVIDIA_INFERENCE_API_KEY: COMPATIBLE_KEY,
NEMOCLAW_AGENT: options.agent,
NEMOCLAW_ENDPOINT_URL: endpointUrl,
NEMOCLAW_MODEL: COMPATIBLE_MODEL,
NEMOCLAW_COMPAT_MODEL: COMPATIBLE_MODEL,
NEMOCLAW_PREFERRED_API: "openai-completions",
NEMOCLAW_PROVIDER: "custom",
NEMOCLAW_SANDBOX_NAME: options.sandboxName,
NEMOCLAW_RECREATE_SANDBOX: "1",
},
env: buildMcpBridgeOnboardEnv({
agent: options.agent,
compatibleKey: COMPATIBLE_KEY,
compatibleModel: COMPATIBLE_MODEL,
endpointUrl,
envOverlay: options.envOverlay,
sandboxName: options.sandboxName,
}),
redactionValues: [COMPATIBLE_KEY],
timeoutMs: 20 * 60_000,
},
Expand Down Expand Up @@ -1374,6 +1375,7 @@ mcpBridgeShardTest("deepagents")(
agent: "langchain-deepagents-code",
sandboxName: DEEPAGENTS_SANDBOX_NAME,
artifactName: "onboard-deepagents-mcp-bridge",
envOverlay: exactMainProof.envOverlay,
});
await exactMainProof.afterOnboard();
cleanup.add("remove Deep Agents MCP bridge", () =>
Expand Down
1 change: 1 addition & 0 deletions test/e2e/live/openshell-exact-main-mcp-proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function prepareExactMainMcpProof(
const { artifacts, cleanup, host, sandbox } = fixture;
const driverConfig = prepareExactMainDriverConfigProof(fixture, sandboxName);
return {
envOverlay: driverConfig.envOverlay,
async afterOnboard(): Promise<void> {
await driverConfig.assertAfterOnboard();
await assertExactMainOpenShellContracts(host, sandboxName);
Expand Down
12 changes: 12 additions & 0 deletions test/e2e/live/openshell-gateway-upgrade-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ export function currentGatewayUpgradeInstallerArgs(
return options.interactive ? [installer] : [installer, ...NON_INTERACTIVE_INSTALLER_ARGS];
}

export function expectedLegacyRegistryVersion(nemoclawRef: string): string | undefined {
switch (nemoclawRef) {
case "v0.0.36":
case "v0.0.55":
return undefined;
case "v0.0.74":
return "0.0.74";
default:
throw new Error(`Unsupported gateway-upgrade registry fixture: ${nemoclawRef}`);
}
}

export function upgradeGatewayStateCleanupScript(pidFile: string): string {
return `set -e
volume_prefix=${GATEWAY_VOLUME_PREFIX}
Expand Down
62 changes: 53 additions & 9 deletions test/e2e/live/openshell-gateway-upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { REPO_ROOT } from "../fixtures/paths.ts";
import type { ShellProbeResult } from "../fixtures/shell-probe.ts";
import {
currentGatewayUpgradeInstallerArgs,
expectedLegacyRegistryVersion,
oldGatewayUpgradeInstallerArgs,
upgradeGatewayCleanupScript,
upgradeGatewayStateCleanupScript,
Expand Down Expand Up @@ -111,7 +112,7 @@ function withoutEnvKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): NodeJS
return Object.fromEntries(Object.entries(env).filter(([key]) => !excluded.has(key)));
}

function shellLoginPrefix(hideUserLocalOpenShell = false): string {
function shellLoginPrefix(hiddenOpenShellDir?: string): string {
const lines = [
"set -euo pipefail",
'if [ -f "$HOME/.bashrc" ]; then',
Expand All @@ -125,11 +126,12 @@ function shellLoginPrefix(hideUserLocalOpenShell = false): string {
"fi",
];
lines.push(
...(hideUserLocalOpenShell
...(hiddenOpenShellDir
? [
'_path_without_user_local=""',
"while IFS= read -r _path_entry; do",
' [ "$_path_entry" = "$HOME/.local/bin" ] && continue',
` [ "$_path_entry" = ${shellQuote(hiddenOpenShellDir)} ] && continue`,
' _path_without_user_local="${_path_without_user_local:+${_path_without_user_local}:}${_path_entry}"',
'done < <(tr ":" "\\n" <<<"$PATH")',
'export PATH="$_path_without_user_local"',
Expand Down Expand Up @@ -166,13 +168,13 @@ async function bash(
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
cwd?: string;
hideUserLocalOpenShell?: boolean;
hiddenOpenShellDir?: string;
redactionValues?: string[];
},
): Promise<ShellProbeResult> {
return host.command(
"bash",
["-lc", `${shellLoginPrefix(options.hideUserLocalOpenShell)}\n${script}`],
["-lc", `${shellLoginPrefix(options.hiddenOpenShellDir)}\n${script}`],
{
cwd: options.cwd ?? REPO_ROOT,
artifactName: options.artifactName,
Expand Down Expand Up @@ -392,7 +394,7 @@ async function runInstallerPayload(
logFile: string,
env: NodeJS.ProcessEnv,
redactionValues: string[] = [],
options: { hideUserLocalOpenShell?: boolean; interactiveInput?: string } = {},
options: { hiddenOpenShellDir?: string; interactiveInput?: string } = {},
): Promise<ShellProbeResult> {
const quotedInstallerArgs = installerArgs.map(shellQuote).join(" ");
const installerCommand = `bash ${quotedInstallerArgs} >${shellQuote(logFile)} 2>&1`;
Expand All @@ -401,7 +403,7 @@ async function runInstallerPayload(
const installerInvocation = options.interactiveInput
? `printf '%s\\n' ${shellQuote(options.interactiveInput)} | script --quiet --return --command ${shellQuote(installerCommand)} /dev/null`
: installerCommand;
const hiddenOpenShellPreflight = options.hideUserLocalOpenShell
const hiddenOpenShellPreflight = options.hiddenOpenShellDir
? [
'test -x "$HOME/.local/bin/openshell"',
"if command -v openshell >/dev/null 2>&1; then",
Expand All @@ -418,7 +420,7 @@ ${installerInvocation}`,
{
artifactName: `${label.replace(/[^a-z0-9_.-]+/gi, "-")}-installer`,
env,
hideUserLocalOpenShell: options.hideUserLocalOpenShell,
hiddenOpenShellDir: options.hiddenOpenShellDir,
redactionValues,
timeoutMs: INSTALL_TIMEOUT_MS,
},
Expand Down Expand Up @@ -553,10 +555,42 @@ git -C "$HOME/.nemoclaw/source" rev-parse --verify HEAD`,
sandboxes?: Record<string, { nemoclawVersion?: unknown; fromDockerfile?: unknown }>;
};
expect(oldRegistry.sandboxes?.[SURVIVOR_SANDBOX]).toBeDefined();
expect(oldRegistry.sandboxes?.[SURVIVOR_SANDBOX]?.nemoclawVersion).toBeUndefined();
expect(oldRegistry.sandboxes?.[SURVIVOR_SANDBOX]?.nemoclawVersion).toBe(
expectedLegacyRegistryVersion(OLD_NEMOCLAW_REF),
);
expect(oldRegistry.sandboxes?.[SURVIVOR_SANDBOX]?.fromDockerfile).toBeUndefined();
}

async function stageOldOpenShellInUserLocalBin(host: HostCliClient): Promise<string> {
const result = await bash(
host,
`active_openshell="$(command -v openshell)"
active_dir="$(dirname "$active_openshell")"
user_local_bin="$HOME/.local/bin"
mkdir -p "$user_local_bin"
for component in openshell openshell-gateway openshell-sandbox; do
test -x "$active_dir/$component"
if [ "$active_dir" != "$user_local_bin" ]; then
install -m 755 "$active_dir/$component" "$user_local_bin/$component"
fi
done
"$user_local_bin/openshell" --version
printf '%s\n' "$active_dir"`,
{ artifactName: "stage-old-openshell-user-local", timeoutMs: 30_000 },
);
expectExitZero(result, "stage the v0.0.55 OpenShell layout in ~/.local/bin");
expectOutputContains(
result,
OLD_OPENSHELL_VERSION,
`staged user-local OpenShell must remain ${OLD_OPENSHELL_VERSION}`,
);
const activeDir = result.stdout.trim().split("\n").at(-1) ?? "";
expect(path.isAbsolute(activeDir), `old OpenShell directory must be absolute: ${activeDir}`).toBe(
true,
);
return activeDir;
}

async function startSurvivorAgentInExistingClaw(host: HostCliClient): Promise<number> {
const markerResult = await bash(
host,
Expand Down Expand Up @@ -605,6 +639,7 @@ async function installCurrentNemoclawUpgrade(
host: HostCliClient,
fakeBaseUrl: string,
currentInstallLog: string,
hiddenOldOpenShellDir?: string,
): Promise<void> {
const currentRef = process.env.NEMOCLAW_CURRENT_NEMOCLAW_REF ?? process.env.GITHUB_SHA ?? "HEAD";
const currentRefResult = await bash(
Expand All @@ -619,6 +654,12 @@ async function installCurrentNemoclawUpgrade(
const resolvedRef = currentRefResult.stdout.trim();
expect(resolvedRef.length).toBeGreaterThan(0);
const exerciseOrdinaryUpgrade = OLD_NEMOCLAW_REF === "v0.0.55";
if (exerciseOrdinaryUpgrade) {
expect(
hiddenOldOpenShellDir,
"the v0.0.55 fixture must record the original OpenShell directory before hiding it",
).toBeTruthy();
}
const baseCurrentEnv = liveEnv({
COMPATIBLE_API_KEY: "dummy",
GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? "",
Expand Down Expand Up @@ -658,7 +699,7 @@ async function installCurrentNemoclawUpgrade(
currentEnv,
redactionValues,
{
hideUserLocalOpenShell: exerciseOrdinaryUpgrade,
hiddenOpenShellDir: exerciseOrdinaryUpgrade ? hiddenOldOpenShellDir : undefined,
// One answer covers a changed usage notice, when present, and the other
// confirms the legacy managed-image recovery prompt.
interactiveInput: exerciseOrdinaryUpgrade ? "yes\nyes" : undefined,
Expand Down Expand Up @@ -851,12 +892,15 @@ runLinuxOpenShellGatewayUpgrade(
});

await installOldNemoclawAndClaw(host, artifacts, fake.baseUrl);
const hiddenOldOpenShellDir =
OLD_NEMOCLAW_REF === "v0.0.55" ? await stageOldOpenShellInUserLocalBin(host) : undefined;
const survivorPid = await startSurvivorAgentInExistingClaw(host);
expect(Number.isInteger(survivorPid) && survivorPid > 0).toBe(true);
await installCurrentNemoclawUpgrade(
host,
fake.baseUrl,
artifacts.pathFor("current-install.log"),
hiddenOldOpenShellDir,
);
await assertSurvivorSandboxAfterUpgrade(host);
},
Expand Down
3 changes: 2 additions & 1 deletion test/e2e/support/issue-6194-tui-post-idle-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,9 @@ describe("live TUI post-idle coverage contract (#6194)", () => {
);
expect(script.match(/send -i \$termSpawn -- "\\t"/gu) ?? []).toHaveLength(2);
expect(script).toContain(
"expect_exact_or_exit $termSpawn {Name:} openshell_sandbox_detail 68 69",
"expect_exact_or_exit $termSpawn {Filesystem Access} openshell_sandbox_detail 68 69",
);
expect(script).not.toContain("expect_exact_or_exit $termSpawn {Name:}");
expect(script).not.toContain("(Dashboard-)?Sandbox:");
expect(script).toContain('send -i $termSpawn -- "r"');
expect(script).toContain(
Expand Down
47 changes: 47 additions & 0 deletions test/e2e/support/mcp-bridge-onboard-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";

import { buildMcpBridgeOnboardEnv } from "../live/mcp-bridge-onboard-env.ts";

const ONBOARD_OPTIONS = {
agent: "langchain-deepagents-code" as const,
baseEnv: { HOME: "/tmp/home", PATH: "/usr/bin" },
compatibleKey: "compatible-test-key",
compatibleModel: "mock/mcp-bridge",
endpointUrl: "https://inference.example.test/v1",
sandboxName: "e2e-mcp-dcode",
};

describe("MCP bridge onboarding environment", () => {
it("passes only exact-main OpenShell overrides after fixed onboarding values", () => {
const env = buildMcpBridgeOnboardEnv({
...ONBOARD_OPTIONS,
envOverlay: {
PATH: "/tmp/exact-main:/usr/bin",
NEMOCLAW_OPENSHELL_BIN: "/tmp/exact-main/openshell",
NEMOCLAW_OPENSHELL_GATEWAY_BIN: "/usr/local/bin/openshell-gateway",
NEMOCLAW_OPENSHELL_SANDBOX_BIN: "/usr/local/bin/openshell-sandbox",
},
});

expect(env).toMatchObject({
PATH: "/tmp/exact-main:/usr/bin",
NEMOCLAW_AGENT: "langchain-deepagents-code",
NEMOCLAW_ENDPOINT_URL: "https://inference.example.test/v1",
NEMOCLAW_OPENSHELL_BIN: "/tmp/exact-main/openshell",
NEMOCLAW_OPENSHELL_GATEWAY_BIN: "/usr/local/bin/openshell-gateway",
NEMOCLAW_OPENSHELL_SANDBOX_BIN: "/usr/local/bin/openshell-sandbox",
});
});

it("rejects protected onboarding key collisions", () => {
expect(() =>
buildMcpBridgeOnboardEnv({
...ONBOARD_OPTIONS,
envOverlay: { NEMOCLAW_AGENT: "openclaw" },
}),
).toThrow("does not allow env overlay key 'NEMOCLAW_AGENT'");
});
});
Loading
Loading