From 3e41d49bc4be3b811428b534a279122cddefff14 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 11:49:33 +0700 Subject: [PATCH 1/7] feat(onboard): persist messaging plan in session for resume Stores the compiled SandboxMessagingPlan in the onboard session so that resume runs can restore the plan to env without re-running enrollment hooks (token paste, QR pairing). Fixes the gap where the registry entry lost its `messaging` field on rebuild because the plan was only held in a process env var that didn't survive across process restarts. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/onboard.ts | 4 ++- .../onboard/machine/handlers/sandbox.test.ts | 2 ++ src/lib/onboard/machine/handlers/sandbox.ts | 14 ++++++++ src/lib/onboard/session-updates.ts | 3 ++ src/lib/state/onboard-session.ts | 32 +++++++++++++++++++ 5 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 7c8cffb831..98abbcc278 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -92,7 +92,7 @@ const { const { setupMessagingChannels: setupMessagingChannelsImpl, } = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); -const { MessagingHostStateApplier }: typeof import("./messaging") = require("./messaging"); +const { MessagingHostStateApplier, MessagingSetupApplier }: typeof import("./messaging") = require("./messaging"); const { clearAgentScopedResumeState, }: typeof import("./onboard/agent-resume-state") = require("./onboard/agent-resume-state"); @@ -6499,6 +6499,8 @@ async function onboard(opts: OnboardOptions = {}): Promise { getSandboxMessagingChannels: (name) => registry.getSandbox(name)?.messagingChannels, setupMessagingChannels, readMessagingChannelConfigFromEnv, + readMessagingPlanFromEnv: () => MessagingSetupApplier.readPlanFromEnv(), + writePlanToEnv: (plan) => MessagingSetupApplier.writePlanToEnv(plan), promptValidatedSandboxName, selectResourceProfileForSandbox: () => selectResourceProfileForSandbox({ isNonInteractive, note, prompt, promptOrDefault }), stopStaleDashboardListenersForSandbox, diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 51dbaf2352..10d1ec9233 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -72,6 +72,8 @@ function createDeps(overrides: Partial ["telegram"], setupMessagingChannels: calls.setupMessaging, readMessagingChannelConfigFromEnv: () => null, + readMessagingPlanFromEnv: () => null, + writePlanToEnv: () => undefined, promptValidatedSandboxName: calls.promptName, selectResourceProfileForSandbox: calls.selectResourceProfile, stopStaleDashboardListenersForSandbox: calls.stopStale, diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 26a26c08c8..5769c927fe 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import type { Session, SessionUpdates } from "../../../state/onboard-session"; import { withSandboxPhaseTrace } from "../../tracing"; import { branchTo, type OnboardStateTransitionResult } from "../result"; @@ -62,6 +63,8 @@ export interface SandboxStateOptions; readMessagingChannelConfigFromEnv(): MessagingChannelConfig | null; + readMessagingPlanFromEnv(): SandboxMessagingPlan | null; + writePlanToEnv(plan: SandboxMessagingPlan): void; promptValidatedSandboxName(agent: Agent): Promise; selectResourceProfileForSandbox(): Promise; stopStaleDashboardListenersForSandbox(sandboxes: unknown[], sandboxName: string): void; @@ -263,21 +266,32 @@ export async function handleSandboxState 0) { deps.note(` [non-interactive] Reusing messaging channel configuration: ${selectedMessagingChannels.join(", ")}`); + // Restore the compiled plan to env so createSandbox can read it via + // MessagingHostStateApplier.readPlanStateFromEnv() and write it to the + // registry. Without this, the plan is lost across process restarts and + // the registry entry loses its messaging state on rebuild. + if (session?.messagingPlan) { + deps.writePlanToEnv(session.messagingPlan); + messagingPlan = session.messagingPlan; + } } } else { const existing = sandboxName ? deps.getSandboxMessagingChannels(sandboxName) ?? session?.messagingChannels ?? null : session?.messagingChannels ?? null; selectedMessagingChannels = await deps.setupMessagingChannels(agent, existing, sandboxName); + messagingPlan = deps.readMessagingPlanFromEnv(); } const messagingChannelConfig = deps.readMessagingChannelConfigFromEnv(); session = deps.updateSession((current) => { current.messagingChannels = selectedMessagingChannels; current.messagingChannelConfig = messagingChannelConfig as Session["messagingChannelConfig"]; + current.messagingPlan = messagingPlan; return current; }); diff --git a/src/lib/onboard/session-updates.ts b/src/lib/onboard/session-updates.ts index 529d22e531..558bc3b62d 100644 --- a/src/lib/onboard/session-updates.ts +++ b/src/lib/onboard/session-updates.ts @@ -3,6 +3,7 @@ import type { WebSearchConfig } from "../inference/web-search"; import type { MessagingChannelConfig } from "../messaging-channel-config"; +import type { SandboxMessagingPlan } from "../messaging/manifest"; import type { HermesAuthMethod, SessionUpdates } from "../state/onboard-session"; export interface OnboardSessionUpdateInput { @@ -18,6 +19,7 @@ export interface OnboardSessionUpdateInput { policyPresets?: string[] | null; messagingChannels?: string[] | null; messagingChannelConfig?: MessagingChannelConfig | null; + messagingPlan?: SandboxMessagingPlan | null; hermesToolGateways?: string[] | null; } @@ -57,6 +59,7 @@ export function toSessionUpdates(updates: OnboardSessionUpdateInput = {}): Sessi if (updates.messagingChannelConfig !== undefined) { normalized.messagingChannelConfig = updates.messagingChannelConfig; } + if (updates.messagingPlan !== undefined) normalized.messagingPlan = updates.messagingPlan; if (updates.hermesToolGateways !== undefined) normalized.hermesToolGateways = updates.hermesToolGateways; return normalized; diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index 7de7b276dd..729bc33d66 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -18,6 +18,7 @@ import { type MessagingChannelConfig, sanitizeMessagingChannelConfig, } from "../messaging-channel-config"; +import type { SandboxMessagingPlan } from "../messaging/manifest"; import { createOnboardMachineEvent, emitOnboardMachineEvent, @@ -104,6 +105,7 @@ export interface Session { policyPresets: string[] | null; messagingChannels: string[] | null; messagingChannelConfig: MessagingChannelConfig | null; + messagingPlan: SandboxMessagingPlan | null; // Channels the operator paused via `nemoclaw channels stop `. // Mirrors `SandboxEntry.disabledChannels` so that `rebuild` — which // destroys the registry entry before calling `onboard --resume` — @@ -182,6 +184,7 @@ export interface SessionUpdates { policyPresets?: string[] | null; messagingChannels?: string[] | null; messagingChannelConfig?: MessagingChannelConfig | null; + messagingPlan?: SandboxMessagingPlan | null; disabledChannels?: string[] | null; migratedLegacyValueHashes?: Record; gpuPassthrough?: boolean; @@ -307,6 +310,27 @@ function parseWechatConfig(value: unknown): WechatConfig | null { return Object.keys(result).length > 0 ? result : null; } +function parseSandboxMessagingPlan(value: unknown): SandboxMessagingPlan | null { + if ( + !isObject(value) || + value.schemaVersion !== 1 || + typeof value.sandboxName !== "string" || + typeof value.agent !== "string" || + typeof value.workflow !== "string" || + !Array.isArray(value.channels) || + !Array.isArray(value.disabledChannels) || + !Array.isArray(value.credentialBindings) || + !isObject(value.networkPolicy) || + !Array.isArray(value.agentRender) || + !Array.isArray(value.buildSteps) || + !Array.isArray(value.stateUpdates) || + !Array.isArray(value.healthChecks) + ) { + return null; + } + return value as unknown as SandboxMessagingPlan; +} + function parseSessionMetadata(value: SessionJsonValue | undefined): SessionMetadata | undefined { if (!isObject(value)) return undefined; return { @@ -459,6 +483,7 @@ export function createSession(overrides: Partial = {}): Session { policyPresets: readStringArray(overrides.policyPresets), messagingChannels: readStringArray(overrides.messagingChannels), messagingChannelConfig: sanitizeMessagingChannelConfig(overrides.messagingChannelConfig), + messagingPlan: parseSandboxMessagingPlan(overrides.messagingPlan), disabledChannels: readStringArray(overrides.disabledChannels), migratedLegacyValueHashes: overrides.migratedLegacyValueHashes ? readStringRecord(overrides.migratedLegacyValueHashes) @@ -501,6 +526,7 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): policyPresets: readStringArray(data.policyPresets), messagingChannels: readStringArray(data.messagingChannels), messagingChannelConfig: sanitizeMessagingChannelConfig(data.messagingChannelConfig), + messagingPlan: parseSandboxMessagingPlan(data.messagingPlan), disabledChannels: readStringArray(data.disabledChannels), migratedLegacyValueHashes: readStringRecord(data.migratedLegacyValueHashes), gpuPassthrough: data.gpuPassthrough === true, @@ -949,6 +975,12 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { const messagingChannelConfig = sanitizeMessagingChannelConfig(updates.messagingChannelConfig); if (messagingChannelConfig) safe.messagingChannelConfig = messagingChannelConfig; } + if (updates.messagingPlan === null) { + safe.messagingPlan = null; + } else { + const messagingPlan = parseSandboxMessagingPlan(updates.messagingPlan); + if (messagingPlan) safe.messagingPlan = messagingPlan; + } if (updates.disabledChannels === null) { safe.disabledChannels = null; } else if (Array.isArray(updates.disabledChannels)) { From 461c3997e3011309d6fdb90414d4449358630991 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 12:06:53 +0700 Subject: [PATCH 2/7] refactor(onboard): move messaging plan env helpers to messaging-channel-setup Exports readMessagingPlanFromEnv and writePlanToEnv from messaging-channel-setup.ts (which already owns MessagingSetupApplier) to keep src/lib/onboard.ts from growing. Collapses the one-name messaging-channel-setup require into a single line to free headroom. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/onboard.ts | 10 ++++------ src/lib/onboard/messaging-channel-setup.ts | 8 ++++++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 98abbcc278..937a74986c 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -89,10 +89,8 @@ const { hasWechatConfigDrift, toSessionWechatConfig, } = require("./onboard/wechat-config") as typeof import("./onboard/wechat-config"); -const { - setupMessagingChannels: setupMessagingChannelsImpl, -} = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); -const { MessagingHostStateApplier, MessagingSetupApplier }: typeof import("./messaging") = require("./messaging"); +const { setupMessagingChannels: setupMessagingChannelsImpl, readMessagingPlanFromEnv, writePlanToEnv } = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); +const { MessagingHostStateApplier }: typeof import("./messaging") = require("./messaging"); const { clearAgentScopedResumeState, }: typeof import("./onboard/agent-resume-state") = require("./onboard/agent-resume-state"); @@ -6499,8 +6497,8 @@ async function onboard(opts: OnboardOptions = {}): Promise { getSandboxMessagingChannels: (name) => registry.getSandbox(name)?.messagingChannels, setupMessagingChannels, readMessagingChannelConfigFromEnv, - readMessagingPlanFromEnv: () => MessagingSetupApplier.readPlanFromEnv(), - writePlanToEnv: (plan) => MessagingSetupApplier.writePlanToEnv(plan), + readMessagingPlanFromEnv, + writePlanToEnv, promptValidatedSandboxName, selectResourceProfileForSandbox: () => selectResourceProfileForSandbox({ isNonInteractive, note, prompt, promptOrDefault }), stopStaleDashboardListenersForSandbox, diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index c0a3928c7e..0593915bcc 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -317,6 +317,14 @@ function printInSandboxQrStatus(manifest: ChannelManifest): void { } } +export function readMessagingPlanFromEnv(): SandboxMessagingPlan | null { + return MessagingSetupApplier.readPlanFromEnv(); +} + +export function writePlanToEnv(plan: SandboxMessagingPlan): void { + MessagingSetupApplier.writePlanToEnv(plan); +} + function resolveMessagingSetupSandboxName(options: SetupSelectedMessagingChannelsOptions): string { const explicitName = normalizeSandboxName(options.sandboxName); if (explicitName) return explicitName; From 9a4ed8edfbfdc6cfb1a9ce7eed1b730b24c79aee Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 13:06:57 +0700 Subject: [PATCH 3/7] fix(onboard): add plan identity guard and test coverage for resume plan persistence - Extract parseSandboxMessagingPlan to messaging-plan-session.ts to keep onboard-session.ts growth under the monolith threshold - Guard plan restoration with sandbox-name + agent identity check so stale plans from renamed sandboxes or agent switches are not reused - Add three behavior assertions in sandbox.test.ts: fresh setup persists env plan to session; matching plan is restored to env on non-interactive resume; mismatched sandbox name skips restoration Co-Authored-By: Claude Sonnet 4.6 --- .../onboard/machine/handlers/sandbox.test.ts | 53 +++++++++++++++++++ src/lib/onboard/machine/handlers/sandbox.ts | 15 ++++-- src/lib/onboard/messaging-plan-session.ts | 29 ++++++++++ src/lib/state/onboard-session.ts | 22 +------- 4 files changed, 95 insertions(+), 24 deletions(-) create mode 100644 src/lib/onboard/messaging-plan-session.ts diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 10d1ec9233..ddfef1487a 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -3,9 +3,27 @@ import { describe, expect, it, vi } from "vitest"; +import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; import { handleSandboxState, type SandboxStateOptions } from "./sandbox"; +function makeMinimalPlan(sandboxName: string, agent = "openclaw"): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName, + agent: agent as SandboxMessagingPlan["agent"], + workflow: "onboard", + channels: [], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + type Gpu = { type: string } | null; type Agent = { displayName?: string } | null; type WebSearchConfig = { fetchEnabled: true }; @@ -318,4 +336,39 @@ describe("handleSandboxState", () => { expect(calls.note).toHaveBeenCalledWith(" [non-interactive] Reusing messaging channel configuration: discord"); expect(result.selectedMessagingChannels).toEqual(["discord"]); }); + + it("persists plan from env into session after fresh messaging setup", async () => { + const mockPlan = makeMinimalPlan("my-assistant"); + const { deps, getSession } = createDeps({ + readMessagingPlanFromEnv: () => mockPlan, + }); + + await handleSandboxState({ ...baseOptions(deps) }); + + expect(getSession().messagingPlan).toEqual(mockPlan); + }); + + it("restores matching plan to env on non-interactive resume", async () => { + const mockPlan = makeMinimalPlan("my-assistant"); + const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"], messagingPlan: mockPlan }); + const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); + const writePlanToEnv = vi.fn(); + const { deps } = createDeps({ getRecordedMessagingChannelsForResume, writePlanToEnv }); + + await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant" }); + + expect(writePlanToEnv).toHaveBeenCalledWith(mockPlan); + }); + + it("does not restore plan to env when sandbox name does not match", async () => { + const stalePlan = makeMinimalPlan("old-sandbox"); + const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"], messagingPlan: stalePlan }); + const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); + const writePlanToEnv = vi.fn(); + const { deps } = createDeps({ getRecordedMessagingChannelsForResume, writePlanToEnv }); + + await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant" }); + + expect(writePlanToEnv).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 5769c927fe..67ce452cc3 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -275,9 +275,18 @@ export async function handleSandboxState { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index 729bc33d66..ce6601cb01 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -19,6 +19,7 @@ import { sanitizeMessagingChannelConfig, } from "../messaging-channel-config"; import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { parseSandboxMessagingPlan } from "../onboard/messaging-plan-session"; import { createOnboardMachineEvent, emitOnboardMachineEvent, @@ -310,27 +311,6 @@ function parseWechatConfig(value: unknown): WechatConfig | null { return Object.keys(result).length > 0 ? result : null; } -function parseSandboxMessagingPlan(value: unknown): SandboxMessagingPlan | null { - if ( - !isObject(value) || - value.schemaVersion !== 1 || - typeof value.sandboxName !== "string" || - typeof value.agent !== "string" || - typeof value.workflow !== "string" || - !Array.isArray(value.channels) || - !Array.isArray(value.disabledChannels) || - !Array.isArray(value.credentialBindings) || - !isObject(value.networkPolicy) || - !Array.isArray(value.agentRender) || - !Array.isArray(value.buildSteps) || - !Array.isArray(value.stateUpdates) || - !Array.isArray(value.healthChecks) - ) { - return null; - } - return value as unknown as SandboxMessagingPlan; -} - function parseSessionMetadata(value: SessionJsonValue | undefined): SessionMetadata | undefined { if (!isObject(value)) return undefined; return { From 2d05201e706c46f1e4933fd13d82b653b18e36b6 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 13:59:58 +0700 Subject: [PATCH 4/7] fix(onboard): prefer env-staged plan over session plan on non-interactive resume rebuild.ts stages an authoritative plan from the registry (which reflects post-stop/-start channel mutations) before calling onboard --resume. Previously, the session plan restoration was unconditionally overwriting that staged plan, causing stopped channels to reappear as active after rebuild. Now the handler checks the env first: if a plan is already staged (rebuild path), it is used as-is. The session plan is only restored when the env is empty, covering the plain process-restart resume case this PR was originally targeting. Also adds a test asserting the rebuild-path preference. Co-Authored-By: Claude Sonnet 4.6 --- .../onboard/machine/handlers/sandbox.test.ts | 32 ++++++++++++++-- src/lib/onboard/machine/handlers/sandbox.ts | 37 +++++++++++-------- 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index ddfef1487a..222ae5edcd 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -348,24 +348,50 @@ describe("handleSandboxState", () => { expect(getSession().messagingPlan).toEqual(mockPlan); }); - it("restores matching plan to env on non-interactive resume", async () => { + it("restores matching session plan to env on non-interactive resume when env is empty", async () => { const mockPlan = makeMinimalPlan("my-assistant"); const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"], messagingPlan: mockPlan }); const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); const writePlanToEnv = vi.fn(); - const { deps } = createDeps({ getRecordedMessagingChannelsForResume, writePlanToEnv }); + const { deps } = createDeps({ + getRecordedMessagingChannelsForResume, + writePlanToEnv, + readMessagingPlanFromEnv: () => null, + }); await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant" }); expect(writePlanToEnv).toHaveBeenCalledWith(mockPlan); }); + it("prefers env-staged plan over session plan on non-interactive resume (rebuild path)", async () => { + const sessionPlan = makeMinimalPlan("my-assistant"); + const rebuiltPlan = makeMinimalPlan("my-assistant"); + const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"], messagingPlan: sessionPlan }); + const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); + const writePlanToEnv = vi.fn(); + const { deps, getSession } = createDeps({ + getRecordedMessagingChannelsForResume, + writePlanToEnv, + readMessagingPlanFromEnv: () => rebuiltPlan, + }); + + await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant" }); + + expect(writePlanToEnv).not.toHaveBeenCalled(); + expect(getSession().messagingPlan).toEqual(rebuiltPlan); + }); + it("does not restore plan to env when sandbox name does not match", async () => { const stalePlan = makeMinimalPlan("old-sandbox"); const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"], messagingPlan: stalePlan }); const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); const writePlanToEnv = vi.fn(); - const { deps } = createDeps({ getRecordedMessagingChannelsForResume, writePlanToEnv }); + const { deps } = createDeps({ + getRecordedMessagingChannelsForResume, + writePlanToEnv, + readMessagingPlanFromEnv: () => null, + }); await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant" }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 67ce452cc3..e6350636dd 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -271,22 +271,27 @@ export async function handleSandboxState 0) { deps.note(` [non-interactive] Reusing messaging channel configuration: ${selectedMessagingChannels.join(", ")}`); - // Restore the compiled plan to env so createSandbox can read it via - // MessagingHostStateApplier.readPlanStateFromEnv() and write it to the - // registry. Without this, the plan is lost across process restarts and - // the registry entry loses its messaging state on rebuild. - // Guard: only restore if the plan's identity matches the current sandbox - // and agent. An agent change or renamed sandbox means the persisted plan - // is stale and must be recompiled through the normal setup path. - const storedPlan = session?.messagingPlan; - const agentName = (agent as { name?: string } | null)?.name; - const planMatchesCurrent = - storedPlan != null && - storedPlan.sandboxName === sandboxName && - (agentName == null || storedPlan.agent === agentName); - if (planMatchesCurrent && storedPlan != null) { - deps.writePlanToEnv(storedPlan); - messagingPlan = storedPlan; + // Prefer a plan already in env over the session plan. rebuild.ts stages + // a fresh plan from the registry entry before calling onboard --resume, + // and that plan reflects post-stop/-start channel mutations. Overwriting + // it with the session plan (saved at initial onboard) would lose the + // disabled state and reactivate stopped channels after rebuild. + // Only restore the session plan when the env is empty, i.e. for plain + // process-restart resumes where no external caller staged a plan. + const envPlan = deps.readMessagingPlanFromEnv(); + if (envPlan) { + messagingPlan = envPlan; + } else { + const storedPlan = session?.messagingPlan; + const agentName = (agent as { name?: string } | null)?.name; + const planMatchesCurrent = + storedPlan != null && + storedPlan.sandboxName === sandboxName && + (agentName == null || storedPlan.agent === agentName); + if (planMatchesCurrent && storedPlan != null) { + deps.writePlanToEnv(storedPlan); + messagingPlan = storedPlan; + } } } } else { From 673d441107604709e5963cfb7214e5b24bf9f1e6 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 16:04:39 +0700 Subject: [PATCH 5/7] refactor(onboard): establish messagingPlan as session source of truth - On non-interactive resume, restore plan from registry (always current after stop/start/add/remove) instead of stale session snapshot; env-first priority preserved so rebuild.ts staging still wins - In rebuild.ts, persist the staged plan to the session alongside messagingChannels/disabledChannels/messagingChannelConfig so the session is fully consistent during the rebuild window - Add getChannelsFromPlan, getDisabledChannelsFromPlan, and getMessagingChannelConfigFromPlan helpers in messaging-plan-session.ts so the next PR can replace the three individual session fields with plan-derived reads - Move MessagingHostStateApplier re-export to messaging-channel-setup and getRegistrySandboxMessagingPlan helper to keep onboard.ts net-neutral Co-Authored-By: Claude Sonnet 4.6 --- src/lib/actions/sandbox/rebuild.ts | 10 ++++-- src/lib/onboard.ts | 4 +-- .../onboard/machine/handlers/sandbox.test.ts | 23 ++++++------ src/lib/onboard/machine/handlers/sandbox.ts | 17 +++++---- src/lib/onboard/messaging-channel-setup.ts | 7 ++++ src/lib/onboard/messaging-plan-session.ts | 35 +++++++++++++++++++ 6 files changed, 72 insertions(+), 24 deletions(-) diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index a6dfbe5dfd..63f06a0219 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -49,6 +49,7 @@ import { MessagingWorkflowPlanner, toMessagingAgentId, } from "../../messaging"; +import type { SandboxMessagingPlan } from "../../messaging/manifest"; import { pruneDisabledMessagingPolicyPresets } from "../../onboard/messaging-policy-presets"; import { captureSandboxListWithGatewayRecovery, @@ -176,7 +177,7 @@ async function stageMessagingManifestPlanForRebuild( sandboxEntry: registry.SandboxEntry, rebuildAgent: string | null, log: (msg: string) => void, -): Promise { +): Promise { const agent = loadAgent(rebuildAgent || "openclaw"); const planner = new MessagingWorkflowPlanner(createBuiltInChannelManifestRegistry()); const plan = await planner.buildRebuildPlanFromSandboxEntry({ @@ -188,7 +189,7 @@ async function stageMessagingManifestPlanForRebuild( if (!plan || plan.channels.length === 0) { MessagingSetupApplier.clearPlanEnv(); log("Messaging manifest rebuild plan: no configured channels"); - return; + return null; } MessagingSetupApplier.writePlanToEnv(plan); log( @@ -196,6 +197,7 @@ async function stageMessagingManifestPlanForRebuild( .map((channel) => channel.channelId) .join(",")}`, ); + return plan; } /** @@ -439,8 +441,9 @@ export async function rebuildSandbox( ); } + let rebuildMessagingPlan: SandboxMessagingPlan | null = null; try { - await stageMessagingManifestPlanForRebuild(sandboxName, sb, rebuildAgent, log); + rebuildMessagingPlan = await stageMessagingManifestPlanForRebuild(sandboxName, sb, rebuildAgent, log); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(""); @@ -681,6 +684,7 @@ export async function rebuildSandbox( s.messagingChannels = rebuildMessagingChannels; s.messagingChannelConfig = rebuildMessagingChannelConfig; s.disabledChannels = rebuildDisabledChannels; + s.messagingPlan = rebuildMessagingPlan; s.hermesToolGateways = rebuildsHermesSandbox ? rebuildHermesToolGateways : []; // Persist inference selection from the about-to-be-removed registry entry // so onboard --resume can recreate with the same provider/model in diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 937a74986c..ebb54bd7f8 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -89,8 +89,7 @@ const { hasWechatConfigDrift, toSessionWechatConfig, } = require("./onboard/wechat-config") as typeof import("./onboard/wechat-config"); -const { setupMessagingChannels: setupMessagingChannelsImpl, readMessagingPlanFromEnv, writePlanToEnv } = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); -const { MessagingHostStateApplier }: typeof import("./messaging") = require("./messaging"); +const { setupMessagingChannels: setupMessagingChannelsImpl, readMessagingPlanFromEnv, writePlanToEnv, getRegistrySandboxMessagingPlan, MessagingHostStateApplier } = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); const { clearAgentScopedResumeState, }: typeof import("./onboard/agent-resume-state") = require("./onboard/agent-resume-state"); @@ -6499,6 +6498,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { readMessagingChannelConfigFromEnv, readMessagingPlanFromEnv, writePlanToEnv, + getRegistrySandboxMessagingPlan, promptValidatedSandboxName, selectResourceProfileForSandbox: () => selectResourceProfileForSandbox({ isNonInteractive, note, prompt, promptOrDefault }), stopStaleDashboardListenersForSandbox, diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 222ae5edcd..b52876f9e8 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -92,6 +92,7 @@ function createDeps(overrides: Partial null, readMessagingPlanFromEnv: () => null, writePlanToEnv: () => undefined, + getRegistrySandboxMessagingPlan: () => null, promptValidatedSandboxName: calls.promptName, selectResourceProfileForSandbox: calls.selectResourceProfile, stopStaleDashboardListenersForSandbox: calls.stopStale, @@ -348,32 +349,34 @@ describe("handleSandboxState", () => { expect(getSession().messagingPlan).toEqual(mockPlan); }); - it("restores matching session plan to env on non-interactive resume when env is empty", async () => { - const mockPlan = makeMinimalPlan("my-assistant"); - const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"], messagingPlan: mockPlan }); + it("restores registry plan to env on non-interactive resume when env is empty", async () => { + const registryPlan = makeMinimalPlan("my-assistant"); + const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"] }); const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); const writePlanToEnv = vi.fn(); const { deps } = createDeps({ getRecordedMessagingChannelsForResume, writePlanToEnv, readMessagingPlanFromEnv: () => null, + getRegistrySandboxMessagingPlan: () => registryPlan, }); await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant" }); - expect(writePlanToEnv).toHaveBeenCalledWith(mockPlan); + expect(writePlanToEnv).toHaveBeenCalledWith(registryPlan); }); - it("prefers env-staged plan over session plan on non-interactive resume (rebuild path)", async () => { - const sessionPlan = makeMinimalPlan("my-assistant"); + it("prefers env-staged plan over registry plan on non-interactive resume (rebuild path)", async () => { + const registryPlan = makeMinimalPlan("my-assistant"); const rebuiltPlan = makeMinimalPlan("my-assistant"); - const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"], messagingPlan: sessionPlan }); + const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"] }); const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); const writePlanToEnv = vi.fn(); const { deps, getSession } = createDeps({ getRecordedMessagingChannelsForResume, writePlanToEnv, readMessagingPlanFromEnv: () => rebuiltPlan, + getRegistrySandboxMessagingPlan: () => registryPlan, }); await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant" }); @@ -382,15 +385,15 @@ describe("handleSandboxState", () => { expect(getSession().messagingPlan).toEqual(rebuiltPlan); }); - it("does not restore plan to env when sandbox name does not match", async () => { - const stalePlan = makeMinimalPlan("old-sandbox"); - const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"], messagingPlan: stalePlan }); + it("does not restore plan to env when registry has no entry", async () => { + const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"] }); const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); const writePlanToEnv = vi.fn(); const { deps } = createDeps({ getRecordedMessagingChannelsForResume, writePlanToEnv, readMessagingPlanFromEnv: () => null, + getRegistrySandboxMessagingPlan: () => null, }); await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant" }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index e6350636dd..07d6208d7b 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -65,6 +65,7 @@ export interface SandboxStateOptions; selectResourceProfileForSandbox(): Promise; stopStaleDashboardListenersForSandbox(sandboxes: unknown[], sandboxName: string): void; @@ -282,15 +283,13 @@ export async function handleSandboxState { return typeof value === "object" && value !== null && !Array.isArray(value); } + +/** Derive the equivalent of session.messagingChannels from a plan. */ +export function getChannelsFromPlan(plan: SandboxMessagingPlan | null | undefined): string[] | null { + if (!plan || plan.channels.length === 0) return null; + return plan.channels.map((c) => c.channelId); +} + +/** Derive the equivalent of session.disabledChannels from a plan. */ +export function getDisabledChannelsFromPlan( + plan: SandboxMessagingPlan | null | undefined, +): string[] | null { + if (!plan) return null; + return plan.disabledChannels.length > 0 ? [...plan.disabledChannels] : null; +} + +/** + * Derive the equivalent of session.messagingChannelConfig from a plan. + * Config inputs (kind === "config") carry their resolved env-key/value pairs + * in plan.channels[].inputs, populated at compile time from process.env. + */ +export function getMessagingChannelConfigFromPlan( + plan: SandboxMessagingPlan | null | undefined, +): MessagingChannelConfig | null { + if (!plan) return null; + const config: Record = {}; + for (const channel of plan.channels) { + for (const input of channel.inputs) { + if (input.kind === "config" && input.sourceEnv && input.value != null) { + config[input.sourceEnv] = String(input.value); + } + } + } + return Object.keys(config).length > 0 ? config : null; +} From 12d5e962f100980a716fb722f205d0d2fd10fefe Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 18:08:41 +0700 Subject: [PATCH 6/7] refactor(policy): migrate messaging policy to plan-derived presets Move messaging policy application into src/lib/messaging/applier/ and replace channel-list-based preset computation with plan.networkPolicy.presets throughout the policy selection, resume, and sandbox create flows. - Add getPolicyPresetsFromPlan helper to messaging-plan-session - Move pruneDisabledMessagingPolicyPresets to messaging/applier/policy-presets; delete merged/computed functions superseded by the compiled plan - Replace enabledChannels/disabledChannels with messagingPolicyPresets in SetupPolicySelectionOptions, SetupPresetSuggestionOptions, and preparePolicyPresetResumeSelection; stale-preset detection now uses ALL_MESSAGING_POLICY_PRESET_NAMES diff against plan presets - Remove selectedMessagingChannels, mergePolicyMessagingChannels, and getActiveSandbox from handlePoliciesState; handler now accepts a single messagingPolicyPresets: string[] derived from the session plan - Wire getPolicyPresetsFromPlan into onboard.ts handlePoliciesState call and prepareInitialSandboxCreatePolicy sandbox create policy - Update rebuild.ts import to new applier location Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/actions/sandbox/rebuild.ts | 2 +- src/lib/messaging/applier/index.ts | 1 + .../messaging/applier/policy-presets.test.ts | 40 ++++ src/lib/messaging/applier/policy-presets.ts | 50 +++++ src/lib/onboard.ts | 7 +- src/lib/onboard/initial-policy.test.ts | 11 +- src/lib/onboard/initial-policy.ts | 14 +- .../onboard/machine/handlers/policies.test.ts | 38 ++-- src/lib/onboard/machine/handlers/policies.ts | 45 +---- src/lib/onboard/messaging-plan-session.ts | 5 + .../onboard/messaging-policy-presets.test.ts | 80 -------- src/lib/onboard/messaging-policy-presets.ts | 105 ---------- .../openclaw-otel-policy-presets.test.ts | 7 +- src/lib/onboard/policy-selection.ts | 186 +++++++++--------- test/rebuild-policy-presets.test.ts | 2 +- 15 files changed, 240 insertions(+), 353 deletions(-) create mode 100644 src/lib/messaging/applier/policy-presets.test.ts create mode 100644 src/lib/messaging/applier/policy-presets.ts delete mode 100644 src/lib/onboard/messaging-policy-presets.test.ts delete mode 100644 src/lib/onboard/messaging-policy-presets.ts diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 63f06a0219..c044e29c58 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -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, diff --git a/src/lib/messaging/applier/index.ts b/src/lib/messaging/applier/index.ts index f9e4dcf486..005ed9759f 100644 --- a/src/lib/messaging/applier/index.ts +++ b/src/lib/messaging/applier/index.ts @@ -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"; diff --git a/src/lib/messaging/applier/policy-presets.test.ts b/src/lib/messaging/applier/policy-presets.test.ts new file mode 100644 index 0000000000..a7bb8aef02 --- /dev/null +++ b/src/lib/messaging/applier/policy-presets.test.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + ALL_MESSAGING_POLICY_PRESET_NAMES, + pruneDisabledMessagingPolicyPresets, +} from "./policy-presets"; + +describe("pruneDisabledMessagingPolicyPresets", () => { + it("removes policy presets for disabled messaging channels", () => { + expect(pruneDisabledMessagingPolicyPresets(["npm", "slack", "pypi"], [" Slack "])).toEqual([ + "npm", + "pypi", + ]); + }); + + it("preserves non-required policy presets when a same-named channel is disabled", () => { + expect( + pruneDisabledMessagingPolicyPresets(["telegram", "npm", "pypi"], ["telegram"]), + ).toEqual(["telegram", "npm", "pypi"]); + }); + + it("returns the original list unchanged when no channels are disabled", () => { + expect(pruneDisabledMessagingPolicyPresets(["npm", "slack"], null)).toEqual(["npm", "slack"]); + }); +}); + +describe("ALL_MESSAGING_POLICY_PRESET_NAMES", () => { + it("includes the slack preset", () => { + expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("slack")).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("telegram")).toBe(false); + }); +}); diff --git a/src/lib/messaging/applier/policy-presets.ts b/src/lib/messaging/applier/policy-presets.ts new file mode 100644 index 0000000000..d654174b75 --- /dev/null +++ b/src/lib/messaging/applier/policy-presets.ts @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL: Record = { + slack: ["slack"], +}; + +/** All preset names that any messaging channel can require. */ +export const ALL_MESSAGING_POLICY_PRESET_NAMES: ReadonlySet = new Set( + Object.values(REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL).flat(), +); + +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; +} + +function requiredMessagingChannelPolicyPresets(channels: string[] | null | undefined): string[] { + const required: string[] = []; + for (const channel of normalizedNames(channels)) { + for (const preset of REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL[channel] || []) { + if (!required.includes(preset)) required.push(preset); + } + } + return required; +} + +/** + * 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, disabled channels are already + * excluded from plan.networkPolicy.presets. + */ +export function pruneDisabledMessagingPolicyPresets( + selectedPresets: string[], + disabledChannels: string[] | null | undefined, +): string[] { + const disabledRequiredPresets = new Set(requiredMessagingChannelPolicyPresets(disabledChannels)); + if (disabledRequiredPresets.size === 0) return selectedPresets; + return selectedPresets.filter( + (preset) => !disabledRequiredPresets.has(preset.trim().toLowerCase()), + ); +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index ebb54bd7f8..df866b448b 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -521,7 +521,7 @@ import { setupHermesToolGateways, stringSetsEqual, } from "./onboard/hermes-managed-tools"; -import { mergePolicyMessagingChannels } from "./onboard/messaging-policy-presets"; +import { getPolicyPresetsFromPlan } from "./onboard/messaging-plan-session"; import { filterEnabledChannelsByAgent, resolveQrSelectedChannels, @@ -3379,6 +3379,7 @@ async function createSandbox( directGpu: effectiveSandboxGpuConfig.sandboxGpuEnabled, dockerGpuPatch: useDockerGpuPatch, additionalPresets: hermesToolGateways, + messagingPolicyPresets: getPolicyPresetsFromPlan(onboardSession.loadSession()?.messagingPlan), }, ); if (initialSandboxPolicy.cleanup) { @@ -6566,15 +6567,13 @@ async function onboard(opts: OnboardOptions = {}): Promise { model, endpointUrl, credentialEnv, - selectedMessagingChannels, + messagingPolicyPresets: getPolicyPresetsFromPlan(onboardSession.loadSession()?.messagingPlan), webSearchConfig, webSearchSupported, hermesToolGateways, agent, deps: { loadSession: onboardSession.loadSession, - getActiveSandbox: (name) => registry.getSandbox(name), - mergePolicyMessagingChannels, verifyCompatibleEndpointSandboxSmoke: (options) => verifyCompatibleEndpointSandboxSmoke({ ...options, diff --git a/src/lib/onboard/initial-policy.test.ts b/src/lib/onboard/initial-policy.test.ts index 5dbfc2fcc8..7a4151d539 100644 --- a/src/lib/onboard/initial-policy.test.ts +++ b/src/lib/onboard/initial-policy.test.ts @@ -175,7 +175,11 @@ network_policies: it("records an existing create-time preset without writing a temp policy", () => { const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n slack: {}\n"); - expect(prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"])).toEqual({ + expect( + prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"], { + messagingPolicyPresets: ["slack"], + }), + ).toEqual({ policyPath: basePolicyPath, appliedPresets: ["slack"], }); @@ -237,7 +241,9 @@ network_policies: it("merges missing create-time presets into a temporary policy", () => { const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); - const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"]); + const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"], { + messagingPolicyPresets: ["slack"], + }); expect(prepared.policyPath).not.toBe(basePolicyPath); expect(prepared.appliedPresets).toEqual(["slack"]); @@ -250,6 +256,7 @@ network_policies: const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"], { + messagingPolicyPresets: ["slack"], additionalPresets: ["nous-web"], }); diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts index 74f5c8dde1..f824e734e4 100644 --- a/src/lib/onboard/initial-policy.ts +++ b/src/lib/onboard/initial-policy.ts @@ -6,7 +6,6 @@ import path from "node:path"; import YAML from "yaml"; import * as policies from "../policy"; -import { requiredMessagingChannelPolicyPresets } from "./messaging-policy-presets"; import { requiredOpenclawOtelPolicyPresets } from "./openclaw-otel-policy-presets"; import { cleanupTempDir, secureTempFile } from "./temp-files"; @@ -211,6 +210,7 @@ export function prepareInitialSandboxCreatePolicy( dockerGpuPatch?: boolean; additionalPresets?: string[]; agentName?: string | null; + messagingPolicyPresets?: string[]; } = {}, ): InitialSandboxPolicy { const directGpuPolicy = options.directGpu @@ -225,13 +225,11 @@ export function prepareInitialSandboxCreatePolicy( ? () => cleanupFns.map((cleanup) => cleanup()).every(Boolean) : undefined; const requestedCreateTimePresets = [ - ...new Set( - [ - ...requiredMessagingChannelPolicyPresets(activeMessagingChannels), - ...requiredOpenclawOtelPolicyPresets(options.agentName ?? "openclaw"), - ...(options.additionalPresets || []), - ], - ), + ...new Set([ + ...(options.messagingPolicyPresets ?? []), + ...requiredOpenclawOtelPolicyPresets(options.agentName ?? "openclaw"), + ...(options.additionalPresets || []), + ]), ]; const dedupe = (values: string[]) => [...new Set(values.filter(Boolean))]; diff --git a/src/lib/onboard/machine/handlers/policies.test.ts b/src/lib/onboard/machine/handlers/policies.test.ts index 1ac343030c..503cba65c5 100644 --- a/src/lib/onboard/machine/handlers/policies.test.ts +++ b/src/lib/onboard/machine/handlers/policies.test.ts @@ -13,14 +13,6 @@ function createDeps(overrides: Partial session), - activeSandbox: vi.fn(() => ({ messagingChannels: ["telegram"], disabledChannels: null })), - mergeChannels: vi.fn( - ( - selected: string[], - recorded: string[], - active: string[] | null | undefined, - ) => (selected.length > 0 ? selected : active ?? recorded), - ), smoke: vi.fn(), prepareResume: vi.fn( ( @@ -48,8 +40,6 @@ function createDeps(overrides: Partial { it("runs compatible endpoint smoke before policy selection", async () => { const { deps, calls } = createDeps(); - const result = await handlePoliciesState(baseOptions(deps)); + const result = await handlePoliciesState({ + ...baseOptions(deps), + messagingPolicyPresets: ["slack"], + }); expect(calls.smoke).toHaveBeenCalledWith({ sandboxName: "my-assistant", @@ -101,7 +94,7 @@ describe("handlePoliciesState", () => { model: "model", endpointUrl: "https://example.com/v1", credentialEnv: "NVIDIA_API_KEY", - messagingChannels: ["telegram"], + messagingChannels: ["slack"], agent: null, }); expect(calls.startStep).toHaveBeenCalledWith("policies", { @@ -114,7 +107,7 @@ describe("handlePoliciesState", () => { "my-assistant", expect.objectContaining({ selectedPresets: null, - enabledChannels: ["telegram"], + messagingPolicyPresets: ["slack"], provider: "provider", webSearchSupported: true, }), @@ -132,18 +125,17 @@ describe("handlePoliciesState", () => { }); }); - it("uses recorded messaging channels when no active selection exists", async () => { - const session = createSession({ messagingChannels: ["slack"] }); - const { deps, calls, setSession } = createDeps({ - getActiveSandbox: vi.fn(() => ({ messagingChannels: null, disabledChannels: null })), - }); - setSession(session); + it("passes plan-derived messaging policy presets through to preparePolicyPresetResumeSelection", async () => { + const { deps, calls } = createDeps(); - await handlePoliciesState(baseOptions(deps)); + await handlePoliciesState({ + ...baseOptions(deps), + messagingPolicyPresets: ["slack"], + }); - expect(calls.setupPolicies).toHaveBeenCalledWith( + expect(calls.prepareResume).toHaveBeenCalledWith( "my-assistant", - expect.objectContaining({ enabledChannels: ["slack"] }), + expect.objectContaining({ messagingPolicyPresets: ["slack"] }), ); }); diff --git a/src/lib/onboard/machine/handlers/policies.ts b/src/lib/onboard/machine/handlers/policies.ts index 0a1c281c35..25705e3847 100644 --- a/src/lib/onboard/machine/handlers/policies.ts +++ b/src/lib/onboard/machine/handlers/policies.ts @@ -17,11 +17,6 @@ export interface PolicyPresetEntry { [key: string]: unknown; } -export interface ActiveSandboxPolicyState { - messagingChannels?: string[] | null; - disabledChannels?: string[] | null; -} - export interface PolicyResumeSelection { policyPresets: string[]; recordedPolicyPresetsNeedReconcile: boolean; @@ -35,20 +30,13 @@ export interface PoliciesStateOptions { model: string; endpointUrl: string | null; credentialEnv: string | null; - selectedMessagingChannels: string[]; + messagingPolicyPresets: string[]; webSearchConfig: WebSearchConfig | null; webSearchSupported: boolean; hermesToolGateways: string[]; agent: Agent; deps: { loadSession(): Session | null; - getActiveSandbox(sandboxName: string): ActiveSandboxPolicyState | null | undefined; - mergePolicyMessagingChannels( - selectedMessagingChannels: string[], - recordedMessagingChannels: string[], - activeMessagingChannels: string[] | null | undefined, - disabledChannels: string[] | null | undefined, - ): string[]; verifyCompatibleEndpointSandboxSmoke(options: { sandboxName: string; provider: string; @@ -62,8 +50,7 @@ export interface PoliciesStateOptions { sandboxName: string, options: { recordedPolicyPresets: string[] | null; - disabledChannels: string[] | null | undefined; - enabledChannels: string[]; + messagingPolicyPresets?: string[] | null; hermesToolGateways: string[]; agent?: string | null; webSearchConfig: WebSearchConfig | null; @@ -81,8 +68,7 @@ export interface PoliciesStateOptions { sandboxName: string, options: { selectedPresets: string[] | null; - enabledChannels: string[]; - disabledChannels?: string[] | null; + messagingPolicyPresets: string[]; webSearchConfig: WebSearchConfig | null; provider: string; agent?: string | null; @@ -106,7 +92,6 @@ export interface PoliciesStateOptions { export interface PoliciesStateResult { session: Session | null; - recordedMessagingChannels: string[]; appliedPolicyPresets: string[]; stateResult: OnboardStateTransitionResult; } @@ -118,7 +103,7 @@ export async function handlePoliciesState({ model, endpointUrl, credentialEnv, - selectedMessagingChannels, + messagingPolicyPresets, webSearchConfig, webSearchSupported, hermesToolGateways, @@ -129,30 +114,22 @@ export async function handlePoliciesState({ const recordedPolicyPresets = Array.isArray(latestSession?.policyPresets) ? latestSession.policyPresets : null; - const recordedMessagingChannels = Array.isArray(latestSession?.messagingChannels) - ? latestSession.messagingChannels - : []; - const activeSandbox = deps.getActiveSandbox(sandboxName); - const policyMessagingChannels = deps.mergePolicyMessagingChannels( - selectedMessagingChannels, - recordedMessagingChannels, - activeSandbox?.messagingChannels, - activeSandbox?.disabledChannels, - ); + deps.verifyCompatibleEndpointSandboxSmoke({ sandboxName, provider, model, endpointUrl, credentialEnv, - messagingChannels: policyMessagingChannels, + // The smoke check only needs to know whether messaging channels are active; + // treat all plan presets as active-channel signals for the provider guard. + messagingChannels: messagingPolicyPresets, agent, }); const policyResumeSelection = deps.preparePolicyPresetResumeSelection(sandboxName, { recordedPolicyPresets, - disabledChannels: activeSandbox?.disabledChannels, - enabledChannels: policyMessagingChannels, + messagingPolicyPresets, hermesToolGateways, agent: normalizeAgentName((agent as { name?: string } | null)?.name), webSearchConfig, @@ -205,8 +182,7 @@ export async function handlePoliciesState({ selectedPresets: Array.isArray(recordedPolicyPresets) ? recordedPolicyPresetsForSupport : null, - enabledChannels: policyMessagingChannels, - disabledChannels: activeSandbox?.disabledChannels, + messagingPolicyPresets, webSearchConfig, provider, // selectOnboardAgent returns null for the default OpenClaw path (no @@ -245,7 +221,6 @@ export async function handlePoliciesState({ return { session, - recordedMessagingChannels, appliedPolicyPresets, stateResult: advanceTo("finalizing", { metadata: { state: "policies", policyPresets: appliedPolicyPresets }, diff --git a/src/lib/onboard/messaging-plan-session.ts b/src/lib/onboard/messaging-plan-session.ts index bb05f97ea2..58686c8cd0 100644 --- a/src/lib/onboard/messaging-plan-session.ts +++ b/src/lib/onboard/messaging-plan-session.ts @@ -43,6 +43,11 @@ export function getDisabledChannelsFromPlan( return plan.disabledChannels.length > 0 ? [...plan.disabledChannels] : null; } +/** Derive the messaging network policy presets for active channels from a plan. */ +export function getPolicyPresetsFromPlan(plan: SandboxMessagingPlan | null | undefined): string[] { + return plan?.networkPolicy.presets ? [...plan.networkPolicy.presets] : []; +} + /** * Derive the equivalent of session.messagingChannelConfig from a plan. * Config inputs (kind === "config") carry their resolved env-key/value pairs diff --git a/src/lib/onboard/messaging-policy-presets.test.ts b/src/lib/onboard/messaging-policy-presets.test.ts deleted file mode 100644 index 43466fa3b2..0000000000 --- a/src/lib/onboard/messaging-policy-presets.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; - -import { - hasDisabledMessagingPolicyPreset, - mergeAppliedPolicyPresetsForDisabledMessagingCleanup, - mergePolicyMessagingChannels, - mergeRequiredMessagingChannelPolicyPresets, - pruneDisabledMessagingPolicyPresets, - requiredMessagingChannelPolicyPresets, -} from "./messaging-policy-presets"; - -describe("messaging policy presets", () => { - it("maps Slack messaging to the Slack network policy preset", () => { - expect(requiredMessagingChannelPolicyPresets(["slack"])).toEqual(["slack"]); - expect(requiredMessagingChannelPolicyPresets([" Slack "])).toEqual(["slack"]); - }); - - it("merges required messaging presets into an existing selection", () => { - expect(mergeRequiredMessagingChannelPolicyPresets(["npm", "pypi"], ["slack"])).toEqual([ - "npm", - "pypi", - "slack", - ]); - }); - - it("does not add a required preset that is not available to the sandbox", () => { - expect( - mergeRequiredMessagingChannelPolicyPresets(["npm"], ["slack"], new Set(["npm"])), - ).toEqual(["npm"]); - }); - - it("merges policy channels while excluding disabled channels", () => { - expect( - mergePolicyMessagingChannels( - ["slack", "telegram"], - [" Slack "], - ["discord", "slack"], - ["slack"], - ), - ).toEqual(["telegram", "discord"]); - }); - - it("removes policy presets for disabled messaging channels", () => { - expect(pruneDisabledMessagingPolicyPresets(["npm", "slack", "pypi"], [" Slack "])).toEqual([ - "npm", - "pypi", - ]); - }); - - it("preserves non-required policy presets when a same-named channel is disabled", () => { - expect( - pruneDisabledMessagingPolicyPresets(["telegram", "npm", "pypi"], ["telegram"]), - ).toEqual(["telegram", "npm", "pypi"]); - }); - - it("detects applied policy presets for disabled messaging channels", () => { - expect(hasDisabledMessagingPolicyPreset(["npm", "slack", "pypi"], ["slack"])).toBe(true); - expect(hasDisabledMessagingPolicyPreset(["telegram", "npm"], ["telegram"])).toBe(false); - }); - - it("preserves unrelated applied presets when cleaning disabled messaging presets", () => { - expect( - mergeAppliedPolicyPresetsForDisabledMessagingCleanup( - ["npm"], - ["npm", "github", "slack"], - ["slack"], - ), - ).toEqual(["npm", "github"]); - expect( - mergeAppliedPolicyPresetsForDisabledMessagingCleanup( - ["npm"], - ["npm", "github"], - ["slack"], - ), - ).toEqual(["npm"]); - }); -}); diff --git a/src/lib/onboard/messaging-policy-presets.ts b/src/lib/onboard/messaging-policy-presets.ts deleted file mode 100644 index c074e31282..0000000000 --- a/src/lib/onboard/messaging-policy-presets.ts +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -const REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL: Record = { - slack: ["slack"], -}; - -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 mergePolicyMessagingChannels( - selectedChannels: string[] | null | undefined, - recordedChannels: string[] | null | undefined, - activeChannels: string[] | null | undefined, - disabledChannels: string[] | null | undefined = null, -): string[] { - const disabled = new Set(normalizedNames(disabledChannels)); - const merged: string[] = []; - for (const channels of [selectedChannels, recordedChannels, activeChannels]) { - for (const channel of normalizedNames(channels)) { - if (!channel || disabled.has(channel) || merged.includes(channel)) continue; - merged.push(channel); - } - } - return merged; -} - -export function requiredMessagingChannelPolicyPresets( - channels: string[] | null | undefined, -): string[] { - const required: string[] = []; - for (const channel of normalizedNames(channels)) { - for (const preset of REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL[channel] || []) { - if (!required.includes(preset)) required.push(preset); - } - } - return required; -} - -export function mergeRequiredMessagingChannelPolicyPresets( - selectedPresets: string[], - channels: string[] | null | undefined, - knownPresetNames?: Iterable | null, -): string[] { - const merged = [...selectedPresets]; - const selected = new Set(merged); - const known = knownPresetNames ? new Set(knownPresetNames) : null; - - for (const preset of requiredMessagingChannelPolicyPresets(channels)) { - if (known && !known.has(preset)) continue; - if (selected.has(preset)) continue; - merged.push(preset); - selected.add(preset); - } - - return merged; -} - -export function pruneDisabledMessagingPolicyPresets( - selectedPresets: string[], - disabledChannels: string[] | null | undefined, -): string[] { - const disabledRequiredPresets = new Set( - requiredMessagingChannelPolicyPresets(disabledChannels), - ); - if (disabledRequiredPresets.size === 0) return selectedPresets; - return selectedPresets.filter( - (preset) => !disabledRequiredPresets.has(preset.trim().toLowerCase()), - ); -} - -export function hasDisabledMessagingPolicyPreset( - selectedPresets: string[], - disabledChannels: string[] | null | undefined, -): boolean { - return ( - pruneDisabledMessagingPolicyPresets(selectedPresets, disabledChannels).length !== - selectedPresets.length - ); -} - -export function mergeAppliedPolicyPresetsForDisabledMessagingCleanup( - selectedPresets: string[], - appliedPresets: string[], - disabledChannels: string[] | null | undefined, -): string[] { - if (!hasDisabledMessagingPolicyPreset(appliedPresets, disabledChannels)) { - return selectedPresets; - } - - const merged = [...selectedPresets]; - for (const preset of pruneDisabledMessagingPolicyPresets(appliedPresets, disabledChannels)) { - if (!merged.includes(preset)) merged.push(preset); - } - return merged; -} diff --git a/src/lib/onboard/openclaw-otel-policy-presets.test.ts b/src/lib/onboard/openclaw-otel-policy-presets.test.ts index af799fc2fa..0300e73b79 100644 --- a/src/lib/onboard/openclaw-otel-policy-presets.test.ts +++ b/src/lib/onboard/openclaw-otel-policy-presets.test.ts @@ -3,12 +3,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -vi.mock("./messaging-policy-presets", () => ({ - mergeRequiredMessagingChannelPolicyPresets: (presets: string[]) => presets, - requiredMessagingChannelPolicyPresets: () => [], +vi.mock("../messaging/applier/policy-presets", () => ({ + ALL_MESSAGING_POLICY_PRESET_NAMES: new Set(["slack"]), pruneDisabledMessagingPolicyPresets: (presets: string[]) => presets, - mergeAppliedPolicyPresetsForDisabledMessagingCleanup: (presets: string[]) => presets, - hasDisabledMessagingPolicyPreset: () => false, })); vi.mock("./hermes-managed-tools", () => ({ diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index 21848e246f..af39ba637e 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { WebSearchConfig } from "../inference/web-search"; +import { ALL_MESSAGING_POLICY_PRESET_NAMES } from "../messaging/applier/policy-presets"; import { filterSetupPolicyPresetNamesForAgent, filterSetupPolicyPresetsForAgent, @@ -12,13 +13,6 @@ import { HERMES_TOOL_GATEWAY_PRESET_NAMES, mergeRequiredHermesToolGatewayPolicyPresets, } from "./hermes-managed-tools"; -import { - hasDisabledMessagingPolicyPreset, - mergeAppliedPolicyPresetsForDisabledMessagingCleanup, - mergeRequiredMessagingChannelPolicyPresets, - pruneDisabledMessagingPolicyPresets, - requiredMessagingChannelPolicyPresets, -} from "./messaging-policy-presets"; import { isOpenclawAgent, mergeRequiredOpenclawOtelPolicyPresets, @@ -46,7 +40,7 @@ type TiersApi = { }; export type SetupPresetSuggestionOptions = { - enabledChannels?: string[] | null; + messagingPolicyPresets?: string[] | null; webSearchConfig?: WebSearchConfig | null; provider?: string | null; agent?: string | null; @@ -60,13 +54,12 @@ export type SetupPolicySelectionOptions = { selectedPresets?: string[] | null; onSelection?: ((policyPresets: string[]) => void) | null; webSearchConfig?: WebSearchConfig | null; - enabledChannels?: string[] | null; + messagingPolicyPresets?: string[] | null; provider?: string | null; agent?: string | null; knownPresetNames?: string[]; webSearchSupported?: boolean | null; hermesToolGateways?: string[] | null; - disabledChannels?: string[] | null; }; export type SetupPolicySelectionDeps = { @@ -103,36 +96,52 @@ export type PreparedPolicyResumeSelection = { export function mergeRequiredSetupPolicyPresets( policyPresets: string[], options: { - enabledChannels?: string[] | null; + messagingPolicyPresets?: string[] | null; hermesToolGateways?: string[] | null; agent?: string | null; knownPresetNames?: string[] | Set | null; env?: NodeJS.ProcessEnv; } = {}, ): string[] { - const agentFilteredPresets = filterSetupPolicyPresetNamesForAgent( - policyPresets, - options.agent, - ); - const mergedPresets = mergeRequiredOpenclawOtelPolicyPresets( - mergeRequiredMessagingChannelPolicyPresets( - mergeRequiredHermesToolGatewayPolicyPresets( - agentFilteredPresets, - options.hermesToolGateways, - options.knownPresetNames, - ), - options.enabledChannels, + const agentFilteredPresets = filterSetupPolicyPresetNamesForAgent(policyPresets, options.agent); + const known = options.knownPresetNames + ? options.knownPresetNames instanceof Set + ? options.knownPresetNames + : new Set(options.knownPresetNames) + : null; + + const withMessaging = mergePresetList( + mergeRequiredHermesToolGatewayPolicyPresets( + agentFilteredPresets, + options.hermesToolGateways, options.knownPresetNames, ), - { - agent: options.agent, - knownPresetNames: options.knownPresetNames, - env: options.env, - }, + options.messagingPolicyPresets ?? [], + known, ); + + const mergedPresets = mergeRequiredOpenclawOtelPolicyPresets(withMessaging, { + agent: options.agent, + knownPresetNames: options.knownPresetNames, + env: options.env, + }); return filterSetupPolicyPresetNamesForAgent(mergedPresets, options.agent); } +function mergePresetList( + base: string[], + additions: string[], + known: Set | null, +): string[] { + const merged = [...base]; + for (const preset of additions) { + if (known && !known.has(preset)) continue; + if (merged.includes(preset)) continue; + merged.push(preset); + } + return merged; +} + export function isStaleBuiltinBravePolicyPreset( name: string, options: { @@ -140,11 +149,7 @@ export function isStaleBuiltinBravePolicyPreset( customPresetNames?: ReadonlySet | null; } = {}, ): boolean { - return ( - name === "brave" && - !options.webSearchConfig && - !options.customPresetNames?.has(name) - ); + return name === "brave" && !options.webSearchConfig && !options.customPresetNames?.has(name); } export function computeSetupPresetSuggestions( @@ -158,7 +163,7 @@ export function computeSetupPresetSuggestions( options: SetupPresetSuggestionOptions = {}, ): string[] { const { - enabledChannels = null, + messagingPolicyPresets = null, webSearchConfig = null, provider = null, agent = null, @@ -189,9 +194,8 @@ export function computeSetupPresetSuggestions( if (tierName === "open" && typeof agent === "string" && agent.trim().toLowerCase() === "hermes") { for (const preset of allHermesToolGatewayPolicyPresets()) add(preset); } - if (Array.isArray(enabledChannels)) { - for (const channel of enabledChannels) add(channel); - for (const preset of requiredMessagingChannelPolicyPresets(enabledChannels)) add(preset); + for (const preset of messagingPolicyPresets ?? []) { + add(preset); } if (Array.isArray(options.hermesToolGateways)) { for (const preset of options.hermesToolGateways) { @@ -206,8 +210,7 @@ export function preparePolicyPresetResumeSelection( sandboxName: string, options: { recordedPolicyPresets: string[] | null; - disabledChannels?: string[] | null; - enabledChannels?: string[] | null; + messagingPolicyPresets?: string[] | null; hermesToolGateways?: string[] | null; agent?: string | null; webSearchConfig?: WebSearchConfig | null; @@ -240,10 +243,7 @@ export function preparePolicyPresetResumeSelection( webSearchConfig: options.webSearchConfig, customPresetNames: customPolicyPresetNames, }); - let policyPresets = pruneDisabledMessagingPolicyPresets( - clampedRecordedPolicyPresets.filter((name) => !isStaleBuiltinBrave(name)), - options.disabledChannels, - ); + let policyPresets = clampedRecordedPolicyPresets.filter((name) => !isStaleBuiltinBrave(name)); const recordedPolicyPresetsNeedReconcile = Array.isArray(options.recordedPolicyPresets) && policyPresets.length !== options.recordedPolicyPresets.length; @@ -255,18 +255,28 @@ export function preparePolicyPresetResumeSelection( customPolicyPresetNames, ) .filter((name) => !isStaleBuiltinBrave(name)); - const disabledMessagingPolicyPresetApplied = hasDisabledMessagingPolicyPreset( - appliedPolicyPresetsForSupport, - options.disabledChannels, + + // Detect stale messaging presets: any applied preset that belongs to the + // messaging-policy set but is absent from the compiled plan's active presets. + // When present, the resume skip is bypassed so the policy step can remove them. + const planMessagingPresets = new Set(options.messagingPolicyPresets ?? []); + const disabledMessagingPolicyPresetApplied = appliedPolicyPresetsForSupport.some( + (preset) => + ALL_MESSAGING_POLICY_PRESET_NAMES.has(preset) && !planMessagingPresets.has(preset), ); - policyPresets = mergeAppliedPolicyPresetsForDisabledMessagingCleanup( - policyPresets, - appliedPolicyPresetsForSupport, - options.disabledChannels, + + // Merge any applied non-stale presets (minus the stale messaging ones) so the + // policy sync step can diff them out cleanly. + const appliedToPreserve = appliedPolicyPresetsForSupport.filter( + (name) => !ALL_MESSAGING_POLICY_PRESET_NAMES.has(name) || planMessagingPresets.has(name), ); + for (const preset of appliedToPreserve) { + if (!policyPresets.includes(preset)) policyPresets.push(preset); + } + if (Array.isArray(options.recordedPolicyPresets)) { policyPresets = mergeRequiredSetupPolicyPresets(policyPresets, { - enabledChannels: options.enabledChannels, + messagingPolicyPresets: options.messagingPolicyPresets, hermesToolGateways: options.hermesToolGateways, agent: options.agent, knownPresetNames: selectablePolicyPresets.map((preset) => preset.name), @@ -299,15 +309,14 @@ async function setupPoliciesWithSelectionInner( const selectedPresets = Array.isArray(options.selectedPresets) ? options.selectedPresets : null; const onSelection = typeof options.onSelection === "function" ? options.onSelection : null; const webSearchConfig = options.webSearchConfig || null; - const enabledChannels = Array.isArray(options.enabledChannels) ? options.enabledChannels : null; + const messagingPolicyPresets = Array.isArray(options.messagingPolicyPresets) + ? options.messagingPolicyPresets + : null; const provider = options.provider || null; const agent = options.agent || null; const hermesToolGateways = Array.isArray(options.hermesToolGateways) ? options.hermesToolGateways : null; - const disabledChannels = Array.isArray(options.disabledChannels) - ? options.disabledChannels - : null; deps.step(8, 8, "Policy presets"); @@ -335,12 +344,15 @@ async function setupPoliciesWithSelectionInner( ); const isStaleBuiltinBrave = (name: string) => isStaleBuiltinBravePolicyPreset(name, { webSearchConfig, customPresetNames }); - const appliedForPreservation = pruneDisabledMessagingPolicyPresets( - applied, - disabledChannels, - ).filter((name) => !isStaleBuiltinBrave(name)); - const pruneDisabledPresets = (presetNames: string[]) => - pruneDisabledMessagingPolicyPresets(presetNames, disabledChannels); + // Preserve applied presets that are not stale messaging presets. Stale + // messaging presets (active in gateway but absent from the plan) are excluded + // so the sync step removes them. + const planMessagingPresets = new Set(messagingPolicyPresets ?? []); + const appliedForPreservation = applied.filter( + (name) => + !isStaleBuiltinBrave(name) && + (!ALL_MESSAGING_POLICY_PRESET_NAMES.has(name) || planMessagingPresets.has(name)), + ); const filterSupportedPresetNames = (presetNames: string[]) => filterSetupPolicyPresetNamesForAgent(presetNames, agent).filter( (name) => @@ -359,13 +371,12 @@ async function setupPoliciesWithSelectionInner( if (chosen !== null) { const knownSelectablePresets = new Set(selectablePresets.map((preset) => preset.name)); chosen = mergeRequiredSetupPolicyPresets(chosen, { - enabledChannels, + messagingPolicyPresets, hermesToolGateways, agent, knownPresetNames: knownSelectablePresets, env: deps.env, }); - chosen = pruneDisabledPresets(chosen); } if (selectedPresets !== null) { @@ -382,18 +393,16 @@ async function setupPoliciesWithSelectionInner( const tierName = await deps.selectPolicyTier(); deps.setPolicyTier?.(sandboxName, tierName); - const suggestions = pruneDisabledPresets( - computeSetupPresetSuggestions(deps, tierName, { - enabledChannels, - webSearchConfig, - provider, - agent, - knownPresetNames: allPresets.map((preset) => preset.name), - webSearchSupported: options.webSearchSupported, - hermesToolGateways, - env: deps.env, - }), - ); + const suggestions = computeSetupPresetSuggestions(deps, tierName, { + messagingPolicyPresets, + webSearchConfig, + provider, + agent, + knownPresetNames: allPresets.map((preset) => preset.name), + webSearchSupported: options.webSearchSupported, + hermesToolGateways, + env: deps.env, + }); if (deps.isNonInteractive()) { const policyMode = (deps.env?.NEMOCLAW_POLICY_MODE || "suggested").trim().toLowerCase(); @@ -430,13 +439,12 @@ async function setupPoliciesWithSelectionInner( } chosen = mergeRequiredSetupPolicyPresets(chosen, { - enabledChannels, + messagingPolicyPresets, hermesToolGateways, agent, knownPresetNames: knownPresets, env: deps.env, }); - chosen = pruneDisabledPresets(chosen); const invalidPresets = chosen.filter((name) => !knownPresets.has(name)); if (invalidPresets.length > 0) { @@ -455,7 +463,9 @@ async function setupPoliciesWithSelectionInner( preserved.push(name); } if (preserved.length > 0) { - deps.note(` [non-interactive] Preserving previously-applied presets: ${preserved.join(", ")}`); + deps.note( + ` [non-interactive] Preserving previously-applied presets: ${preserved.join(", ")}`, + ); } } @@ -475,17 +485,15 @@ async function setupPoliciesWithSelectionInner( ...suggestions.filter((name) => knownNames.has(name) && !applied.includes(name)), ]; const resolvedPresets = await deps.selectTierPresetsAndAccess(tierName, allPresets, extraSelected); - const interactiveChoice = pruneDisabledPresets( - mergeRequiredSetupPolicyPresets( - resolvedPresets.map((preset) => preset.name), - { - enabledChannels, - hermesToolGateways, - agent, - knownPresetNames: knownNames, - env: deps.env, - }, - ), + const interactiveChoice = mergeRequiredSetupPolicyPresets( + resolvedPresets.map((preset) => preset.name), + { + messagingPolicyPresets, + hermesToolGateways, + agent, + knownPresetNames: knownNames, + env: deps.env, + }, ); if (onSelection) onSelection(interactiveChoice); diff --git a/test/rebuild-policy-presets.test.ts b/test/rebuild-policy-presets.test.ts index d47d188fb9..18944cde3e 100644 --- a/test/rebuild-policy-presets.test.ts +++ b/test/rebuild-policy-presets.test.ts @@ -12,7 +12,7 @@ import os from "node:os"; import path from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { pruneDisabledMessagingPolicyPresets } from "../src/lib/onboard/messaging-policy-presets"; +import { pruneDisabledMessagingPolicyPresets } from "../src/lib/messaging/applier/policy-presets"; type ManifestWithOptionalPresets = { version: number; From 6d8b47c9d2d3fa75578faa947da7b093659d2b8d Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 18:17:39 +0700 Subject: [PATCH 7/7] Revert "refactor(policy): migrate messaging policy to plan-derived presets" This reverts commit 12d5e962f100980a716fb722f205d0d2fd10fefe. --- src/lib/actions/sandbox/rebuild.ts | 2 +- src/lib/messaging/applier/index.ts | 1 - .../messaging/applier/policy-presets.test.ts | 40 ---- src/lib/messaging/applier/policy-presets.ts | 50 ----- src/lib/onboard.ts | 7 +- src/lib/onboard/initial-policy.test.ts | 11 +- src/lib/onboard/initial-policy.ts | 14 +- .../onboard/machine/handlers/policies.test.ts | 38 ++-- src/lib/onboard/machine/handlers/policies.ts | 45 ++++- src/lib/onboard/messaging-plan-session.ts | 5 - .../onboard/messaging-policy-presets.test.ts | 80 ++++++++ src/lib/onboard/messaging-policy-presets.ts | 105 ++++++++++ .../openclaw-otel-policy-presets.test.ts | 7 +- src/lib/onboard/policy-selection.ts | 186 +++++++++--------- test/rebuild-policy-presets.test.ts | 2 +- 15 files changed, 353 insertions(+), 240 deletions(-) delete mode 100644 src/lib/messaging/applier/policy-presets.test.ts delete mode 100644 src/lib/messaging/applier/policy-presets.ts create mode 100644 src/lib/onboard/messaging-policy-presets.test.ts create mode 100644 src/lib/onboard/messaging-policy-presets.ts diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index c044e29c58..63f06a0219 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -50,7 +50,7 @@ import { toMessagingAgentId, } from "../../messaging"; import type { SandboxMessagingPlan } from "../../messaging/manifest"; -import { pruneDisabledMessagingPolicyPresets } from "../../messaging/applier/policy-presets"; +import { pruneDisabledMessagingPolicyPresets } from "../../onboard/messaging-policy-presets"; import { captureSandboxListWithGatewayRecovery, printSandboxListFailureWithRecoveryContext, diff --git a/src/lib/messaging/applier/index.ts b/src/lib/messaging/applier/index.ts index 005ed9759f..f9e4dcf486 100644 --- a/src/lib/messaging/applier/index.ts +++ b/src/lib/messaging/applier/index.ts @@ -6,5 +6,4 @@ export * from "./host-state-applier"; export * from "./agent-config"; export * from "./openshell-provider"; export * from "./policy"; -export * from "./policy-presets"; export type * from "./types"; diff --git a/src/lib/messaging/applier/policy-presets.test.ts b/src/lib/messaging/applier/policy-presets.test.ts deleted file mode 100644 index a7bb8aef02..0000000000 --- a/src/lib/messaging/applier/policy-presets.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; - -import { - ALL_MESSAGING_POLICY_PRESET_NAMES, - pruneDisabledMessagingPolicyPresets, -} from "./policy-presets"; - -describe("pruneDisabledMessagingPolicyPresets", () => { - it("removes policy presets for disabled messaging channels", () => { - expect(pruneDisabledMessagingPolicyPresets(["npm", "slack", "pypi"], [" Slack "])).toEqual([ - "npm", - "pypi", - ]); - }); - - it("preserves non-required policy presets when a same-named channel is disabled", () => { - expect( - pruneDisabledMessagingPolicyPresets(["telegram", "npm", "pypi"], ["telegram"]), - ).toEqual(["telegram", "npm", "pypi"]); - }); - - it("returns the original list unchanged when no channels are disabled", () => { - expect(pruneDisabledMessagingPolicyPresets(["npm", "slack"], null)).toEqual(["npm", "slack"]); - }); -}); - -describe("ALL_MESSAGING_POLICY_PRESET_NAMES", () => { - it("includes the slack preset", () => { - expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("slack")).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("telegram")).toBe(false); - }); -}); diff --git a/src/lib/messaging/applier/policy-presets.ts b/src/lib/messaging/applier/policy-presets.ts deleted file mode 100644 index d654174b75..0000000000 --- a/src/lib/messaging/applier/policy-presets.ts +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -const REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL: Record = { - slack: ["slack"], -}; - -/** All preset names that any messaging channel can require. */ -export const ALL_MESSAGING_POLICY_PRESET_NAMES: ReadonlySet = new Set( - Object.values(REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL).flat(), -); - -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; -} - -function requiredMessagingChannelPolicyPresets(channels: string[] | null | undefined): string[] { - const required: string[] = []; - for (const channel of normalizedNames(channels)) { - for (const preset of REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL[channel] || []) { - if (!required.includes(preset)) required.push(preset); - } - } - return required; -} - -/** - * 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, disabled channels are already - * excluded from plan.networkPolicy.presets. - */ -export function pruneDisabledMessagingPolicyPresets( - selectedPresets: string[], - disabledChannels: string[] | null | undefined, -): string[] { - const disabledRequiredPresets = new Set(requiredMessagingChannelPolicyPresets(disabledChannels)); - if (disabledRequiredPresets.size === 0) return selectedPresets; - return selectedPresets.filter( - (preset) => !disabledRequiredPresets.has(preset.trim().toLowerCase()), - ); -} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index df866b448b..ebb54bd7f8 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -521,7 +521,7 @@ import { setupHermesToolGateways, stringSetsEqual, } from "./onboard/hermes-managed-tools"; -import { getPolicyPresetsFromPlan } from "./onboard/messaging-plan-session"; +import { mergePolicyMessagingChannels } from "./onboard/messaging-policy-presets"; import { filterEnabledChannelsByAgent, resolveQrSelectedChannels, @@ -3379,7 +3379,6 @@ async function createSandbox( directGpu: effectiveSandboxGpuConfig.sandboxGpuEnabled, dockerGpuPatch: useDockerGpuPatch, additionalPresets: hermesToolGateways, - messagingPolicyPresets: getPolicyPresetsFromPlan(onboardSession.loadSession()?.messagingPlan), }, ); if (initialSandboxPolicy.cleanup) { @@ -6567,13 +6566,15 @@ async function onboard(opts: OnboardOptions = {}): Promise { model, endpointUrl, credentialEnv, - messagingPolicyPresets: getPolicyPresetsFromPlan(onboardSession.loadSession()?.messagingPlan), + selectedMessagingChannels, webSearchConfig, webSearchSupported, hermesToolGateways, agent, deps: { loadSession: onboardSession.loadSession, + getActiveSandbox: (name) => registry.getSandbox(name), + mergePolicyMessagingChannels, verifyCompatibleEndpointSandboxSmoke: (options) => verifyCompatibleEndpointSandboxSmoke({ ...options, diff --git a/src/lib/onboard/initial-policy.test.ts b/src/lib/onboard/initial-policy.test.ts index 7a4151d539..5dbfc2fcc8 100644 --- a/src/lib/onboard/initial-policy.test.ts +++ b/src/lib/onboard/initial-policy.test.ts @@ -175,11 +175,7 @@ network_policies: it("records an existing create-time preset without writing a temp policy", () => { const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n slack: {}\n"); - expect( - prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"], { - messagingPolicyPresets: ["slack"], - }), - ).toEqual({ + expect(prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"])).toEqual({ policyPath: basePolicyPath, appliedPresets: ["slack"], }); @@ -241,9 +237,7 @@ network_policies: it("merges missing create-time presets into a temporary policy", () => { const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); - const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"], { - messagingPolicyPresets: ["slack"], - }); + const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"]); expect(prepared.policyPath).not.toBe(basePolicyPath); expect(prepared.appliedPresets).toEqual(["slack"]); @@ -256,7 +250,6 @@ network_policies: const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"], { - messagingPolicyPresets: ["slack"], additionalPresets: ["nous-web"], }); diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts index f824e734e4..74f5c8dde1 100644 --- a/src/lib/onboard/initial-policy.ts +++ b/src/lib/onboard/initial-policy.ts @@ -6,6 +6,7 @@ import path from "node:path"; import YAML from "yaml"; import * as policies from "../policy"; +import { requiredMessagingChannelPolicyPresets } from "./messaging-policy-presets"; import { requiredOpenclawOtelPolicyPresets } from "./openclaw-otel-policy-presets"; import { cleanupTempDir, secureTempFile } from "./temp-files"; @@ -210,7 +211,6 @@ export function prepareInitialSandboxCreatePolicy( dockerGpuPatch?: boolean; additionalPresets?: string[]; agentName?: string | null; - messagingPolicyPresets?: string[]; } = {}, ): InitialSandboxPolicy { const directGpuPolicy = options.directGpu @@ -225,11 +225,13 @@ export function prepareInitialSandboxCreatePolicy( ? () => cleanupFns.map((cleanup) => cleanup()).every(Boolean) : undefined; const requestedCreateTimePresets = [ - ...new Set([ - ...(options.messagingPolicyPresets ?? []), - ...requiredOpenclawOtelPolicyPresets(options.agentName ?? "openclaw"), - ...(options.additionalPresets || []), - ]), + ...new Set( + [ + ...requiredMessagingChannelPolicyPresets(activeMessagingChannels), + ...requiredOpenclawOtelPolicyPresets(options.agentName ?? "openclaw"), + ...(options.additionalPresets || []), + ], + ), ]; const dedupe = (values: string[]) => [...new Set(values.filter(Boolean))]; diff --git a/src/lib/onboard/machine/handlers/policies.test.ts b/src/lib/onboard/machine/handlers/policies.test.ts index 503cba65c5..1ac343030c 100644 --- a/src/lib/onboard/machine/handlers/policies.test.ts +++ b/src/lib/onboard/machine/handlers/policies.test.ts @@ -13,6 +13,14 @@ function createDeps(overrides: Partial session), + activeSandbox: vi.fn(() => ({ messagingChannels: ["telegram"], disabledChannels: null })), + mergeChannels: vi.fn( + ( + selected: string[], + recorded: string[], + active: string[] | null | undefined, + ) => (selected.length > 0 ? selected : active ?? recorded), + ), smoke: vi.fn(), prepareResume: vi.fn( ( @@ -40,6 +48,8 @@ function createDeps(overrides: Partial { it("runs compatible endpoint smoke before policy selection", async () => { const { deps, calls } = createDeps(); - const result = await handlePoliciesState({ - ...baseOptions(deps), - messagingPolicyPresets: ["slack"], - }); + const result = await handlePoliciesState(baseOptions(deps)); expect(calls.smoke).toHaveBeenCalledWith({ sandboxName: "my-assistant", @@ -94,7 +101,7 @@ describe("handlePoliciesState", () => { model: "model", endpointUrl: "https://example.com/v1", credentialEnv: "NVIDIA_API_KEY", - messagingChannels: ["slack"], + messagingChannels: ["telegram"], agent: null, }); expect(calls.startStep).toHaveBeenCalledWith("policies", { @@ -107,7 +114,7 @@ describe("handlePoliciesState", () => { "my-assistant", expect.objectContaining({ selectedPresets: null, - messagingPolicyPresets: ["slack"], + enabledChannels: ["telegram"], provider: "provider", webSearchSupported: true, }), @@ -125,17 +132,18 @@ describe("handlePoliciesState", () => { }); }); - it("passes plan-derived messaging policy presets through to preparePolicyPresetResumeSelection", async () => { - const { deps, calls } = createDeps(); - - await handlePoliciesState({ - ...baseOptions(deps), - messagingPolicyPresets: ["slack"], + it("uses recorded messaging channels when no active selection exists", async () => { + const session = createSession({ messagingChannels: ["slack"] }); + const { deps, calls, setSession } = createDeps({ + getActiveSandbox: vi.fn(() => ({ messagingChannels: null, disabledChannels: null })), }); + setSession(session); - expect(calls.prepareResume).toHaveBeenCalledWith( + await handlePoliciesState(baseOptions(deps)); + + expect(calls.setupPolicies).toHaveBeenCalledWith( "my-assistant", - expect.objectContaining({ messagingPolicyPresets: ["slack"] }), + expect.objectContaining({ enabledChannels: ["slack"] }), ); }); diff --git a/src/lib/onboard/machine/handlers/policies.ts b/src/lib/onboard/machine/handlers/policies.ts index 25705e3847..0a1c281c35 100644 --- a/src/lib/onboard/machine/handlers/policies.ts +++ b/src/lib/onboard/machine/handlers/policies.ts @@ -17,6 +17,11 @@ export interface PolicyPresetEntry { [key: string]: unknown; } +export interface ActiveSandboxPolicyState { + messagingChannels?: string[] | null; + disabledChannels?: string[] | null; +} + export interface PolicyResumeSelection { policyPresets: string[]; recordedPolicyPresetsNeedReconcile: boolean; @@ -30,13 +35,20 @@ export interface PoliciesStateOptions { model: string; endpointUrl: string | null; credentialEnv: string | null; - messagingPolicyPresets: string[]; + selectedMessagingChannels: string[]; webSearchConfig: WebSearchConfig | null; webSearchSupported: boolean; hermesToolGateways: string[]; agent: Agent; deps: { loadSession(): Session | null; + getActiveSandbox(sandboxName: string): ActiveSandboxPolicyState | null | undefined; + mergePolicyMessagingChannels( + selectedMessagingChannels: string[], + recordedMessagingChannels: string[], + activeMessagingChannels: string[] | null | undefined, + disabledChannels: string[] | null | undefined, + ): string[]; verifyCompatibleEndpointSandboxSmoke(options: { sandboxName: string; provider: string; @@ -50,7 +62,8 @@ export interface PoliciesStateOptions { sandboxName: string, options: { recordedPolicyPresets: string[] | null; - messagingPolicyPresets?: string[] | null; + disabledChannels: string[] | null | undefined; + enabledChannels: string[]; hermesToolGateways: string[]; agent?: string | null; webSearchConfig: WebSearchConfig | null; @@ -68,7 +81,8 @@ export interface PoliciesStateOptions { sandboxName: string, options: { selectedPresets: string[] | null; - messagingPolicyPresets: string[]; + enabledChannels: string[]; + disabledChannels?: string[] | null; webSearchConfig: WebSearchConfig | null; provider: string; agent?: string | null; @@ -92,6 +106,7 @@ export interface PoliciesStateOptions { export interface PoliciesStateResult { session: Session | null; + recordedMessagingChannels: string[]; appliedPolicyPresets: string[]; stateResult: OnboardStateTransitionResult; } @@ -103,7 +118,7 @@ export async function handlePoliciesState({ model, endpointUrl, credentialEnv, - messagingPolicyPresets, + selectedMessagingChannels, webSearchConfig, webSearchSupported, hermesToolGateways, @@ -114,22 +129,30 @@ export async function handlePoliciesState({ const recordedPolicyPresets = Array.isArray(latestSession?.policyPresets) ? latestSession.policyPresets : null; - + const recordedMessagingChannels = Array.isArray(latestSession?.messagingChannels) + ? latestSession.messagingChannels + : []; + const activeSandbox = deps.getActiveSandbox(sandboxName); + const policyMessagingChannels = deps.mergePolicyMessagingChannels( + selectedMessagingChannels, + recordedMessagingChannels, + activeSandbox?.messagingChannels, + activeSandbox?.disabledChannels, + ); deps.verifyCompatibleEndpointSandboxSmoke({ sandboxName, provider, model, endpointUrl, credentialEnv, - // The smoke check only needs to know whether messaging channels are active; - // treat all plan presets as active-channel signals for the provider guard. - messagingChannels: messagingPolicyPresets, + messagingChannels: policyMessagingChannels, agent, }); const policyResumeSelection = deps.preparePolicyPresetResumeSelection(sandboxName, { recordedPolicyPresets, - messagingPolicyPresets, + disabledChannels: activeSandbox?.disabledChannels, + enabledChannels: policyMessagingChannels, hermesToolGateways, agent: normalizeAgentName((agent as { name?: string } | null)?.name), webSearchConfig, @@ -182,7 +205,8 @@ export async function handlePoliciesState({ selectedPresets: Array.isArray(recordedPolicyPresets) ? recordedPolicyPresetsForSupport : null, - messagingPolicyPresets, + enabledChannels: policyMessagingChannels, + disabledChannels: activeSandbox?.disabledChannels, webSearchConfig, provider, // selectOnboardAgent returns null for the default OpenClaw path (no @@ -221,6 +245,7 @@ export async function handlePoliciesState({ return { session, + recordedMessagingChannels, appliedPolicyPresets, stateResult: advanceTo("finalizing", { metadata: { state: "policies", policyPresets: appliedPolicyPresets }, diff --git a/src/lib/onboard/messaging-plan-session.ts b/src/lib/onboard/messaging-plan-session.ts index 58686c8cd0..bb05f97ea2 100644 --- a/src/lib/onboard/messaging-plan-session.ts +++ b/src/lib/onboard/messaging-plan-session.ts @@ -43,11 +43,6 @@ export function getDisabledChannelsFromPlan( return plan.disabledChannels.length > 0 ? [...plan.disabledChannels] : null; } -/** Derive the messaging network policy presets for active channels from a plan. */ -export function getPolicyPresetsFromPlan(plan: SandboxMessagingPlan | null | undefined): string[] { - return plan?.networkPolicy.presets ? [...plan.networkPolicy.presets] : []; -} - /** * Derive the equivalent of session.messagingChannelConfig from a plan. * Config inputs (kind === "config") carry their resolved env-key/value pairs diff --git a/src/lib/onboard/messaging-policy-presets.test.ts b/src/lib/onboard/messaging-policy-presets.test.ts new file mode 100644 index 0000000000..43466fa3b2 --- /dev/null +++ b/src/lib/onboard/messaging-policy-presets.test.ts @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + hasDisabledMessagingPolicyPreset, + mergeAppliedPolicyPresetsForDisabledMessagingCleanup, + mergePolicyMessagingChannels, + mergeRequiredMessagingChannelPolicyPresets, + pruneDisabledMessagingPolicyPresets, + requiredMessagingChannelPolicyPresets, +} from "./messaging-policy-presets"; + +describe("messaging policy presets", () => { + it("maps Slack messaging to the Slack network policy preset", () => { + expect(requiredMessagingChannelPolicyPresets(["slack"])).toEqual(["slack"]); + expect(requiredMessagingChannelPolicyPresets([" Slack "])).toEqual(["slack"]); + }); + + it("merges required messaging presets into an existing selection", () => { + expect(mergeRequiredMessagingChannelPolicyPresets(["npm", "pypi"], ["slack"])).toEqual([ + "npm", + "pypi", + "slack", + ]); + }); + + it("does not add a required preset that is not available to the sandbox", () => { + expect( + mergeRequiredMessagingChannelPolicyPresets(["npm"], ["slack"], new Set(["npm"])), + ).toEqual(["npm"]); + }); + + it("merges policy channels while excluding disabled channels", () => { + expect( + mergePolicyMessagingChannels( + ["slack", "telegram"], + [" Slack "], + ["discord", "slack"], + ["slack"], + ), + ).toEqual(["telegram", "discord"]); + }); + + it("removes policy presets for disabled messaging channels", () => { + expect(pruneDisabledMessagingPolicyPresets(["npm", "slack", "pypi"], [" Slack "])).toEqual([ + "npm", + "pypi", + ]); + }); + + it("preserves non-required policy presets when a same-named channel is disabled", () => { + expect( + pruneDisabledMessagingPolicyPresets(["telegram", "npm", "pypi"], ["telegram"]), + ).toEqual(["telegram", "npm", "pypi"]); + }); + + it("detects applied policy presets for disabled messaging channels", () => { + expect(hasDisabledMessagingPolicyPreset(["npm", "slack", "pypi"], ["slack"])).toBe(true); + expect(hasDisabledMessagingPolicyPreset(["telegram", "npm"], ["telegram"])).toBe(false); + }); + + it("preserves unrelated applied presets when cleaning disabled messaging presets", () => { + expect( + mergeAppliedPolicyPresetsForDisabledMessagingCleanup( + ["npm"], + ["npm", "github", "slack"], + ["slack"], + ), + ).toEqual(["npm", "github"]); + expect( + mergeAppliedPolicyPresetsForDisabledMessagingCleanup( + ["npm"], + ["npm", "github"], + ["slack"], + ), + ).toEqual(["npm"]); + }); +}); diff --git a/src/lib/onboard/messaging-policy-presets.ts b/src/lib/onboard/messaging-policy-presets.ts new file mode 100644 index 0000000000..c074e31282 --- /dev/null +++ b/src/lib/onboard/messaging-policy-presets.ts @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL: Record = { + slack: ["slack"], +}; + +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 mergePolicyMessagingChannels( + selectedChannels: string[] | null | undefined, + recordedChannels: string[] | null | undefined, + activeChannels: string[] | null | undefined, + disabledChannels: string[] | null | undefined = null, +): string[] { + const disabled = new Set(normalizedNames(disabledChannels)); + const merged: string[] = []; + for (const channels of [selectedChannels, recordedChannels, activeChannels]) { + for (const channel of normalizedNames(channels)) { + if (!channel || disabled.has(channel) || merged.includes(channel)) continue; + merged.push(channel); + } + } + return merged; +} + +export function requiredMessagingChannelPolicyPresets( + channels: string[] | null | undefined, +): string[] { + const required: string[] = []; + for (const channel of normalizedNames(channels)) { + for (const preset of REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL[channel] || []) { + if (!required.includes(preset)) required.push(preset); + } + } + return required; +} + +export function mergeRequiredMessagingChannelPolicyPresets( + selectedPresets: string[], + channels: string[] | null | undefined, + knownPresetNames?: Iterable | null, +): string[] { + const merged = [...selectedPresets]; + const selected = new Set(merged); + const known = knownPresetNames ? new Set(knownPresetNames) : null; + + for (const preset of requiredMessagingChannelPolicyPresets(channels)) { + if (known && !known.has(preset)) continue; + if (selected.has(preset)) continue; + merged.push(preset); + selected.add(preset); + } + + return merged; +} + +export function pruneDisabledMessagingPolicyPresets( + selectedPresets: string[], + disabledChannels: string[] | null | undefined, +): string[] { + const disabledRequiredPresets = new Set( + requiredMessagingChannelPolicyPresets(disabledChannels), + ); + if (disabledRequiredPresets.size === 0) return selectedPresets; + return selectedPresets.filter( + (preset) => !disabledRequiredPresets.has(preset.trim().toLowerCase()), + ); +} + +export function hasDisabledMessagingPolicyPreset( + selectedPresets: string[], + disabledChannels: string[] | null | undefined, +): boolean { + return ( + pruneDisabledMessagingPolicyPresets(selectedPresets, disabledChannels).length !== + selectedPresets.length + ); +} + +export function mergeAppliedPolicyPresetsForDisabledMessagingCleanup( + selectedPresets: string[], + appliedPresets: string[], + disabledChannels: string[] | null | undefined, +): string[] { + if (!hasDisabledMessagingPolicyPreset(appliedPresets, disabledChannels)) { + return selectedPresets; + } + + const merged = [...selectedPresets]; + for (const preset of pruneDisabledMessagingPolicyPresets(appliedPresets, disabledChannels)) { + if (!merged.includes(preset)) merged.push(preset); + } + return merged; +} diff --git a/src/lib/onboard/openclaw-otel-policy-presets.test.ts b/src/lib/onboard/openclaw-otel-policy-presets.test.ts index 0300e73b79..af799fc2fa 100644 --- a/src/lib/onboard/openclaw-otel-policy-presets.test.ts +++ b/src/lib/onboard/openclaw-otel-policy-presets.test.ts @@ -3,9 +3,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -vi.mock("../messaging/applier/policy-presets", () => ({ - ALL_MESSAGING_POLICY_PRESET_NAMES: new Set(["slack"]), +vi.mock("./messaging-policy-presets", () => ({ + mergeRequiredMessagingChannelPolicyPresets: (presets: string[]) => presets, + requiredMessagingChannelPolicyPresets: () => [], pruneDisabledMessagingPolicyPresets: (presets: string[]) => presets, + mergeAppliedPolicyPresetsForDisabledMessagingCleanup: (presets: string[]) => presets, + hasDisabledMessagingPolicyPreset: () => false, })); vi.mock("./hermes-managed-tools", () => ({ diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index af39ba637e..21848e246f 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import type { WebSearchConfig } from "../inference/web-search"; -import { ALL_MESSAGING_POLICY_PRESET_NAMES } from "../messaging/applier/policy-presets"; import { filterSetupPolicyPresetNamesForAgent, filterSetupPolicyPresetsForAgent, @@ -13,6 +12,13 @@ import { HERMES_TOOL_GATEWAY_PRESET_NAMES, mergeRequiredHermesToolGatewayPolicyPresets, } from "./hermes-managed-tools"; +import { + hasDisabledMessagingPolicyPreset, + mergeAppliedPolicyPresetsForDisabledMessagingCleanup, + mergeRequiredMessagingChannelPolicyPresets, + pruneDisabledMessagingPolicyPresets, + requiredMessagingChannelPolicyPresets, +} from "./messaging-policy-presets"; import { isOpenclawAgent, mergeRequiredOpenclawOtelPolicyPresets, @@ -40,7 +46,7 @@ type TiersApi = { }; export type SetupPresetSuggestionOptions = { - messagingPolicyPresets?: string[] | null; + enabledChannels?: string[] | null; webSearchConfig?: WebSearchConfig | null; provider?: string | null; agent?: string | null; @@ -54,12 +60,13 @@ export type SetupPolicySelectionOptions = { selectedPresets?: string[] | null; onSelection?: ((policyPresets: string[]) => void) | null; webSearchConfig?: WebSearchConfig | null; - messagingPolicyPresets?: string[] | null; + enabledChannels?: string[] | null; provider?: string | null; agent?: string | null; knownPresetNames?: string[]; webSearchSupported?: boolean | null; hermesToolGateways?: string[] | null; + disabledChannels?: string[] | null; }; export type SetupPolicySelectionDeps = { @@ -96,52 +103,36 @@ export type PreparedPolicyResumeSelection = { export function mergeRequiredSetupPolicyPresets( policyPresets: string[], options: { - messagingPolicyPresets?: string[] | null; + enabledChannels?: string[] | null; hermesToolGateways?: string[] | null; agent?: string | null; knownPresetNames?: string[] | Set | null; env?: NodeJS.ProcessEnv; } = {}, ): string[] { - const agentFilteredPresets = filterSetupPolicyPresetNamesForAgent(policyPresets, options.agent); - const known = options.knownPresetNames - ? options.knownPresetNames instanceof Set - ? options.knownPresetNames - : new Set(options.knownPresetNames) - : null; - - const withMessaging = mergePresetList( - mergeRequiredHermesToolGatewayPolicyPresets( - agentFilteredPresets, - options.hermesToolGateways, + const agentFilteredPresets = filterSetupPolicyPresetNamesForAgent( + policyPresets, + options.agent, + ); + const mergedPresets = mergeRequiredOpenclawOtelPolicyPresets( + mergeRequiredMessagingChannelPolicyPresets( + mergeRequiredHermesToolGatewayPolicyPresets( + agentFilteredPresets, + options.hermesToolGateways, + options.knownPresetNames, + ), + options.enabledChannels, options.knownPresetNames, ), - options.messagingPolicyPresets ?? [], - known, + { + agent: options.agent, + knownPresetNames: options.knownPresetNames, + env: options.env, + }, ); - - const mergedPresets = mergeRequiredOpenclawOtelPolicyPresets(withMessaging, { - agent: options.agent, - knownPresetNames: options.knownPresetNames, - env: options.env, - }); return filterSetupPolicyPresetNamesForAgent(mergedPresets, options.agent); } -function mergePresetList( - base: string[], - additions: string[], - known: Set | null, -): string[] { - const merged = [...base]; - for (const preset of additions) { - if (known && !known.has(preset)) continue; - if (merged.includes(preset)) continue; - merged.push(preset); - } - return merged; -} - export function isStaleBuiltinBravePolicyPreset( name: string, options: { @@ -149,7 +140,11 @@ export function isStaleBuiltinBravePolicyPreset( customPresetNames?: ReadonlySet | null; } = {}, ): boolean { - return name === "brave" && !options.webSearchConfig && !options.customPresetNames?.has(name); + return ( + name === "brave" && + !options.webSearchConfig && + !options.customPresetNames?.has(name) + ); } export function computeSetupPresetSuggestions( @@ -163,7 +158,7 @@ export function computeSetupPresetSuggestions( options: SetupPresetSuggestionOptions = {}, ): string[] { const { - messagingPolicyPresets = null, + enabledChannels = null, webSearchConfig = null, provider = null, agent = null, @@ -194,8 +189,9 @@ export function computeSetupPresetSuggestions( if (tierName === "open" && typeof agent === "string" && agent.trim().toLowerCase() === "hermes") { for (const preset of allHermesToolGatewayPolicyPresets()) add(preset); } - for (const preset of messagingPolicyPresets ?? []) { - add(preset); + if (Array.isArray(enabledChannels)) { + for (const channel of enabledChannels) add(channel); + for (const preset of requiredMessagingChannelPolicyPresets(enabledChannels)) add(preset); } if (Array.isArray(options.hermesToolGateways)) { for (const preset of options.hermesToolGateways) { @@ -210,7 +206,8 @@ export function preparePolicyPresetResumeSelection( sandboxName: string, options: { recordedPolicyPresets: string[] | null; - messagingPolicyPresets?: string[] | null; + disabledChannels?: string[] | null; + enabledChannels?: string[] | null; hermesToolGateways?: string[] | null; agent?: string | null; webSearchConfig?: WebSearchConfig | null; @@ -243,7 +240,10 @@ export function preparePolicyPresetResumeSelection( webSearchConfig: options.webSearchConfig, customPresetNames: customPolicyPresetNames, }); - let policyPresets = clampedRecordedPolicyPresets.filter((name) => !isStaleBuiltinBrave(name)); + let policyPresets = pruneDisabledMessagingPolicyPresets( + clampedRecordedPolicyPresets.filter((name) => !isStaleBuiltinBrave(name)), + options.disabledChannels, + ); const recordedPolicyPresetsNeedReconcile = Array.isArray(options.recordedPolicyPresets) && policyPresets.length !== options.recordedPolicyPresets.length; @@ -255,28 +255,18 @@ export function preparePolicyPresetResumeSelection( customPolicyPresetNames, ) .filter((name) => !isStaleBuiltinBrave(name)); - - // Detect stale messaging presets: any applied preset that belongs to the - // messaging-policy set but is absent from the compiled plan's active presets. - // When present, the resume skip is bypassed so the policy step can remove them. - const planMessagingPresets = new Set(options.messagingPolicyPresets ?? []); - const disabledMessagingPolicyPresetApplied = appliedPolicyPresetsForSupport.some( - (preset) => - ALL_MESSAGING_POLICY_PRESET_NAMES.has(preset) && !planMessagingPresets.has(preset), + const disabledMessagingPolicyPresetApplied = hasDisabledMessagingPolicyPreset( + appliedPolicyPresetsForSupport, + options.disabledChannels, ); - - // Merge any applied non-stale presets (minus the stale messaging ones) so the - // policy sync step can diff them out cleanly. - const appliedToPreserve = appliedPolicyPresetsForSupport.filter( - (name) => !ALL_MESSAGING_POLICY_PRESET_NAMES.has(name) || planMessagingPresets.has(name), + policyPresets = mergeAppliedPolicyPresetsForDisabledMessagingCleanup( + policyPresets, + appliedPolicyPresetsForSupport, + options.disabledChannels, ); - for (const preset of appliedToPreserve) { - if (!policyPresets.includes(preset)) policyPresets.push(preset); - } - if (Array.isArray(options.recordedPolicyPresets)) { policyPresets = mergeRequiredSetupPolicyPresets(policyPresets, { - messagingPolicyPresets: options.messagingPolicyPresets, + enabledChannels: options.enabledChannels, hermesToolGateways: options.hermesToolGateways, agent: options.agent, knownPresetNames: selectablePolicyPresets.map((preset) => preset.name), @@ -309,14 +299,15 @@ async function setupPoliciesWithSelectionInner( const selectedPresets = Array.isArray(options.selectedPresets) ? options.selectedPresets : null; const onSelection = typeof options.onSelection === "function" ? options.onSelection : null; const webSearchConfig = options.webSearchConfig || null; - const messagingPolicyPresets = Array.isArray(options.messagingPolicyPresets) - ? options.messagingPolicyPresets - : null; + const enabledChannels = Array.isArray(options.enabledChannels) ? options.enabledChannels : null; const provider = options.provider || null; const agent = options.agent || null; const hermesToolGateways = Array.isArray(options.hermesToolGateways) ? options.hermesToolGateways : null; + const disabledChannels = Array.isArray(options.disabledChannels) + ? options.disabledChannels + : null; deps.step(8, 8, "Policy presets"); @@ -344,15 +335,12 @@ async function setupPoliciesWithSelectionInner( ); const isStaleBuiltinBrave = (name: string) => isStaleBuiltinBravePolicyPreset(name, { webSearchConfig, customPresetNames }); - // Preserve applied presets that are not stale messaging presets. Stale - // messaging presets (active in gateway but absent from the plan) are excluded - // so the sync step removes them. - const planMessagingPresets = new Set(messagingPolicyPresets ?? []); - const appliedForPreservation = applied.filter( - (name) => - !isStaleBuiltinBrave(name) && - (!ALL_MESSAGING_POLICY_PRESET_NAMES.has(name) || planMessagingPresets.has(name)), - ); + const appliedForPreservation = pruneDisabledMessagingPolicyPresets( + applied, + disabledChannels, + ).filter((name) => !isStaleBuiltinBrave(name)); + const pruneDisabledPresets = (presetNames: string[]) => + pruneDisabledMessagingPolicyPresets(presetNames, disabledChannels); const filterSupportedPresetNames = (presetNames: string[]) => filterSetupPolicyPresetNamesForAgent(presetNames, agent).filter( (name) => @@ -371,12 +359,13 @@ async function setupPoliciesWithSelectionInner( if (chosen !== null) { const knownSelectablePresets = new Set(selectablePresets.map((preset) => preset.name)); chosen = mergeRequiredSetupPolicyPresets(chosen, { - messagingPolicyPresets, + enabledChannels, hermesToolGateways, agent, knownPresetNames: knownSelectablePresets, env: deps.env, }); + chosen = pruneDisabledPresets(chosen); } if (selectedPresets !== null) { @@ -393,16 +382,18 @@ async function setupPoliciesWithSelectionInner( const tierName = await deps.selectPolicyTier(); deps.setPolicyTier?.(sandboxName, tierName); - const suggestions = computeSetupPresetSuggestions(deps, tierName, { - messagingPolicyPresets, - webSearchConfig, - provider, - agent, - knownPresetNames: allPresets.map((preset) => preset.name), - webSearchSupported: options.webSearchSupported, - hermesToolGateways, - env: deps.env, - }); + const suggestions = pruneDisabledPresets( + computeSetupPresetSuggestions(deps, tierName, { + enabledChannels, + webSearchConfig, + provider, + agent, + knownPresetNames: allPresets.map((preset) => preset.name), + webSearchSupported: options.webSearchSupported, + hermesToolGateways, + env: deps.env, + }), + ); if (deps.isNonInteractive()) { const policyMode = (deps.env?.NEMOCLAW_POLICY_MODE || "suggested").trim().toLowerCase(); @@ -439,12 +430,13 @@ async function setupPoliciesWithSelectionInner( } chosen = mergeRequiredSetupPolicyPresets(chosen, { - messagingPolicyPresets, + enabledChannels, hermesToolGateways, agent, knownPresetNames: knownPresets, env: deps.env, }); + chosen = pruneDisabledPresets(chosen); const invalidPresets = chosen.filter((name) => !knownPresets.has(name)); if (invalidPresets.length > 0) { @@ -463,9 +455,7 @@ async function setupPoliciesWithSelectionInner( preserved.push(name); } if (preserved.length > 0) { - deps.note( - ` [non-interactive] Preserving previously-applied presets: ${preserved.join(", ")}`, - ); + deps.note(` [non-interactive] Preserving previously-applied presets: ${preserved.join(", ")}`); } } @@ -485,15 +475,17 @@ async function setupPoliciesWithSelectionInner( ...suggestions.filter((name) => knownNames.has(name) && !applied.includes(name)), ]; const resolvedPresets = await deps.selectTierPresetsAndAccess(tierName, allPresets, extraSelected); - const interactiveChoice = mergeRequiredSetupPolicyPresets( - resolvedPresets.map((preset) => preset.name), - { - messagingPolicyPresets, - hermesToolGateways, - agent, - knownPresetNames: knownNames, - env: deps.env, - }, + const interactiveChoice = pruneDisabledPresets( + mergeRequiredSetupPolicyPresets( + resolvedPresets.map((preset) => preset.name), + { + enabledChannels, + hermesToolGateways, + agent, + knownPresetNames: knownNames, + env: deps.env, + }, + ), ); if (onSelection) onSelection(interactiveChoice); diff --git a/test/rebuild-policy-presets.test.ts b/test/rebuild-policy-presets.test.ts index 18944cde3e..d47d188fb9 100644 --- a/test/rebuild-policy-presets.test.ts +++ b/test/rebuild-policy-presets.test.ts @@ -12,7 +12,7 @@ import os from "node:os"; import path from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { pruneDisabledMessagingPolicyPresets } from "../src/lib/messaging/applier/policy-presets"; +import { pruneDisabledMessagingPolicyPresets } from "../src/lib/onboard/messaging-policy-presets"; type ManifestWithOptionalPresets = { version: number;