Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions agents/hermes/policy-permissive.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,15 @@ network_policies:
protocol: rest
enforcement: enforce
access: full
credential_binding:
provider: "{sandboxName}-discord-bridge"
- host: gateway.discord.gg
port: 443
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 @@ -180,6 +184,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
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
91 changes: 90 additions & 1 deletion 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 @@ -225,6 +226,7 @@ let errSpy: MockInstance;
let exitMock: MockInstance;
let promptMock: MockInstance;
let getCredentialMock: MockInstance;
let saveCredentialMock: MockInstance;
let updateSandboxMock: MockInstance;
let upsertMock: MockInstance;
let runOpenshellMock: MockInstance;
Expand Down Expand Up @@ -274,6 +276,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 @@ -322,7 +325,7 @@ beforeEach(() => {
// Credentials store: staged token (no real prompt) + controllable prompt.
getCredentialMock = vi.spyOn(store, "getCredential").mockReturnValue(null);
promptMock = vi.spyOn(store, "prompt").mockResolvedValue("");
vi.spyOn(store, "saveCredential").mockImplementation(() => undefined);
saveCredentialMock = vi.spyOn(store, "saveCredential").mockImplementation(() => undefined);

// Agent gate: OpenClaw support is derived from channel manifests.
vi.spyOn(defs, "loadAgent").mockReturnValue(agentFixture("openclaw"));
Expand Down Expand Up @@ -523,6 +526,92 @@ 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 },
);
});

it("does not remove a pre-existing provider after a Hermes Discord identity conflict", async () => {
const originalEntry = { ...makeEmptyEntry("alpha"), agent: "hermes" } as SandboxEntry;
arrangeRegistry({ current: originalEntry });
vi.mocked(defs.loadAgent).mockReturnValue(agentFixture("hermes"));
getCredentialMock.mockImplementation((key: string) =>
key === "DISCORD_BOT_TOKEN" ? DISCORD_TOKEN : null,
);
upsertMock.mockImplementationOnce(() => {
throw Object.assign(
new Error("alpha-discord-bridge does not match the required binding"),
{
code: "NEMOCLAW_MESSAGING_PROVIDER_BINDING_CONFLICT",
mutatedProviderNames: [],
},
);
});

await expect(addSandboxChannel("alpha", { channel: "discord" })).rejects.toThrow(
"process.exit(1)",
);

expect(updateSandboxMock).not.toHaveBeenCalled();
expect(registry.getSandbox("alpha")).toBe(originalEntry);
expect(
runOpenshellMock.mock.calls
.map(([args]) => (args as string[]).join(" "))
.filter((command) => command.includes("provider detach") || command.includes("delete")),
).toEqual([]);
});

it("does not persist a multi-provider add when identity preflight fails", async () => {
const originalEntry = makeEmptyEntry("alpha");
arrangeRegistry({ current: originalEntry });
const slackBot = "xoxb-alpha-slack-bot-token";
const slackApp = "xapp-alpha-slack-app-token";
getCredentialMock.mockImplementation((key: string) =>
key === "SLACK_BOT_TOKEN" ? slackBot : key === "SLACK_APP_TOKEN" ? slackApp : null,
);
upsertMock.mockImplementationOnce(() => {
throw Object.assign(new Error("alpha-slack-app does not match the required binding"), {
code: "NEMOCLAW_MESSAGING_PROVIDER_BINDING_CONFLICT",
mutatedProviderNames: [],
});
});

await expect(addSandboxChannel("alpha", { channel: "slack" })).rejects.toThrow(
"process.exit(1)",
);

expect(upsertMock.mock.calls[0]?.[0]).toHaveLength(2);
expect(saveCredentialMock).not.toHaveBeenCalled();
expect(applyPresetMock).not.toHaveBeenCalled();
expect(updateSandboxMock).not.toHaveBeenCalled();
expect(rebuildSandboxMock).not.toHaveBeenCalled();
expect(registry.getSandbox("alpha")).toBe(originalEntry);
expect(
runOpenshellMock.mock.calls
.map(([args]) => (args as string[]).join(" "))
.filter((command) => command.includes("provider detach") || command.includes("delete")),
).toEqual([]);
});

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

type LegacyOnboardProvidersModule = {
isMessagingProviderBindingConflict(
error: unknown,
): error is Error & { readonly mutatedProviderNames: readonly string[] };
upsertMessagingProviders(
tokenDefs: MessagingProviderTokenDefinition[],
run: typeof runOpenshell,
Expand Down Expand Up @@ -45,6 +49,12 @@ type GooglechatWebhookProxy = Pick<
* onboarding and rebuild modules at policy-channel import time.
*/
export const policyChannelDependencies = {
isMessagingProviderBindingConflict(
error: unknown,
): error is Error & { readonly mutatedProviderNames: readonly string[] } {
const providers = require("../../onboard/providers") as LegacyOnboardProvidersModule;
return providers.isMessagingProviderBindingConflict(error);
},
upsertMessagingProviders(
tokenDefs: MessagingProviderTokenDefinition[],
options?: MessagingProviderUpsertOptions,
Expand Down
30 changes: 22 additions & 8 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,13 +901,24 @@ 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: ${
err instanceof Error ? err.message : String(err)
}`,
);
if (policyChannelDependencies.isMessagingProviderBindingConflict(err)) {
if (err.mutatedProviderNames.length > 0) {
console.error(
` ${YW}⚠${R} Provider state changed before the identity conflict; inspect ${err.mutatedProviderNames.join(", ")} before retrying.`,
);
}
process.exit(1);
}
const teardown = await applyChannelRemoveToGatewayAndRegistry(
sandboxName,
channelName,
Expand Down Expand Up @@ -1435,12 +1450,10 @@ async function addSandboxChannelUnlocked(
const existing = getCredential(key);
if (existing != null) priorCreds[key] = existing;
}
persistChannelTokens(acquired);
// Push to the gateway and update the registry NOW so that answering
// "rebuild later" (or running non-interactively) does not silently
// discard the change. Pre-fix this was safe because saveCredential()
// wrote credentials.json; with env-only persistence, exiting before
// the rebuild used to drop the queued token.
// Register every provider before credentials or durable channel state are
// saved. Exact-binding preflight can then reject the complete set without
// leaving a partial add behind. Credentials still persist before the policy
// and rebuild steps, so choosing "rebuild later" keeps the queued token.
const registeredBridge = await applyChannelAddToGatewayAndRegistry(
sandboxName,
canonical,
Expand All @@ -1449,6 +1462,7 @@ async function addSandboxChannelUnlocked(
if (registeredBridge) {
console.log(` ${G}✓${R} Registered ${canonical} bridge with the OpenShell gateway.`);
}
persistChannelTokens(acquired);

if (
!applyChannelPresetIfAvailable(sandboxName, canonical, "add", {
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
Loading
Loading