diff --git a/src/lib/agent/base-image-hermes-resolution.test.ts b/src/lib/agent/base-image-hermes-resolution.test.ts index 8081e019156..4aa235162d6 100644 --- a/src/lib/agent/base-image-hermes-resolution.test.ts +++ b/src/lib/agent/base-image-hermes-resolution.test.ts @@ -127,7 +127,24 @@ describe("Hermes base-image resolver integration", () => { fs.rmSync(testRoot, { force: true, recursive: true }); }); - it("stages Hermes on aarch64 with a Dockerfile-pinned platform digest produced by the resolver path (#6313)", () => { + it("stages Hermes on aarch64 when the resolved Dockerfile-pinned platform digest has the pinned inventory (#6313)", () => { + const captureByEntrypointAndPinnedInventory = new Map([ + ["/opt/hermes/.venv/bin/python\0false", "nemoclaw-hermes-mcp-runtime-ok"], + ["/usr/bin/ldd\0false", "ldd (GNU libc) 2.41"], + ["/bin/sh\0true", "nemoclaw-security-inventory-ok"], + ]); + dockerMocks.capture.mockImplementation((args: string[]) => { + const entrypoint = args[args.indexOf("--entrypoint") + 1]; + const requestsPinnedInventory = args.some((arg) => + arg.includes("nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1"), + ); + return ( + captureByEntrypointAndPinnedInventory.get( + `${entrypoint}\0${String(requestsPinnedInventory)}`, + ) ?? "" + ); + }); + const result = stageHermesSandbox(); expect(fs.readFileSync(result.stagedDockerfile, "utf8")).toContain( @@ -149,6 +166,12 @@ describe("Hermes base-image resolver integration", () => { trackedRef, { ignoreError: true }, ); + expect(dockerMocks.capture).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.stringContaining("nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1"), + ]), + expect.objectContaining({ ignoreError: true }), + ); }, 15_000); it("stops before a release fallback when the tracked Hermes base fails qualification (#10826)", () => { diff --git a/src/lib/agent/base-image-hermes.test.ts b/src/lib/agent/base-image-hermes.test.ts index e715ba93961..ab49b26d722 100644 --- a/src/lib/agent/base-image-hermes.test.ts +++ b/src/lib/agent/base-image-hermes.test.ts @@ -78,15 +78,63 @@ describe("agent base image provisioning", () => { const options = resolveSandboxBaseImageMock.mock.calls[0]?.[0] as { validateImage?: (imageRef: string) => boolean; }; + dockerCaptureMock.mockReturnValueOnce("nemoclaw-hermes-mcp-runtime-ok").mockReturnValue(""); + + expect(options.validateImage?.("hermes-base:stale-inventory")).toBe(false); + expect(dockerCaptureMock).toHaveBeenCalledTimes(2); + }); + }); + + it("rejects the older security inventory without pinned resolver provenance (#10947)", () => { + withMockedDocker(({ ensureAgentBaseImage, dockerCaptureMock, resolveSandboxBaseImageMock }) => { + ensureAgentBaseImage(makeAgent()); + const options = resolveSandboxBaseImageMock.mock.calls[0]?.[0] as { + validateImage?: (imageRef: string, context?: { source: string }) => boolean; + }; dockerCaptureMock .mockReturnValueOnce("nemoclaw-hermes-mcp-runtime-ok") - .mockReturnValueOnce(""); + .mockReturnValueOnce("") + .mockReturnValueOnce("nemoclaw-security-inventory-ok"); - expect(options.validateImage?.("hermes-base:stale-inventory")).toBe(false); + expect(options.validateImage?.("hermes-base:cached-old", { source: "local" })).toBe(false); expect(dockerCaptureMock).toHaveBeenCalledTimes(2); }); }); + it("accepts the reviewed security inventory in the Dockerfile-pinned Hermes base", () => { + withMockedDocker(({ ensureAgentBaseImage, dockerCaptureMock, resolveSandboxBaseImageMock }) => { + ensureAgentBaseImage(makeAgent()); + const options = resolveSandboxBaseImageMock.mock.calls[0]?.[0] as { + pinnedRemoteRef?: string; + validateImage?: ( + imageRef: string, + context?: { source: string; pinnedRemoteRef?: string }, + ) => boolean; + }; + dockerCaptureMock + .mockReturnValueOnce("nemoclaw-hermes-mcp-runtime-ok") + .mockReturnValueOnce("") + .mockReturnValueOnce("nemoclaw-security-inventory-ok"); + + expect(options.pinnedRemoteRef).toMatch( + /^ghcr\.io\/nvidia\/nemoclaw\/hermes-sandbox-base@sha256:[0-9a-f]{64}$/, + ); + expect( + options.validateImage?.(options.pinnedRemoteRef!, { + source: "pinned", + pinnedRemoteRef: options.pinnedRemoteRef, + }), + ).toBe(true); + const inventoryProbe = dockerCaptureMock.mock.calls[2]?.[0] as string[]; + expect(inventoryProbe).toEqual( + expect.arrayContaining([ + options.pinnedRemoteRef, + expect.stringContaining("nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1"), + ]), + ); + }); + }); + it("rejects a Hermes base that has MCP but lacks ACP", () => { withMockedDocker(({ ensureAgentBaseImage, dockerCaptureMock, resolveSandboxBaseImageMock }) => { ensureAgentBaseImage(makeAgent()); diff --git a/src/lib/agent/base-image.ts b/src/lib/agent/base-image.ts index 75d2043997a..c438236dd69 100644 --- a/src/lib/agent/base-image.ts +++ b/src/lib/agent/base-image.ts @@ -37,12 +37,16 @@ import { SANDBOX_BASE_RESOLUTION_SCHEMA, SANDBOX_BASE_TAG, type SandboxBaseImageResolution, + type SandboxBaseImageValidationContext, SandboxBaseImageResolutionError, type SandboxBaseImageResolutionMetadata, type TrustedLocalBaseImageOverride, versionGte, } from "../sandbox-base-image"; -import { sandboxBaseImageHasSecurityInventory } from "../sandbox-base-image/security-inventory"; +import { + hermesSandboxBaseImageHasSecurityInventory, + sandboxBaseImageHasSecurityInventory, +} from "../sandbox-base-image/security-inventory"; import { getAgentSandboxBaseImageEnvVar } from "./base-image-env"; import { createDeepAgentsCodeBaseImageResolutionOptions } from "./deep-agents-code-base-image"; import type { AgentDefinition } from "./defs"; @@ -328,11 +332,16 @@ function createAgentBaseImageResolutionOptions( options: EnsureAgentBaseImageOptions, ): ResolveBaseImageOptions { const imageName = `ghcr.io/nvidia/nemoclaw/${agent.name}-sandbox-base`; + const pinnedRemoteRef = getHermesPinnedRemoteBaseRef(agent) ?? undefined; const validationOptions = agent.name === "hermes" ? { - validateImage: (imageRef: string) => - hermesBaseImageSupportsMcp(imageRef) && sandboxBaseImageHasSecurityInventory(imageRef), + validateImage: (imageRef: string, context?: SandboxBaseImageValidationContext) => + hermesBaseImageSupportsMcp(imageRef) && + hermesSandboxBaseImageHasSecurityInventory( + imageRef, + context?.source === "pinned" && context.pinnedRemoteRef === pinnedRemoteRef, + ), validationDescription: "the required MCP Streamable HTTP and ACP runtimes and the immutable security package inventory", } @@ -342,7 +351,6 @@ function createAgentBaseImageResolutionOptions( validationDescription: "the immutable security package inventory", } : createDeepAgentsCodeBaseImageResolutionOptions(agent, dockerfilePath); - const pinnedRemoteRef = getHermesPinnedRemoteBaseRef(agent) ?? undefined; return { imageName, dockerfilePath, @@ -417,7 +425,12 @@ export function bindLocalAgentBaseImageToPinnedProvenance( ) { return null; } - if (resolutionOptions.validateImage && !resolutionOptions.validateImage(imageRef)) return null; + if ( + resolutionOptions.validateImage && + !resolutionOptions.validateImage(imageRef, { source: "pinned", pinnedRemoteRef }) + ) { + return null; + } const digest = resolvedRemoteRef.slice(resolvedRemoteRef.indexOf("@") + 1); const metadata = createSandboxBaseImageResolutionMetadata( resolutionOptions, diff --git a/src/lib/sandbox-base-image-agent-resolution.test.ts b/src/lib/sandbox-base-image-agent-resolution.test.ts index dca8e044bac..71b65ac64ce 100644 --- a/src/lib/sandbox-base-image-agent-resolution.test.ts +++ b/src/lib/sandbox-base-image-agent-resolution.test.ts @@ -122,7 +122,7 @@ describe("agent-specific sandbox base-image resolution", () => { ignoreError: true, suppressOutput: true, }); - expect(validateImage).toHaveBeenCalledWith(staleRef); + expect(validateImage).toHaveBeenCalledWith(staleRef, { source: "override" }); expect(warn).toHaveBeenCalledWith( expect.stringContaining("lacks a required runtime capability"), ); diff --git a/src/lib/sandbox-base-image-platform-digest.test.ts b/src/lib/sandbox-base-image-platform-digest.test.ts index bf209c7c033..5373714aed8 100644 --- a/src/lib/sandbox-base-image-platform-digest.test.ts +++ b/src/lib/sandbox-base-image-platform-digest.test.ts @@ -99,6 +99,7 @@ describe("sandbox base-image pinned platform digest resolution", () => { }); it("returns a Dockerfile-pinned platform digest from the resolver path", () => { + const validateImage = vi.fn(() => true); dockerMocks.imageInspect.mockImplementation((ref: string) => ({ status: ref === REF || ref === PLATFORM_REF ? 0 : 1, })); @@ -123,6 +124,7 @@ describe("sandbox base-image pinned platform digest resolution", () => { ...resolutionOptions(), pinnedRemoteRef: REF, requirePinnedRemoteRef: true, + validateImage, }); expect(resolved).toEqual({ @@ -145,6 +147,10 @@ describe("sandbox base-image pinned platform digest resolution", () => { expect(dockerMocks.imageInspectFormat).toHaveBeenCalledWith("{{json .RepoDigests}}", REF, { ignoreError: true, }); + expect(validateImage).toHaveBeenCalledWith(REF, { + source: "pinned", + pinnedRemoteRef: REF, + }); expect(dockerMocks.build).not.toHaveBeenCalled(); }); diff --git a/src/lib/sandbox-base-image-release-resolution.test.ts b/src/lib/sandbox-base-image-release-resolution.test.ts index cce544ea33e..782925072ac 100644 --- a/src/lib/sandbox-base-image-release-resolution.test.ts +++ b/src/lib/sandbox-base-image-release-resolution.test.ts @@ -231,8 +231,12 @@ describe("sandbox base-image release resolution", () => { suppressOutput: true, }); expect(dockerMocks.pull).toHaveBeenCalledTimes(1); - expect(state.validateImage).toHaveBeenNthCalledWith(1, RELEASE_REF); - expect(state.validateImage).toHaveBeenNthCalledWith(2, RELEASE_REF); + expect(state.validateImage).toHaveBeenNthCalledWith(1, RELEASE_REF, { + source: "version-tag", + }); + expect(state.validateImage).toHaveBeenNthCalledWith(2, RELEASE_REF, { + source: "version-tag", + }); expect(dockerMocks.build).toHaveBeenCalledTimes(1); }); diff --git a/src/lib/sandbox-base-image.ts b/src/lib/sandbox-base-image.ts index a4e47613899..68cb18eaf33 100644 --- a/src/lib/sandbox-base-image.ts +++ b/src/lib/sandbox-base-image.ts @@ -280,7 +280,7 @@ function resolveContentAddressedLocalOverride( ); } } - if (options.validateImage && !options.validateImage(imageRef)) { + if (options.validateImage && !options.validateImage(imageRef, { source: "local" })) { throw new SandboxBaseImageResolutionError( `${options.label || "Sandbox base image"} local override '${imageRef}' lacks ` + `${options.validationDescription || "a required runtime capability"}.`, @@ -313,7 +313,15 @@ function validatePulledCandidate( } } - if (options.validateImage && !options.validateImage(imageRef)) { + if ( + options.validateImage && + !options.validateImage(imageRef, { + source, + ...(candidateOptions.pinnedRemoteRef + ? { pinnedRemoteRef: candidateOptions.pinnedRemoteRef } + : {}), + }) + ) { if (warn) { console.warn(" Warning: sandbox base image lacks a required runtime capability."); } @@ -407,7 +415,10 @@ function resolveLocalCandidate( const check = options.requireOpenshellSandboxAbi ? imageMeetsMinimumGlibc(imageRef, options.minGlibcVersion || OPENSHELL_SANDBOX_MIN_GLIBC) : { ok: true, version: null }; - if (check.ok && (!options.validateImage || options.validateImage(imageRef))) { + if ( + check.ok && + (!options.validateImage || options.validateImage(imageRef, { source: "local" })) + ) { addTraceEvent("nemoclaw.sandbox_base_image.local_fallback_reuse"); return { ref: imageRef, digest: null, source: "local", glibcVersion: check.version }; } @@ -456,7 +467,7 @@ function resolveLocalCandidate( return null; } - if (options.validateImage && !options.validateImage(imageRef)) { + if (options.validateImage && !options.validateImage(imageRef, { source: "local" })) { console.error(" Local sandbox base image lacks a required runtime capability."); return null; } diff --git a/src/lib/sandbox-base-image/resolution-metadata.test.ts b/src/lib/sandbox-base-image/resolution-metadata.test.ts index f1b48cae341..09de48829f4 100644 --- a/src/lib/sandbox-base-image/resolution-metadata.test.ts +++ b/src/lib/sandbox-base-image/resolution-metadata.test.ts @@ -204,7 +204,10 @@ describe("sandbox base-image resolution metadata lifecycle", () => { KEY, ), ).toBeNull(); - expect(validateImage).toHaveBeenCalledWith(REF); + expect(validateImage).toHaveBeenCalledWith(REF, { + source: metadata.source, + ...(metadata.pinnedRemoteRef ? { pinnedRemoteRef: metadata.pinnedRemoteRef } : {}), + }); expect(mocks.addTraceEvent).toHaveBeenCalledWith("nemoclaw.sandbox_base_image.cache_stale", { reason: "custom_validation_failed", }); diff --git a/src/lib/sandbox-base-image/resolution-metadata.ts b/src/lib/sandbox-base-image/resolution-metadata.ts index 0dadb09f3ec..c9dfb28660d 100644 --- a/src/lib/sandbox-base-image/resolution-metadata.ts +++ b/src/lib/sandbox-base-image/resolution-metadata.ts @@ -179,7 +179,13 @@ export function reuseSandboxBaseImageResolutionHint( addTraceEvent("nemoclaw.sandbox_base_image.cache_stale", { reason: validation.reason }); return null; } - if (options.validateImage && !options.validateImage(hint.ref)) { + if ( + options.validateImage && + !options.validateImage(hint.ref, { + source: hint.source, + ...(hint.pinnedRemoteRef ? { pinnedRemoteRef: hint.pinnedRemoteRef } : {}), + }) + ) { addTraceEvent("nemoclaw.sandbox_base_image.cache_stale", { reason: "custom_validation_failed", }); diff --git a/src/lib/sandbox-base-image/security-inventory.ts b/src/lib/sandbox-base-image/security-inventory.ts index b354595f62e..c41a58aceb7 100644 --- a/src/lib/sandbox-base-image/security-inventory.ts +++ b/src/lib/sandbox-base-image/security-inventory.ts @@ -4,6 +4,10 @@ import { dockerCapture } from "../adapters/docker"; const SECURITY_INVENTORY_PROBE_OK = "nemoclaw-security-inventory-ok"; +const CURRENT_PYTHON_HTMLPARSER_FIX_PACKAGE = + "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u5+nemoclaw1"; +const PINNED_HERMES_BASE_PYTHON_HTMLPARSER_FIX_PACKAGE = + "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1"; export const SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY = [ "libexpat1=2.8.3-1", @@ -14,11 +18,18 @@ export const SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY = [ "vim-tiny=2:9.2.0858-1", "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw2", "libssl3t64=3.5.7-1~deb13u2", - "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u5+nemoclaw1", + CURRENT_PYTHON_HTMLPARSER_FIX_PACKAGE, "perl-base=5.44.0-1nemoclaw1", "perl=5.44.0-1nemoclaw1", ] as const; +const PINNED_HERMES_BASE_SECURITY_PACKAGE_INVENTORY = SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY.map( + (packageSpec) => + packageSpec === CURRENT_PYTHON_HTMLPARSER_FIX_PACKAGE + ? PINNED_HERMES_BASE_PYTHON_HTMLPARSER_FIX_PACKAGE + : packageSpec, +); + export const OPENCLAW_SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY = [ ...SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY, "libevent-core-2.1-7t64=2.1.13-stable-1", @@ -70,6 +81,18 @@ export function sandboxBaseImageHasSecurityInventory(imageRef: string): boolean return sandboxBaseImageHasPackageInventory(imageRef, SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY); } +/** Accept the reviewed older inventory only with pinned-base provenance. */ +export function hermesSandboxBaseImageHasSecurityInventory( + imageRef: string, + allowPinnedInventory = false, +): boolean { + return ( + sandboxBaseImageHasSecurityInventory(imageRef) || + (allowPinnedInventory && + sandboxBaseImageHasPackageInventory(imageRef, PINNED_HERMES_BASE_SECURITY_PACKAGE_INVENTORY)) + ); +} + export function openClawSandboxBaseImageHasSecurityInventory(imageRef: string): boolean { return sandboxBaseImageHasPackageInventory( imageRef, diff --git a/src/lib/sandbox-base-image/types.ts b/src/lib/sandbox-base-image/types.ts index c44b823f077..0f5551ee8d9 100644 --- a/src/lib/sandbox-base-image/types.ts +++ b/src/lib/sandbox-base-image/types.ts @@ -53,7 +53,7 @@ export type ResolveBaseImageOptions = { pinnedRemoteRef?: string; requirePinnedRemoteRef?: boolean; allowLocalFallback?: boolean; - validateImage?: (imageRef: string) => boolean; + validateImage?: (imageRef: string, context?: SandboxBaseImageValidationContext) => boolean; validationDescription?: string; resolutionHint?: SandboxBaseImageResolutionMetadata | null; forceRefresh?: boolean; @@ -74,6 +74,11 @@ export type SandboxBaseImageResolution = { metadata?: SandboxBaseImageResolutionMetadata; }; +export type SandboxBaseImageValidationContext = Pick< + SandboxBaseImageResolution, + "source" | "pinnedRemoteRef" +>; + export type LocalImageMetadata = { Id?: unknown; RepoDigests?: unknown; diff --git a/test/e2e/fixtures/hermes-acp-live.ts b/test/e2e/fixtures/hermes-acp-live.ts index e90b3ed9886..38ea136b6aa 100644 --- a/test/e2e/fixtures/hermes-acp-live.ts +++ b/test/e2e/fixtures/hermes-acp-live.ts @@ -6,6 +6,7 @@ import { setTimeout as sleep } from "node:timers/promises"; import type { ArtifactSink } from "./artifacts.ts"; import type { SandboxClient } from "./clients/sandbox.ts"; import { type ChildProcessProgress, spawnObservedChild } from "./observed-child-process.ts"; +import type { ShellProbeResult } from "./shell-probe.ts"; import { superviseChild } from "../../helpers/process-supervisor.ts"; const ACP_SCENARIO_TIMEOUT_MS = 3 * 60_000; @@ -66,6 +67,24 @@ export function hermesAcpLiveHostEnv(source: NodeJS.ProcessEnv): NodeJS.ProcessE return result; } +export function hermesAcpGatewayStoppedPreconditionPassed(result: ShellProbeResult): boolean { + const text = `${result.stdout}\n${result.stderr}`; + if (result.exitCode === 0) { + return ( + !result.timedOut && + result.signal === null && + /^Status:[ \t]*Disconnected[ \t]*\r?$/imu.test(result.stdout) + ); + } + return ( + !result.timedOut && + result.signal === null && + /client error \(Connect\)/iu.test(text) && + /tcp connect error/iu.test(text) && + /Connection refused \(os error (?:61|111)\)/iu.test(text) + ); +} + export function isAcpResponse(message: unknown, id: number): message is JsonObject { return ( typeof message === "object" && diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index f8fa398c7b5..adc241f9167 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -15,6 +15,7 @@ import { expect, test } from "../fixtures/e2e-test.ts"; import { HERMES_ACP_LIFECYCLE_BUDGET_MS, type HermesAcpLiveScenario, + hermesAcpGatewayStoppedPreconditionPassed, runHermesAcpLiveScenario, } from "../fixtures/hermes-acp-live.ts"; import { @@ -824,8 +825,7 @@ test( timeoutMs: 30_000, }); expect( - stoppedGatewayStatus.exitCode === 0 && - /^Status:[ \t]*Disconnected[ \t]*\r?$/imu.test(stoppedGatewayStatus.stdout), + hermesAcpGatewayStoppedPreconditionPassed(stoppedGatewayStatus), resultText(stoppedGatewayStatus), ).toBe(true); const gatewayRecoveryPassed = await runAcpScenario("gateway-recovery"); diff --git a/test/e2e/support/hermes-acp-live.test.ts b/test/e2e/support/hermes-acp-live.test.ts index e08ce10b0af..bfdf840be15 100644 --- a/test/e2e/support/hermes-acp-live.test.ts +++ b/test/e2e/support/hermes-acp-live.test.ts @@ -14,6 +14,7 @@ import { acpMessageContainsPong, createHermesAcpPromptEvidenceTracker, hermesAcpExchangeEvidencePassed, + hermesAcpGatewayStoppedPreconditionPassed, hermesAcpLiveHostEnv, hermesAcpScenarioTimeoutMs, isAcpResponse, @@ -22,6 +23,83 @@ import { } from "../fixtures/hermes-acp-live.ts"; describe("Hermes ACP live evidence boundary", () => { + const shellResult = ({ + exitCode, + signal = null, + stderr = "", + stdout = "", + timedOut = false, + }: { + exitCode: number; + signal?: NodeJS.Signals | null; + stderr?: string; + stdout?: string; + timedOut?: boolean; + }) => ({ + command: ["openshell", "status"], + exitCode, + signal, + timedOut, + stdout, + stderr, + artifacts: { stdout: "", stderr: "", result: "" }, + }); + + it("recognizes the OpenShell 0.0.116 stopped-gateway response (#10947)", () => { + expect( + hermesAcpGatewayStoppedPreconditionPassed( + shellResult({ + exitCode: 1, + stderr: + "Error: × client error (Connect)\n ├─▶ tcp connect error\n ╰─▶ Connection refused (os error 111)\n", + }), + ), + ).toBe(true); + expect( + hermesAcpGatewayStoppedPreconditionPassed( + shellResult({ exitCode: 0, stdout: "Status: Disconnected\nGateway: nemoclaw\n" }), + ), + ).toBe(true); + expect( + hermesAcpGatewayStoppedPreconditionPassed( + shellResult({ + exitCode: 0, + stdout: "Status: Disconnected\nGateway: nemoclaw\n", + timedOut: true, + }), + ), + ).toBe(false); + expect( + hermesAcpGatewayStoppedPreconditionPassed( + shellResult({ + exitCode: 0, + signal: "SIGTERM", + stdout: "Status: Disconnected\nGateway: nemoclaw\n", + }), + ), + ).toBe(false); + expect( + hermesAcpGatewayStoppedPreconditionPassed( + shellResult({ exitCode: 1, stderr: "Error: permission denied\n" }), + ), + ).toBe(false); + expect( + hermesAcpGatewayStoppedPreconditionPassed( + shellResult({ exitCode: 1, stderr: "Connection refused", timedOut: true }), + ), + ).toBe(false); + expect( + hermesAcpGatewayStoppedPreconditionPassed( + shellResult({ + exitCode: 1, + signal: "SIGTERM", + stderr: + "Error: × client error (Connect)\n ├─▶ tcp connect error\n ╰─▶ Connection refused (os error 111)\n", + }), + ), + ).toBe(false); + }); + it.each(["installed", "checkout"] as const)( "initializes through the %s adapter", async (installation) => {