From cc89a7d6fe701845b00f379902c0cfa2888c9fc9 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 13 Aug 2026 22:59:14 -0700 Subject: [PATCH 1/4] fix(onboard): parse portable runtime info as JSON --- .../gateway-sandbox-reachability.test.ts | 44 +++++++++++++++++++ .../onboard/gateway-sandbox-reachability.ts | 38 +++++++++++----- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/src/lib/onboard/gateway-sandbox-reachability.test.ts b/src/lib/onboard/gateway-sandbox-reachability.test.ts index 67a04bf73f2..2d241416138 100644 --- a/src/lib/onboard/gateway-sandbox-reachability.test.ts +++ b/src/lib/onboard/gateway-sandbox-reachability.test.ts @@ -123,6 +123,50 @@ describe("isSandboxBridgeGatewayReachable", () => { expect(result.detail).toContain("not found"); }); + it.each([ + ["Docker", '{"ServerVersion":"29.7.0"}'], + ["Podman", '{"version":{"Version":"5.7.0"}}'], + ])( + "accepts %s JSON when the portable runtime is reachable but its network is not inspectable", + async (_runtime, stdout) => { + vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); + const runtimeProbeImpl = vi.fn(() => ({ status: 0, stdout })); + + const result = await isSandboxBridgeGatewayReachable({ + inspectNetworkImpl: () => undefined, + runtimeProbeImpl, + timeoutSec: 7, + usesHostGatewayRouteImpl: () => false, + }); + + expect(result).toMatchObject({ + ok: false, + reason: "probe_unavailable", + networkName: "openshell-docker", + }); + expect(runtimeProbeImpl).toHaveBeenCalledWith(["info", "--format", "{{json .}}"], 17_000); + }, + ); + + it.each(["", "not JSON", "{}"])( + "rejects an exit-zero portable runtime response without valid daemon JSON: %j", + async (stdout) => { + vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); + + const result = await isSandboxBridgeGatewayReachable({ + inspectNetworkImpl: () => undefined, + runtimeProbeImpl: () => ({ status: 0, stdout }), + usesHostGatewayRouteImpl: () => false, + }); + + expect(result).toMatchObject({ + ok: false, + reason: "docker_daemon_unreachable", + networkName: "openshell-docker", + }); + }, + ); + it("classifies an unavailable portable daemon before route inspection completes", async () => { vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); const runtimeProbeImpl = vi.fn(() => ({ diff --git a/src/lib/onboard/gateway-sandbox-reachability.ts b/src/lib/onboard/gateway-sandbox-reachability.ts index dbb93fb2032..5ee4a4a7f3e 100644 --- a/src/lib/onboard/gateway-sandbox-reachability.ts +++ b/src/lib/onboard/gateway-sandbox-reachability.ts @@ -15,6 +15,7 @@ import os from "node:os"; import { dockerCapture, dockerRun } from "../adapters/docker/run"; import { failLine, warnLine } from "../cli/terminal-style"; import { GATEWAY_PORT } from "../core/ports"; +import { parseDockerDaemonObservation } from "../domain/docker-host"; import { cliDisplayName, cliName } from "./branding"; import { isPortableExperimentalProfile, @@ -94,7 +95,7 @@ export interface SandboxBridgeReachabilityOptions { inspectNetworkImpl?: (networkName: string) => DockerBridgeNetworkInfo | undefined; usesHostGatewayRouteImpl?: () => boolean; - runtimeProbeImpl?: () => SandboxBridgeProbeRunResult; + runtimeProbeImpl?: (args: readonly string[], timeoutMs: number) => SandboxBridgeProbeRunResult; /** Inject a precomputed image-cache result; bypasses real pre-pull. */ ensureImageCachedOverride?: import("./preflight").EnsureProbeImageCachedResult; } @@ -205,11 +206,27 @@ function buildOpenShellDockerRoute( }; } -function outputTail(value: unknown): string | undefined { +function outputText(value: unknown): string | undefined { if (value === undefined || value === null) return undefined; const raw = Buffer.isBuffer(value) ? value.toString("utf8") : String(value); const text = raw.trim(); - return text ? text.slice(-400) : undefined; + return text || undefined; +} + +function outputTail(value: unknown): string | undefined { + return outputText(value)?.slice(-400); +} + +function isReachableRuntimeInfo(result: SandboxBridgeProbeRunResult): boolean { + if (result.status !== 0) return false; + const stdout = outputText(result.stdout); + if (!stdout) return false; + try { + JSON.parse(stdout); + } catch { + return false; + } + return parseDockerDaemonObservation(stdout).reachable; } function summarizeProbeResult(result: SandboxBridgeProbeRunResult): string { @@ -286,13 +303,7 @@ export async function isSandboxBridgeGatewayReachable( const runImpl = opts.runImpl ?? defaultRunImpl; const portableProfile = isPortableExperimentalProfile(); - const runtimeProbe = - opts.runtimeProbeImpl ?? - (() => - defaultRunImpl( - ["info", "--format", "{{.ServerVersion}}"], - timeoutSec * 1000 + PROBE_RUN_OVERHEAD_MS, - )); + const runtimeProbe = opts.runtimeProbeImpl ?? defaultRunImpl; const network = inspectNetwork(networkName); const route = buildOpenShellDockerRoute( @@ -303,8 +314,11 @@ export async function isSandboxBridgeGatewayReachable( ); if (!route) { if (portableProfile) { - const runtimeResult = runtimeProbe(); - if (runtimeResult.status !== 0) { + const runtimeResult = runtimeProbe( + ["info", "--format", "{{json .}}"], + timeoutSec * 1000 + PROBE_RUN_OVERHEAD_MS, + ); + if (!isReachableRuntimeInfo(runtimeResult)) { return { ok: false, reason: "docker_daemon_unreachable", From 3acaa2a41d13261aa3c37fba54f0a16fb63f8b54 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 13 Aug 2026 23:03:09 -0700 Subject: [PATCH 2/4] fix(launch): reuse qualified agent identity --- .../connect-qualified-session-setup.test.ts | 47 +++++++++++++++++-- src/lib/actions/sandbox/connect.ts | 9 ++-- src/lib/actions/sandbox/launch.test.ts | 27 ++++++++++- src/lib/actions/sandbox/launch.ts | 2 +- 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/lib/actions/sandbox/connect-qualified-session-setup.test.ts b/src/lib/actions/sandbox/connect-qualified-session-setup.test.ts index b9a69e190df..68fd931b142 100644 --- a/src/lib/actions/sandbox/connect-qualified-session-setup.test.ts +++ b/src/lib/actions/sandbox/connect-qualified-session-setup.test.ts @@ -3,13 +3,14 @@ import { describe, expect, it, vi } from "vitest"; +import { loadAgent } from "../../agent/defs"; import type { SandboxEntry } from "../../state/registry"; import { completeInteractiveSessionSetup, completeReadinessQualifiedInteractiveSessionSetup, } from "./connect"; -function entry(agent: string): SandboxEntry { +function entry(agent: string | null): SandboxEntry { return { name: "alpha", agent, @@ -35,20 +36,58 @@ describe("readiness-qualified interactive session setup", () => { it("does not run the complete pairing path for qualified OpenClaw state (#9023)", () => { const runApprovalPass = vi.fn(); - completeReadinessQualifiedInteractiveSessionSetup("alpha", entry("openclaw"), runApprovalPass); + completeReadinessQualifiedInteractiveSessionSetup( + "alpha", + loadAgent("openclaw"), + entry("openclaw"), + runApprovalPass, + ); expect(runApprovalPass).not.toHaveBeenCalled(); }); - it.each(["hermes", "langchain-deepagents-code", "unknown-agent"])( + it("uses the qualified OpenClaw identity for a legacy registry entry (#9023)", () => { + const runApprovalPass = vi.fn(); + + completeReadinessQualifiedInteractiveSessionSetup( + "alpha", + loadAgent("openclaw"), + entry(null), + runApprovalPass, + ); + + expect(runApprovalPass).not.toHaveBeenCalled(); + }); + + it.each(["hermes", "langchain-deepagents-code"])( "keeps the complete session path for %s (#9023)", (agent) => { const runApprovalPass = vi.fn(); - completeReadinessQualifiedInteractiveSessionSetup("alpha", entry(agent), runApprovalPass); + completeReadinessQualifiedInteractiveSessionSetup( + "alpha", + loadAgent(agent), + entry(agent), + runApprovalPass, + ); expect(runApprovalPass).toHaveBeenCalledOnce(); expect(runApprovalPass).toHaveBeenCalledWith("alpha", "nemoclaw"); }, ); + + it("keeps the complete session path when sandbox state is unavailable (#9023)", () => { + const runApprovalPass = vi.fn(); + + completeReadinessQualifiedInteractiveSessionSetup( + "alpha", + loadAgent("openclaw"), + null, + runApprovalPass, + () => "nemoclaw", + ); + + expect(runApprovalPass).toHaveBeenCalledOnce(); + expect(runApprovalPass).toHaveBeenCalledWith("alpha", "nemoclaw"); + }); }); diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts index 4909092860c..6b8eb4b379c 100644 --- a/src/lib/actions/sandbox/connect.ts +++ b/src/lib/actions/sandbox/connect.ts @@ -1224,16 +1224,17 @@ export function completeInteractiveSessionSetup( runApprovalPass(sandboxName, gatewayName); } -/** Preserve non-OpenClaw setup after current OpenClaw pairing qualification. */ +/** Preserve session setup after launch readiness accepts a trusted agent identity. */ export function completeReadinessQualifiedInteractiveSessionSetup( sandboxName: string, + agent: AgentDefinition, sb: SandboxEntry | null, runApprovalPass = runConnectAutoPairApprovalPass, + resolveFallbackGateway = getSandboxTargetGatewayName, ): void { maybeEnsureHermesToolGatewayBroker(sb); - const agentName = String(sb?.agent ?? "").trim(); - if (agentName === "openclaw") return; - const gatewayName = sb ? resolveSandboxGatewayName(sb) : getSandboxTargetGatewayName(sandboxName); + if (sb && agent.name === "openclaw") return; + const gatewayName = sb ? resolveSandboxGatewayName(sb) : resolveFallbackGateway(sandboxName); runApprovalPass(sandboxName, gatewayName); } diff --git a/src/lib/actions/sandbox/launch.test.ts b/src/lib/actions/sandbox/launch.test.ts index 4d8362d96a1..3fcd572dc1a 100644 --- a/src/lib/actions/sandbox/launch.test.ts +++ b/src/lib/actions/sandbox/launch.test.ts @@ -48,7 +48,7 @@ vi.mock("./launch-readiness", () => ({ import { launchSandbox } from "./launch"; -function sandboxEntry(agentName: string): SandboxEntry { +function sandboxEntry(agentName: string | null): SandboxEntry { return { name: "alpha", agent: agentName, @@ -335,6 +335,7 @@ describe("launchSandbox", () => { expect(mocks.printInteractiveSessionHints).toHaveBeenCalledWith("alpha"); expect(mocks.completeReadinessQualifiedInteractiveSessionSetup).toHaveBeenCalledWith( "alpha", + openclaw, sb, ); expect(mocks.completeInteractiveSessionSetup).not.toHaveBeenCalled(); @@ -349,6 +350,29 @@ describe("launchSandbox", () => { expect(launchedCommand()).toEqual(["bash", "-lc", "openclaw tui"]); }); + it("passes the qualified OpenClaw identity for legacy registry state (#9023)", async () => { + const openclaw = loadAgent("openclaw"); + const sb = sandboxEntry(null); + mocks.inspectLaunchReadiness.mockResolvedValue({ + kind: "accepted", + category: "accepted", + agent: openclaw, + sb, + }); + + await launchSandbox("alpha"); + + expect(mocks.prepareInteractiveSession).not.toHaveBeenCalled(); + expect(mocks.completeReadinessQualifiedInteractiveSessionSetup).toHaveBeenCalledWith( + "alpha", + openclaw, + sb, + ); + expect(mocks.completeInteractiveSessionSetup).not.toHaveBeenCalled(); + expect(mocks.publishLaunchReadiness).not.toHaveBeenCalled(); + expect(launchedCommand()).toEqual(["bash", "-lc", "openclaw tui"]); + }); + it("does not mutate after its epoch is replaced by a newer accepted lease (#8942)", async () => { const openclaw = loadAgent("openclaw"); const sb = sandboxEntry("openclaw"); @@ -381,6 +405,7 @@ describe("launchSandbox", () => { ); expect(mocks.completeReadinessQualifiedInteractiveSessionSetup).toHaveBeenCalledWith( "alpha", + openclaw, sb, ); expect(mocks.execSandbox).toHaveBeenCalledOnce(); diff --git a/src/lib/actions/sandbox/launch.ts b/src/lib/actions/sandbox/launch.ts index ebc32171501..b18ba171ef2 100644 --- a/src/lib/actions/sandbox/launch.ts +++ b/src/lib/actions/sandbox/launch.ts @@ -82,7 +82,7 @@ export async function launchSandbox( while (true) { if (decision.kind === "accepted") { printInteractiveSessionHints(sandboxName); - completeReadinessQualifiedInteractiveSessionSetup(sandboxName, decision.sb); + completeReadinessQualifiedInteractiveSessionSetup(sandboxName, decision.agent, decision.sb); session = { agent: decision.agent, sb: decision.sb }; break; } From 0cbf006d35e1abbbe6faeca709fad05663806572 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 13 Aug 2026 23:06:29 -0700 Subject: [PATCH 3/4] fix(onboard): include required name in resume hint --- src/lib/onboard/exit-step-failure.test.ts | 13 +++++++++++-- src/lib/onboard/exit-step-failure.ts | 2 +- src/lib/onboard/resume-hint.test.ts | 19 +++++++++++++++++-- src/lib/onboard/resume-hint.ts | 14 +++++++------- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/lib/onboard/exit-step-failure.test.ts b/src/lib/onboard/exit-step-failure.test.ts index 379ce8f3dbd..692aa120aa5 100644 --- a/src/lib/onboard/exit-step-failure.test.ts +++ b/src/lib/onboard/exit-step-failure.test.ts @@ -104,7 +104,7 @@ describe("terminal step failure helper", () => { complete = false; listeners[0](1); errorSpy.mockRestore(); - expect(errors.join("\n")).toContain("onboard --resume"); + expect(errors.join("\n")).toContain("onboard --resume --name "); expect(errors.join("\n")).toContain("onboard --experimental-profile portable --fresh"); const loaded = requireLoadedSession(); @@ -222,7 +222,16 @@ describe("incomplete-onboard --resume backstop (#6003)", () => { it("prints the resume hint when a step was in progress at exit", () => { session.saveSession(session.createSession({ lastStepStarted: "inference" })); - expect(runExitHandler(1)).toContain("onboard --resume"); + expect(runExitHandler(1)).toContain("onboard --resume --name "); + }); + + it("keeps the short resume hint when the sandbox name was recorded", () => { + session.saveSession( + session.createSession({ lastStepStarted: "inference", sandboxName: "alpha" }), + ); + const output = runExitHandler(1); + expect(output).toContain("onboard --resume"); + expect(output).not.toContain("--name "); }); it("stays silent when no step had started", () => { diff --git a/src/lib/onboard/exit-step-failure.ts b/src/lib/onboard/exit-step-failure.ts index c58bf063ddb..b81f9852e25 100644 --- a/src/lib/onboard/exit-step-failure.ts +++ b/src/lib/onboard/exit-step-failure.ts @@ -56,7 +56,7 @@ export function registerIncompleteOnboardExitFailureHandler( // printOnboardResumeHint also self-dedupes against tailored hints. const interrupted = markLastStartedStepFailed(deps, message, true); if (!interrupted) return; - printOnboardResumeHint(portable); + printOnboardResumeHint(portable, undefined, interrupted.sandboxName); }; processLike.once("exit", (code) => { diff --git a/src/lib/onboard/resume-hint.test.ts b/src/lib/onboard/resume-hint.test.ts index 3aa45c0a4ae..1519445c7ce 100644 --- a/src/lib/onboard/resume-hint.test.ts +++ b/src/lib/onboard/resume-hint.test.ts @@ -23,14 +23,29 @@ describe("onboard resume hint", () => { expect(text).toContain("--fresh"); }); + it("includes the required name argument when no sandbox name was recorded", () => { + const lines: string[] = []; + printOnboardResumeHint(false, (message) => lines.push(message), null); + + expect(lines).toContain(" nemoclaw onboard --resume --name "); + }); + + it("keeps the short resume command when the sandbox name was recorded", () => { + const lines: string[] = []; + printOnboardResumeHint(false, (message) => lines.push(message), "alpha"); + + expect(lines).toContain(" nemoclaw onboard --resume"); + expect(lines.join("\n")).not.toContain("--name "); + }); + it("prints portable resume recovery guidance when the portable env is set (#9035)", () => { const prev = process.env[portableEnv]; process.env[portableEnv] = "portable"; try { const lines: string[] = []; - printOnboardResumeHint(undefined, (message) => lines.push(message)); + printOnboardResumeHint(undefined, (message) => lines.push(message), null); const text = lines.join("\n"); - expect(text).toContain("onboard --resume"); + expect(text).toContain("onboard --resume --name "); expect(text).toContain("restored from the checkpoint"); expect(text).toContain("onboard --experimental-profile portable --fresh"); } finally { diff --git a/src/lib/onboard/resume-hint.ts b/src/lib/onboard/resume-hint.ts index e19a27e2f23..3bdf46ecea4 100644 --- a/src/lib/onboard/resume-hint.ts +++ b/src/lib/onboard/resume-hint.ts @@ -4,13 +4,12 @@ import { CLI_NAME } from "../cli/branding"; import { isPortableExperimentalProfile } from "./experimental/portable-profile"; -export function onboardResumeRecoveryCommand(): string { - return `${CLI_NAME} onboard --resume`; +export function onboardResumeRecoveryCommand(sandboxName?: string | null): string { + const nameArg = sandboxName === null ? " --name " : ""; + return `${CLI_NAME} onboard --resume${nameArg}`; } -export function onboardFreshRecoveryCommand( - portable = isPortableExperimentalProfile(), -): string { +export function onboardFreshRecoveryCommand(portable = isPortableExperimentalProfile()): string { return portable ? `${CLI_NAME} onboard --experimental-profile portable --fresh` : `${CLI_NAME} onboard --fresh`; @@ -35,19 +34,20 @@ let resumeHintShown = false; export function printOnboardResumeHint( portable = isPortableExperimentalProfile(), log: (message: string) => void = (message) => console.error(message), + sandboxName?: string | null, ): void { if (resumeHintShown) return; resumeHintShown = true; log(""); if (portable) { log(" Onboarding did not finish. Resume from the step that failed with:"); - log(` ${onboardResumeRecoveryCommand()}`); + log(` ${onboardResumeRecoveryCommand(sandboxName)}`); log(" The portable profile and rootless Podman authority are restored from the checkpoint."); log(" To start over instead, run:"); log(` ${onboardFreshRecoveryCommand(true)}`); } else { log(" Onboarding did not finish. Resume from the step that failed with:"); - log(` ${onboardResumeRecoveryCommand()}`); + log(` ${onboardResumeRecoveryCommand(sandboxName)}`); log(" Completed steps are skipped; pass --fresh instead to start over."); } } From 5fc6746bf34c38d4c93ef72489a07d5de62f83ba Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 13 Aug 2026 23:44:43 -0700 Subject: [PATCH 4/4] fix(onboard): bound runtime probe diagnostics Signed-off-by: Senthil Ravichandran --- .../gateway-sandbox-reachability.test.ts | 26 ++++++++++++++++++- .../onboard/gateway-sandbox-reachability.ts | 18 ++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/gateway-sandbox-reachability.test.ts b/src/lib/onboard/gateway-sandbox-reachability.test.ts index 2d241416138..003ea1f68ae 100644 --- a/src/lib/onboard/gateway-sandbox-reachability.test.ts +++ b/src/lib/onboard/gateway-sandbox-reachability.test.ts @@ -167,6 +167,30 @@ describe("isSandboxBridgeGatewayReachable", () => { }, ); + it("does not expose rejected runtime JSON in the rendered daemon diagnostic", async () => { + vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); + const credential = "https://proxy-user:proxy-secret@proxy.example:8443"; + + const result = await isSandboxBridgeGatewayReachable({ + inspectNetworkImpl: () => undefined, + runtimeProbeImpl: () => ({ + status: 0, + stdout: JSON.stringify({ HttpProxy: credential, ServerVersion: "" }), + }), + usesHostGatewayRouteImpl: () => false, + }); + const message = formatSandboxBridgeUnreachableMessage(result); + + expect(result).toMatchObject({ + ok: false, + reason: "docker_daemon_unreachable", + detail: "Docker-compatible runtime info did not contain a recognized daemon version", + }); + expect(message).not.toContain(credential); + expect(message).not.toContain("proxy-secret"); + expect(message).not.toContain("HttpProxy"); + }); + it("classifies an unavailable portable daemon before route inspection completes", async () => { vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); const runtimeProbeImpl = vi.fn(() => ({ @@ -185,7 +209,7 @@ describe("isSandboxBridgeGatewayReachable", () => { reason: "docker_daemon_unreachable", networkName: "openshell-docker", }); - expect(result.detail).toContain("Cannot connect to Podman"); + expect(result.detail).toBe("Docker-compatible runtime info probe exited with status 1"); expect(runtimeProbeImpl).toHaveBeenCalledOnce(); }); diff --git a/src/lib/onboard/gateway-sandbox-reachability.ts b/src/lib/onboard/gateway-sandbox-reachability.ts index 5ee4a4a7f3e..ac70ac5df7d 100644 --- a/src/lib/onboard/gateway-sandbox-reachability.ts +++ b/src/lib/onboard/gateway-sandbox-reachability.ts @@ -240,6 +240,22 @@ function summarizeProbeResult(result: SandboxBridgeProbeRunResult): string { return details.length > 0 ? details.join(" | ") : "docker run did not complete the probe"; } +function summarizeRuntimeInfoProbeResult(result: SandboxBridgeProbeRunResult): string { + if (isProbeTimeout(result)) { + return "Docker-compatible runtime info probe timed out"; + } + if (result.status === 0) { + return "Docker-compatible runtime info did not contain a recognized daemon version"; + } + if (result.signal) { + return `Docker-compatible runtime info probe ended with signal ${result.signal}`; + } + if (result.status !== null) { + return `Docker-compatible runtime info probe exited with status ${result.status}`; + } + return "Docker-compatible runtime info probe did not complete"; +} + function isNameResolutionFailure(detail: string): boolean { return /bad address|name or service not known|temporary failure in name resolution|could not resolve|getaddrinfo/i.test( detail, @@ -323,7 +339,7 @@ export async function isSandboxBridgeGatewayReachable( ok: false, reason: "docker_daemon_unreachable", networkName, - detail: summarizeProbeResult(runtimeResult), + detail: summarizeRuntimeInfoProbeResult(runtimeResult), }; } }