From b3d610f91b6ab4a3c4f7af25292a63cddf08a951 Mon Sep 17 00:00:00 2001 From: Shawn Xie Date: Sat, 18 Jul 2026 00:20:09 +0000 Subject: [PATCH 1/2] fix(rebuild): reuse gateway web-search credential in preflight (#7097) The rebuild web-search preflight demanded a host BRAVE_API_KEY / TAVILY_API_KEY even though saveCredential stages web-search keys to the process env only and the OpenShell gateway provider is the durable system of record. A fresh rebuild process therefore failed preflight with 'Brave Search credential is invalid' for a sandbox whose web search works, unless the key was re-exported by hand. Accept the same gateway credential-only provider binding the OpenClaw recreate path already reuses (messaging-prep requiresExactOpenClawProviderBinding), and keep the host-key validation path for staged keys and for agents that never reuse the binding. Signed-off-by: Shawn Xie --- .../sandbox/rebuild-target-runtime.test.ts | 143 ++++++++++++++++++ .../actions/sandbox/rebuild-target-runtime.ts | 51 ++++++- 2 files changed, 190 insertions(+), 4 deletions(-) diff --git a/src/lib/actions/sandbox/rebuild-target-runtime.test.ts b/src/lib/actions/sandbox/rebuild-target-runtime.test.ts index bd0939c6b44..e011a3fabf5 100644 --- a/src/lib/actions/sandbox/rebuild-target-runtime.test.ts +++ b/src/lib/actions/sandbox/rebuild-target-runtime.test.ts @@ -6,15 +6,33 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ detectGpu: vi.fn(), enforceDockerGpuPatchPreserveNetwork: vi.fn(), + ensureValidatedWebSearchCredential: vi.fn(), isDockerDesktopWslRuntime: vi.fn(), isLinuxDockerDriverGatewayEnabled: vi.fn(), preflightRebuildCredentials: vi.fn(), + readGatewayProviderMetadata: vi.fn(), + runOpenshell: vi.fn(), +})); + +vi.mock("../../adapters/openshell/runtime", () => ({ + runOpenshell: mocks.runOpenshell, })); vi.mock("../../inference/nim", () => ({ detectGpu: mocks.detectGpu, })); +vi.mock("../../onboard/gateway-provider-metadata", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readGatewayProviderMetadata: mocks.readGatewayProviderMetadata }; +}); + +vi.mock("./rebuild-onboard-dependencies", () => ({ + rebuildOnboardDependencies: { + ensureValidatedWebSearchCredential: mocks.ensureValidatedWebSearchCredential, + }, +})); + vi.mock("../../onboard/docker-driver-platform", () => ({ isLinuxDockerDriverGatewayEnabled: mocks.isLinuxDockerDriverGatewayEnabled, })); @@ -124,3 +142,128 @@ describe("preflightRebuildTargetRuntime GPU route", () => { expect(bail).not.toHaveBeenCalled(); }); }); + +describe("preflightRebuildTargetRuntime web search credential", () => { + const WEB_SEARCH_TARGET = { + ...TARGET, + durableConfig: { webSearchConfig: { fetchEnabled: true, provider: "brave" } }, + } as unknown as RebuildTargetConfig; + const WEB_SEARCH_ENTRY = { name: "my-assistant", mcp: null } as unknown as RebuildSandboxEntry; + const GATEWAY_BINDING_METADATA = { + name: "my-assistant-brave-search", + type: "brave", + credentialKeys: ["BRAVE_API_KEY"], + configKeys: [], + }; + + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(process, "platform", { ...platformDescriptor, value: "linux" }); + vi.stubEnv("BRAVE_API_KEY", ""); + mocks.detectGpu.mockReturnValue({ + type: "nvidia", + name: "NVIDIA test GPU", + count: 1, + totalMemoryMB: 24_576, + perGpuMB: 24_576, + nimCapable: true, + platform: "linux", + }); + mocks.isLinuxDockerDriverGatewayEnabled.mockReturnValue(true); + mocks.isDockerDesktopWslRuntime.mockReturnValue(false); + mocks.enforceDockerGpuPatchPreserveNetwork.mockResolvedValue(false); + mocks.preflightRebuildCredentials.mockReturnValue(true); + }); + + afterEach(() => { + Object.defineProperty(process, "platform", platformDescriptor); + vi.unstubAllEnvs(); + }); + + async function runPreflight( + target: RebuildTargetConfig = WEB_SEARCH_TARGET, + ): Promise<{ result: unknown; log: ReturnType; bail: ReturnType }> { + const log = vi.fn(); + const bail = vi.fn(); + const result = await preflightRebuildTargetRuntime( + target, + WEB_SEARCH_ENTRY, + RECREATE_OPTIONS, + log, + bail as never, + { skipImagePreflight: true }, + ); + return { result, log, bail }; + } + + it("reuses the gateway-registered web search credential when no host key is staged (#7097)", async () => { + mocks.readGatewayProviderMetadata.mockReturnValue(GATEWAY_BINDING_METADATA); + + const { result, log, bail } = await runPreflight(); + + expect(result).toEqual({ + ok: true, + preparedImage: null, + requiresGatewayProviderReconfigure: false, + }); + expect(mocks.readGatewayProviderMetadata).toHaveBeenCalledWith( + "my-assistant-brave-search", + mocks.runOpenshell, + "nemoclaw", + ); + expect(mocks.ensureValidatedWebSearchCredential).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringContaining("my-assistant-brave-search")); + expect(bail).not.toHaveBeenCalled(); + }); + + it("fails preflight when neither a host key nor a matching gateway binding exists", async () => { + mocks.readGatewayProviderMetadata.mockReturnValue(null); + mocks.ensureValidatedWebSearchCredential.mockRejectedValue( + new Error("Brave Search requires BRAVE_API_KEY or a saved Brave Search credential."), + ); + + const { result, bail } = await runPreflight(); + + expect(result).toEqual({ ok: false }); + expect(mocks.ensureValidatedWebSearchCredential).toHaveBeenCalledWith( + WEB_SEARCH_TARGET.durableConfig.webSearchConfig, + true, + ); + expect(bail).toHaveBeenCalledWith("Brave Search credential preflight failed"); + }); + + it("validates the staged host key instead of reusing the gateway binding", async () => { + vi.stubEnv("BRAVE_API_KEY", "staged-key"); + mocks.ensureValidatedWebSearchCredential.mockResolvedValue("staged-key"); + + const { result, bail } = await runPreflight(); + + expect(result).toEqual({ + ok: true, + preparedImage: null, + requiresGatewayProviderReconfigure: false, + }); + expect(mocks.readGatewayProviderMetadata).not.toHaveBeenCalled(); + expect(mocks.ensureValidatedWebSearchCredential).toHaveBeenCalledOnce(); + expect(bail).not.toHaveBeenCalled(); + }); + + it("keeps the validation path for non-OpenClaw agents that never reuse the binding", async () => { + const hermesTarget = { + ...WEB_SEARCH_TARGET, + durableConfig: { webSearchConfig: { fetchEnabled: true, provider: "tavily" } }, + agentDefinition: { name: "hermes", webSearch: { supported: true, providers: ["tavily"] } }, + } as unknown as RebuildTargetConfig; + mocks.ensureValidatedWebSearchCredential.mockResolvedValue("gateway-side-key"); + + const { result } = await runPreflight(hermesTarget); + + expect(result).toEqual({ + ok: true, + preparedImage: null, + requiresGatewayProviderReconfigure: false, + }); + expect(mocks.readGatewayProviderMetadata).not.toHaveBeenCalled(); + expect(mocks.ensureValidatedWebSearchCredential).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-target-runtime.ts b/src/lib/actions/sandbox/rebuild-target-runtime.ts index 3e7f7b95a30..108c3ca197b 100644 --- a/src/lib/actions/sandbox/rebuild-target-runtime.ts +++ b/src/lib/actions/sandbox/rebuild-target-runtime.ts @@ -1,8 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { runOpenshell } from "../../adapters/openshell/runtime"; +import { getCredential } from "../../credentials/store"; import * as nim from "../../inference/nim"; import { + type WebSearchProvider, webSearchEnvFor, webSearchLabelFor, webSearchProviderForConfig, @@ -12,6 +15,11 @@ import { isLinuxDockerDriverGatewayEnabled } from "../../onboard/docker-driver-p import { enforceDockerGpuPatchPreserveNetwork } from "../../onboard/docker-gpu-local-inference"; import { initialDockerGpuRoute, resolveDockerGpuRoutePlan } from "../../onboard/docker-gpu-route"; import { isDockerDesktopWslRuntime } from "../../onboard/docker-gpu-sandbox-create"; +import { resolveSandboxGatewayName } from "../../onboard/gateway-binding"; +import { + matchesGatewayCredentialOnlyProviderBinding, + readGatewayProviderMetadata, +} from "../../onboard/gateway-provider-metadata"; import { resolveSandboxGpuConfig } from "../../onboard/sandbox-gpu-mode"; import { agentSupportsWebSearchProvider } from "../../onboard/web-search-support"; import { redact } from "../../security/redact"; @@ -22,7 +30,6 @@ import { } from "./rebuild-credential-preflight"; import type { PreparedRebuildImage } from "./rebuild-custom-image-preflight"; import * as rebuildImagePreflight from "./rebuild-custom-image-preflight"; -import type { RebuildDurableConfig } from "./rebuild-durable-config"; import type { RebuildSandboxEntry } from "./rebuild-flow-helpers"; import type { RebuildRecreateOnboardOpts } from "./rebuild-gpu-opt-out"; import { rebuildOnboardDependencies } from "./rebuild-onboard-dependencies"; @@ -31,14 +38,50 @@ import { disposePreparedBuildContext } from "./rebuild-prepared-image-context"; import type { RebuildResumeConfig } from "./rebuild-resume-config"; import type { RebuildTargetConfig } from "./rebuild-target-config"; +/** + * Whether recreate can reuse the web-search credential already registered with + * the sandbox's OpenShell gateway provider. `saveCredential` stages web-search + * keys to the process env only — the gateway provider is the durable system of + * record — so a fresh `rebuild` process can hold no host credential for a + * sandbox whose web search works (#7097). Recreate reuses that gateway binding + * for the OpenClaw agent (messaging-prep `requiresExactOpenClawProviderBinding`), + * so the preflight accepts the same binding instead of demanding a host key + * the recreate will never read. Agents that never reuse the binding, and any + * run with a host key staged, keep the validation path. + */ +function canReuseGatewayWebSearchCredential( + target: RebuildTargetConfig, + sb: RebuildSandboxEntry, + provider: WebSearchProvider, + log: RebuildLog, +): boolean { + if (target.agentDefinition) return false; + const credentialEnv = webSearchEnvFor(provider); + if (getCredential(credentialEnv)) return false; + const providerName = `${sb.name}-${provider}-search`; + const matches = matchesGatewayCredentialOnlyProviderBinding( + readGatewayProviderMetadata(providerName, runOpenshell, resolveSandboxGatewayName(sb)), + { name: providerName, type: provider, credentialKey: credentialEnv }, + ); + if (matches) { + log( + `Web search preflight: reusing the ${provider} credential registered with gateway provider '${providerName}'; no host ${credentialEnv} is required`, + ); + } + return matches; +} + async function preflightRebuildWebSearchCredential( - durableConfig: RebuildDurableConfig, + target: RebuildTargetConfig, + sb: RebuildSandboxEntry, + log: RebuildLog, bail: RebuildBail, ): Promise { - const config = durableConfig.webSearchConfig; + const config = target.durableConfig.webSearchConfig; if (!config) return true; const provider = webSearchProviderForConfig(config); const label = webSearchLabelFor(provider); + if (canReuseGatewayWebSearchCredential(target, sb, provider, log)) return true; try { const credential = await rebuildOnboardDependencies.ensureValidatedWebSearchCredential( config, @@ -186,7 +229,7 @@ export async function preflightRebuildTargetRuntime( preparedImage = customImage.prepared; } try { - if (!(await preflightRebuildWebSearchCredential(target.durableConfig, bail))) { + if (!(await preflightRebuildWebSearchCredential(target, sb, log, bail))) { return { ok: false }; } From f6e61cdb067631230c8e12e002b185628b41e2f1 Mon Sep 17 00:00:00 2001 From: Shawn Xie Date: Sat, 18 Jul 2026 05:02:48 +0000 Subject: [PATCH 2/2] test(rebuild): cover mismatched gateway web-search binding (#7097) CodeRabbit review follow-up on PR #7129: the suite covered the missing-metadata path but not a present-but-mismatched gateway binding. Assert the preflight falls through to credential validation and fails closed when the binding's credential keys do not match the recorded provider. Co-Authored-By: Claude Fable 5 Signed-off-by: Shawn Xie --- .../sandbox/rebuild-target-runtime.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/lib/actions/sandbox/rebuild-target-runtime.test.ts b/src/lib/actions/sandbox/rebuild-target-runtime.test.ts index e011a3fabf5..060ae1e6958 100644 --- a/src/lib/actions/sandbox/rebuild-target-runtime.test.ts +++ b/src/lib/actions/sandbox/rebuild-target-runtime.test.ts @@ -232,6 +232,23 @@ describe("preflightRebuildTargetRuntime web search credential", () => { expect(bail).toHaveBeenCalledWith("Brave Search credential preflight failed"); }); + it("fails closed when the gateway binding does not match the recorded provider (#7097)", async () => { + mocks.readGatewayProviderMetadata.mockReturnValue({ + ...GATEWAY_BINDING_METADATA, + credentialKeys: ["TAVILY_API_KEY"], + }); + mocks.ensureValidatedWebSearchCredential.mockRejectedValue( + new Error("Brave Search credential is unavailable."), + ); + + const { result, bail } = await runPreflight(); + + expect(result).toEqual({ ok: false }); + expect(mocks.readGatewayProviderMetadata).toHaveBeenCalledOnce(); + expect(mocks.ensureValidatedWebSearchCredential).toHaveBeenCalledOnce(); + expect(bail).toHaveBeenCalledWith("Brave Search credential preflight failed"); + }); + it("validates the staged host key instead of reusing the gateway binding", async () => { vi.stubEnv("BRAVE_API_KEY", "staged-key"); mocks.ensureValidatedWebSearchCredential.mockResolvedValue("staged-key");