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
160 changes: 160 additions & 0 deletions src/lib/actions/sandbox/rebuild-target-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("../../onboard/gateway-provider-metadata")>();
return { ...actual, readGatewayProviderMetadata: mocks.readGatewayProviderMetadata };
});

vi.mock("./rebuild-onboard-dependencies", () => ({
rebuildOnboardDependencies: {
ensureValidatedWebSearchCredential: mocks.ensureValidatedWebSearchCredential,
},
}));

vi.mock("../../onboard/docker-driver-platform", () => ({
isLinuxDockerDriverGatewayEnabled: mocks.isLinuxDockerDriverGatewayEnabled,
}));
Expand Down Expand Up @@ -124,3 +142,145 @@ 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<typeof vi.fn>; bail: ReturnType<typeof vi.fn> }> {
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");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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

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();
});
});
51 changes: 47 additions & 4 deletions src/lib/actions/sandbox/rebuild-target-runtime.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand All @@ -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";
Expand All @@ -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<boolean> {
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,
Expand Down Expand Up @@ -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 };
}

Expand Down