Skip to content
Draft
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
2 changes: 1 addition & 1 deletion src/lib/actions/sandbox/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import {
toMessagingAgentId,
} from "../../messaging";
import type { SandboxMessagingPlan } from "../../messaging/manifest";
import { pruneDisabledMessagingPolicyPresets } from "../../onboard/messaging-policy-presets";
import { pruneDisabledMessagingPolicyPresets } from "../../messaging/applier/policy-presets";
import {
captureSandboxListWithGatewayRecovery,
printSandboxListFailureWithRecoveryContext,
Expand Down
1 change: 1 addition & 0 deletions src/lib/messaging/applier/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export * from "./host-state-applier";
export * from "./agent-config";
export * from "./openshell-provider";
export * from "./policy";
export * from "./policy-presets";
export type * from "./types";
150 changes: 150 additions & 0 deletions src/lib/messaging/applier/policy-presets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";

import { createChannelManifestRegistry, type ChannelManifest } from "../manifest";
import {
ALL_MESSAGING_POLICY_PRESET_NAMES,
filterActiveMessagingPresets,
hasDisabledMessagingPreset,
messagingPolicyKeysByChannel,
pruneDisabledMessagingPolicyPresets,
requiredMessagingChannelPolicyPresets,
} from "./policy-presets";

function manifest(id: string, policyPresets: ChannelManifest["policyPresets"]): ChannelManifest {
return {
schemaVersion: 1,
id,
displayName: id,
supportedAgents: ["openclaw", "hermes"],
auth: { mode: "token-paste" },
inputs: [],
credentials: [],
policyPresets,
render: [],
state: {},
hooks: [],
};
}

describe("pruneDisabledMessagingPolicyPresets", () => {
it("removes policy presets for disabled messaging channels", () => {
expect(pruneDisabledMessagingPolicyPresets(["npm", "slack", "pypi"], [" Slack "])).toEqual([
"npm",
"pypi",
]);
});

it("removes telegram preset when telegram channel is disabled", () => {
expect(
pruneDisabledMessagingPolicyPresets(["telegram", "npm", "pypi"], ["telegram"]),
).toEqual(["npm", "pypi"]);
});

it("removes discord preset when discord channel is disabled", () => {
expect(pruneDisabledMessagingPolicyPresets(["discord", "npm"], ["discord"])).toEqual(["npm"]);
});

it("removes wechat preset when wechat channel is disabled", () => {
expect(pruneDisabledMessagingPolicyPresets(["wechat", "npm"], ["wechat"])).toEqual(["npm"]);
});

it("removes whatsapp preset when whatsapp channel is disabled", () => {
expect(pruneDisabledMessagingPolicyPresets(["whatsapp", "npm"], ["whatsapp"])).toEqual(["npm"]);
});

it("preserves presets for non-messaging same-named items", () => {
expect(pruneDisabledMessagingPolicyPresets(["npm", "pypi"], ["npm"])).toEqual(["npm", "pypi"]);
});

it("returns the original list unchanged when no channels are disabled", () => {
expect(pruneDisabledMessagingPolicyPresets(["npm", "slack"], null)).toEqual(["npm", "slack"]);
});
});

describe("requiredMessagingChannelPolicyPresets", () => {
it("derives fallback channel presets from manifests", () => {
const registry = createChannelManifestRegistry([
manifest("matrix", [{ name: "matrix-policy", policyKeys: ["matrix_bridge"] }]),
]);

expect(requiredMessagingChannelPolicyPresets(["matrix"], registry)).toEqual(["matrix-policy"]);
expect(pruneDisabledMessagingPolicyPresets(["npm", "matrix-policy"], ["matrix"], registry)).toEqual([
"npm",
]);
});

it("derives agent policy keys from manifest aliases", () => {
const registry = createChannelManifestRegistry([
manifest("matrix", [
{
name: "matrix-policy",
policyKeys: ["matrix_default"],
agentPolicyKeys: { hermes: ["matrix_hermes"] },
},
]),
]);

expect(messagingPolicyKeysByChannel("hermes", registry)).toEqual({
matrix: ["matrix_hermes"],
});
});
});

describe("ALL_MESSAGING_POLICY_PRESET_NAMES", () => {
it("includes all messaging channel presets", () => {
expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("slack")).toBe(true);
expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("telegram")).toBe(true);
expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("discord")).toBe(true);
expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("wechat")).toBe(true);
expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("whatsapp")).toBe(true);
});

it("does not include non-messaging presets", () => {
expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("npm")).toBe(false);
expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("pypi")).toBe(false);
expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("brave")).toBe(false);
});
});

