Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ab59550
test(messaging): cover Hermes Discord credential binding
jyaunches Aug 21, 2026
e7796bb
fix(messaging): bind Hermes Discord credentials
jyaunches Aug 21, 2026
cd9f257
fix(onboard): deduplicate provider cleanup suffixes
jyaunches Aug 21, 2026
c03f880
fix(messaging): verify existing static profiles
jyaunches Aug 21, 2026
2d5c0f6
fix(messaging): replay static provider binding
jyaunches Aug 21, 2026
d9146d4
test(messaging): cover static policy passthrough
jyaunches Aug 21, 2026
777bac5
fix(messaging): fail closed on unresolved policy bindings
jyaunches Aug 21, 2026
b2aa0bd
fix(messaging): enforce static bindings on channel add
jyaunches Aug 21, 2026
6890b5a
fix(messaging): revalidate static profile reuse
jyaunches Aug 21, 2026
031f682
fix(shields): preserve Hermes Discord bindings
jyaunches Aug 21, 2026
ae09374
fix(messaging): preserve conflicting providers
jyaunches Aug 21, 2026
1fbf968
test(shields): prove invalid identity fails before staging
jyaunches Aug 21, 2026
3110720
fix(messaging): preflight provider identities
jyaunches Aug 21, 2026
56f1bd3
test(messaging): align provider preflight fixtures
jyaunches Aug 21, 2026
c085f0a
fix(policy): explain binding materialization failures
jyaunches Aug 21, 2026
c9435f8
fix(providers): scope static profile checks
jyaunches Aug 21, 2026
1f1bac2
merge(main): incorporate MCP prerequisite
jyaunches Aug 21, 2026
18932f0
fix(policy): materialize Hermes permissive binding
jyaunches Aug 21, 2026
2785f69
test(policy): make mode assertion portable
jyaunches Aug 21, 2026
eed1a94
merge: sync main for rebuild doctor timeout
jyaunches Aug 21, 2026
1080ecc
fix(e2e): parse Hermes policy metadata before binding
jyaunches Aug 22, 2026
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
"bin/",
"dist/",
"src/lib/messaging/channels/**/policy/*.{yaml,yml}",
"src/lib/messaging/channels/**/provider-profile/*.{yaml,yml}",
"nemoclaw/dist/",
"nemoclaw/openclaw.plugin.json",
"nemoclaw/package.json",
Expand Down
26 changes: 26 additions & 0 deletions src/lib/actions/sandbox/policy-channel-conflict.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function successfulOpenshellResult(): ReturnType<typeof runtime.runOpenshell> {

const TELEGRAM_TOKEN = "123456:AAH-secret-bot-token-value";
const TELEGRAM_HASH = hashCredential(TELEGRAM_TOKEN) as string;
const DISCORD_TOKEN = "discord-test-token";

// Build a minimal plan-backed SandboxEntry for conflict-detection fixtures.
// Callers supply credential bindings as { providerEnvKey, credentialHash? }.
Expand Down Expand Up @@ -274,6 +275,7 @@ beforeEach(() => {
delete process.env.SLACK_APP_TOKEN;
delete process.env.SLACK_ALLOWED_USERS;
delete process.env.SLACK_ALLOWED_CHANNELS;
delete process.env.DISCORD_BOT_TOKEN;
delete process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY;
delete process.env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION;
delete process.env.WECHAT_BOT_TOKEN;
Expand Down Expand Up @@ -523,6 +525,30 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
expect(updateSandboxMock).toHaveBeenCalledWith("alpha", expect.any(Object));
});

it("registers Hermes Discord with the exact static provider binding", async () => {
arrangeRegistry({
current: { ...makeEmptyEntry("alpha"), agent: "hermes" } as SandboxEntry,
});
vi.mocked(defs.loadAgent).mockReturnValue(agentFixture("hermes"));
getCredentialMock.mockImplementation((key: string) =>
key === "DISCORD_BOT_TOKEN" ? DISCORD_TOKEN : null,
);

await addSandboxChannel("alpha", { channel: "discord" });

expect(upsertMock).toHaveBeenCalledWith(
[
{
name: "alpha-discord-bridge",
envKey: "DISCORD_BOT_TOKEN",
token: DISCORD_TOKEN,
providerType: "discord-hermes-static-v1",
},
],
{ bestEffort: true, requireExactBindings: true },
);
});

// Scenario 6
it("idempotent same-sandbox re-add does not self-conflict", async () => {
arrangeRegistry({
Expand Down
1 change: 1 addition & 0 deletions src/lib/actions/sandbox/policy-channel-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type MessagingProviderTokenDefinition = {
type MessagingProviderUpsertOptions = {
replaceExisting?: boolean;
bestEffort?: boolean;
requireExactBindings?: boolean;
};

type LegacyOnboardProvidersModule = {
Expand Down
11 changes: 9 additions & 2 deletions src/lib/actions/sandbox/policy-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
bridgeProviderNamesForChannel,
bridgeSecretEnvsForChannel,
collectMessagingBridgeTokenDefs,
staticMessagingProviderTypeForChannel,
} from "../../onboard/messaging-bridge-provider";
import { getStoredMessagingChannelConfig } from "../../onboard/messaging-config";
import type { MessagingTokenDef } from "../../onboard/messaging-prep";
Expand Down Expand Up @@ -847,10 +848,13 @@ async function applyChannelAddToGatewayAndRegistry(
channelName: string,
acquired: Record<string, string>,
): Promise<boolean> {
const sandboxAgent = registry.getSandbox(sandboxName)?.agent;
const staticProviderType = staticMessagingProviderTypeForChannel(channelName, sandboxAgent);
const tokenDefs: MessagingTokenDef[] = Object.entries(acquired).map(([envKey, token]) => ({
name: bridgeProviderName(sandboxName, channelName, envKey),
envKey,
token,
...(staticProviderType ? { providerType: staticProviderType } : {}),
}));
// Bridge channels declare no manifest credentials, so the loop above yields
// nothing for them. Their provider must be created HERE (same seam onboarding
Expand All @@ -860,7 +864,7 @@ async function applyChannelAddToGatewayAndRegistry(
sandboxName,
// Unnormalized: the bridge profile filter owns the unset default and rejects
// an agent no profile declares.
agent: registry.getSandbox(sandboxName)?.agent,
agent: sandboxAgent,
enabledChannels: [channelName],
disabledChannelNames: new Set<string>(),
getCredential,
Expand Down Expand Up @@ -897,7 +901,10 @@ async function applyChannelAddToGatewayAndRegistry(
try {
// bestEffort: failures throw (instead of process.exit inside the helper)
// so a partial add can be torn down below before exiting.
policyChannelDependencies.upsertMessagingProviders(tokenDefs, { bestEffort: true });
policyChannelDependencies.upsertMessagingProviders(tokenDefs, {
bestEffort: true,
requireExactBindings: true,
});
} catch (err) {
console.error(
` ✗ Failed to register '${channelName}' providers with the gateway: ${
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe("runSandboxSnapshot restore: baseline exclusions", () => {
expect(f.prepareInitialSandboxCreatePolicyMock).toHaveBeenCalledWith(
"/repo/agents/hermes/policy-additions.yaml",
[],
{ agentName: "hermes", baselineExclusions: [exclusion] },
{ agentName: "hermes", sandboxName: "beta", baselineExclusions: [exclusion] },
);
const createArgs = f.streamSandboxCreateMock.mock.calls[0]?.[1] ?? [];
expect(createArgs[createArgs.indexOf("--policy") + 1]).toBe("/tmp/snapshot-clone-policy.yaml");
Expand Down
8 changes: 6 additions & 2 deletions src/lib/actions/sandbox/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,10 @@ function resolveCloneDashboardEnvArgs(
return envArgs;
}

async function prepareSnapshotClonePolicy(srcEntry: SandboxEntry): Promise<{
async function prepareSnapshotClonePolicy(
srcEntry: SandboxEntry,
targetSandbox: string,
): Promise<{
policyPath: string;
cleanup?: () => boolean;
}> {
Expand All @@ -399,6 +402,7 @@ async function prepareSnapshotClonePolicy(srcEntry: SandboxEntry): Promise<{
const { prepareInitialSandboxCreatePolicy } = await import("../../onboard/initial-policy");
return prepareInitialSandboxCreatePolicy(baseline.policyPath, activeMessagingChannels, {
agentName,
sandboxName: targetSandbox,
baselineExclusions,
});
}
Expand Down Expand Up @@ -1555,7 +1559,7 @@ async function runSnapshotRestoreUnlocked(
const dstDashboardPort = allocateCloneDashboardPort(targetSandbox, lockedSourceEntry);
const dstHermesApiPort = allocateCloneHermesApiPort(targetSandbox, lockedSourceEntry);
const dashboardEnvArgs = resolveCloneDashboardEnvArgs(lockedSourceEntry, dstDashboardPort);
const clonePolicy = await prepareSnapshotClonePolicy(lockedSourceEntry);
const clonePolicy = await prepareSnapshotClonePolicy(lockedSourceEntry, targetSandbox);
try {
if (targetExists) {
if (targetEntry) {
Expand Down
6 changes: 6 additions & 0 deletions src/lib/messaging/channels/discord/policy/hermes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ network_policies:
port: 443
protocol: rest
enforcement: enforce
credential_binding:
provider: "{sandboxName}-discord-bridge"
rules:
- allow: { method: GET, path: "/**" }
- allow: { method: POST, path: "/**" }
Expand All @@ -34,6 +36,8 @@ network_policies:
protocol: websocket
enforcement: enforce
websocket_credential_rewrite: true
credential_binding:
provider: "{sandboxName}-discord-bridge"
rules:
- allow: { method: GET, path: "/**" }
- allow: { method: WEBSOCKET_TEXT, path: "/**" }
Expand All @@ -42,6 +46,8 @@ network_policies:
protocol: websocket
enforcement: enforce
websocket_credential_rewrite: true
credential_binding:
provider: "{sandboxName}-discord-bridge"
rules:
- allow: { method: GET, path: "/**" }
- allow: { method: WEBSOCKET_TEXT, path: "/**" }
Expand Down
19 changes: 19 additions & 0 deletions src/lib/messaging/channels/discord/provider-profile/hermes.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

id: discord-hermes-static-v1
display_name: Discord Bot (Hermes)
description: Endpointless Discord bot credential for sandbox policy binding
category: agent
credentials:
- name: bot_token
description: Discord bot token
env_vars:
- DISCORD_BOT_TOKEN
required: true
auth_style: header
header_name: Authorization
query_param: ''
endpoints: []
binaries: []
inference_capable: false
28 changes: 28 additions & 0 deletions src/lib/messaging/channels/policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,34 @@ describe("messaging channel policy presets", () => {
expect(policy.listMessagingChannelPolicyPresets()).toEqual([]);
});

it("returns policy content unchanged when it has no sandbox placeholder", () => {
const content = "preset:\n name: discord\nnetwork_policies:\n discord: {}\n";
const policy = createPolicyWithFixtures([{ channelId: "discord", presetName: "discord" }], {
discord: content,
});

expect(
policy.loadMessagingChannelPolicyPreset("discord", { sandboxName: "test-sandbox" }),
).toBe(content);
});

it("rejects policy content with an unresolved sandbox placeholder", () => {
const content = [
"preset:",
" name: discord",
"network_policies:",
" discord:",
" credential_binding:",
' provider: "{sandboxName}-discord-bridge"',
"",
].join("\n");
const policy = createPolicyWithFixtures([{ channelId: "discord", presetName: "discord" }], {
discord: content,
});

expect(policy.loadMessagingChannelPolicyPreset("discord")).toBeNull();
});

it("ships a policy file for every manifest-supported agent and preset", () => {
const missing = listBuiltInMessagingChannelManifests().flatMap((manifest) =>
manifest.supportedAgents.flatMap((agent) =>
Expand Down
17 changes: 13 additions & 4 deletions src/lib/messaging/channels/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import fs from "node:fs";
import path from "node:path";
import YAML from "yaml";

import { isValidName } from "../../sandbox-name-contract";
import { ROOT } from "../../state/paths";
import type { MessagingAgentId } from "../manifest";
import { listMessagingPolicyPresetMetadata } from "./metadata";
Expand All @@ -18,6 +19,11 @@ type PolicyPresetMetadataReader = (options: {
readonly agent?: MessagingAgentId;
}) => readonly PolicyPresetLocator[];

export type MessagingChannelPolicyLoadOptions = {
readonly agent?: MessagingAgentId | string | null;
readonly sandboxName?: string;
};

const CHANNELS_ROOT = path.join(ROOT, "src", "lib", "messaging", "channels");
const POLICY_FILE_BY_AGENT: Readonly<Record<MessagingAgentId, string>> = {
openclaw: "openclaw.yaml",
Expand All @@ -39,7 +45,7 @@ export interface MessagingChannelPolicyResolver {
) => string | null;
readonly loadMessagingChannelPolicyPreset: (
presetName: string,
options?: { readonly agent?: MessagingAgentId | string | null },
options?: MessagingChannelPolicyLoadOptions,
) => string | null;
readonly listMessagingChannelPolicyPresets: (options?: {
readonly agent?: MessagingAgentId | string | null;
Expand Down Expand Up @@ -124,13 +130,16 @@ export function createMessagingChannelPolicyResolver(

function loadMessagingChannelPolicyPreset(
presetName: string,
options: { readonly agent?: MessagingAgentId | string | null } = {},
options: MessagingChannelPolicyLoadOptions = {},
): string | null {
const file = resolveMessagingChannelPolicyPresetPath(presetName, options.agent);
if (!file) return null;
const content = deps.readFileSync(file, "utf-8");
const header = readPresetHeader(content);
return header?.name === presetName ? content : null;
if (header?.name !== presetName) return null;
if (!content.includes("{sandboxName}")) return content;
if (options.sandboxName === undefined || !isValidName(options.sandboxName)) return null;
return content.replaceAll("{sandboxName}", options.sandboxName);
}

function listMessagingChannelPolicyPresets(
Expand Down Expand Up @@ -172,7 +181,7 @@ export function resolveMessagingChannelPolicyPresetPath(

export function loadMessagingChannelPolicyPreset(
presetName: string,
options: { readonly agent?: MessagingAgentId | string | null } = {},
options: MessagingChannelPolicyLoadOptions = {},
): string | null {
return defaultPolicyResolver.loadMessagingChannelPolicyPreset(presetName, options);
}
Expand Down
47 changes: 47 additions & 0 deletions src/lib/onboard/checkpoint-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,53 @@ describe("requiredMessagingProviderBindings", () => {

expect(requiredMessagingProviderBindings("my-assistant", disabledPlan)).toEqual([]);
});

it("replaces the generic Hermes Discord binding with the active static profile", () => {
const plan: SandboxMessagingPlan = {
schemaVersion: 1,
sandboxName: "hermes-discord",
agent: "hermes",
workflow: "onboard",
channels: [
{
channelId: "discord",
displayName: "Discord",
authMode: "token-paste",
active: true,
selected: true,
configured: true,
disabled: false,
inputs: [],
hooks: [],
},
],
disabledChannels: [],
credentialBindings: [
{
channelId: "discord",
credentialId: "discordBotToken",
sourceInput: "botToken",
providerName: "hermes-discord-discord-bridge",
providerEnvKey: "DISCORD_BOT_TOKEN",
placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN",
credentialAvailable: true,
},
],
networkPolicy: { presets: [], entries: [] },
agentRender: [],
buildSteps: [],
stateUpdates: [],
healthChecks: [],
};

expect(requiredMessagingProviderBindings("hermes-discord", plan)).toEqual([
{
name: "hermes-discord-discord-bridge",
type: "discord-hermes-static-v1",
credentialEnv: "DISCORD_BOT_TOKEN",
},
]);
});
});

describe("planSandboxCreateReplay never opens a second sandbox (#5961)", () => {
Expand Down
37 changes: 24 additions & 13 deletions src/lib/onboard/checkpoint-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import type {
import { HERMES_TAVILY_PROVIDER_PROFILE_ID } from "./brave-provider-profile";
import type { OnboardMachineState } from "./machine/types";
import { ONBOARD_MACHINE_STATES } from "./machine/types";
import { listMessagingBridgeProfiles } from "./messaging-bridge-provider";
import {
listMessagingBridgeProfiles,
messagingBridgeProfilesForAgent,
staticMessagingProviderTypeForChannel,
} from "./messaging-bridge-provider";

export interface CheckpointedMachineSession {
readonly checkpoint: OnboardCheckpoint | null;
Expand Down Expand Up @@ -140,22 +144,29 @@ export function requiredMessagingProviderBindings(
): CheckpointProviderBinding[] {
if (!plan) return [];
const activeChannels = new Set(getActiveChannelIdsFromPlan(plan));
const bindings = plan.credentialBindings
.filter((binding) => activeChannels.has(binding.channelId))
.map((binding) => ({
const profiles = messagingBridgeProfilesForAgent(plan.agent, listMessagingBridgeProfiles());
const bindings = new Map<string, CheckpointProviderBinding>();
for (const binding of plan.credentialBindings) {
if (!activeChannels.has(binding.channelId)) continue;
bindings.set(binding.providerName, {
name: binding.providerName,
type: "generic",
type:
staticMessagingProviderTypeForChannel(binding.channelId, plan.agent, profiles) ?? "generic",
credentialEnv: binding.providerEnvKey,
}));
for (const profile of listMessagingBridgeProfiles()) {
if (profile.agent !== plan.agent || !activeChannels.has(profile.channelId)) continue;
bindings.push({
name: `${sandboxName}-${profile.channelId}-bridge`,
type: profile.profileId,
credentialEnv: profile.credentialKey,
});
}
return bindings;
for (const profile of profiles) {
if (!activeChannels.has(profile.channelId)) continue;
const name = `${sandboxName}-${profile.channelId}-bridge`;
const existing = bindings.get(name);
bindings.set(
name,
existing
? { ...existing, type: profile.profileId }
: { name, type: profile.profileId, credentialEnv: profile.credentialKey },
);
}
return [...bindings.values()];
}

export interface SandboxCreateObservation {
Expand Down
Loading
Loading