diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 038650b0cc..874954581e 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -152,6 +152,7 @@ jobs: env: EXPECTED_SHA: ${{ inputs.checkout_sha || github.sha }} GITHUB_TOKEN: ${{ github.token }} + REQUIRE_MANAGED_IMAGE_PUBLICATION: "1" shell: bash run: | set -euo pipefail @@ -5327,8 +5328,8 @@ jobs: run: bash .github/scripts/docker-auth-cleanup.sh jetson-nvmap-gpu: - needs: generate-matrix - if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.allow_jetson_dispatch && (inputs.checkout_repository == '' || inputs.checkout_repository == github.repository) && ((inputs.jobs == '' && inputs.targets == '') || contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'jetson-nvmap-gpu')))) }} + needs: [base-image-publication, generate-matrix] + if: ${{ always() && needs['base-image-publication'].result == 'success' && needs['generate-matrix'].result == 'success' && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.allow_jetson_dispatch && (inputs.checkout_repository == '' || inputs.checkout_repository == github.repository) && ((inputs.jobs == '' && inputs.targets == '') || contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'jetson-nvmap-gpu')))) }} concurrency: group: jetson-nvmap-gpu-dispatch cancel-in-progress: false diff --git a/agents/langchain-deepagents-code/patch-managed-deepagents-code.py b/agents/langchain-deepagents-code/patch-managed-deepagents-code.py index 2ddc23dd88..1a6cd37b39 100644 --- a/agents/langchain-deepagents-code/patch-managed-deepagents-code.py +++ b/agents/langchain-deepagents-code/patch-managed-deepagents-code.py @@ -721,11 +721,15 @@ def create_cli_agent(model, assistant_id, *args, **kwargs): ) assert_unique_callable_tool_names( - kwargs.get("tools"), kwargs.get("mcp_server_info") - ) - has_loaded_mcp_tools = any( - getattr(info, "tools", ()) for info in kwargs.get("mcp_server_info") or () - ) + kwargs.get("tools"), + kwargs.get("mcp_server_info"), + kwargs.get("mcp_tools"), + ) + # Deep Agents Code 0.1.55 passes the exact loaded MCP tool objects + # separately from the status-oriented server metadata. The metadata can be + # empty or lag the executable catalog, so it must not decide whether the + # progressive middleware is installed. + has_loaded_mcp_tools = bool(kwargs.get("mcp_tools")) if has_loaded_mcp_tools: from deepagents_code.progressive_tool_disclosure import ( progressive_tool_disclosure_enabled, diff --git a/agents/langchain-deepagents-code/progressive_tool_disclosure.py b/agents/langchain-deepagents-code/progressive_tool_disclosure.py index 57799bb38c..334c0a5fc7 100644 --- a/agents/langchain-deepagents-code/progressive_tool_disclosure.py +++ b/agents/langchain-deepagents-code/progressive_tool_disclosure.py @@ -280,6 +280,7 @@ def _tool_description(tool: BaseTool | dict[str, Any]) -> str: def assert_unique_callable_tool_names( tools: Sequence[object] | None, mcp_server_info: Sequence[object] | None, + mcp_tools: Sequence[object] | None = None, ) -> None: """Reject ambiguous or non-managed registrations before graph creation. @@ -287,8 +288,9 @@ def assert_unique_callable_tool_names( registry keyed by resolved callable name. Its model schema selection and executor lookup do not share the same duplicate-name rule, so accepting two implementations can bind one schema and execute another. Keep the executor - registry and MCP metadata as separate views: one loaded MCP tool normally - appears once in each, while duplicates within either view are ambiguous. + registry, loaded MCP tools, and MCP metadata as separate views: one loaded + MCP tool normally appears in each view, while duplicates within one view are + ambiguous. """ collisions: set[str] = set() registered_owners: dict[str, list[str]] = {} @@ -334,6 +336,23 @@ def assert_unique_callable_tool_names( f"({', '.join(owners)})" ) + loaded_mcp_owners: dict[str, list[str]] = {} + for index, tool in enumerate(mcp_tools or ()): + name = _tool_name(tool) + if name is None: + continue + owner = f"loaded MCP tool[{index}]" + loaded_mcp_owners.setdefault(name, []).append(owner) + if name in CORE_TOOL_NAMES: + collisions.add(f"{owner} is a non-managed owner of reserved name {name!r}") + + for name, owners in loaded_mcp_owners.items(): + if len(owners) > 1: + collisions.add( + f"resolved callable name {name!r} has multiple loaded MCP implementations " + f"({', '.join(owners)})" + ) + if collisions: detail = "; ".join(sorted(collisions)) raise RuntimeError( diff --git a/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py b/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py index 69cf5fbbda..3b7af00d64 100644 --- a/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py +++ b/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py @@ -770,6 +770,7 @@ def probe(value: str = "") -> str: "progressive", [regular_a, regular_b], [], + [], ), "regular_mcp": ( "progressive", @@ -786,6 +787,7 @@ def probe(value: str = "") -> str: ), ) ], + [], ), "cross_mcp": ( "progressive", @@ -803,11 +805,13 @@ def probe(value: str = "") -> str: ) for server in ("alpha", "alpha_beta") ], + [], ), "reserved_progressive": ( "progressive", [reserved_regular], [], + [], ), "reserved_mcp": ( "progressive", @@ -824,16 +828,34 @@ def probe(value: str = "") -> str: ), ) ], + [], ), "duplicate_direct": ( "direct", [regular_a, regular_b], [], + [], ), "reserved_direct": ( "direct", [collision_tool("execute", "reserved-direct")], [], + [], + ), + "duplicate_loaded_mcp": ( + "direct", + [], + [], + [ + collision_tool("loaded_duplicate", "loaded-a"), + collision_tool("loaded_duplicate", "loaded-b"), + ], + ), + "reserved_loaded_mcp": ( + "direct", + [], + [], + [collision_tool("execute", "reserved-loaded")], ), } original_cli_factory = agent_module._nemoclaw_original_create_cli_agent @@ -848,7 +870,7 @@ def forbidden_original(*args: Any, **kwargs: Any) -> None: previous = os.environ.get("NEMOCLAW_TOOL_DISCLOSURE") try: errors: dict[str, str] = {} - for label, (mode, tools, info) in collision_cases.items(): + for label, (mode, tools, info, mcp_tools) in collision_cases.items(): os.environ["NEMOCLAW_TOOL_DISCLOSURE"] = mode try: create_cli_agent( @@ -856,6 +878,7 @@ def forbidden_original(*args: Any, **kwargs: Any) -> None: assistant_id="callable-namespace-validator", tools=tools, mcp_server_info=info, + mcp_tools=mcp_tools, ) except RuntimeError as exc: errors[label] = str(exc) @@ -878,6 +901,9 @@ def forbidden_original(*args: Any, **kwargs: Any) -> None: assert "reserved name 'search_tools'" in errors["reserved_mcp"] assert "multiple registered implementations" in errors["duplicate_direct"] assert "reserved name 'execute'" in errors["reserved_direct"] + assert "multiple loaded MCP implementations" in errors["duplicate_loaded_mcp"] + assert "loaded MCP tool[0]" in errors["reserved_loaded_mcp"] + assert "reserved name 'execute'" in errors["reserved_loaded_mcp"] def _validate_direct_mode_execution() -> None: @@ -889,6 +915,18 @@ def direct_probe(value: str) -> str: executions.append(value) return "direct-proof" + # Match the exact metadata shape emitted by the pinned MCP wrapper. Without + # coherent read-only hints, the headless MCP guard correctly rejects this + # fixture before the direct executor can prove the disclosure mode. + direct_probe.metadata = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + "_deepagents_code_mcp": True, + "_deepagents_code_mcp_server": "direct-runtime-validator", + } + info = MCPServerInfo( name="direct-runtime-validator", transport="http", @@ -916,6 +954,7 @@ def direct_probe(value: str) -> str: enable_memory=False, enable_skills=False, enable_shell=False, + mcp_tools=[direct_probe], mcp_server_info=[info], ) agent.invoke( @@ -1026,6 +1065,15 @@ def isolated_probe() -> str: """Return an isolated probe capability.""" return "isolated-proof" + isolated_probe.metadata = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + "_deepagents_code_mcp": True, + "_deepagents_code_mcp_server": "runtime-validator", + } + model = ScriptedModel(scenario="subagent") info = MCPServerInfo( name="runtime-validator", @@ -1049,6 +1097,7 @@ def isolated_probe() -> str: enable_memory=False, enable_skills=False, enable_shell=False, + mcp_tools=[isolated_probe], mcp_server_info=[info], ) agent.invoke( diff --git a/src/lib/onboard/docker-gpu-patch-finalize.test.ts b/src/lib/onboard/docker-gpu-patch-finalize.test.ts index 3ba19ac1e8..83b3c54d91 100644 --- a/src/lib/onboard/docker-gpu-patch-finalize.test.ts +++ b/src/lib/onboard/docker-gpu-patch-finalize.test.ts @@ -162,7 +162,9 @@ describe("finalizeDockerGpuPatchBackup", () => { ]); }); - it("accepts Error only when the stopped replacement is the sole labeled container (#9962)", () => { + it.each(["Error", "Deleting"])( + "accepts %s only when the stopped replacement is the sole labeled container (#9962)", + (phase) => { const replacementContainerId = "a".repeat(64); const events: string[] = []; const dockerStop = vi.fn(() => { @@ -183,7 +185,7 @@ describe("finalizeDockerGpuPatchBackup", () => { }); const runOpenshell = vi.fn(() => { events.push("observe error"); - return { status: 0, stdout: "alpha 2026-08-23 01:40:35 Error\n" }; + return { status: 0, stdout: `alpha 2026-08-23 01:40:35 ${phase}\n` }; }); const outcome = finalizeDockerGpuPatchBackup( @@ -216,7 +218,8 @@ describe("finalizeDockerGpuPatchBackup", () => { ]), expect.objectContaining({ ignoreError: true }), ); - }); + }, + ); it("caps Error corroboration to the remaining lifecycle-release budget (#9962)", () => { const replacementContainerId = "a".repeat(64); diff --git a/src/lib/onboard/docker-gpu-patch-finalize.ts b/src/lib/onboard/docker-gpu-patch-finalize.ts index 20fa338cc5..e464192d96 100644 --- a/src/lib/onboard/docker-gpu-patch-finalize.ts +++ b/src/lib/onboard/docker-gpu-patch-finalize.ts @@ -128,7 +128,7 @@ export function finalizeDockerGpuPatchBackup( ? waitForOpenShellSandboxLifecycleRelease(sandboxName, lifecycleReleaseTimeoutSecs, { runOpenshell: deps.runOpenshell, sleep: deps.sleep, - soleLabeledReplacementCorroboratesError: (remainingMs) => + soleLabeledReplacementCorroboratesRetiringPhase: (remainingMs) => isSoleLabeledReplacement( sandboxName, options.result.newContainerId, diff --git a/src/lib/onboard/docker-gpu-supervisor-reconnect.test.ts b/src/lib/onboard/docker-gpu-supervisor-reconnect.test.ts index ef4a16aafc..1c5a8adbef 100644 --- a/src/lib/onboard/docker-gpu-supervisor-reconnect.test.ts +++ b/src/lib/onboard/docker-gpu-supervisor-reconnect.test.ts @@ -31,7 +31,6 @@ describe("Docker GPU final lifecycle release", () => { ["a gateway error", "Error: gateway unavailable\n"], ["a phase-free row", "beta 2026-08-21 05:53:18\n"], ["an unrecognized phase", "beta 2026-08-21 05:53:18 Retiring\n"], - ["the selected sandbox in Deleting", "alpha 2026-08-21 05:53:18 Deleting\n"], ["the selected sandbox in Ready", "alpha 2026-08-21 05:53:18 Ready\n"], ["the selected sandbox in Provisioning", "alpha 2026-08-21 05:53:18 Provisioning\n"], ["the selected sandbox in Error", "alpha 2026-08-21 05:53:18 Error\n"], @@ -48,6 +47,26 @@ describe("Docker GPU final lifecycle release", () => { expect(runOpenshell).toHaveBeenCalledTimes(2); }); + it.each(["Error", "Deleting"])( + "accepts a corroborated stopped replacement in %s", + (phase) => { + const corroborate = vi.fn(() => true); + const runOpenshell = vi.fn(() => ({ + status: 0, + stdout: `alpha 2026-08-23 01:40:35 ${phase}\n`, + })); + + expect( + waitForOpenShellSandboxLifecycleRelease("alpha", 1, { + runOpenshell, + sleep: vi.fn(), + soleLabeledReplacementCorroboratesRetiringPhase: corroborate, + }), + ).toBe(true); + expect(corroborate).toHaveBeenCalledOnce(); + }, + ); + it.each([ ["a failed probe", { status: 1, stderr: "gateway unavailable" }], ["a probe without an exit status", { status: null, stderr: "timed out" }], @@ -80,7 +99,7 @@ describe("Docker GPU final lifecycle release", () => { waitForOpenShellSandboxLifecycleRelease("alpha", 1, { runOpenshell, sleep: vi.fn(), - soleLabeledReplacementCorroboratesError: corroborate, + soleLabeledReplacementCorroboratesRetiringPhase: corroborate, }), ).toBe(false); } finally { diff --git a/src/lib/onboard/docker-gpu-supervisor-reconnect.ts b/src/lib/onboard/docker-gpu-supervisor-reconnect.ts index 2988438661..89f36d730b 100644 --- a/src/lib/onboard/docker-gpu-supervisor-reconnect.ts +++ b/src/lib/onboard/docker-gpu-supervisor-reconnect.ts @@ -78,12 +78,13 @@ type DockerLifecycleReleaseDeps = Pick< "runOpenshell" | "sleep" > & { /** - * Corroborating evidence for an Error row from a Docker query that confirms - * the transaction-owned replacement is the sole labeled sandbox container. + * Corroborating evidence for an Error or Deleting row from a Docker query + * that confirms the transaction-owned replacement is the sole labeled + * sandbox container. * The callback must fail closed and keep its child within the supplied * remaining lifecycle-release budget. */ - soleLabeledReplacementCorroboratesError?: (remainingMs: number) => boolean; + soleLabeledReplacementCorroboratesRetiringPhase?: (remainingMs: number) => boolean; }; /** @@ -97,10 +98,12 @@ type DockerLifecycleReleaseDeps = Pick< * OpenShell processes the stale deletion before the new registration. * - The caller enters this wait only after the replacement reached Ready and * was deliberately stopped. A successful list normally omits the sandbox - * name. An Error row is also sufficient only when a separate bounded Docker - * query confirms that exact stopped replacement is the sole remaining - * labeled container. This corroborates the release condition; the OpenShell - * row alone is not an identity-bound ownership receipt. + * name. An Error or Deleting row is also sufficient only when a separate + * bounded Docker query confirms that exact stopped replacement is the sole + * remaining labeled container. This corroborates the release condition; + * the OpenShell row alone is not an identity-bound ownership receipt. The + * Deleting case breaks the otherwise circular wait where OpenShell retains + * the row until that exact replacement emits its restart event. * - `waits for the sandbox name to disappear before restarting the * replacement (#9531)` protects the event order. `rejects final handoff when * OpenShell never releases the deleting lifecycle record (#9531)` protects @@ -132,19 +135,22 @@ export function waitForOpenShellSandboxLifecycleRelease( const output = String(result.stdout ?? "").trim(); const entries = parseLiveSandboxEntries(output); const sandboxPresent = entries.some((entry) => entry.name === sandboxName); - const stoppedReplacementError = entries.some( - (entry) => entry.name === sandboxName && entry.phase === "Error", + const stoppedReplacementRetiring = entries.some( + (entry) => + entry.name === sandboxName && (entry.phase === "Error" || entry.phase === "Deleting"), ); const hasPhaseBearingEntry = entries.some((entry) => entry.phase !== null); const explicitEmptyList = output === "No sandboxes found" || output === "No sandboxes found."; const remainingBeforeCorroborationMs = deadline - Date.now(); - const soleLabeledReplacementCorroboratesError = - stoppedReplacementError && + const soleLabeledReplacementCorroboratesRetiringPhase = + stoppedReplacementRetiring && remainingBeforeCorroborationMs > 0 && - deps.soleLabeledReplacementCorroboratesError?.(remainingBeforeCorroborationMs) === true; + deps.soleLabeledReplacementCorroboratesRetiringPhase?.( + remainingBeforeCorroborationMs, + ) === true; if ( explicitEmptyList || - soleLabeledReplacementCorroboratesError || + soleLabeledReplacementCorroboratesRetiringPhase || (hasPhaseBearingEntry && !sandboxPresent) ) { return true; diff --git a/src/lib/onboard/managed-image-catalog.test.ts b/src/lib/onboard/managed-image-catalog.test.ts index ca86f2241d..e0da0b7905 100644 --- a/src/lib/onboard/managed-image-catalog.test.ts +++ b/src/lib/onboard/managed-image-catalog.test.ts @@ -472,23 +472,49 @@ describe("managed image GHCR catalog", () => { ).rejects.toThrow(/source revision does not match the expected revision/); }); - it("rejects a qualification revision from a different immutable release", async () => { + it("discovers the immutable release from a qualification revision", async () => { + const publishedRelease = "v0.0.96"; const fixture = catalogFixture({ openclaw: { rootReference: REVISION, - labels: { "org.opencontainers.image.version": "v0.0.96" }, + labels: { "org.opencontainers.image.version": publishedRelease }, + }, + hermes: { labels: { "org.opencontainers.image.version": publishedRelease } }, + "langchain-deepagents-code": { + labels: { "org.opencontainers.image.version": publishedRelease }, }, }); - await expect( - resolveManagedImageCatalogFromGhcr({ - release: RELEASE, - revision: REVISION, - fetchImpl: fixture.fetchImpl, - }), - ).rejects.toThrow(/image release does not match the expected release/); + const catalog = await resolveManagedImageCatalogFromGhcr({ + release: RELEASE, + revision: REVISION, + fetchImpl: fixture.fetchImpl, + }); + + expect( + SHIPPED_MANAGED_IMAGE_AGENTS.map( + (agent) => (catalog[agent] as { source: { release: string } }).source.release, + ), + ).toEqual(SHIPPED_MANAGED_IMAGE_AGENTS.map(() => publishedRelease)); }); + it.each(["", "0.0.97", "latest"])( + "rejects malformed image release label %j", + async (release) => { + const fixture = registryFixture("openclaw", { + labels: { "org.opencontainers.image.version": release }, + }); + + await expect( + resolveManagedImageContractFromGhcr({ + agent: "openclaw", + release: RELEASE, + fetchImpl: fixture.fetchImpl, + }), + ).rejects.toThrow(/image release is not a supported release version/); + }, + ); + it("fails closed when a dependent cohort alias is torn or absent", async () => { const fixture = catalogFixture({ hermes: { missingRoot: true } }); diff --git a/src/lib/onboard/managed-image/catalog.ts b/src/lib/onboard/managed-image/catalog.ts index d5513a8b09..12d10cd738 100644 --- a/src/lib/onboard/managed-image/catalog.ts +++ b/src/lib/onboard/managed-image/catalog.ts @@ -445,6 +445,7 @@ function validateImageLabels( expectedRelease?: string, ): { readonly cohort: ManagedImagePublicationCohort; + readonly release: string; readonly revision: string; } { if (imageConfig.os !== "linux" || imageConfig.architecture !== platformArchitecture(platform)) { @@ -474,14 +475,16 @@ function validateImageLabels( if (typeof cohort !== "string" || !COHORT_PATTERN.test(cohort)) { return invalid(`'${agent}' image publication cohort is not a supported identity`); } - if ( - expectedRelease !== undefined && - labels["org.opencontainers.image.version"] !== expectedRelease - ) { + const release = labels["org.opencontainers.image.version"]; + if (typeof release !== "string" || !RELEASE_PATTERN.test(release)) { + return invalid(`'${agent}' image release is not a supported release version`); + } + if (expectedRelease !== undefined && release !== expectedRelease) { return invalid(`'${agent}' image release does not match the expected release`); } return { cohort: cohort as ManagedImagePublicationCohort, + release, revision, }; } @@ -544,7 +547,7 @@ async function resolveManagedImageContractAtReferenceFromGhcr(options: { source: { repository: MANAGED_IMAGE_SOURCE_REPOSITORY, revision: identity.revision, - release, + release: identity.release, cohort: identity.cohort, }, startupProfileContractVersion: MANAGED_IMAGE_STARTUP_PROFILE_CONTRACT_VERSION, @@ -579,6 +582,7 @@ export async function resolveManagedImageContractFromGhcr(options: { release, platform, fetchImpl, + expectedRelease: release, }), ); } @@ -606,7 +610,7 @@ export async function resolveManagedImageCatalogFromGhcr(options: { platform, fetchImpl, ...(revision === undefined ? {} : { expectedRevision: revision }), - ...(revision === undefined ? {} : { expectedRelease: release }), + ...(revision === undefined ? { expectedRelease: release } : {}), }); const cohortReference = `cohort-${openclaw.source.cohort}`; const dependentResults = await Promise.allSettled( @@ -617,11 +621,11 @@ export async function resolveManagedImageCatalogFromGhcr(options: { await resolveManagedImageContractAtReferenceFromGhcr({ agent, reference: cohortReference, - release, + release: openclaw.source.release, platform, fetchImpl, expectedCohort: openclaw.source.cohort, - ...(revision === undefined ? {} : { expectedRelease: release }), + expectedRelease: openclaw.source.release, expectedRevision: openclaw.source.revision, }), ] as const, diff --git a/src/lib/runtime-recovery.test.ts b/src/lib/runtime-recovery.test.ts index bfd281465f..0b1e7669be 100644 --- a/src/lib/runtime-recovery.test.ts +++ b/src/lib/runtime-recovery.test.ts @@ -40,6 +40,7 @@ describe("runtime recovery helpers", () => { "beta 2026-06-25 09:41:00 CrashLoopBackOff", "gamma 2026-06-25 09:42:00 Creating", "delta 2026-06-25 09:43:00 Evicted", + "epsilon 2026-06-25 09:44:00 Deleting", ].join("\n"), ), ).toEqual([ @@ -47,6 +48,7 @@ describe("runtime recovery helpers", () => { { name: "beta", phase: "CrashLoopBackOff" }, { name: "gamma", phase: "Creating" }, { name: "delta", phase: "Evicted" }, + { name: "epsilon", phase: "Deleting" }, ]); }); diff --git a/src/lib/runtime-recovery.ts b/src/lib/runtime-recovery.ts index 179d9de519..0361fc7a24 100644 --- a/src/lib/runtime-recovery.ts +++ b/src/lib/runtime-recovery.ts @@ -21,6 +21,7 @@ const LIVE_SANDBOX_DISPLAY_PHASES = new Set([ "Provisioning", "Creating", "Pending", + "Deleting", "Terminating", "Error", "Failed", diff --git a/test/e2e/fixtures/hermes-discord-policy-binding.ts b/test/e2e/fixtures/hermes-discord-policy-binding.ts index 7ce99ef38d..b4e5a6bcbe 100644 --- a/test/e2e/fixtures/hermes-discord-policy-binding.ts +++ b/test/e2e/fixtures/hermes-discord-policy-binding.ts @@ -18,6 +18,7 @@ export function bindHermesDiscordPolicyEndpoint( providerName: string, host: string, port: number, + protocol?: string, ): void { const source = fs.readFileSync(policyFile, "utf8"); const policy = parseOpenShellPolicy(source).policy; @@ -30,8 +31,12 @@ export function bindHermesDiscordPolicyEndpoint( if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) { return false; } - const value = candidate as { host?: unknown; port?: unknown }; - return value.host === host && value.port === port; + const value = candidate as { host?: unknown; port?: unknown; protocol?: unknown }; + return ( + value.host === host && + value.port === port && + (protocol === undefined || value.protocol === protocol) + ); }) as Record | undefined; if (!endpoint) throw new Error("fake Discord endpoint is missing from the base policy"); @@ -41,11 +46,11 @@ export function bindHermesDiscordPolicyEndpoint( } function main(): void { - const [policyFile, providerName, host, rawPort] = process.argv.slice(2); + const [policyFile, providerName, host, rawPort, protocol] = process.argv.slice(2); if (!policyFile || !providerName || !host || !rawPort) { throw new Error("usage: hermes-discord-policy-binding "); } - bindHermesDiscordPolicyEndpoint(policyFile, providerName, host, Number(rawPort)); + 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/live/hermes-discord.test.ts b/test/e2e/live/hermes-discord.test.ts index 2db059e26e..d887633ae5 100644 --- a/test/e2e/live/hermes-discord.test.ts +++ b/test/e2e/live/hermes-discord.test.ts @@ -311,7 +311,10 @@ async def main(): kwargs = {"gateway": URL(f"${HERMES_DISCORD_HTTP_PROXY_GATEWAY_TEMPLATE}")} params = inspect.signature(from_client).parameters if "initial" in params: - kwargs["initial"] = False + # A fresh proof must identify immediately. discord.py deliberately + # sleeps before a non-initial IDENTIFY, which leaves only heartbeat + # traffic on this short-lived credential-rewrite connection. + kwargs["initial"] = True if "compress" in params: kwargs["compress"] = False elif "zlib" in params: diff --git a/test/e2e/live/jetson-nvmap-gpu.test.ts b/test/e2e/live/jetson-nvmap-gpu.test.ts index ef5fe4edea..d9d22fadf8 100644 --- a/test/e2e/live/jetson-nvmap-gpu.test.ts +++ b/test/e2e/live/jetson-nvmap-gpu.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; +import { getBuildIdentity } from "../../../src/lib/core/version"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { cleanupWhenCommandAvailable, @@ -21,14 +22,18 @@ const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-jetson-nvmap"; const INFERENCE_API_KEY = "jetson-nvmap-e2e-key"; const INFERENCE_MODEL = "jetson-nvmap-e2e"; const TIMEOUT_MS = 50 * 60_000; +const MANAGED_IMAGE_SOURCE_REVISION = getBuildIdentity({ rootDir: REPO_ROOT }).sourceRevision; function env(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { return { ...buildAvailabilityProbeEnv(), + E2E_MANAGED_IMAGE_REVISION: MANAGED_IMAGE_SOURCE_REVISION, + GITHUB_ACTIONS: "true", HOME: process.env.HOME, NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NEMOCLAW_JETSON_WORKSPACE: process.env.NEMOCLAW_JETSON_WORKSPACE, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_E2E_EXPECTED_SHA: MANAGED_IMAGE_SOURCE_REVISION, NEMOCLAW_RECREATE_SANDBOX: "1", NEMOCLAW_SANDBOX_GPU: "0", NEMOCLAW_SANDBOX_NAME: SANDBOX_NAME, diff --git a/test/e2e/live/mcp-bridge.test.ts b/test/e2e/live/mcp-bridge.test.ts index 2358f76fa2..70e117f3dd 100644 --- a/test/e2e/live/mcp-bridge.test.ts +++ b/test/e2e/live/mcp-bridge.test.ts @@ -74,7 +74,6 @@ import { } from "./mcp-bridge-servers.ts"; import { assertAuthenticatedMcpDiscovery, - assertAuthenticatedMcpDiscoveryWithOneRestart, assertAuthenticatedMcpRediscovery, assertAuthenticatedMcpToolDiscovery, } from "./mcp-bridge-tool-discovery.ts"; @@ -1129,22 +1128,16 @@ mcpBridgeShardTest("hermes")( expectedAdapter: "hermes-config", artifactPrefix: "hermes", }); - const initialDiscoveryOffset = fakeMcp.requests.length; const providerName = await addBridgeAndReadStatus(host, { sandboxName: HERMES_SANDBOX_NAME, mcpUrl, expectedAdapter: "hermes-config", artifactPrefix: "hermes", }); - await assertAuthenticatedMcpDiscoveryWithOneRestart(fakeMcp, { - requestOffset: initialDiscoveryOffset, - expectedSecret: HOST_SECRET, - label: "Hermes initial MCP discovery", - restart: async () => { - progress.event("Hermes MCP discovery did not reach the fixture; restarting once"); - await restartBridgeWithoutHostSecret(host, HERMES_SANDBOX_NAME, "hermes-discovery-retry"); - }, - }); + // Hermes discovery is intentionally allowed to finish on the next agent + // turn when the bounded startup window expires. Prove the product contract + // through the real gateway instead of requiring an eager startup request. + await assertHermesToolCall("hermes-real-mcp-tool-call-initial"); await assertAuthenticatedMcpToolDiscovery(host, fakeMcp, { artifacts, sandboxName: HERMES_SANDBOX_NAME, diff --git a/test/e2e/live/openclaw-agent-assertion.ts b/test/e2e/live/openclaw-agent-assertion.ts index 1a0d0827c0..70953b8a93 100644 --- a/test/e2e/live/openclaw-agent-assertion.ts +++ b/test/e2e/live/openclaw-agent-assertion.ts @@ -208,7 +208,8 @@ If progressive tool disclosure is active, you may use tool_search, tool_describe Do not invoke any other target tool. Do not use web_search, Brave Search, or Tavily Search. Set web_fetch maxChars to no more than 8000. Only after web_fetch returns a numeric NVDA price with its source date or timestamp, reply with one JSON object and no Markdown. -Set status to NVDA_PERSONAL_AGENT_OK, symbol to NVDA, price to a JSON number, source_url to the exact HTTPS URL passed to web_fetch, and as_of to the source's ISO 8601 date or timestamp.`; +Set status to NVDA_PERSONAL_AGENT_OK, symbol to NVDA, price to a JSON number, source_url to the exact HTTPS URL passed to web_fetch, and as_of to the quote's own market or update timestamp converted to ISO 8601. +For a Unix-epoch quote field such as regularMarketTime, convert that field to ISO 8601. Never use the current clock, fetch time, or an unrelated date for as_of.`; export async function runPersonalStockAgentAssertion( host: HostCliClient, diff --git a/test/e2e/live/openclaw-discord-pairing.test.ts b/test/e2e/live/openclaw-discord-pairing.test.ts index 26cdba73be..ed6c5cf0a6 100644 --- a/test/e2e/live/openclaw-discord-pairing.test.ts +++ b/test/e2e/live/openclaw-discord-pairing.test.ts @@ -139,6 +139,7 @@ test("OpenClaw Discord pairing request is shared with connect-shell approval", { api: fakeGateway, protocol: "websocket", rewrite: "websocket-credential-rewrite", + providerName: `${SANDBOX_NAME}-discord-bridge`, env, redactions, artifactName: "apply-discord-gateway-policy", diff --git a/test/e2e/live/openclaw-pairing-helpers.ts b/test/e2e/live/openclaw-pairing-helpers.ts index fd92bf8fed..9a6307bcd8 100644 --- a/test/e2e/live/openclaw-pairing-helpers.ts +++ b/test/e2e/live/openclaw-pairing-helpers.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import path from "node:path"; import type { ArtifactSink } from "../fixtures/artifacts.ts"; import type { CleanupRegistry } from "../fixtures/cleanup.ts"; @@ -9,6 +10,7 @@ import type { HostCliClient } from "../fixtures/clients/host.ts"; import type { SandboxClient } from "../fixtures/clients/sandbox.ts"; import { expect } from "../fixtures/e2e-test.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; +import { REPO_ROOT } from "../fixtures/paths.ts"; import { type FakeDockerApi, startFakeDockerApi } from "./messaging-providers-helpers.ts"; import { cleanupSandbox, @@ -203,6 +205,7 @@ export async function applyFakePolicy(options: { api: FakeDockerApi; protocol: "rest" | "websocket"; rewrite: "request-body-credential-rewrite" | "websocket-credential-rewrite"; + providerName: string; env: NodeJS.ProcessEnv; redactions: string[]; artifactName: string; @@ -225,6 +228,35 @@ export async function applyFakePolicy(options: { 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, + "host.openshell.internal", + 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( diff --git a/test/e2e/live/openclaw-slack-pairing.test.ts b/test/e2e/live/openclaw-slack-pairing.test.ts index 549beaf6d1..195cea5b89 100644 --- a/test/e2e/live/openclaw-slack-pairing.test.ts +++ b/test/e2e/live/openclaw-slack-pairing.test.ts @@ -168,6 +168,7 @@ test("OpenClaw Slack Socket Mode pairing request is shared with connect-shell ap api: fakeSlack, protocol: "rest", rewrite: "request-body-credential-rewrite", + providerName: `${SANDBOX_NAME}-slack-bridge`, env, redactions, artifactName: "apply-slack-rest-policy", @@ -178,6 +179,7 @@ test("OpenClaw Slack Socket Mode pairing request is shared with connect-shell ap api: fakeSlack, protocol: "websocket", rewrite: "websocket-credential-rewrite", + providerName: `${SANDBOX_NAME}-slack-app`, env, redactions, artifactName: "apply-slack-websocket-policy", diff --git a/test/e2e/live/openshell-gateway-upgrade.test.ts b/test/e2e/live/openshell-gateway-upgrade.test.ts index a8e51acc01..dd6b4aad46 100644 --- a/test/e2e/live/openshell-gateway-upgrade.test.ts +++ b/test/e2e/live/openshell-gateway-upgrade.test.ts @@ -429,18 +429,18 @@ function expectStatePreservedAcrossUpgrade( legacy: OpenClawStateContract, upgraded: OpenClawStateContract, ): void { - expect(upgraded.placeholderEnvKeys).toContain("COMPATIBLE_API_KEY"); - - // This custom-provider fixture sets COMPATIBLE_API_KEY, not - // NVIDIA_INFERENCE_API_KEY, so v0.0.89 intentionally does not create the - // NVIDIA auth-profile keyRef. Preserve any references the frozen runtime - // does emit without inventing one for this route. + expect(legacy.placeholderEnvKeys).toContain("COMPATIBLE_API_KEY"); + expect(upgraded.placeholderEnvKeys).toEqual([]); + + // The current rebuild intentionally omits COMPATIBLE_API_KEY from its host + // environment. After trusted post-restore finalization (#9946), the + // credential remains gateway-held instead of being projected back into the + // sandbox environment. The upgraded agent turn below proves that the exact + // credential still reaches the compatible endpoint. Preserve any key refs + // the frozen runtime emitted without inventing one for this route. for (const keyRefId of legacy.keyRefIds) { expect(upgraded.keyRefIds).toContain(keyRefId); } - for (const envKey of legacy.placeholderEnvKeys) { - expect(upgraded.placeholderEnvKeys).toContain(envKey); - } } async function assertOpenClawAgentSecretBoundary( diff --git a/test/e2e/live/rebuild-hermes-swap.ts b/test/e2e/live/rebuild-hermes-swap.ts new file mode 100644 index 0000000000..b026220e7f --- /dev/null +++ b/test/e2e/live/rebuild-hermes-swap.ts @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; +import type { CleanupRegistry } from "../fixtures/cleanup.ts"; +import { assertExitZero } from "../fixtures/clients/command.ts"; +import type { HostCliClient } from "../fixtures/clients/index.ts"; +import { + HERMES_REBUILD_SWAP_BYTES, + needsHermesRebuildSwap, + parseActiveSwapBytes, +} from "../fixtures/hermes-rebuild-swap.ts"; + +const HERMES_REBUILD_SWAP_FILE = "/mnt/nemoclaw-hermes-rebuild.swap"; + +async function createHermesRebuildSwap(host: HostCliClient): Promise { + const githubActions = process.env.GITHUB_ACTIONS === "true"; + if (!githubActions) return false; + + const probeOptions = { + env: buildAvailabilityProbeEnv(), + timeoutMs: 30_000, + }; + const current = await host.command( + "swapon", + ["--show", "--bytes", "--noheadings", "--output", "SIZE"], + { + ...probeOptions, + artifactName: "prereq-hermes-rebuild-swap-before", + }, + ); + assertExitZero(current, "inspect active swap before Hermes rebuild"); + if ( + !needsHermesRebuildSwap({ + activeSwapBytes: parseActiveSwapBytes(current.stdout), + githubActions, + }) + ) { + return false; + } + + const provision = await host.command( + "sudo", + [ + "bash", + "-c", + `set -euo pipefail +swap_file="$1" +swap_size_bytes="$2" +if test -e "$swap_file"; then + printf 'refusing to replace existing swap path: %s\n' "$swap_file" >&2 + exit 1 +fi +cleanup_failed_provision() { + status=$? + trap - EXIT + if ((status != 0)); then + swapoff "$swap_file" >/dev/null 2>&1 || true + rm -f -- "$swap_file" || true + fi + exit "$status" +} +trap cleanup_failed_provision EXIT +fallocate -l "$swap_size_bytes" "$swap_file" +chmod 0600 "$swap_file" +mkswap "$swap_file" +swapon "$swap_file" +trap - EXIT`, + "hermes-rebuild-swap", + HERMES_REBUILD_SWAP_FILE, + String(HERMES_REBUILD_SWAP_BYTES), + ], + { + ...probeOptions, + artifactName: "prereq-hermes-rebuild-swap-provision", + timeoutMs: 2 * 60_000, + }, + ); + assertExitZero(provision, "provision swap for Hermes rebuild"); + return true; +} + +async function verifyHermesRebuildSwap(host: HostCliClient): Promise { + const verified = await host.command( + "swapon", + ["--show", "--bytes", "--noheadings", "--output", "SIZE"], + { + artifactName: "prereq-hermes-rebuild-swap-after", + env: buildAvailabilityProbeEnv(), + timeoutMs: 30_000, + }, + ); + assertExitZero(verified, "inspect active swap after Hermes rebuild provisioning"); + if (parseActiveSwapBytes(verified.stdout) < HERMES_REBUILD_SWAP_BYTES) { + throw new Error("Hermes rebuild swap remains below the required capacity after provisioning"); + } +} + +async function cleanupHermesRebuildSwap(host: HostCliClient): Promise { + const result = await host.command( + "sudo", + [ + "bash", + "-c", + `set -euo pipefail +swap_file="$1" +status=0 +swapoff "$swap_file" || status=$? +rm -f -- "$swap_file" || status=$? +test ! -e "$swap_file" || status=1 +if swapon --show --noheadings --output NAME | grep -Fqx -- "$swap_file"; then + status=1 +fi +exit "$status"`, + "hermes-rebuild-swap-cleanup", + HERMES_REBUILD_SWAP_FILE, + ], + { + artifactName: "cleanup-hermes-rebuild-swap", + env: buildAvailabilityProbeEnv(), + timeoutMs: 2 * 60_000, + }, + ); + assertExitZero(result, "remove Hermes rebuild swap"); +} + +export async function prepareHermesRebuildSwap( + host: HostCliClient, + cleanup: Pick, +): Promise { + const created = await createHermesRebuildSwap(host); + if (!created) return; + cleanup.trackDisposable("remove Hermes rebuild swap", () => cleanupHermesRebuildSwap(host)); + await verifyHermesRebuildSwap(host); +} diff --git a/test/e2e/live/rebuild-hermes.test.ts b/test/e2e/live/rebuild-hermes.test.ts index 8976095e79..2ec0601136 100644 --- a/test/e2e/live/rebuild-hermes.test.ts +++ b/test/e2e/live/rebuild-hermes.test.ts @@ -20,11 +20,6 @@ import { snapshotFile, writeJsonFile, } from "../fixtures/file-state.ts"; -import { - HERMES_REBUILD_SWAP_BYTES, - needsHermesRebuildSwap, - parseActiveSwapBytes, -} from "../fixtures/hermes-rebuild-swap.ts"; import { CLI_ENTRYPOINT, REPO_ROOT } from "../fixtures/paths.ts"; import { listCredentialLeakPaths } from "../fixtures/phases/state-validation.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; @@ -64,6 +59,7 @@ import { import { buildRebuildHermesOldSandboxDockerfile } from "./rebuild-hermes-old-sandbox.ts"; import { REBUILD_HERMES_PHASES } from "./rebuild-hermes-phases.ts"; import { buildHermesRuntimeExecArgs } from "./rebuild-hermes-runtime-exec.ts"; +import { prepareHermesRebuildSwap } from "./rebuild-hermes-swap.ts"; import { REBUILD_HERMES_STATE } from "./rebuild-hermes-state-fixture.ts"; import { buildRebuildHermesTimingSummary, describeRunnerClass } from "./rebuild-hermes-timing.ts"; @@ -140,71 +136,6 @@ const LIVE_TIMEOUT_MS = 70 * 60_000; // generous diagnostic tail without letting a stuck child exhaust the hosted // runner by growing the fixture's in-memory stdout/stderr buffers forever. const LONG_COMMAND_CAPTURE_LIMIT_BYTES = 4 * 1024 * 1024; -const HERMES_REBUILD_SWAP_FILE = "/mnt/nemoclaw-hermes-rebuild.swap"; - -async function ensureHermesRebuildSwap(host: HostCliClient): Promise { - const githubActions = process.env.GITHUB_ACTIONS === "true"; - if (!githubActions) return; - - const probeOptions = { - env: buildAvailabilityProbeEnv(), - timeoutMs: 30_000, - }; - const current = await host.command( - "swapon", - ["--show", "--bytes", "--noheadings", "--output", "SIZE"], - { - ...probeOptions, - artifactName: "prereq-hermes-rebuild-swap-before", - }, - ); - expectExitZero(current, "inspect active swap before Hermes rebuild"); - if ( - !needsHermesRebuildSwap({ - activeSwapBytes: parseActiveSwapBytes(current.stdout), - githubActions, - }) - ) { - return; - } - - const provision = await host.command( - "sudo", - [ - "bash", - "-c", - `set -euo pipefail -swap_file="$1" -swap_size_bytes="$2" -swapoff "$swap_file" 2>/dev/null || true -rm -f "$swap_file" -fallocate -l "$swap_size_bytes" "$swap_file" -chmod 0600 "$swap_file" -mkswap "$swap_file" -swapon "$swap_file"`, - "hermes-rebuild-swap", - HERMES_REBUILD_SWAP_FILE, - String(HERMES_REBUILD_SWAP_BYTES), - ], - { - ...probeOptions, - artifactName: "prereq-hermes-rebuild-swap-provision", - timeoutMs: 2 * 60_000, - }, - ); - expectExitZero(provision, "provision swap for Hermes rebuild"); - - const verified = await host.command( - "swapon", - ["--show", "--bytes", "--noheadings", "--output", "SIZE"], - { - ...probeOptions, - artifactName: "prereq-hermes-rebuild-swap-after", - }, - ); - expectExitZero(verified, "inspect active swap after Hermes rebuild provisioning"); - expect(parseActiveSwapBytes(verified.stdout)).toBeGreaterThanOrEqual(HERMES_REBUILD_SWAP_BYTES); -} function inspectKanbanTaskArgs(sandboxName: string): string[] { const script = [ @@ -684,7 +615,7 @@ test(STALE_BASE_REBUILD "rebuild-Hermes must invoke the checked-out CLI through NEMOCLAW_CLI_BIN", ).toBe(CLI_ENTRYPOINT); await ensureRebuildHermesHostTools(host); - await ensureHermesRebuildSwap(host); + await prepareHermesRebuildSwap(host, cleanup); const dockerInfo = await host.command("docker", ["info"], { artifactName: "prereq-docker-info", diff --git a/test/e2e/support/base-image-publication-workflow-boundary.test.ts b/test/e2e/support/base-image-publication-workflow-boundary.test.ts index 23c3570d68..52221a7949 100644 --- a/test/e2e/support/base-image-publication-workflow-boundary.test.ts +++ b/test/e2e/support/base-image-publication-workflow-boundary.test.ts @@ -196,6 +196,10 @@ describe("base-image publication workflow boundary (#7372)", () => { "verifier SHA", (value) => (gateSteps(value)[3].env!.EXPECTED_SHA = "${{ inputs.checkout_sha }}"), ], + [ + "managed-image publication requirement", + (value) => (gateSteps(value)[3].env!.REQUIRE_MANAGED_IMAGE_PUBLICATION = "0"), + ], [ "verifier command", (value) => { diff --git a/test/e2e/support/base-image-publication.test.ts b/test/e2e/support/base-image-publication.test.ts index ab8b3fc871..a1ed7f41ef 100644 --- a/test/e2e/support/base-image-publication.test.ts +++ b/test/e2e/support/base-image-publication.test.ts @@ -611,7 +611,7 @@ describe("base-image publication evidence", () => { ); it("reconfirms the selected run identity after reading job history (#9549)", () => { - expect(() => validateBoundRun(workflowRun(), selectedRun())).not.toThrow(); + expect(validateBoundRun(workflowRun(), selectedRun())).toEqual(selectedRun()); expect(() => validateBoundRun( workflowRun({ conclusion: "cancelled" }), @@ -738,6 +738,76 @@ describe("base-image publication evidence", () => { expect(sleeps).toBe(0); }); + it("waits for managed-image publication when downstream E2E requires it", async () => { + const inProgressRun = workflowRun({ status: "in_progress", conclusion: null }); + const responses = [ + workflowMetadata(), + runsPayload([inProgressRun]), + { total_count: 3, jobs: successfulJobs() }, + inProgressRun, + runsPayload([workflowRun()]), + { total_count: 3, jobs: successfulJobs() }, + workflowRun(), + ]; + let currentTime = 0; + + await expect( + waitForBaseImagePublication({ + history: history(), + request: async () => responses.shift(), + requireWorkflowSuccess: true, + waitMs: 100, + pollMs: 10, + now: () => currentTime, + sleep: async (milliseconds) => { + currentTime += milliseconds; + }, + }), + ).resolves.toMatchObject({ id: RUN_ID, conclusion: "success" }); + expect(currentTime).toBe(10); + }); + + it("returns the completed detailed run when the workflow list is stale", async () => { + const listedRun = workflowRun({ status: "in_progress", conclusion: null }); + const completedRun = workflowRun(); + const responses = [ + workflowMetadata(), + runsPayload([listedRun]), + { total_count: 3, jobs: successfulJobs() }, + completedRun, + ]; + + await expect( + waitForBaseImagePublication({ + history: history(), + request: async () => responses.shift(), + requireWorkflowSuccess: true, + waitMs: 100, + pollMs: 10, + }), + ).resolves.toEqual(selectedRun()); + }); + + it("rejects failed managed-image publication before E2E consumers start", async () => { + const failedRun = workflowRun({ conclusion: "failure" }); + const responses = [ + workflowMetadata(), + runsPayload([failedRun]), + { total_count: 3, jobs: successfulJobs() }, + failedRun, + ]; + + await expect( + waitForBaseImagePublication({ + history: history(), + request: async () => responses.shift(), + requireWorkflowSuccess: true, + waitMs: 100, + pollMs: 10, + }), + ).rejects.toThrow(/managed-image publication workflow did not complete successfully/u); + }); + it.each(["failure", "cancelled"] as const)( "accepts required publishers after unrelated downstream work concludes %s (#9549)", async (conclusion) => { diff --git a/test/e2e/support/hermes-discord-policy-binding.test.ts b/test/e2e/support/hermes-discord-policy-binding.test.ts index 55e47c99bc..07b98e2ccc 100644 --- a/test/e2e/support/hermes-discord-policy-binding.test.ts +++ b/test/e2e/support/hermes-discord-policy-binding.test.ts @@ -12,7 +12,7 @@ import YAML from "yaml"; const HELPER = path.resolve(import.meta.dirname, "../fixtures/hermes-discord-policy-binding.ts"); const tempDirs: string[] = []; -function runBinding(policyFile: string) { +function runBinding(policyFile: string, protocol?: string) { return spawnSync( process.execPath, [ @@ -23,6 +23,7 @@ function runBinding(policyFile: string) { "e2e-hermes-discord-discord-bridge", "host.docker.internal", "43117", + ...(protocol ? [protocol] : []), ], { encoding: "utf8", timeout: 15_000 }, ); @@ -77,4 +78,37 @@ describe("Hermes Discord E2E policy binding", () => { }); expect(fs.statSync(policyFile).mode & 0o777).toBe(0o600); }); + + it("binds only the requested protocol when a fake host and port are shared", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-messaging-policy-")); + tempDirs.push(tempDir); + const policyFile = path.join(tempDir, "policy.yaml"); + fs.writeFileSync( + policyFile, + [ + "version: 1", + "network_policies:", + " fake:", + " endpoints:", + " - host: host.docker.internal", + " port: 43117", + " protocol: rest", + " - host: host.docker.internal", + " port: 43117", + " protocol: websocket", + "", + ].join("\n"), + ); + + const result = runBinding(policyFile, "websocket"); + const endpoints = YAML.parse(fs.readFileSync(policyFile, "utf8")).network_policies.fake + .endpoints as Array>; + + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + expect(endpoints[0]).not.toHaveProperty("credential_binding"); + expect(endpoints[1]).toHaveProperty("credential_binding", { + provider: "e2e-hermes-discord-discord-bridge", + }); + }); }); diff --git a/test/e2e/support/hermes-rebuild-swap.test.ts b/test/e2e/support/hermes-rebuild-swap.test.ts index ea4984ad43..16d28ded3d 100644 --- a/test/e2e/support/hermes-rebuild-swap.test.ts +++ b/test/e2e/support/hermes-rebuild-swap.test.ts @@ -1,16 +1,26 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { HostCliClient } from "../fixtures/clients/index.ts"; import { HERMES_REBUILD_SWAP_BYTES, needsHermesRebuildSwap, parseActiveSwapBytes, } from "../fixtures/hermes-rebuild-swap.ts"; +import { prepareHermesRebuildSwap } from "../live/rebuild-hermes-swap.ts"; + +function result(exitCode = 0, stdout = "", stderr = "") { + return { exitCode, signal: null, stderr, stdout }; +} describe("Hermes rebuild swap", () => { + afterEach(() => vi.unstubAllEnvs()); + it("adds active swap sizes reported by swapon", () => { expect(parseActiveSwapBytes("17179869184\n17179869184\n")).toBe(HERMES_REBUILD_SWAP_BYTES); }); @@ -42,15 +52,114 @@ describe("Hermes rebuild swap", () => { expect(needsHermesRebuildSwap({ activeSwapBytes: 0, githubActions: false })).toBe(false); }); - it("checks the fallback before the live Docker fixture starts", () => { - const source = fs.readFileSync( - path.resolve(import.meta.dirname, "../live/rebuild-hermes.test.ts"), - "utf8", + it("registers cleanup before verifying and removes the created swap", async () => { + vi.stubEnv("GITHUB_ACTIONS", "true"); + let cleanupAction: (() => Promise | void) | undefined; + const trackDisposable = vi.fn((name: string, action: () => Promise | void) => { + expect(name).toBe("remove Hermes rebuild swap"); + cleanupAction = action; + }); + const command = vi + .fn<(_commandName: string, _args?: string[]) => Promise>>() + .mockResolvedValueOnce(result(0, "0\n")) + .mockResolvedValueOnce(result()) + .mockImplementationOnce(async () => { + expect(trackDisposable).toHaveBeenCalledOnce(); + return result(0, `${String(HERMES_REBUILD_SWAP_BYTES)}\n`); + }) + .mockResolvedValueOnce(result()); + + await prepareHermesRebuildSwap( + { command } as unknown as HostCliClient, + { trackDisposable }, + ); + + expect(command.mock.calls.map(([commandName]) => commandName)).toEqual([ + "swapon", + "sudo", + "swapon", + ]); + expect(cleanupAction).toEqual(expect.any(Function)); + await cleanupAction?.(); + expect(command.mock.calls.map(([commandName]) => commandName)).toEqual([ + "swapon", + "sudo", + "swapon", + "sudo", + ]); + }); + + it("propagates cleanup failure", async () => { + vi.stubEnv("GITHUB_ACTIONS", "true"); + const responses = [ + result(0, "0\n"), + result(), + result(0, `${String(HERMES_REBUILD_SWAP_BYTES)}\n`), + result(1, "", "swap remains active"), + ]; + let cleanupAction: (() => Promise | void) | undefined; + const command = vi.fn( + async (_commandName: string, _args: string[] = []) => responses.shift() ?? result(1), + ); + + await prepareHermesRebuildSwap( + { command } as unknown as HostCliClient, + { + trackDisposable: (_name, action) => { + cleanupAction = action; + }, + }, + ); + + await expect(cleanupAction?.()).rejects.toThrow("remove Hermes rebuild swap failed"); + }); + + it("removes a new swap path when provisioning fails after allocation", async () => { + vi.stubEnv("GITHUB_ACTIONS", "true"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-swap-test-")); + const binDirectory = path.join(directory, "bin"); + const swapPath = path.join(directory, "rebuild.swap"); + fs.mkdirSync(binDirectory); + fs.writeFileSync( + path.join(binDirectory, "fallocate"), + '#!/usr/bin/env bash\nset -euo pipefail\n: > "$3"\n', + { mode: 0o755 }, ); - const ensureSwap = source.indexOf("await ensureHermesRebuildSwap(host);"); - const dockerProbe = source.indexOf('host.command("docker", ["info"]'); + fs.writeFileSync(path.join(binDirectory, "mkswap"), "#!/usr/bin/env bash\nexit 42\n", { + mode: 0o755, + }); + fs.writeFileSync(path.join(binDirectory, "swapoff"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + const trackDisposable = vi.fn(); + const command = vi + .fn() + .mockResolvedValueOnce(result(0, "0\n")) + .mockImplementationOnce(async (_commandName: string, args: string[]) => { + const execution = spawnSync( + "bash", + ["-c", args[2], args[3], swapPath, args[5]], + { + encoding: "utf8", + env: { ...process.env, PATH: `${binDirectory}:${process.env.PATH ?? ""}` }, + }, + ); + expect(execution.status).toBe(42); + expect(fs.existsSync(swapPath)).toBe(false); + return result(execution.status ?? 1, execution.stdout, execution.stderr); + }); - expect(ensureSwap).toBeGreaterThan(-1); - expect(dockerProbe).toBeGreaterThan(ensureSwap); + try { + await expect( + prepareHermesRebuildSwap( + { command } as unknown as HostCliClient, + { trackDisposable }, + ), + ).rejects.toThrow("provision swap for Hermes rebuild failed"); + expect(trackDisposable).not.toHaveBeenCalled(); + expect(fs.existsSync(swapPath)).toBe(false); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } }); }); diff --git a/test/e2e/support/jetson-workflow-boundary.test.ts b/test/e2e/support/jetson-workflow-boundary.test.ts index 3f5e656a0c..cedacab0c6 100644 --- a/test/e2e/support/jetson-workflow-boundary.test.ts +++ b/test/e2e/support/jetson-workflow-boundary.test.ts @@ -18,6 +18,19 @@ function validateWorkflowMutation( } describe("Jetson nvmap GPU E2E workflow boundary", () => { + it("waits for the exact managed-image publication before Jetson dispatch", () => { + const errors = validateWorkflowMutation((workflow) => { + const job = (workflow.jobs as Record)["jetson-nvmap-gpu"] as { + needs?: unknown; + }; + job.needs = "generate-matrix"; + }); + + expect(errors).toContain( + "jetson-nvmap-gpu job must depend on managed publication and generate-matrix", + ); + }); + it("keeps manual Jetson dispatch disabled by default (#8142)", () => { const inputErrors = validateWorkflowMutation((workflow) => { const triggers = (workflow.on ?? workflow[true as unknown as string]) as { diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index 24967aa597..1ef62aad39 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -99,10 +99,7 @@ function pythonStringMap(source: string, constantName: string): Record, -): void { +function expectVersionsMatchLock(requirementsLock: string, versions: Record): void { expect(versions, "deepagents-code must be present in the version map").toHaveProperty( "deepagents-code", ); @@ -123,16 +120,19 @@ const TARGETED_ADVISORY_VERSIONS = [ ] as const; describe("targeted dependency advisory review", () => { - it.each(TARGETED_ADVISORY_VERSIONS)("documents the reviewed %s %s pin", (distribution, version) => { - const normalizedDistribution = distribution.replaceAll("-", "[-_]"); - const normalizedVersion = version.replaceAll(".", "\\."); - expect(readAgentFile("dependency-review.md")).toMatch( - new RegExp( - `(?:^|[^A-Za-z0-9_-])${normalizedDistribution}\\s+${normalizedVersion}(?=[^0-9.]|$)`, - "im", - ), - ); - }); + it.each(TARGETED_ADVISORY_VERSIONS)( + "documents the reviewed %s %s pin", + (distribution, version) => { + const normalizedDistribution = distribution.replaceAll("-", "[-_]"); + const normalizedVersion = version.replaceAll(".", "\\."); + expect(readAgentFile("dependency-review.md")).toMatch( + new RegExp( + `(?:^|[^A-Za-z0-9_-])${normalizedDistribution}\\s+${normalizedVersion}(?=[^0-9.]|$)`, + "im", + ), + ); + }, + ); }); function writeMinimalWheel(directory: string): string { @@ -1173,6 +1173,7 @@ describe("LangChain Deep Agents Code image contracts", () => { it("keeps image validator versions aligned with the reviewed lockfile", () => { const requirementsLock = readAgentFile("requirements.lock"); + const progressiveValidator = readAgentFile("validate-progressive-tool-disclosure.py"); const pluginMetadata = readAgentFile("profile-plugin/pyproject.toml"); const pluginVersion = pluginMetadata.match(/^version = "([^"]+)"$/m)?.[1]; expect(pluginVersion).toBe("0.1.0"); @@ -1185,7 +1186,7 @@ describe("LangChain Deep Agents Code image contracts", () => { expectVersionsMatchLock(requirementsLock, profileValidatorVersions); expectVersionsMatchLock( requirementsLock, - pythonStringMap(readAgentFile("validate-progressive-tool-disclosure.py"), "PINNED_VERSIONS"), + pythonStringMap(progressiveValidator, "PINNED_VERSIONS"), ); const observabilityValidator = readAgentFile("validate-observability.py"); @@ -1213,6 +1214,59 @@ describe("LangChain Deep Agents Code image contracts", () => { expectVersionsMatchLock(requirementsLock, e2eVersions); }); + it("assigns the read-only MCP contract to each loaded validator tool", () => { + const validatorPath = path.join( + repoRoot, + "agents", + "langchain-deepagents-code", + "validate-progressive-tool-disclosure.py", + ); + const metadata = JSON.parse( + execFileSync( + "python3", + [ + "-c", + `import ast +import json +import sys + +tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) +values = [] +for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + if not any(isinstance(target, ast.Attribute) and target.attr == "metadata" for target in node.targets): + continue + value = ast.literal_eval(node.value) + if isinstance(value, dict) and value.get("_deepagents_code_mcp") is True: + values.append(value) +print(json.dumps(values, sort_keys=True))`, + validatorPath, + ], + { encoding: "utf8" }, + ), + ) as Array>; + + expect(metadata).toEqual([ + { + _deepagents_code_mcp: true, + _deepagents_code_mcp_server: "direct-runtime-validator", + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + { + _deepagents_code_mcp: true, + _deepagents_code_mcp_server: "runtime-validator", + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + ]); + }); + it.each([ ["aiohttp", "3.14.3"], ["cryptography", "50.0.0"], diff --git a/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts b/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts index 64e808ac47..f92d620965 100644 --- a/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts +++ b/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts @@ -504,15 +504,30 @@ def observability_counts(result): os.environ.pop("NEMOCLAW_TOOL_DISCLOSURE", None) no_mcp = counts(agent.create_cli_agent(None, "assistant")) -empty_mcp = counts(agent.create_cli_agent(None, "assistant", mcp_server_info=[Info(())])) -active = counts(agent.create_cli_agent(None, "assistant", mcp_server_info=[Info(("mcp_echo",))])) +empty_mcp = counts( + agent.create_cli_agent( + None, + "assistant", + mcp_tools=[], + mcp_server_info=[Info(("metadata_only",))], + ) +) +active = counts( + agent.create_cli_agent( + None, + "assistant", + mcp_tools=[NamedTool("mcp_echo")], + mcp_server_info=[Info(())], + ) +) parent_only = harness.BaseTool("parent_only", "Parent graph tool") subagent_only = harness.BaseTool("subagent_only", "Subagent graph tool") subagent_result = agent.create_cli_agent( None, "assistant", tools=[parent_only], - mcp_server_info=[Info(("mcp_echo",))], + mcp_tools=[NamedTool("mcp_echo")], + mcp_server_info=[Info(())], subagents=[ {"name": "inherits", "middleware": []}, {"name": "overrides", "middleware": [], "tools": [subagent_only]}, @@ -544,7 +559,14 @@ subagent_catalogs = { "visible": [tool.name for tool in subagent_visible.tools], } os.environ["NEMOCLAW_TOOL_DISCLOSURE"] = "direct" -direct = counts(agent.create_cli_agent(None, "assistant", mcp_server_info=[Info(("mcp_echo",))])) +direct = counts( + agent.create_cli_agent( + None, + "assistant", + mcp_tools=[NamedTool("mcp_echo")], + mcp_server_info=[Info(())], + ) +) os.environ["NEMOCLAW_OBSERVABILITY"] = "true" observability_noncanonical = observability_counts( @@ -622,7 +644,12 @@ finally: os.environ["NEMOCLAW_TOOL_DISCLOSURE"] = "invalid" try: - agent.create_cli_agent(None, "assistant", mcp_server_info=[Info(("mcp_echo",))]) + agent.create_cli_agent( + None, + "assistant", + mcp_tools=[NamedTool("mcp_echo")], + mcp_server_info=[Info(())], + ) except RuntimeError as exc: invalid = str(exc) else: diff --git a/tools/e2e/base-image-publication.mts b/tools/e2e/base-image-publication.mts index 7954d84a46..2c5bff93fd 100644 --- a/tools/e2e/base-image-publication.mts +++ b/tools/e2e/base-image-publication.mts @@ -102,6 +102,7 @@ export type PublicationSelection = export interface PublicationWaitOptions { history: FirstParentHistory; request: (path: string) => Promise; + requireWorkflowSuccess?: boolean; waitMs: number; pollMs: number; now?: () => number; @@ -515,7 +516,7 @@ export function validatePublisherJobs(payload: unknown, run: PublicationRun): "p return pending ? "pending" : "ready"; } -export function validateBoundRun(payload: unknown, expected: PublicationRun): void { +export function validateBoundRun(payload: unknown, expected: PublicationRun): PublicationRun { const actual = validateRun(payload, 0, expected.workflowId); if ( actual.id !== expected.id || @@ -526,6 +527,7 @@ export function validateBoundRun(payload: unknown, expected: PublicationRun): vo `selected base-image workflow changed while evidence was verified; ${expected.url}`, ); } + return actual; } async function collectPaginationAttempt( @@ -638,14 +640,25 @@ export async function waitForBaseImagePublication( ); } let publisherState: "pending" | "ready"; + let validatedRun = selection.run; try { const jobs = await collectPaginated(options.request, jobsPath, "jobs"); publisherState = validatePublisherJobs(jobs, selection.run); if (publisherState === "ready") { - validateBoundRun( + const boundRun = validateBoundRun( await options.request(`/repos/${REPOSITORY}/actions/runs/${selection.run.id}`), selection.run, ); + validatedRun = boundRun; + if (options.requireWorkflowSuccess === true) { + if (boundRun.status !== "completed") { + publisherState = "pending"; + } else if (boundRun.conclusion !== "success") { + throw new Error( + `managed-image publication workflow did not complete successfully; ${boundRun.url}`, + ); + } + } } } catch (error) { throw publicationEvidenceError(error, selection.run); @@ -656,7 +669,7 @@ export async function waitForBaseImagePublication( `timed out validating base-image publication for ${selection.run.headSha}; ${selection.run.url}`, ); } - return selection.run; + return validatedRun; } } @@ -789,6 +802,7 @@ export async function main(argv = process.argv.slice(2), env = process.env): Pro const token = env.GITHUB_TOKEN ?? ""; const expectedSha = env.EXPECTED_SHA ?? ""; const outputPath = env.GITHUB_OUTPUT ?? ""; + const requireManagedImagePublication = env.REQUIRE_MANAGED_IMAGE_PUBLICATION ?? "0"; const workspace = env.GITHUB_WORKSPACE ?? process.cwd(); if (token.length === 0 || token.includes("\r") || token.includes("\n")) { throw new Error("GITHUB_TOKEN must be a non-empty single-line value"); @@ -806,6 +820,9 @@ export async function main(argv = process.argv.slice(2), env = process.env): Pro if (env.GITHUB_SHA !== expectedSha) { throw new Error("EXPECTED_SHA must match GITHUB_SHA"); } + if (requireManagedImagePublication !== "0" && requireManagedImagePublication !== "1") { + throw new Error("REQUIRE_MANAGED_IMAGE_PUBLICATION must be 0 or 1"); + } const workflowSource = readFileSync(resolve(workspace, WORKFLOW_PATH), "utf8"); const paths = parseBaseImagePushPaths(workflowSource); @@ -813,6 +830,7 @@ export async function main(argv = process.argv.slice(2), env = process.env): Pro const run = await waitForBaseImagePublication({ history, request: (path) => githubRequest(path, token), + requireWorkflowSuccess: requireManagedImagePublication === "1", waitMs: waitSeconds * 1000, pollMs: pollSeconds * 1000, }); diff --git a/tools/e2e/operations-workflow-boundary.mts b/tools/e2e/operations-workflow-boundary.mts index e0f59fae09..090b63b02e 100644 --- a/tools/e2e/operations-workflow-boundary.mts +++ b/tools/e2e/operations-workflow-boundary.mts @@ -640,6 +640,7 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): env: { EXPECTED_SHA: "${{ inputs.checkout_sha || github.sha }}", GITHUB_TOKEN: "${{ github.token }}", + REQUIRE_MANAGED_IMAGE_PUBLICATION: "1", }, shell: "bash", run: [ diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index b3c3c401a9..880f4b6c10 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -1741,11 +1741,11 @@ function validateAllowJetsonDispatchInput(errors: string[], dispatchInputs: Work function validateJetsonControllerBoundary(errors: string[], jobs: WorkflowRecord): void { const job = asRecord(jobs["jetson-nvmap-gpu"]); - if (job.needs !== "generate-matrix") { - errors.push("jetson-nvmap-gpu job must depend on generate-matrix"); + if (!isDeepStrictEqual(job.needs, ["base-image-publication", "generate-matrix"])) { + errors.push("jetson-nvmap-gpu job must depend on managed publication and generate-matrix"); } const trustedPushOrManualSelector = - "${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.allow_jetson_dispatch && (inputs.checkout_repository == '' || inputs.checkout_repository == github.repository) && ((inputs.jobs == '' && inputs.targets == '') || contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'jetson-nvmap-gpu')))) }}"; + "${{ always() && needs['base-image-publication'].result == 'success' && needs['generate-matrix'].result == 'success' && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.allow_jetson_dispatch && (inputs.checkout_repository == '' || inputs.checkout_repository == github.repository) && ((inputs.jobs == '' && inputs.targets == '') || contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'jetson-nvmap-gpu')))) }}"; if (job.if !== trustedPushOrManualSelector) { errors.push( "jetson-nvmap-gpu job must run on trusted main pushes and require opt-in for same-repository manual selections",