describe("hasDisabledMessagingPreset", () => {
it("returns true when a messaging preset is not in the active set", () => {
expect(hasDisabledMessagingPreset(["slack", "npm"], new Set())).toBe(true);
});

it("returns false when all messaging presets are in the active set", () => {
expect(hasDisabledMessagingPreset(["slack", "npm"], new Set(["slack"]))).toBe(false);
});

it("returns false for non-messaging presets not in the active set", () => {
expect(hasDisabledMessagingPreset(["npm", "pypi"], new Set())).toBe(false);
});

it("detects stale telegram preset", () => {
expect(hasDisabledMessagingPreset(["telegram", "npm"], new Set())).toBe(true);
});
});

describe("filterActiveMessagingPresets", () => {
it("keeps non-messaging presets regardless of plan", () => {
expect(filterActiveMessagingPresets(["npm", "pypi"], new Set())).toEqual(["npm", "pypi"]);
});

it("removes messaging presets absent from the plan", () => {
expect(filterActiveMessagingPresets(["npm", "slack", "telegram"], new Set(["slack"]))).toEqual([
"npm",
"slack",
]);
});

it("retains messaging presets present in the plan", () => {
const active = new Set(["slack", "telegram"]);
expect(filterActiveMessagingPresets(["slack", "telegram", "npm"], active)).toEqual([
"slack",
"telegram",
"npm",
]);
});
});
127 changes: 127 additions & 0 deletions src/lib/messaging/applier/policy-presets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { createBuiltInChannelManifestRegistry } from "../channels";
import type {
ChannelManifest,
ChannelManifestRegistry,
ChannelPolicyPresetReference,
ChannelPolicyPresetSpec,
MessagingAgentId,
} from "../manifest";

const BUILT_IN_CHANNEL_MANIFEST_REGISTRY = createBuiltInChannelManifestRegistry();

function normalizePolicyPreset(preset: ChannelPolicyPresetReference): ChannelPolicyPresetSpec {
return typeof preset === "string" ? { name: preset } : preset;
}

function manifestPolicyPresetNames(manifest: ChannelManifest): string[] {
return (manifest.policyPresets ?? []).map((preset) => normalizePolicyPreset(preset).name);
}

function manifestPolicyKeys(manifest: ChannelManifest, agent: MessagingAgentId): string[] {
return (manifest.policyPresets ?? []).flatMap((preset) => {
const policy = normalizePolicyPreset(preset);
return [...(policy.agentPolicyKeys?.[agent] ?? policy.policyKeys ?? [policy.name])];
});
}

function getRegistry(registry?: ChannelManifestRegistry | null): ChannelManifestRegistry {
return registry ?? BUILT_IN_CHANNEL_MANIFEST_REGISTRY;
}

/** All preset names that any messaging channel can require. */
export const ALL_MESSAGING_POLICY_PRESET_NAMES: ReadonlySet<string> = new Set(
getRegistry()
.list()
.flatMap((manifest) => manifestPolicyPresetNames(manifest)),
);

function normalizedNames(values: string[] | null | undefined): string[] {
if (!Array.isArray(values)) return [];
const names: string[] = [];
for (const value of values) {
if (typeof value !== "string") continue;
const name = value.trim().toLowerCase();
if (!name || names.includes(name)) continue;
names.push(name);
}
return names;
}

export function requiredMessagingChannelPolicyPresets(
channels: string[] | null | undefined,
registry?: ChannelManifestRegistry | null,
): string[] {
const manifestRegistry = getRegistry(registry);
const required: string[] = [];
for (const channel of normalizedNames(channels)) {
const manifest = manifestRegistry.get(channel);
if (!manifest) continue;
for (const preset of manifestPolicyPresetNames(manifest)) {
if (!required.includes(preset)) required.push(preset);
}
}
return required;
}

export function messagingPolicyKeysByChannel(
agent: MessagingAgentId,
registry?: ChannelManifestRegistry | null,
): Record<string, string[]> {
const result: Record<string, string[]> = {};
for (const manifest of getRegistry(registry).listAvailable({ agent })) {
const keys = manifestPolicyKeys(manifest, agent);
if (keys.length > 0) result[manifest.id] = keys;
}
return result;
}

/**
* Removes from selectedPresets any preset exclusively required by a disabled
* channel. Used when restoring presets from backup manifests where no compiled
* plan is available. For plan-aware paths, use getPolicyPresetsFromPlan which
* derives presets from enabled plan entries only.
*/
export function pruneDisabledMessagingPolicyPresets(
selectedPresets: string[],
disabledChannels: string[] | null | undefined,
registry?: ChannelManifestRegistry | null,
): string[] {
const disabledRequiredPresets = new Set(
requiredMessagingChannelPolicyPresets(disabledChannels, registry),
);
if (disabledRequiredPresets.size === 0) return selectedPresets;
return selectedPresets.filter(
(preset) => !disabledRequiredPresets.has(preset.trim().toLowerCase()),
);
}

