From 6fef6e5170a8ced0d43ce14c6274548edf6a4e9d Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 10 Aug 2026 14:31:11 -0700 Subject: [PATCH 1/2] fix(e2e): repair managed image gateway cleanup Signed-off-by: Apurv Kumaria --- .../checks/run-managed-image-openshell-e2e.ts | 78 +++++++++++++++---- src/lib/onboard.ts | 2 + ...d-image-protected-runtime-contract.test.ts | 57 ++++++++++++++ 3 files changed, 124 insertions(+), 13 deletions(-) diff --git a/scripts/checks/run-managed-image-openshell-e2e.ts b/scripts/checks/run-managed-image-openshell-e2e.ts index bb6c6826938..3c891d71f60 100644 --- a/scripts/checks/run-managed-image-openshell-e2e.ts +++ b/scripts/checks/run-managed-image-openshell-e2e.ts @@ -131,7 +131,7 @@ const MANAGED_IMAGE_E2E_ENVIRONMENT_KEYS = [ "PATH", ] as const; -type OnboardModule = { +export type OnboardModule = { openshellArgv(args: string[]): string[]; runOpenshell(args: string[], opts?: Record): ReturnType; runCaptureOpenshell(args: string[], opts?: Record): string; @@ -139,6 +139,35 @@ type OnboardModule = { startGatewayForRecovery(options: { gatewayName: string; gatewayPort: number }): Promise; }; +const REQUIRED_ONBOARD_HOOKS = [ + "openshellArgv", + "runOpenshell", + "runCaptureOpenshell", + "sleepSeconds", + "startGatewayForRecovery", +] as const satisfies readonly (keyof OnboardModule)[]; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function resolveManagedImageOnboardModule(value: unknown): OnboardModule { + const candidate = isObject(value) && "default" in value ? value.default : value; + const missingHooks = REQUIRED_ONBOARD_HOOKS.filter( + (hook) => !isObject(candidate) || typeof candidate[hook] !== "function", + ); + if (missingHooks.length > 0) { + throw new Error( + `managed-image onboard module contract requires callable hook(s): ${missingHooks.join(", ")}`, + ); + } + return candidate as OnboardModule; +} + +export async function loadManagedImageOnboardModule(): Promise { + return resolveManagedImageOnboardModule(await import("../../src/lib/onboard.ts")); +} + function requiredValue(argv: readonly string[], flag: string): string { const index = argv.indexOf(flag); const value = index >= 0 ? argv[index + 1] : undefined; @@ -263,7 +292,11 @@ function processExists(pid: number): boolean { } } -function stopProcess(pid: number | null): boolean { +function yieldToProcessEvents(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function stopProcess(pid: number | null): Promise { if (!pid || !processExists(pid)) return true; try { process.kill(pid, "SIGTERM"); @@ -272,7 +305,7 @@ function stopProcess(pid: number | null): boolean { } for (let attempt = 0; attempt < 50; attempt += 1) { if (!processExists(pid)) return true; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); + await yieldToProcessEvents(100); } try { process.kill(pid, "SIGKILL"); @@ -281,7 +314,7 @@ function stopProcess(pid: number | null): boolean { } for (let attempt = 0; attempt < 20; attempt += 1) { if (!processExists(pid)) return true; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); + await yieldToProcessEvents(100); } return !processExists(pid); } @@ -323,18 +356,18 @@ function createProtectedAuthorityStore(stateDir: string): ManagedBootstrapAuthor }; } -async function assertGatewayPortAvailable(): Promise { +async function assertGatewayPortAvailable(port = GATEWAY_PORT): Promise { await new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); server.once("error", () => { reject( new Error( - `refusing to disturb an existing listener on the managed-image E2E gateway port ${GATEWAY_PORT}`, + `refusing to disturb an existing listener on the managed-image E2E gateway port ${port}`, ), ); }); - server.listen(GATEWAY_PORT, "127.0.0.1", () => { + server.listen(port, "127.0.0.1", () => { server.close((error) => { if (error) reject(error); else resolve(); @@ -343,6 +376,26 @@ async function assertGatewayPortAvailable(): Promise { }); } +export async function stopManagedImageOpenShellGateway( + pid: number | null, + port: number, +): Promise { + const processStopped = await stopProcess(pid); + let listenerStopped = true; + try { + await assertGatewayPortAvailable(port); + } catch { + listenerStopped = false; + } + const failures = [ + ...(!processStopped ? [`OpenShell gateway process ${String(pid)} did not stop`] : []), + ...(!listenerStopped + ? [`OpenShell gateway listener on port ${String(port)} did not stop`] + : []), + ]; + if (failures.length > 0) throw new Error(failures.join("; ")); +} + function managedConfigPath(agent: ManagedStartupAgent): string { switch (agent) { case "openclaw": @@ -741,10 +794,7 @@ async function run { + it("loads every callable onboarding hook required by the protected runner (#8759)", async () => { + const onboard = await loadManagedImageOnboardModule(); + + expect(onboard.runOpenshell).toBeTypeOf("function"); + expect(() => + resolveManagedImageOnboardModule({ + default: { ...onboard, runOpenshell: undefined }, + }), + ).toThrow(/managed-image onboard module contract.*runOpenshell/u); + }); + + it("yields while reaping the owned gateway and releases its listener (#8759)", async () => { + const child = spawn( + process.execPath, + [ + "-e", + [ + 'const net = require("node:net");', + "const server = net.createServer();", + 'server.listen(0, "127.0.0.1", () => process.stdout.write(`${server.address().port}\\n`));', + 'process.on("SIGTERM", () => server.close(() => process.exit(0)));', + ].join(""), + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + const exited = once(child, "exit"); + + try { + const [chunk] = await once(child.stdout!, "data"); + const port = Number.parseInt(String(chunk).trim(), 10); + + expect(port).toBeGreaterThan(0); + await stopManagedImageOpenShellGateway(child.pid ?? null, port); + await exited; + + const replacement = net.createServer(); + await new Promise((resolve, reject) => { + replacement.once("error", reject); + replacement.listen(port, "127.0.0.1", resolve); + }); + await new Promise((resolve, reject) => { + replacement.close((error) => (error ? reject(error) : resolve())); + }); + } finally { + if (child.exitCode === null) { + child.kill("SIGKILL"); + await exited; + } + } + }); + it("assigns every protected agent and route a unique OpenShell-compatible sandbox name (#8497)", () => { const routeKinds = [...MANAGED_IMAGE_LOCAL_INFERENCE_KINDS, "rollback"] as const; const qualifications = PROTECTED_MANAGED_IMAGE_AGENTS.flatMap((agent) => From 2cd4649370a9cae3f07c5e1ad2f8eb2b8486fda9 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 10 Aug 2026 14:56:05 -0700 Subject: [PATCH 2/2] refactor(e2e): reduce managed image fix size Signed-off-by: Apurv Kumaria --- .../checks/run-managed-image-openshell-e2e.ts | 75 ++++--------------- src/lib/onboard.ts | 6 +- ...d-image-protected-runtime-contract.test.ts | 60 +++------------ 3 files changed, 27 insertions(+), 114 deletions(-) diff --git a/scripts/checks/run-managed-image-openshell-e2e.ts b/scripts/checks/run-managed-image-openshell-e2e.ts index 3c891d71f60..f95d1de783f 100644 --- a/scripts/checks/run-managed-image-openshell-e2e.ts +++ b/scripts/checks/run-managed-image-openshell-e2e.ts @@ -131,7 +131,7 @@ const MANAGED_IMAGE_E2E_ENVIRONMENT_KEYS = [ "PATH", ] as const; -export type OnboardModule = { +type OnboardModule = { openshellArgv(args: string[]): string[]; runOpenshell(args: string[], opts?: Record): ReturnType; runCaptureOpenshell(args: string[], opts?: Record): string; @@ -139,33 +139,12 @@ export type OnboardModule = { startGatewayForRecovery(options: { gatewayName: string; gatewayPort: number }): Promise; }; -const REQUIRED_ONBOARD_HOOKS = [ - "openshellArgv", - "runOpenshell", - "runCaptureOpenshell", - "sleepSeconds", - "startGatewayForRecovery", -] as const satisfies readonly (keyof OnboardModule)[]; - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - export function resolveManagedImageOnboardModule(value: unknown): OnboardModule { - const candidate = isObject(value) && "default" in value ? value.default : value; - const missingHooks = REQUIRED_ONBOARD_HOOKS.filter( - (hook) => !isObject(candidate) || typeof candidate[hook] !== "function", - ); - if (missingHooks.length > 0) { - throw new Error( - `managed-image onboard module contract requires callable hook(s): ${missingHooks.join(", ")}`, - ); - } - return candidate as OnboardModule; -} - -export async function loadManagedImageOnboardModule(): Promise { - return resolveManagedImageOnboardModule(await import("../../src/lib/onboard.ts")); + const candidate = (value as { default?: OnboardModule }).default ?? (value as OnboardModule); + // biome-ignore format: keep the harness contract compact + const missing = (["openshellArgv", "runOpenshell", "runCaptureOpenshell", "sleepSeconds", "startGatewayForRecovery"] as const).find((hook) => typeof candidate?.[hook] !== "function"); + if (missing) throw new Error(`missing callable onboard hook: ${missing}`); + return candidate; } function requiredValue(argv: readonly string[], flag: string): string { @@ -292,11 +271,7 @@ function processExists(pid: number): boolean { } } -function yieldToProcessEvents(milliseconds: number): Promise { - return new Promise((resolve) => setTimeout(resolve, milliseconds)); -} - -async function stopProcess(pid: number | null): Promise { +export async function stopProcess(pid: number | null): Promise { if (!pid || !processExists(pid)) return true; try { process.kill(pid, "SIGTERM"); @@ -305,7 +280,7 @@ async function stopProcess(pid: number | null): Promise { } for (let attempt = 0; attempt < 50; attempt += 1) { if (!processExists(pid)) return true; - await yieldToProcessEvents(100); + await new Promise((resolve) => setTimeout(resolve, 100)); } try { process.kill(pid, "SIGKILL"); @@ -314,7 +289,7 @@ async function stopProcess(pid: number | null): Promise { } for (let attempt = 0; attempt < 20; attempt += 1) { if (!processExists(pid)) return true; - await yieldToProcessEvents(100); + await new Promise((resolve) => setTimeout(resolve, 100)); } return !processExists(pid); } @@ -356,7 +331,7 @@ function createProtectedAuthorityStore(stateDir: string): ManagedBootstrapAuthor }; } -async function assertGatewayPortAvailable(port = GATEWAY_PORT): Promise { +export async function assertGatewayPortAvailable(port = GATEWAY_PORT): Promise { await new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); @@ -376,26 +351,6 @@ async function assertGatewayPortAvailable(port = GATEWAY_PORT): Promise { }); } -export async function stopManagedImageOpenShellGateway( - pid: number | null, - port: number, -): Promise { - const processStopped = await stopProcess(pid); - let listenerStopped = true; - try { - await assertGatewayPortAvailable(port); - } catch { - listenerStopped = false; - } - const failures = [ - ...(!processStopped ? [`OpenShell gateway process ${String(pid)} did not stop`] : []), - ...(!listenerStopped - ? [`OpenShell gateway listener on port ${String(port)} did not stop`] - : []), - ]; - if (failures.length > 0) throw new Error(failures.join("; ")); -} - function managedConfigPath(agent: ManagedStartupAgent): string { switch (agent) { case "openclaw": @@ -794,7 +749,7 @@ async function run cleanupErrors.push(`OpenShell gateway listener on port ${String(GATEWAY_PORT)} did not stop`)); if (onboard) { const removeGateway = commandResult( onboard.openshellArgv(["gateway", "remove", "nemoclaw"]), diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 56bb9878b92..bc387644787 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4507,10 +4507,8 @@ module.exports = { startDockerDriverGateway, findAvailableDashboardPort, startGatewayForRecovery, - openshellArgv, - runOpenshell, - runCaptureOpenshell, - sleepSeconds, + // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail + ...{ openshellArgv, runOpenshell, runCaptureOpenshell, sleepSeconds }, agentSupportsWebSearch, agentSupportsWebSearchProvider, createSetupInference, diff --git a/test/managed-image-protected-runtime-contract.test.ts b/test/managed-image-protected-runtime-contract.test.ts index 35181511abd..dd57ab63d84 100644 --- a/test/managed-image-protected-runtime-contract.test.ts +++ b/test/managed-image-protected-runtime-contract.test.ts @@ -3,7 +3,6 @@ import { spawn } from "node:child_process"; import { once } from "node:events"; -import net from "node:net"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -18,69 +17,30 @@ import { withManagedImageLocalInferenceProfile, } from "../scripts/checks/managed-image-protected-runtime-contract.ts"; import { - loadManagedImageOnboardModule, + assertGatewayPortAvailable, managedImageLocalInferenceBaseUrl, managedImageOpenShellBasePolicyPath, managedImageOpenShellCommittedProbe, managedImageOpenShellProbe, parseManagedImageOpenShellE2eInputs, resolveManagedImageOnboardModule, - stopManagedImageOpenShellGateway, + stopProcess, } from "../scripts/checks/run-managed-image-openshell-e2e.ts"; const IMAGE = `localhost:5000/nemoclaw-managed-protected/openclaw@sha256:${"a".repeat(64)}`; const VALID_SANDBOX = "managed-openclaw"; describe("protected managed-image runtime contract", () => { - it("loads every callable onboarding hook required by the protected runner (#8759)", async () => { - const onboard = await loadManagedImageOnboardModule(); - + it("validates onboarding hooks and reaps the owned gateway listener (#8759)", async () => { + const onboard = resolveManagedImageOnboardModule(await import("../src/lib/onboard.js")); expect(onboard.runOpenshell).toBeTypeOf("function"); - expect(() => - resolveManagedImageOnboardModule({ - default: { ...onboard, runOpenshell: undefined }, - }), - ).toThrow(/managed-image onboard module contract.*runOpenshell/u); - }); - - it("yields while reaping the owned gateway and releases its listener (#8759)", async () => { - const child = spawn( - process.execPath, - [ - "-e", - [ - 'const net = require("node:net");', - "const server = net.createServer();", - 'server.listen(0, "127.0.0.1", () => process.stdout.write(`${server.address().port}\\n`));', - 'process.on("SIGTERM", () => server.close(() => process.exit(0)));', - ].join(""), - ], - { stdio: ["ignore", "pipe", "pipe"] }, - ); - const exited = once(child, "exit"); - - try { - const [chunk] = await once(child.stdout!, "data"); - const port = Number.parseInt(String(chunk).trim(), 10); + expect(() => resolveManagedImageOnboardModule({})).toThrow(/openshellArgv/u); - expect(port).toBeGreaterThan(0); - await stopManagedImageOpenShellGateway(child.pid ?? null, port); - await exited; - - const replacement = net.createServer(); - await new Promise((resolve, reject) => { - replacement.once("error", reject); - replacement.listen(port, "127.0.0.1", resolve); - }); - await new Promise((resolve, reject) => { - replacement.close((error) => (error ? reject(error) : resolve())); - }); - } finally { - if (child.exitCode === null) { - child.kill("SIGKILL"); - await exited; - } - } + // biome-ignore format: keep the focused child-listener regression compact + const child = spawn(process.execPath, ["-e", 'const n=require("node:net"),s=n.createServer();s.listen(0,"127.0.0.1",()=>process.send(s.address().port));setTimeout(()=>process.exit(),10000).unref()'], { stdio: ["ignore", "ignore", "ignore", "ipc"] }); + const [port] = await once(child, "message"); + expect(await stopProcess(child.pid ?? null)).toBe(true); + await expect(assertGatewayPortAvailable(port as number)).resolves.toBeUndefined(); }); it("assigns every protected agent and route a unique OpenShell-compatible sandbox name (#8497)", () => {