From a92c71021a1833afc9c31a40c43399c38b3fc81f Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Tue, 14 Jul 2026 21:37:54 +0800 Subject: [PATCH 1/2] fix(onboard): treat an unreachable custom endpoint as a transport failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Choosing "Other OpenAI-compatible endpoint" and entering a DNS-unreachable base URL failed the SSRF preflight and silently looped back to provider selection: no probe guidance, no retry/back/exit prompt, and no clean exit. The credential-failure and other transport-failure paths already offer that recovery, but the preflight built a synthetic failure with curlStatus 0, so it classified as "unknown" and fell through to the silent selection loop. When the preflight fails because the host does not resolve (an unreachable or non-existent endpoint), mark the synthetic failure with curl's "could not resolve host" status (6). It then routes through the existing transport recovery — a DNS/VPN/endpoint-URL hint plus a retry/back/exit prompt with a clean exit — matching the credential path. A private-IP SSRF block keeps status 0 (it resolved fine; the address is just refused), so its behavior is unchanged. Fixes #6854 Co-Authored-By: Claude Opus 4.8 Signed-off-by: Yanyun Liao --- .../inference-selection-validation.test.ts | 34 +++++++++++++++++++ .../onboard/inference-selection-validation.ts | 17 ++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard/inference-selection-validation.test.ts b/src/lib/onboard/inference-selection-validation.test.ts index 7d4c6ba37b5..e3d2317a2e0 100644 --- a/src/lib/onboard/inference-selection-validation.test.ts +++ b/src/lib/onboard/inference-selection-validation.test.ts @@ -160,6 +160,40 @@ 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" })); + const promptValidationRecovery = vi.fn(async () => "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(); + const recovery = promptValidationRecovery.mock.calls[0]?.[1] as { kind?: string }; + expect(recovery.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: "", }, ], From e775de722947c0ade4335643ad63b0ad25bbc9a7 Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Tue, 14 Jul 2026 21:46:58 +0800 Subject: [PATCH 2/2] test(onboard): capture recovery via closure to satisfy strict tuple typecheck The #6854 regression test read `promptValidationRecovery.mock.calls[0][1]`, which fails typecheck:cli (tsconfig.cli.json includes tests): the zero-arg mock's call tuple has no index 1. Capture the recovery argument through a typed closure instead. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Yanyun Liao --- src/lib/onboard/inference-selection-validation.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard/inference-selection-validation.test.ts b/src/lib/onboard/inference-selection-validation.test.ts index e3d2317a2e0..a05e90a614f 100644 --- a/src/lib/onboard/inference-selection-validation.test.ts +++ b/src/lib/onboard/inference-selection-validation.test.ts @@ -162,7 +162,11 @@ 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" })); - const promptValidationRecovery = vi.fn(async () => "retry" as const); + 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, @@ -186,8 +190,7 @@ describe("inference selection validation", () => { // receives a transport classification (DNS/VPN/URL hint + retry/back/exit), // not the silent selection loop the private-IP path takes. expect(promptValidationRecovery).toHaveBeenCalled(); - const recovery = promptValidationRecovery.mock.calls[0]?.[1] as { kind?: string }; - expect(recovery.kind).toBe("transport"); + expect(capturedRecovery?.kind).toBe("transport"); expect(probeOpenAiLikeEndpoint).not.toHaveBeenCalled(); } finally { error.mockRestore();