/**
* Returns true if any preset in the list is a messaging policy preset that is
* absent from the active plan preset set. Used to detect stale messaging
* presets still applied in the gateway after a channel stop/disable.
*/
export function hasDisabledMessagingPreset(
presets: readonly string[],
activePlanPresets: ReadonlySet<string>,
): boolean {
return presets.some(
(preset) => ALL_MESSAGING_POLICY_PRESET_NAMES.has(preset) && !activePlanPresets.has(preset),
);
}

/**
* Filters a preset list to retain only non-messaging presets and messaging
* presets that appear in the active plan preset set. Used to exclude stale
* gateway presets from the resume/preservation set.
*/
export function filterActiveMessagingPresets(
presets: readonly string[],
activePlanPresets: ReadonlySet<string>,
): string[] {
return presets.filter(
(name) => !ALL_MESSAGING_POLICY_PRESET_NAMES.has(name) || activePlanPresets.has(name),
);
}
8 changes: 7 additions & 1 deletion src/lib/messaging/channels/telegram/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,13 @@ export const telegramManifest = {
placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN",
},
],
policyPresets: [{ name: "telegram", policyKeys: ["telegram_bot"] }],
policyPresets: [
{
name: "telegram",
policyKeys: ["telegram_bot"],
agentPolicyKeys: { hermes: ["telegram"] },
},
],
render: [
{
id: "telegram-openclaw-account",
Expand Down
6 changes: 6 additions & 0 deletions src/lib/messaging/compiler/manifest-compiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,12 @@ describe("ManifestCompiler", () => {
policyKeys: ["wechat_bridge"],
source: "manifest",
});
expect(plan.networkPolicy.entries.find((entry) => entry.channelId === "telegram")).toEqual({
channelId: "telegram",
presetName: "telegram",
policyKeys: ["telegram"],
source: "agent-alias",
});
expect(plan.agentRender.map((render) => `${render.channelId}:${render.target}`)).toEqual([
"telegram:~/.hermes/.env",
"telegram:~/.hermes/config.yaml",
Expand Down
12 changes: 9 additions & 3 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,10 @@ import {
setupHermesToolGateways,
stringSetsEqual,
} from "./onboard/hermes-managed-tools";
import { mergePolicyMessagingChannels } from "./onboard/messaging-policy-presets";
import {
getEnabledChannelIdsFromPlan,
getPolicyPresetsFromPlan,
} from "./onboard/messaging-plan-session";
import {
filterEnabledChannelsByAgent,
resolveQrSelectedChannels,
Expand Down Expand Up @@ -2725,6 +2728,7 @@ async function createSandbox(
sandboxGpuConfig: SandboxGpuConfig | null = null,
resourceProfile: import("./resources-cmd").ResourceProfile | null = null,
hermesToolGateways: string[] = [],
messagingPlan: import("./messaging/manifest").SandboxMessagingPlan | null = null,
) {
step(6, 8, "Creating sandbox");

Expand Down Expand Up @@ -3346,13 +3350,16 @@ async function createSandbox(
dockerGpuSandboxCreate.resolveDockerGpuSandboxCreatePlan(effectiveSandboxGpuConfig, {
dockerDriverGateway: isLinuxDockerDriverGatewayEnabled(),
});
const activeMessagingPlan = messagingPlan ?? readMessagingPlanFromEnv();
const planEnabledMessagingChannels = getEnabledChannelIdsFromPlan(activeMessagingPlan);
const initialSandboxPolicy = prepareInitialSandboxCreatePolicy(
basePolicyPath,
activeMessagingChannels,
planEnabledMessagingChannels ?? activeMessagingChannels,
{
directGpu: effectiveSandboxGpuConfig.sandboxGpuEnabled,
dockerGpuPatch: useDockerGpuPatch,
additionalPresets: hermesToolGateways,
messagingPolicyPresets: getPolicyPresetsFromPlan(activeMessagingPlan),
},
);
if (initialSandboxPolicy.cleanup) {
Expand Down Expand Up @@ -6548,7 +6555,6 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
deps: {
loadSession: onboardSession.loadSession,
getActiveSandbox: (name) => registry.getSandbox(name),
mergePolicyMessagingChannels,
verifyCompatibleEndpointSandboxSmoke: (options) =>
verifyCompatibleEndpointSandboxSmoke({
...options,
Expand Down
Loading
Loading