diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 99101728417..feb8ece113a 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -66,7 +66,6 @@ const { bestEffortForwardStop } = require("./onboard/forward-cleanup"); const { buildCompatibleEndpointSandboxSmokeCommand, buildCompatibleEndpointSandboxSmokeScript, - shouldRunCompatibleEndpointSandboxSmoke, verifyCompatibleEndpointSandboxSmoke, }: typeof import("./onboard/compatible-endpoint-smoke") = require("./onboard/compatible-endpoint-smoke"); const { @@ -3595,7 +3594,6 @@ module.exports = { readRecordedNimContainer, readRecordedEndpointUrl, isInferenceRouteReady, - shouldRunCompatibleEndpointSandboxSmoke, isNonInteractive, isOpenclawReady, arePolicyPresetsApplied, diff --git a/src/lib/onboard/compatible-endpoint-smoke.test.ts b/src/lib/onboard/compatible-endpoint-smoke.test.ts index 1bb57bd8566..6f2f8e23efc 100644 --- a/src/lib/onboard/compatible-endpoint-smoke.test.ts +++ b/src/lib/onboard/compatible-endpoint-smoke.test.ts @@ -22,7 +22,6 @@ import { buildCompatibleEndpointSandboxSmokeCommand, buildCompatibleEndpointSandboxSmokeScript, buildProviderNeutralInferenceSandboxSmokeScript, - shouldRunCompatibleEndpointSandboxSmoke, spawnOutputToString, verifyCompatibleEndpointSandboxSmoke, } from "./compatible-endpoint-smoke"; @@ -255,20 +254,22 @@ time.sleep = lambda seconds: sleep_delays.append(seconds) } describe("compatible endpoint sandbox smoke helpers", () => { - it("runs only for OpenClaw compatible-endpoint sandboxes with messaging", () => { - expect(shouldRunCompatibleEndpointSandboxSmoke("compatible-endpoint", ["telegram"])).toBe(true); - expect( - shouldRunCompatibleEndpointSandboxSmoke("compatible-endpoint", ["telegram"], { - name: "openclaw", - }), - ).toBe(true); - expect( - shouldRunCompatibleEndpointSandboxSmoke("compatible-endpoint", ["telegram"], { - name: "hermes", - }), - ).toBe(false); - expect(shouldRunCompatibleEndpointSandboxSmoke("nvidia-prod", ["telegram"])).toBe(false); - expect(shouldRunCompatibleEndpointSandboxSmoke("compatible-endpoint", [])).toBe(false); + it.each([ + { agent: { name: "hermes" as const }, provider: "compatible-endpoint" }, + { agent: { name: "openclaw" as const }, provider: "nvidia-prod" }, + ])("skips sandbox smoke for $agent.name with $provider", ({ agent, provider }) => { + const runOpenshell = vi.fn(); + + verifyCompatibleEndpointSandboxSmoke({ + sandboxName: "smoke-sandbox", + provider, + model: "nvidia/nemotron-3-ultra", + runOpenshell, + redact: (value) => value, + agent, + }); + + expect(runOpenshell).not.toHaveBeenCalled(); }); it("normalizes spawn output values to strings", () => { @@ -364,6 +365,7 @@ describe("compatible endpoint sandbox smoke helpers", () => { label: "provider-neutral", forceCanonicalRoute: true, provider: "vllm-local", + messagingChannels: [] as string[], expected: ["Provider-neutral inference provider", "inference.local route cannot reach"], unexpected: "Telegram", }, @@ -371,8 +373,17 @@ describe("compatible endpoint sandbox smoke helpers", () => { label: "compatible-endpoint messaging", forceCanonicalRoute: false, provider: "compatible-endpoint", - expected: ["Compatible endpoint provider", "sandbox would start Telegram"], - unexpected: "Provider-neutral inference provider", + messagingChannels: ["telegram"], + expected: ["Compatible endpoint provider", "inference.local route cannot reach"], + unexpected: "Telegram", + }, + { + label: "compatible-endpoint without messaging", + forceCanonicalRoute: false, + provider: "compatible-endpoint", + messagingChannels: [] as string[], + expected: ["Compatible endpoint provider", "inference.local route cannot reach"], + unexpected: "Telegram", }, ])("reports mode-accurate $label provider lookup failures", (testCase) => { const errors: string[] = []; @@ -391,7 +402,7 @@ describe("compatible endpoint sandbox smoke helpers", () => { model: "qwen3.5-9b", runOpenshell: vi.fn().mockReturnValue({ status: 1, stderr: "provider query failed" }), redact: (value) => value, - messagingChannels: ["telegram"], + messagingChannels: testCase.messagingChannels, forceCanonicalRoute: testCase.forceCanonicalRoute, }), ).toThrow("process.exit(1)"); @@ -408,6 +419,51 @@ describe("compatible endpoint sandbox smoke helpers", () => { } }); + it.each([ + { label: "none", messagingChannels: [] as string[] }, + { label: "Telegram", messagingChannels: ["telegram"] }, + ])( + "reports a channel-agnostic sandbox smoke failure for $label messaging (#10405)", + ({ messagingChannels }) => { + const errors: string[] = []; + const error = vi.spyOn(console, "error").mockImplementation((message) => { + errors.push(String(message)); + }); + const exit = vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + const runOpenshell = vi + .fn() + .mockReturnValueOnce({ status: 0, stdout: "provider ready" }) + .mockReturnValueOnce({ status: 1, stderr: "curl exit 7" }); + + try { + expect(() => + verifyCompatibleEndpointSandboxSmoke({ + sandboxName: "no-messaging-sandbox", + provider: "compatible-endpoint", + model: "issue-10405-model", + runOpenshell, + redact: (value) => value, + messagingChannels, + }), + ).toThrow("process.exit(1)"); + + expect(runOpenshell).toHaveBeenCalledTimes(2); + const diagnostics = errors.join("\n"); + expect(diagnostics).toContain("Compatible endpoint sandbox smoke check failed"); + expect(diagnostics).toContain( + "Messaging setup is not the root cause; the sandbox inference.local route failed.", + ); + expect(diagnostics).toContain("curl exit 7"); + expect(diagnostics).not.toContain("Telegram"); + } finally { + exit.mockRestore(); + error.mockRestore(); + } + }, + ); + it.each(providerNeutralCases)( "runs a real provider-neutral $service request inside the $agentName sandbox", ({ agentName, service, provider, port, directHealthPath }) => { diff --git a/src/lib/onboard/compatible-endpoint-smoke.ts b/src/lib/onboard/compatible-endpoint-smoke.ts index cdcf7f789c1..c4a19eeda21 100644 --- a/src/lib/onboard/compatible-endpoint-smoke.ts +++ b/src/lib/onboard/compatible-endpoint-smoke.ts @@ -87,24 +87,6 @@ function nonNegativeInt(value: number | undefined, fallback: number): number { return rounded >= 0 ? rounded : fallback; } -/** - * Returns whether onboarding should validate the compatible endpoint through - * the OpenClaw sandbox instead of only checking host-side configuration. - */ -export function shouldRunCompatibleEndpointSandboxSmoke( - provider: string | null | undefined, - messagingChannels: string[] | null | undefined, - agent: CompatibleEndpointSmokeAgent = null, -): boolean { - const agentName = agent?.name || "openclaw"; - return ( - agentName === "openclaw" && - provider === "compatible-endpoint" && - Array.isArray(messagingChannels) && - messagingChannels.length > 0 - ); -} - /** * Converts child-process output into text for diagnostics without assuming * whether Node returned strings, buffers, nulls, or primitive values. @@ -132,21 +114,20 @@ export function verifyCompatibleEndpointSandboxSmoke(options: { /** Recheck policy authority after the sandbox proof and before success output. */ beforeSuccess?: () => void; }): void { + const agentName = options.agent?.name || "openclaw"; if ( options.forceCanonicalRoute !== true && - !shouldRunCompatibleEndpointSandboxSmoke( - options.provider, - options.messagingChannels, - options.agent, - ) + (agentName !== "openclaw" || options.provider !== "compatible-endpoint") ) { return; } + const hasMessagingChannels = + Array.isArray(options.messagingChannels) && options.messagingChannels.length > 0; console.log( options.forceCanonicalRoute ? " Verifying provider-neutral inference through the sandbox runtime..." - : " Verifying compatible endpoint through the messaging sandbox...", + : " Verifying compatible endpoint through the sandbox runtime...", ); const providerResult = options.runOpenshell(["provider", "get", options.provider], { @@ -168,9 +149,7 @@ export function verifyCompatibleEndpointSandboxSmoke(options: { : ` Compatible endpoint provider '${options.provider}' is missing from the OpenShell gateway.`, ); console.error( - options.forceCanonicalRoute - ? " The sandbox inference.local route cannot reach the selected model provider." - : " The sandbox would start Telegram, but agent turns would fail before reaching the model.", + " The sandbox inference.local route cannot reach the selected model provider.", ); if (providerDetails) { console.error(` ${compactText(options.redact(providerDetails)).slice(0, 800)}`); @@ -235,7 +214,9 @@ export function verifyCompatibleEndpointSandboxSmoke(options: { : " Compatible endpoint sandbox smoke check failed.", ); if (!options.forceCanonicalRoute) { - console.error(" Telegram provider startup is not the root cause; inference.local failed."); + console.error( + " Messaging setup is not the root cause; the sandbox inference.local route failed.", + ); } if (smokeOutput) console.error(` ${compactText(options.redact(smokeOutput)).slice(0, 1200)}`); process.exit(smokeResult.status || 1); diff --git a/src/lib/onboard/machine/handlers/policies.test.ts b/src/lib/onboard/machine/handlers/policies.test.ts index 9e10c83885c..b0012477756 100644 --- a/src/lib/onboard/machine/handlers/policies.test.ts +++ b/src/lib/onboard/machine/handlers/policies.test.ts @@ -65,6 +65,30 @@ describe("handlePoliciesState", () => { }); }); + it("passes an empty messaging selection to the compatible endpoint smoke (#10405)", async () => { + const { deps, calls } = createDeps({ + getActiveSandbox: vi.fn(() => ({ + messaging: null, + policyAuthority: "nemoclaw-managed" as const, + })), + }); + + await handlePoliciesState({ + ...baseOptions(deps), + provider: "compatible-endpoint", + selectedMessagingChannels: [], + }); + + expect(calls.smoke).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "compatible-endpoint", + messagingChannels: [], + agent: null, + }), + ); + expect(calls.complete).toHaveBeenCalledOnce(); + }); + it("uses recorded messaging channels when no active selection exists", async () => { const session = createSession({ messagingPlan: makeMessagingPlan({ channels: ["slack"] }) }); const { deps, calls, setSession } = createDeps({