Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions src/lib/actions/sandbox/connect-qualified-session-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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");
});
});
9 changes: 5 additions & 4 deletions src/lib/actions/sandbox/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
27 changes: 26 additions & 1 deletion src/lib/actions/sandbox/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -335,6 +335,7 @@ describe("launchSandbox", () => {
expect(mocks.printInteractiveSessionHints).toHaveBeenCalledWith("alpha");
expect(mocks.completeReadinessQualifiedInteractiveSessionSetup).toHaveBeenCalledWith(
"alpha",
openclaw,
sb,
);
expect(mocks.completeInteractiveSessionSetup).not.toHaveBeenCalled();
Expand All @@ -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");
Expand Down Expand Up @@ -381,6 +405,7 @@ describe("launchSandbox", () => {
);
expect(mocks.completeReadinessQualifiedInteractiveSessionSetup).toHaveBeenCalledWith(
"alpha",
openclaw,
sb,
);
expect(mocks.execSandbox).toHaveBeenCalledOnce();
Expand Down
2 changes: 1 addition & 1 deletion src/lib/actions/sandbox/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
13 changes: 11 additions & 2 deletions src/lib/onboard/exit-step-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sandbox>");
expect(errors.join("\n")).toContain("onboard --experimental-profile portable --fresh");

const loaded = requireLoadedSession();
Expand Down Expand Up @@ -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 <sandbox>");
});

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 <sandbox>");
});

it("stays silent when no step had started", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/onboard/exit-step-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
70 changes: 69 additions & 1 deletion src/lib/onboard/gateway-sandbox-reachability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,74 @@ 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("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(() => ({
Expand All @@ -141,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();
});

Expand Down
56 changes: 43 additions & 13 deletions src/lib/onboard/gateway-sandbox-reachability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand All @@ -223,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,
Expand Down Expand Up @@ -286,13 +319,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(
Expand All @@ -303,13 +330,16 @@ 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",
networkName,
detail: summarizeProbeResult(runtimeResult),
detail: summarizeRuntimeInfoProbeResult(runtimeResult),
};
}
}
Expand Down
Loading
Loading