diff --git a/src/lib/onboard/inference-selection-validation.test.ts b/src/lib/onboard/inference-selection-validation.test.ts index 7d4c6ba37b5..a05e90a614f 100644 --- a/src/lib/onboard/inference-selection-validation.test.ts +++ b/src/lib/onboard/inference-selection-validation.test.ts @@ -160,6 +160,43 @@ describe("inference selection validation", () => { } }); + it("routes an unreachable custom endpoint through transport recovery, not a silent loop (#6854)", async () => { + const probeOpenAiLikeEndpoint = vi.fn(() => ({ ok: true, api: "openai-completions" })); + let capturedRecovery: { kind?: string } | undefined; + const promptValidationRecovery = vi.fn(async (_label: string, recovery: { kind?: string }) => { + capturedRecovery = recovery; + return "retry" as const; + }); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const helpers = createInferenceSelectionValidationHelpers({ + isNonInteractive: () => false, + agentProductName: () => "OpenClaw", + getCredential: () => "test-key", + probeOpenAiLikeEndpoint, + promptValidationRecovery, + resolveEndpointHost: async () => { + throw new Error("getaddrinfo ENOTFOUND example.invalid"); + }, + }); + + try { + await helpers.validateCustomOpenAiLikeSelection( + "Custom endpoint", + "https://example.invalid/v1", + "model-a", + "COMPATIBLE_API_KEY", + ); + // A DNS-unreachable endpoint is a transport failure: the recovery prompt + // receives a transport classification (DNS/VPN/URL hint + retry/back/exit), + // not the silent selection loop the private-IP path takes. + expect(promptValidationRecovery).toHaveBeenCalled(); + expect(capturedRecovery?.kind).toBe("transport"); + expect(probeOpenAiLikeEndpoint).not.toHaveBeenCalled(); + } finally { + error.mockRestore(); + } + }); + it.each([ "http://127.0.0.1:8000/v1", "https://inference.local/v1", diff --git a/src/lib/onboard/inference-selection-validation.ts b/src/lib/onboard/inference-selection-validation.ts index 1b90fcc8c85..de86e253921 100644 --- a/src/lib/onboard/inference-selection-validation.ts +++ b/src/lib/onboard/inference-selection-validation.ts @@ -162,15 +162,26 @@ export function createInferenceSelectionValidationHelpers( // the probe could otherwise rebind to a private/internal address after this // public preflight (TOCTOU — cv review, #6293). if (preflight.ok) return { pinnedAddresses: preflight.addresses }; + const reason = preflight.reason ?? "endpoint resolves to a private/internal address"; + // A preflight failure because the host does not resolve (an unreachable / + // non-existent endpoint) is a transport failure, not an endpoint-policy + // rejection. Mark it with curl's "could not resolve host" status (6) so it + // routes through the transport recovery path — a DNS/VPN/endpoint-URL hint + // plus a retry/back/exit prompt — instead of silently looping back to + // provider selection. A private-IP SSRF block keeps status 0: it resolved + // fine, the address is just refused. (#6854) + const unresolvedHost = /cannot resolve endpoint host|did not resolve to any address/i.test( + reason, + ); const syntheticProbe = { ok: false as const, - message: preflight.reason, + message: reason, failures: [ { name: "SSRF preflight", httpStatus: 0, - curlStatus: 0, - message: preflight.reason ?? "endpoint resolves to a private/internal address", + curlStatus: unresolvedHost ? 6 : 0, + message: reason, body: "", }, ],