diff --git a/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx b/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx index 4669a240d96..c0d95dc2fe9 100644 --- a/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx +++ b/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx @@ -222,8 +222,9 @@ $$nemoclaw gateway-token --quiet ``` Before post-restore repairs, NemoClaw verifies that the recreated sandbox still identifies as Hermes and exits nonzero if its identity does not match the rebuild target. -After state restore, NemoClaw restores managed MCP configuration through the normal lifecycle, then restarts the Hermes gateway and verifies or recovers its health before performing final MCP reconciliation. -The gateway starts during recreation and reads its durable state before the restore replaces it, so the restart is what binds the running gateway to the restored state. +After state restore, NemoClaw restarts the Hermes gateway so it reads the restored durable state, then restores managed MCP configuration through the normal lifecycle. +MCP restoration performs an acknowledged gateway reload, so NemoClaw finishes by verifying the final running gateway and its managed MCP state without replacing that verified process again. +The gateway starts during recreation and reads its durable state before the restore replaces it, which is why the first post-restore restart must happen before managed MCP restoration. `rebuild` exits nonzero instead of reporting success when it cannot verify final gateway health or managed MCP state. Follow the printed recovery guidance, using `$$nemoclaw gateway restart` first for gateway health, `$$nemoclaw recover` when the restart does not restore verified health, and `$$nemoclaw mcp restart` for incomplete managed MCP restoration. diff --git a/src/lib/actions/sandbox/rebuild-hermes-post-restore.test.ts b/src/lib/actions/sandbox/rebuild-hermes-post-restore.test.ts index 1a0176cedd9..5515b786730 100644 --- a/src/lib/actions/sandbox/rebuild-hermes-post-restore.test.ts +++ b/src/lib/actions/sandbox/rebuild-hermes-post-restore.test.ts @@ -10,6 +10,8 @@ import { import { ensureHermesGatewayAfterStateRestore, ensureHermesGatewayAfterStateRestoreForCronGate, + verifyHermesGatewayAfterStateRestore, + verifyHermesGatewayAfterStateRestoreForCronGate, } from "./rebuild-hermes-post-restore"; const RESTART_SUCCEEDED = { @@ -164,6 +166,71 @@ describe("binding the Hermes gateway to restored state", () => { ).toEqual({ state: "unverified" }); expect(observeHermesCronReplacement).toHaveBeenCalledTimes(2); }); + + it("verifies the final cron-bound gateway without restarting after MCP restoration (#8472)", () => { + const original = { pid: 41, start_time: 902, drain_token: "restore-token" }; + const replacement = { pid: 77, start_time: 903, drain_token: "restore-token" }; + const restartSandboxGateway = vi.fn(() => RESTART_SUCCEEDED); + const observeHermesCronReplacement = vi.fn(() => replacement); + + expect( + verifyHermesGatewayAfterStateRestoreForCronGate("alpha", "hermes", "restarted", original, { + restartSandboxGateway, + observeHermesCronReplacement, + checkAndRecoverSandboxProcesses: () => ({ + checked: true, + wasRunning: true, + recovered: false, + }), + }), + ).toEqual({ state: "healthy", replacementIdentity: replacement }); + expect(restartSandboxGateway).not.toHaveBeenCalled(); + expect(observeHermesCronReplacement).toHaveBeenCalledTimes(2); + }); + + it("rejects unstable final cron identity without restarting after MCP restoration (#8472)", () => { + const restartSandboxGateway = vi.fn(() => RESTART_SUCCEEDED); + const observeHermesCronReplacement = vi + .fn() + .mockReturnValueOnce({ pid: 77, start_time: 903, drain_token: "restore-token" }) + .mockReturnValueOnce({ pid: 88, start_time: 904, drain_token: "restore-token" }); + + expect( + verifyHermesGatewayAfterStateRestoreForCronGate( + "alpha", + "hermes", + "restarted", + { pid: 41, start_time: 902, drain_token: "restore-token" }, + { + restartSandboxGateway, + observeHermesCronReplacement, + checkAndRecoverSandboxProcesses: () => ({ + checked: true, + wasRunning: true, + recovered: false, + }), + }, + ), + ).toEqual({ state: "unverified" }); + expect(restartSandboxGateway).not.toHaveBeenCalled(); + }); + + it("preserves MCP reconciliation refusal during restart-free final verification (#7084)", () => { + const restartSandboxGateway = vi.fn(() => RESTART_SUCCEEDED); + + expect( + verifyHermesGatewayAfterStateRestore("alpha", "hermes", "restarted", { + restartSandboxGateway, + checkAndRecoverSandboxProcesses: () => ({ + checked: true, + wasRunning: true, + recovered: false, + mcpReconciliationRefused: true, + }), + }), + ).toBe("unverified"); + expect(restartSandboxGateway).not.toHaveBeenCalled(); + }); }); describe("Hermes gateway post-restore recheck", () => { @@ -267,7 +334,7 @@ describe("Hermes rebuild post-restore verification", () => { expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry]); }); - it("accepts restored MCP configuration only after final gateway recovery (#7084)", async () => { + it("restores MCP after gateway restart and before final health verification (#7084)", async () => { const mcpEntry = { server: "blender", providerName: "nemoclaw-mcp-alpha-blender", @@ -276,9 +343,9 @@ describe("Hermes rebuild post-restore verification", () => { agentName: "hermes", checkAndRecoverSandboxProcesses: () => ({ checked: true, - wasRunning: false, - recovered: true, - forwardRecovered: true, + wasRunning: true, + recovered: false, + forwardRecovered: false, }), mcpPreparation: { entries: [mcpEntry], @@ -293,11 +360,14 @@ describe("Hermes rebuild post-restore verification", () => { ).resolves.toBeUndefined(); expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry]); + expect(harness.restartSandboxGatewaySpy.mock.invocationCallOrder[0]).toBeLessThan( + harness.restoreMcpBridgesAfterRebuildSpy.mock.invocationCallOrder[0], + ); expect(harness.restoreMcpBridgesAfterRebuildSpy.mock.invocationCallOrder[0]).toBeLessThan( harness.checkAndRecoverSandboxProcessesSpy.mock.invocationCallOrder[0], ); expect(harness.logSpy).toHaveBeenCalledWith( - expect.stringContaining("Hermes gateway recovered after state restore"), + expect.stringContaining("Hermes gateway restarted and verified after state restore"), ); }); diff --git a/src/lib/actions/sandbox/rebuild-hermes-post-restore.ts b/src/lib/actions/sandbox/rebuild-hermes-post-restore.ts index da68305db87..4af6b972ff4 100644 --- a/src/lib/actions/sandbox/rebuild-hermes-post-restore.ts +++ b/src/lib/actions/sandbox/rebuild-hermes-post-restore.ts @@ -79,6 +79,11 @@ export type HermesPostRestoreGatewayState = | "recovered" | "unverified"; +export type HermesPostRestoreGatewayRestartState = + | "not-applicable" + | "restarted" + | "restart-failed"; + type GatewayRecoveryObservation = { checked: boolean; wasRunning: boolean | null; @@ -116,20 +121,24 @@ export interface HermesPostRestoreGatewayVerification { * pre-restore result for the life of the process — the WhatsApp bridge reads * its paired session that way — so the gateway can be alive and healthy while * still serving the state the rebuild replaced. A liveness check cannot see - * that difference, so restart first and let the check report on the process - * that restart produced. `relaunchManagedSupervisorSession` already restarts - * after its own restore for the same reason. + * that difference, so restart before runtime restoration and let the final + * check report on the process left by every intervening acknowledged reload. + * `relaunchManagedSupervisorSession` already restarts after its own restore + * for the same reason. * - * A gated rebuild keeps the root-owned cron drain active while this function - * replaces and verifies the gateway. The caller then completes the held - * transaction against the replacement process before dispatch can resume. + * The split restart/verify exports let rebuild insert MCP restoration between + * those two steps. Hermes MCP restoration performs its own acknowledged + * gateway reload, so a later unconditional restart would discard the runtime + * identity whose MCP load just converged. A gated rebuild keeps the root-owned + * cron drain active across restart, MCP restoration, and final verification. */ export function ensureHermesGatewayAfterStateRestore( sandboxName: string, agentName: string, deps: HermesPostRestoreGatewayDeps = {}, ): HermesPostRestoreGatewayState { - return ensureHermesGatewayAfterStateRestoreImpl(sandboxName, agentName, deps).state; + const restartState = restartHermesGatewayAfterStateRestore(sandboxName, agentName, deps); + return verifyHermesGatewayAfterStateRestore(sandboxName, agentName, restartState, deps); } export function ensureHermesGatewayAfterStateRestoreForCronGate( @@ -138,7 +147,49 @@ export function ensureHermesGatewayAfterStateRestoreForCronGate( originalIdentity: HermesCronRestoreIdentity, deps: HermesPostRestoreGatewayDeps = {}, ): HermesPostRestoreGatewayVerification { - return ensureHermesGatewayAfterStateRestoreImpl(sandboxName, agentName, deps, originalIdentity); + const restartState = restartHermesGatewayAfterStateRestore(sandboxName, agentName, deps); + return verifyHermesGatewayAfterStateRestoreForCronGate( + sandboxName, + agentName, + restartState, + originalIdentity, + deps, + ); +} + +export function restartHermesGatewayAfterStateRestore( + sandboxName: string, + agentName: string, + deps: HermesPostRestoreGatewayDeps = {}, +): HermesPostRestoreGatewayRestartState { + if (agentName !== "hermes") return "not-applicable"; + const restart = deps.restartSandboxGateway ?? processRecovery.restartSandboxGateway; + return restart(sandboxName, { quiet: true }).ok ? "restarted" : "restart-failed"; +} + +export function verifyHermesGatewayAfterStateRestore( + sandboxName: string, + agentName: string, + restartState: HermesPostRestoreGatewayRestartState, + deps: HermesPostRestoreGatewayDeps = {}, +): HermesPostRestoreGatewayState { + return verifyHermesGatewayAfterStateRestoreImpl(sandboxName, agentName, restartState, deps).state; +} + +export function verifyHermesGatewayAfterStateRestoreForCronGate( + sandboxName: string, + agentName: string, + restartState: HermesPostRestoreGatewayRestartState, + originalIdentity: HermesCronRestoreIdentity, + deps: HermesPostRestoreGatewayDeps = {}, +): HermesPostRestoreGatewayVerification { + return verifyHermesGatewayAfterStateRestoreImpl( + sandboxName, + agentName, + restartState, + deps, + originalIdentity, + ); } function sameGatewayIdentity( @@ -148,15 +199,15 @@ function sameGatewayIdentity( return left.pid === right.pid && left.start_time === right.start_time; } -function ensureHermesGatewayAfterStateRestoreImpl( +function verifyHermesGatewayAfterStateRestoreImpl( sandboxName: string, agentName: string, + restartState: HermesPostRestoreGatewayRestartState, deps: HermesPostRestoreGatewayDeps, originalIdentity?: HermesCronRestoreIdentity, ): HermesPostRestoreGatewayVerification { if (agentName !== "hermes") return { state: "not-applicable" }; - const restart = deps.restartSandboxGateway ?? processRecovery.restartSandboxGateway; - const restarted = restart(sandboxName, { quiet: true }).ok; + const restarted = restartState === "restarted"; const checkAndRecover = deps.checkAndRecoverSandboxProcesses ?? processRecovery.checkAndRecoverSandboxProcesses; const observeReplacement = deps.observeHermesCronReplacement ?? observeHermesCronReplacement; diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts index edf08ee5189..33eb2a60368 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts @@ -58,13 +58,17 @@ describe("rebuild post-restore phase", () => { skipReason: "not-needed", } as never); vi.spyOn(rebuildMcp, "restoreMcpAfterRebuild").mockResolvedValue(true); - vi.spyOn(rebuildHermesPostRestore, "ensureHermesGatewayAfterStateRestore").mockImplementation( + vi.spyOn(rebuildHermesPostRestore, "restartHermesGatewayAfterStateRestore").mockImplementation( + (_sandboxName, targetAgentName) => + targetAgentName === "hermes" ? "restarted" : "not-applicable", + ); + vi.spyOn(rebuildHermesPostRestore, "verifyHermesGatewayAfterStateRestore").mockImplementation( (_sandboxName, targetAgentName) => targetAgentName === "hermes" ? "healthy" : "not-applicable", ); vi.spyOn( rebuildHermesPostRestore, - "ensureHermesGatewayAfterStateRestoreForCronGate", + "verifyHermesGatewayAfterStateRestoreForCronGate", ).mockReturnValue({ state: "healthy", replacementIdentity: { pid: 77, start_time: 903, drain_token: "restore-token" }, @@ -135,17 +139,23 @@ describe("rebuild post-restore phase", () => { const events: string[] = []; let dispatchHeld = true; const attemptDispatch = () => events.push(dispatchHeld ? "dispatch-blocked" : "dispatch-ran"); + vi.mocked(rebuildHermesPostRestore.restartHermesGatewayAfterStateRestore).mockImplementation( + () => { + events.push("restart"); + attemptDispatch(); + return "restarted"; + }, + ); vi.mocked(rebuildMcp.restoreMcpAfterRebuild).mockImplementation(async () => { events.push("mcp"); attemptDispatch(); return true; }); vi.mocked( - rebuildHermesPostRestore.ensureHermesGatewayAfterStateRestoreForCronGate, + rebuildHermesPostRestore.verifyHermesGatewayAfterStateRestoreForCronGate, ).mockImplementation(() => { - events.push("restart"); - attemptDispatch(); events.push("health-verified"); + attemptDispatch(); return { state: "healthy", replacementIdentity: { pid: 77, start_time: 903, drain_token: "restore-token" }, @@ -176,14 +186,16 @@ describe("rebuild post-restore phase", () => { await runRebuildPostRestorePhase(args); expect(events).toEqual([ - "mcp", - "dispatch-blocked", "restart", "dispatch-blocked", + "mcp", + "dispatch-blocked", "health-verified", + "dispatch-blocked", "release", "dispatch-ran", ]); + expect(rebuildHermesPostRestore.restartHermesGatewayAfterStateRestore).toHaveBeenCalledOnce(); expect(args.log).toHaveBeenCalledWith( "Hermes cron restore gate released: pid=77, startTime=903", ); @@ -200,7 +212,7 @@ describe("rebuild post-restore phase", () => { it("leaves the cron gate active when replacement verification fails (#8472)", async () => { agentName = "hermes"; vi.mocked( - rebuildHermesPostRestore.ensureHermesGatewayAfterStateRestoreForCronGate, + rebuildHermesPostRestore.verifyHermesGatewayAfterStateRestoreForCronGate, ).mockReturnValue({ state: "unverified" }); const args = { ...input(), @@ -225,6 +237,45 @@ describe("rebuild post-restore phase", () => { ); }); + it("keeps restart failure ahead of MCP repair and final verification (#8472)", async () => { + agentName = "hermes"; + const events: string[] = []; + vi.mocked(rebuildHermesPostRestore.restartHermesGatewayAfterStateRestore).mockImplementation( + () => { + events.push("restart-failed"); + return "restart-failed"; + }, + ); + vi.mocked(rebuildMcp.restoreMcpAfterRebuild).mockImplementation(async () => { + events.push("mcp"); + return true; + }); + vi.mocked( + rebuildHermesPostRestore.verifyHermesGatewayAfterStateRestoreForCronGate, + ).mockImplementation((_sandboxName, _agentName, restartState) => { + events.push(`verify:${restartState}`); + return { state: "unverified" }; + }); + const args = { + ...input(), + hermesCronRestoreIdentity: { + pid: 41, + start_time: 902, + drain_token: "restore-token", + }, + }; + + await runRebuildPostRestorePhase(args); + + expect(events).toEqual(["restart-failed", "mcp", "verify:restart-failed"]); + expect( + rebuildHermesPostRestore.completeHermesCronRestoreAfterGatewayReplacement, + ).not.toHaveBeenCalled(); + expect(args.bail).toHaveBeenCalledWith( + "Hermes cron restore validation failed; dispatch was not re-enabled.", + ); + }); + it("leaves the cron gate active when replacement completion fails (#8472)", async () => { agentName = "hermes"; vi.mocked( @@ -329,7 +380,7 @@ describe("rebuild post-restore phase", () => { agentName = "hermes"; vi.mocked(rebuildMcp.restoreMcpAfterRebuild).mockResolvedValue(false); vi.mocked( - rebuildHermesPostRestore.ensureHermesGatewayAfterStateRestoreForCronGate, + rebuildHermesPostRestore.verifyHermesGatewayAfterStateRestoreForCronGate, ).mockReturnValue({ state: "unverified" }); const args = { ...input(), @@ -405,7 +456,7 @@ describe("rebuild post-restore phase", () => { it("does not print the Hermes API token notice when post-restore verification is incomplete (#7175)", async () => { agentName = "hermes"; - vi.mocked(rebuildHermesPostRestore.ensureHermesGatewayAfterStateRestore).mockReturnValue( + vi.mocked(rebuildHermesPostRestore.verifyHermesGatewayAfterStateRestore).mockReturnValue( "unverified", ); const args = input(); @@ -450,7 +501,7 @@ describe("rebuild post-restore phase", () => { it("prints the Hermes API token notice after gateway recovery (#7175)", async () => { agentName = "hermes"; - vi.mocked(rebuildHermesPostRestore.ensureHermesGatewayAfterStateRestore).mockReturnValue( + vi.mocked(rebuildHermesPostRestore.verifyHermesGatewayAfterStateRestore).mockReturnValue( "recovered", ); const args = input(); @@ -561,7 +612,7 @@ describe("rebuild post-restore phase", () => { const output = vi.mocked(console.log).mock.calls.flat().map(String).join("\n"); // Every incomplete-recovery report this path can emit for an OpenClaw // rebuild. The Hermes gateway report is unreachable here because - // ensureHermesGatewayAfterStateRestore returns "not-applicable" for + // verifyHermesGatewayAfterStateRestore returns "not-applicable" for // OpenClaw; baseline exclusions are covered by the #7194 test above. const ordered = [ "State restore was incomplete", diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts index f002f8da7bd..74c14dbe47f 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts @@ -19,11 +19,12 @@ import type { RebuildBail, RebuildLog } from "./rebuild-credential-preflight"; import type { RebuildSandboxEntry } from "./rebuild-flow-helpers"; import { completeHermesCronRestoreAfterGatewayReplacement, - ensureHermesGatewayAfterStateRestore, - ensureHermesGatewayAfterStateRestoreForCronGate, type HermesCronRestoreIdentity, isHermesCronRestoreDrainMarkerRollbackFailure, printHermesGatewayRestoreRecovery, + restartHermesGatewayAfterStateRestore, + verifyHermesGatewayAfterStateRestore, + verifyHermesGatewayAfterStateRestoreForCronGate, } from "./rebuild-hermes-post-restore"; import { type McpRebuildPreparation, @@ -283,15 +284,27 @@ export async function runRebuildPostRestorePhase( } } + // Restart before restoring MCP. The Hermes MCP transaction performs an + // acknowledged reload of its own; restarting afterwards would replace the + // only runtime whose managed MCP configuration was proven to have loaded. + const hermesGatewayRestartState = restartHermesGatewayAfterStateRestore( + sandboxName, + targetAgentName, + ); const mcpBridgeRestoreUnverified = !(await restoreMcpAfterRebuild(sandboxName, mcpEntries)); const hermesGatewayVerification = hermesCronRestoreIdentity - ? ensureHermesGatewayAfterStateRestoreForCronGate( + ? verifyHermesGatewayAfterStateRestoreForCronGate( sandboxName, targetAgentName, + hermesGatewayRestartState, hermesCronRestoreIdentity, ) : { - state: ensureHermesGatewayAfterStateRestore(sandboxName, targetAgentName), + state: verifyHermesGatewayAfterStateRestore( + sandboxName, + targetAgentName, + hermesGatewayRestartState, + ), replacementIdentity: undefined, }; const hermesGatewayRestoreState = hermesGatewayVerification.state; diff --git a/test/e2e/live/hermes-discord-proxy.ts b/test/e2e/live/hermes-discord-proxy.ts new file mode 100644 index 00000000000..97619659442 --- /dev/null +++ b/test/e2e/live/hermes-discord-proxy.ts @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export function hermesDiscordHttpProxyWebSocketUrl(host: string, port: number | string): string { + return `http://${host}:${port}/gateway`; +} diff --git a/test/e2e/live/hermes-discord.test.ts b/test/e2e/live/hermes-discord.test.ts index a3b61082b66..44810a75ad1 100644 --- a/test/e2e/live/hermes-discord.test.ts +++ b/test/e2e/live/hermes-discord.test.ts @@ -14,6 +14,7 @@ import { expect, test } from "../fixtures/e2e-test.ts"; import { REPO_ROOT } from "../fixtures/paths.ts"; import { buildProcessTokenProbe } from "../fixtures/process-token-probe.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; +import { hermesDiscordHttpProxyWebSocketUrl } from "./hermes-discord-proxy.ts"; import { type FakeDockerApi, startFakeDockerApi } from "./messaging-providers-helpers.ts"; import { runSecondaryCleanup as bestEffortLifecycleCleanup, @@ -36,6 +37,10 @@ const DISCORD_ALLOWED_IDS = process.env.DISCORD_ALLOWED_IDS ?? "1005536447329222 const DISCORD_REQUIRE_MENTION = process.env.DISCORD_REQUIRE_MENTION ?? "0"; const HERMES_HEALTH_URL = "http://localhost:8642/health"; const FAKE_DISCORD_HOST = "host.docker.internal"; +const HERMES_DISCORD_HTTP_PROXY_GATEWAY_TEMPLATE = hermesDiscordHttpProxyWebSocketUrl( + "{host}", + "{port}", +); function commandEnv(apiKey?: string, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { return phase6Env({ @@ -270,7 +275,12 @@ async def main(): client.http._global_over.set() try: from_client = discord.gateway.DiscordWebSocket.from_client - kwargs = {"gateway": URL(f"ws://{host}:{port}/gateway")} + # aiohttp preserves the target scheme in the absolute-form request it + # sends to an HTTP proxy. OpenShell accepts WebSocket upgrades through + # that proxy as HTTP requests with Upgrade headers, matching the raw + # Node proof below; a ws:// absolute-form target is rejected with 400 + # before it reaches the fake gateway. + kwargs = {"gateway": URL(f"${HERMES_DISCORD_HTTP_PROXY_GATEWAY_TEMPLATE}")} params = inspect.signature(from_client).parameters if "initial" in params: kwargs["initial"] = False diff --git a/test/e2e/support/hermes-discord-proxy-request.test.ts b/test/e2e/support/hermes-discord-proxy-request.test.ts new file mode 100644 index 00000000000..0c477283ed6 --- /dev/null +++ b/test/e2e/support/hermes-discord-proxy-request.test.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { hermesDiscordHttpProxyWebSocketUrl } from "../live/hermes-discord-proxy.ts"; + +describe("Hermes Discord proxy request", () => { + it("uses HTTP absolute-form for the native WebSocket upgrade through OpenShell", () => { + const gateway = new URL(hermesDiscordHttpProxyWebSocketUrl("host.docker.internal", 32_768)); + + expect(gateway.protocol).toBe("http:"); + expect(gateway.host).toBe("host.docker.internal:32768"); + expect(gateway.pathname).toBe("/gateway"); + }); +});