Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
10 changes: 10 additions & 0 deletions src/lib/onboard/initial-policy-real-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,16 @@ describe("initial sandbox policy real preset merge", () => {
expect(discordBinaries).toContain("/opt/hermes/.venv/bin/python");
expect(discordBinaries).not.toContain("/usr/bin/node");

const boundProviders =
policy.network_policies?.discord?.endpoints
?.map((endpoint) => endpoint.credential_binding?.provider)
.filter(Boolean) ?? [];
expect(boundProviders).toEqual([
"hermes-channel-discord-bridge",
"hermes-channel-discord-bridge",
"hermes-channel-discord-bridge",
]);

const discordRules =
policy.network_policies?.discord?.endpoints
?.find((endpoint) => endpoint.host === "discord.com")
Expand Down
10 changes: 5 additions & 5 deletions src/lib/onboard/messaging-prep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,7 @@ describe("prepareCreateSandboxMessaging", () => {
expect(result.missingBridgeChannels).toEqual(["googlechat"]);
});

it("does not reuse the bridge provider of a disabled channel", () => {
// The matcher would accept this provider, so only the disabled guard can
// keep it out — and a disabled channel is not a missing one either.
it("retains an exact bridge provider while its channel runtime is disabled (#9773)", () => {
const providerMatchesGatewayCredential = vi.fn(() => true);

const result = prepareCreateSandboxMessaging(
Expand All @@ -241,9 +239,11 @@ describe("prepareCreateSandboxMessaging", () => {
}),
);

expect(result.reusableMessagingProviders).toEqual([]);
expect(result.reusableMessagingProviders).toEqual(["demo-googlechat-bridge"]);
expect(result.reusableMessagingChannels).toEqual(["googlechat"]);
expect(result.messagingTokenDefs.map(({ name }) => name)).not.toContain("demo-googlechat-bridge");
expect(result.missingBridgeChannels).toEqual([]);
expect(providerMatchesGatewayCredential).not.toHaveBeenCalled();
expect(providerMatchesGatewayCredential).toHaveBeenCalled();
});

it("reports missing Brave API keys before registering extra placeholder providers", () => {
Expand Down
11 changes: 6 additions & 5 deletions src/lib/onboard/messaging-prep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export function prepareCreateSandboxMessaging(
);
const messagingProviderProfiles = messagingBridgeProfilesForAgent(input.agentName);

const messagingTokenDefs: MessagingTokenDef[] = listMessagingCredentialMetadata()
const messagingCredentialDefs: MessagingTokenDef[] = listMessagingCredentialMetadata()
.map((credential) => ({
name: credential.providerNameTemplate.replaceAll("{sandboxName}", input.sandboxName),
envKey: credential.providerEnvKey,
Expand All @@ -94,8 +94,10 @@ export function prepareCreateSandboxMessaging(
messagingProviderProfiles,
) ?? MESSAGING_CREDENTIAL_PROVIDER_TYPE,
}))
.filter(({ envKey }) => !enabledEnvKeys || enabledEnvKeys.has(envKey))
.filter(({ envKey }) => !disabledEnvKeys.has(envKey));
.filter(({ envKey }) => !enabledEnvKeys || enabledEnvKeys.has(envKey));
const messagingTokenDefs = messagingCredentialDefs.filter(
({ envKey }) => !disabledEnvKeys.has(envKey),
);

const webSearchEnabled = braveProviderProfile.shouldEnableWebSearch(input.webSearchConfig);
const webSearchProvider = webSearch.webSearchProviderForConfig(input.webSearchConfig);
Expand Down Expand Up @@ -178,7 +180,7 @@ export function prepareCreateSandboxMessaging(
const reusableMessagingChannels: string[] = [];

if (input.enabledChannels != null) {
for (const { name, envKey, token, providerType } of messagingTokenDefs) {
for (const { name, envKey, token, providerType } of messagingCredentialDefs) {
if (token) continue;
const channel = input.getMessagingChannelForEnvKey(envKey);
if (!channel || !input.enabledChannels.includes(channel)) continue;
Expand All @@ -205,7 +207,6 @@ export function prepareCreateSandboxMessaging(
for (const profile of bridgeProfiles) {
const channel = profile.channelId;
if (!input.enabledChannels.includes(channel)) continue;
if (disabledChannelNames.has(channel)) continue;
for (const name of bridgeProviderNamesForChannel(input.sandboxName, channel, [profile])) {
if (messagingTokenDefs.some((def) => def.name === name && def.token)) continue;
if (reusableMessagingProviders.includes(name)) continue;
Expand Down
32 changes: 5 additions & 27 deletions src/lib/onboard/sandbox-create-intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,27 +27,6 @@ function filterMessagingProviderRequestsByEnabledChannel(
return requests.filter(({ channel }) => !channel || !disabledChannelNames.has(channel));
}

function resolveTokenProviderChannelMap(
requests: readonly SandboxCreateMessagingProviderRequest[],
): Map<string, string> {
const providerChannels = new Map<string, string>();
for (const { channel, name } of requests) {
if (channel) providerChannels.set(name, channel);
}
return providerChannels;
}

function filterMessagingProvidersByEnabledChannel(
providerNames: string[],
providerChannels: ReadonlyMap<string, string>,
disabledChannelNames: ReadonlySet<string>,
): string[] {
return providerNames.filter((providerName) => {
const channel = providerChannels.get(providerName);
return !channel || !disabledChannelNames.has(channel);
});
}

function resolveActiveMessagingChannels({
channels,
disabledChannelNames,
Expand Down Expand Up @@ -157,7 +136,6 @@ export function resolveSandboxCreateIntent({
messagingProviderRequests,
disabledChannelNames,
);
const providerChannels = resolveTokenProviderChannelMap(messagingProviderRequests);
const activeMessagingChannels = resolveActiveMessagingChannels({
channels,
disabledChannelNames,
Expand All @@ -166,11 +144,11 @@ export function resolveSandboxCreateIntent({
primaryMessagingCredentialEnvKeys,
reusableMessagingChannels,
});
const enabledReusableMessagingProviders = filterMessagingProvidersByEnabledChannel(
[...new Set(reusableMessagingProviders)],
providerChannels,
disabledChannelNames,
);
// A credential-bound policy can outlive an active channel while the channel is
// stopped, Shields is lowered, or a rebuild replays preserved policy state.
// Keep every exact reusable provider attached to the replacement sandbox; the
// disabled plan still suppresses channel startup, render, and runtime effects.
const enabledReusableMessagingProviders = [...new Set(reusableMessagingProviders)];

const normalizedInferenceProvider = inferenceProvider?.trim() || null;

Expand Down
49 changes: 14 additions & 35 deletions src/lib/onboard/sandbox-create-plan-materialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,27 +192,6 @@ export function validateSandboxCreateIntentBindings(
});
}

function resolveProviderChannelMap(
requests: readonly SandboxCreateMessagingProviderRequest[],
): Map<string, string> {
const providerChannels = new Map<string, string>();
for (const { channel, name } of requests) {
if (channel) providerChannels.set(name, channel);
}
return providerChannels;
}

function filterDisabledMessagingProviders(
providerNames: string[],
providerChannels: ReadonlyMap<string, string>,
disabledChannelNames: ReadonlySet<string>,
): string[] {
return providerNames.filter((providerName) => {
const channel = providerChannels.get(providerName);
return !channel || !disabledChannelNames.has(channel);
});
}

/** Materialize policy, route metadata, resources, and providers from a secretless intent. */
export function materializeSandboxCreatePlan({
intent,
Expand Down Expand Up @@ -265,20 +244,20 @@ export function materializeSandboxCreatePlan({
];

runProviderPreDeleteCleanup();
const providerChannels = resolveProviderChannelMap(intent.messagingProviderRequests);
const messagingProviders = filterDisabledMessagingProviders(
[
...new Set([
...upsertMessagingProviders(enabledMessagingTokenDefs, {
replaceExisting: true,
allowedSandboxes: [intent.sandboxName],
}),
...intent.reusableMessagingProviders,
]),
],
providerChannels,
new Set(intent.disabledChannelNames),
);
// Reusable providers hold gateway-side credential authority. Keep their exact
// attachment on the replacement even when the channel runtime is disabled:
// the initial policy can still carry a credential_binding during rebuild or
// Shields transitions, while enabledMessagingTokenDefs continues to suppress
// new provider creation for disabled channels.
const messagingProviders = [
...new Set([
...upsertMessagingProviders(enabledMessagingTokenDefs, {
replaceExisting: true,
allowedSandboxes: [intent.sandboxName],
}),
...intent.reusableMessagingProviders,
]),
];
const createProviders = new Set<string>();
if (intent.inferenceProvider) createProviders.add(intent.inferenceProvider);
for (const provider of messagingProviders) createProviders.add(provider);
Expand Down
71 changes: 69 additions & 2 deletions src/lib/onboard/sandbox-create-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ const selectedSandboxGpuConfig: SandboxGpuCreateConfig = {
sandboxGpuDevice: "nvidia.com/gpu=0",
};

const disabledSandboxGpuConfig: SandboxGpuCreateConfig = {
sandboxGpuEnabled: false,
sandboxGpuDevice: null,
hostGpuDetected: false,
};

afterEach(() => {
vi.unstubAllEnvs();
});
Expand Down Expand Up @@ -150,7 +156,7 @@ describe("resolveSandboxCreateIntent", () => {
expect(JSON.stringify(requests)).not.toContain("telegram-super-secret");
});

it("resolves deterministic serializable intent without execution artifacts", () => {
it("resolves serializable intent and keeps stopped-channel providers attached (#9773)", () => {
const input = {
basePolicyPath: "/repo/policy.yaml",
sandboxName: "sandbox",
Expand Down Expand Up @@ -206,7 +212,10 @@ describe("resolveSandboxCreateIntent", () => {
"sandbox-telegram-bridge",
"sandbox-slack-bridge",
]);
expect(first.reusableMessagingProviders).toEqual(["sandbox-existing-discord"]);
expect(first.reusableMessagingProviders).toEqual([
"sandbox-existing-discord",
"sandbox-slack-bridge",
]);
expect(first.extraProviders).toEqual(["custom-provider"]);
expect(first.staleExtraProviders).toEqual(["stale-provider"]);
expect(first.resourceCreateArgs).toEqual(["--cpu", "4", "--memory", "16Gi"]);
Expand Down Expand Up @@ -237,6 +246,64 @@ describe("resolveSandboxCreateIntent", () => {
expect(JSON.stringify(first)).not.toContain("/tmp/");
});

it("attaches an exact reusable provider while its channel runtime is stopped (#9773)", () => {
const intent = resolveSandboxCreateIntent({
basePolicyPath: "/repo/hermes-policy.yaml",
sandboxName: "sandbox",
channels,
enabledChannels: ["discord"],
disabledChannelNames: new Set(["discord"]),
messagingProviderRequests: [
{
name: "sandbox-discord-bridge",
envKey: "DISCORD_BOT_TOKEN",
providerType: "discord-hermes-static-v1",
credentialConfigured: false,
channel: "discord",
},
],
primaryMessagingCredentialEnvKeys: ["DISCORD_BOT_TOKEN"],
reusableMessagingChannels: [],
reusableMessagingProviders: ["sandbox-discord-bridge"],
extraProviders: [],
hermesToolGateways: [],
sandboxGpuConfig: disabledSandboxGpuConfig,
gpuCreateArgs: [],
gpuRoutePlan: "none",
sandboxGpuLogMessage: null,
agentName: "hermes",
policyTier: "balanced",
});
const upsertMessagingProviders = vi.fn(() => []);

const plan = materializeSandboxCreatePlan({
intent,
fromRef: "/tmp/Dockerfile",
messagingTokenDefs: [
{
name: "sandbox-discord-bridge",
envKey: "DISCORD_BOT_TOKEN",
token: null,
providerType: "discord-hermes-static-v1",
},
],
runProviderPreDeleteCleanup: vi.fn(),
upsertMessagingProviders,
getHermesToolGatewayProviderName: vi.fn(),
prepareInitialSandboxCreatePolicy: vi.fn(() => ({
policyPath: "/tmp/policy.yaml",
appliedPresets: [],
})),
});

expect(upsertMessagingProviders).toHaveBeenCalledWith([], {
replaceExisting: true,
allowedSandboxes: ["sandbox"],
});
expect(plan.messagingProviders).toEqual(["sandbox-discord-bridge"]);
expect(plan.createArgs).toContain("sandbox-discord-bridge");
});

it("keeps the real gateway provider while excluding direct host-local inference policy", () => {
const intent = resolveSandboxCreateIntent({
basePolicyPath: "/repo/policy.yaml",
Expand Down
Loading