From 3e41d49bc4be3b811428b534a279122cddefff14 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 11:49:33 +0700 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 fed526ce7d5574cadaeaafef35afa0945b62f957 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 18:08:41 +0700 Subject: [PATCH 06/10] 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 f541954e4e8c42a85c33cc16f0f56e35460b1746 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 21:27:37 +0700 Subject: [PATCH 07/10] fix(policy): restore channel-name suggestions and plan-based initial policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CI failures in the messaging policy migration: 1. onboard-messaging.test.ts — prepareInitialSandboxCreatePolicy received empty messagingPolicyPresets because createSandbox was called with messagingPlan=null when invoked directly (bypassing the handler). Fix: thread messagingPlan through the createSandbox dep signature; fall back to readMessagingPlanFromEnv() when caller passes null (direct call path). 2. onboard-policy-suggestions.test.ts — computeSetupPresetSuggestions stopped adding channel names (telegram, discord) as preset suggestions because the old enabledChannels loop was removed. Fix: add messagingChannelIds option that adds channel IDs as suggested preset names, mirroring the old behavior. Also add inactive-preset filtering: when messagingPolicyPresets is explicitly provided (even as []), remove messaging presets not in the active set so disabled-channel presets don't appear via open-tier defaults. 3. onboard-preset-diff.test.ts — tests used enabledChannels/disabledChannels which are no longer on SetupPolicySelectionOptions. Updated to messagingPolicyPresets (for required/merge) and messagingPolicyPresets: [] (for disabled-channel pruning). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/onboard.ts | 7 ++- .../onboard/machine/handlers/sandbox.test.ts | 2 + src/lib/onboard/machine/handlers/sandbox.ts | 2 + src/lib/onboard/policy-selection.ts | 28 ++++++++++++ test/onboard-policy-suggestions.test.ts | 45 ++++++++++--------- test/onboard-preset-diff.test.ts | 8 ++-- 6 files changed, 64 insertions(+), 28 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index df866b448b..26872e425d 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2750,6 +2750,7 @@ async function createSandbox( sandboxGpuConfig: SandboxGpuConfig | null = null, resourceProfile: import("./resources-cmd").ResourceProfile | null = null, hermesToolGateways: string[] = [], + messagingPlan: import("./messaging/manifest").SandboxMessagingPlan | null = null, ) { step(6, 8, "Creating sandbox"); @@ -3379,7 +3380,7 @@ async function createSandbox( directGpu: effectiveSandboxGpuConfig.sandboxGpuEnabled, dockerGpuPatch: useDockerGpuPatch, additionalPresets: hermesToolGateways, - messagingPolicyPresets: getPolicyPresetsFromPlan(onboardSession.loadSession()?.messagingPlan), + messagingPolicyPresets: getPolicyPresetsFromPlan(messagingPlan ?? readMessagingPlanFromEnv()), }, ); if (initialSandboxPolicy.cleanup) { @@ -5820,6 +5821,8 @@ async function setupPoliciesWithSelection( sandboxName: string, options: SetupPolicySelectionOptions = {}, ) { + const sessionPlan = onboardSession.loadSession()?.messagingPlan; + const messagingChannelIds = sessionPlan?.channels.map((c) => c.channelId) ?? null; const selectedTier = await setupPoliciesWithSelectionImpl( { policies, @@ -5837,7 +5840,7 @@ async function setupPoliciesWithSelection( env: process.env, }, sandboxName, - options, + { messagingChannelIds, ...options }, ); return selectedTier; } diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index b52876f9e8..0c0cf36ca2 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -167,6 +167,7 @@ describe("handleSandboxState", () => { { sandboxGpuEnabled: false, mode: "0" }, null, [], + null, ); expect(calls.updateSandbox).toHaveBeenCalledWith("my-assistant", expect.objectContaining({ model: "model", provider: "provider" })); // Default-marking is deferred to finalization (#4614) — the sandbox step must not set it. @@ -318,6 +319,7 @@ describe("handleSandboxState", () => { { sandboxGpuEnabled: false, mode: "0" }, null, [], + null, ); expect(result.webSearchConfig).toBeNull(); }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 07d6208d7b..b61613e5ee 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -84,6 +84,7 @@ export interface SandboxStateOptions; updateSandboxRegistry(sandboxName: string, updates: Record): void; getSandboxAgentRegistryFields(agent: Agent, agentVersionKnown: boolean): Record; @@ -331,6 +332,7 @@ export async function handleSandboxState void) | null; webSearchConfig?: WebSearchConfig | null; messagingPolicyPresets?: string[] | null; + /** Channel IDs whose names map to known policy presets (e.g. "telegram", "discord"). */ + messagingChannelIds?: string[] | null; provider?: string | null; agent?: string | null; knownPresetNames?: string[]; @@ -197,11 +201,31 @@ export function computeSetupPresetSuggestions( for (const preset of messagingPolicyPresets ?? []) { add(preset); } + // Channel IDs that map directly to same-named policy presets (e.g. "telegram", "discord"). + for (const channelId of options.messagingChannelIds ?? []) { + add(channelId); + } if (Array.isArray(options.hermesToolGateways)) { for (const preset of options.hermesToolGateways) { if (HERMES_TOOL_GATEWAY_PRESET_NAMES.has(preset)) add(preset); } } + // When messaging state is explicitly provided, remove messaging presets not + // in the active set (e.g. disabled-channel presets that appear in open tiers). + if (messagingPolicyPresets !== null) { + const activeMessagingPresets = new Set([ + ...messagingPolicyPresets, + ...(options.messagingChannelIds ?? []), + ]); + for (let i = suggestions.length - 1; i >= 0; i--) { + if ( + ALL_MESSAGING_POLICY_PRESET_NAMES.has(suggestions[i]) && + !activeMessagingPresets.has(suggestions[i]) + ) { + suggestions.splice(i, 1); + } + } + } return suggestions; } @@ -312,6 +336,9 @@ async function setupPoliciesWithSelectionInner( const messagingPolicyPresets = Array.isArray(options.messagingPolicyPresets) ? options.messagingPolicyPresets : null; + const messagingChannelIds = Array.isArray(options.messagingChannelIds) + ? options.messagingChannelIds + : null; const provider = options.provider || null; const agent = options.agent || null; const hermesToolGateways = Array.isArray(options.hermesToolGateways) @@ -395,6 +422,7 @@ async function setupPoliciesWithSelectionInner( deps.setPolicyTier?.(sandboxName, tierName); const suggestions = computeSetupPresetSuggestions(deps, tierName, { messagingPolicyPresets, + messagingChannelIds, webSearchConfig, provider, agent, diff --git a/test/onboard-policy-suggestions.test.ts b/test/onboard-policy-suggestions.test.ts index ed990340a0..23b68f196a 100644 --- a/test/onboard-policy-suggestions.test.ts +++ b/test/onboard-policy-suggestions.test.ts @@ -11,7 +11,8 @@ const { computeSetupPresetSuggestions: ( tierName: string, options: { - enabledChannels?: string[] | null; + messagingPolicyPresets?: string[] | null; + messagingChannelIds?: string[] | null; knownPresetNames: string[]; provider?: string | null; agent?: string | null; @@ -170,14 +171,14 @@ describe("onboard policy preset suggestions", () => { it("adds openclaw-pricing to tier suggestions when agent is openclaw", () => { const knownWithPricing = [...known, "openclaw-pricing"]; const openclawSuggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithPricing, agent: "openclaw", }); expect(openclawSuggestions).toContain("openclaw-pricing"); const hermesSuggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithPricing, agent: "hermes", }); @@ -185,20 +186,20 @@ describe("onboard policy preset suggestions", () => { // Default/blank agents are OpenClaw in the lower-level helpers too. const nullAgentSuggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithPricing, agent: null, }); expect(nullAgentSuggestions).toContain("openclaw-pricing"); const omittedAgentSuggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithPricing, }); expect(omittedAgentSuggestions).toContain("openclaw-pricing"); const blankAgentSuggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithPricing, agent: " ", }); @@ -208,7 +209,7 @@ describe("onboard policy preset suggestions", () => { it("adds local OTEL policy to tier suggestions only when OpenClaw OTEL is enabled", () => { const knownWithOtel = [...known, "openclaw-pricing", "openclaw-diagnostics-otel-local"]; const openclawSuggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithOtel, agent: "openclaw", env: { NEMOCLAW_OPENCLAW_OTEL: "1" }, @@ -216,7 +217,7 @@ describe("onboard policy preset suggestions", () => { expect(openclawSuggestions).toContain("openclaw-diagnostics-otel-local"); const remoteSuggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithOtel, agent: "openclaw", env: { @@ -227,7 +228,7 @@ describe("onboard policy preset suggestions", () => { expect(remoteSuggestions).not.toContain("openclaw-diagnostics-otel-local"); const disabledSuggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithOtel, agent: "openclaw", env: { NEMOCLAW_OPENCLAW_OTEL: "0" }, @@ -237,7 +238,7 @@ describe("onboard policy preset suggestions", () => { it("returns balanced tier defaults without messaging presets when no channels enabled", () => { const suggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: known, }); expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew", "weather"]); @@ -245,7 +246,7 @@ describe("onboard policy preset suggestions", () => { it("adds Brave to balanced tier defaults only when web search is configured", () => { const suggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: known, webSearchConfig: { fetchEnabled: true }, webSearchSupported: true, @@ -255,7 +256,7 @@ describe("onboard policy preset suggestions", () => { it("filters tier defaults to known presets for agent-specific onboarding", () => { const suggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: known.filter((name) => name !== "brave"), }); expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew", "weather"]); @@ -275,7 +276,7 @@ describe("onboard policy preset suggestions", () => { it("drops Brave tier defaults when web search is unsupported", () => { const suggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: known, webSearchSupported: false, }); @@ -285,7 +286,7 @@ describe("onboard policy preset suggestions", () => { it("adds all Hermes Nous tool policy presets for Hermes open tier only", () => { const knownWithPricing = [...known, "openclaw-pricing"]; const hermesOpen = computeSetupPresetSuggestions("open", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithPricing, agent: "hermes", }); @@ -297,7 +298,7 @@ describe("onboard policy preset suggestions", () => { expect(hermesOpen).not.toContain("openclaw-pricing"); const openclawOpen = computeSetupPresetSuggestions("open", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithPricing, agent: "openclaw", }); @@ -332,7 +333,7 @@ describe("onboard policy preset suggestions", () => { it("does not add explicitly requested Hermes Nous presets to OpenClaw suggestions", () => { const suggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: known, agent: "openclaw", hermesToolGateways: ["nous-web", "nous-code"], @@ -343,7 +344,7 @@ describe("onboard policy preset suggestions", () => { it("forwards enabled messaging channels into tier suggestions", () => { const suggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: ["telegram"], + messagingChannelIds: ["telegram"], knownPresetNames: known, }); expect(suggestions).toContain("telegram"); @@ -351,7 +352,7 @@ describe("onboard policy preset suggestions", () => { expect(suggestions).not.toContain("brave"); const multi = computeSetupPresetSuggestions("balanced", { - enabledChannels: ["discord", "slack"], + messagingChannelIds: ["discord", "slack"], knownPresetNames: known, }); expect(multi).toContain("discord"); @@ -360,7 +361,7 @@ describe("onboard policy preset suggestions", () => { it("does not duplicate channels already present in the tier", () => { const suggestions = computeSetupPresetSuggestions("open", { - enabledChannels: ["telegram", "slack"], + messagingChannelIds: ["telegram", "slack"], knownPresetNames: known, }); expect(suggestions.filter((name: string) => name === "telegram")).toHaveLength(1); @@ -369,7 +370,7 @@ describe("onboard policy preset suggestions", () => { it("drops channel names that are not known presets", () => { const suggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: ["telegram", "not-a-real-preset"], + messagingChannelIds: ["telegram", "not-a-real-preset"], knownPresetNames: known, }); expect(suggestions).toContain("telegram"); @@ -401,9 +402,9 @@ describe("onboard policy preset suggestions", () => { ).toContain("local-inference"); }); - it("ignores enabledChannels when null", () => { + it("ignores messagingChannelIds when null", () => { const suggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: null, + messagingChannelIds: null, knownPresetNames: known, }); expect(suggestions).not.toContain("telegram"); diff --git a/test/onboard-preset-diff.test.ts b/test/onboard-preset-diff.test.ts index 9391ecc199..7c9be07f0e 100644 --- a/test/onboard-preset-diff.test.ts +++ b/test/onboard-preset-diff.test.ts @@ -356,7 +356,7 @@ console.log = () => {}; try { const chosen = await setupPoliciesWithSelection("test-sb", { selectedPresets: ["npm", "pypi"], - enabledChannels: ["slack"], + messagingPolicyPresets: ["slack"], }); process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); } catch (err) { @@ -390,7 +390,7 @@ console.log = () => {}; (async () => { try { const chosen = await setupPoliciesWithSelection("test-sb", { - enabledChannels: ["slack"], + messagingPolicyPresets: ["slack"], }); process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); } catch (err) { @@ -424,7 +424,7 @@ console.log = () => {}; (async () => { try { const chosen = await setupPoliciesWithSelection("test-sb", { - disabledChannels: ["slack"], + messagingPolicyPresets: [], }); process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); } catch (err) { @@ -455,7 +455,7 @@ console.log = () => {}; (async () => { try { const chosen = await setupPoliciesWithSelection("test-sb", { - disabledChannels: ["slack"], + messagingPolicyPresets: [], }); process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); } catch (err) { From e5702c73f737498acb135632ce902f1d494b405a Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 22:50:50 +0700 Subject: [PATCH 08/10] refactor(policy): move messagingChannelIds derivation out of onboard.ts The source-shape budget blocks net growth in src/lib/onboard.ts. Move the messagingChannelIds derivation (session plan channel ID extraction) into handlers/policies.ts where latestSession is already loaded, removing the two extra lines from the onboard.ts setupPoliciesWithSelection wrapper. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/onboard.ts | 4 +--- src/lib/onboard/machine/handlers/policies.ts | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index eb997ec463..16a3522832 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -5796,8 +5796,6 @@ async function setupPoliciesWithSelection( sandboxName: string, options: SetupPolicySelectionOptions = {}, ) { - const sessionPlan = onboardSession.loadSession()?.messagingPlan; - const messagingChannelIds = sessionPlan?.channels.map((c) => c.channelId) ?? null; const selectedTier = await setupPoliciesWithSelectionImpl( { policies, @@ -5815,7 +5813,7 @@ async function setupPoliciesWithSelection( env: process.env, }, sandboxName, - { messagingChannelIds, ...options }, + options, ); return selectedTier; } diff --git a/src/lib/onboard/machine/handlers/policies.ts b/src/lib/onboard/machine/handlers/policies.ts index 25705e3847..86340d6753 100644 --- a/src/lib/onboard/machine/handlers/policies.ts +++ b/src/lib/onboard/machine/handlers/policies.ts @@ -69,6 +69,7 @@ export interface PoliciesStateOptions { options: { selectedPresets: string[] | null; messagingPolicyPresets: string[]; + messagingChannelIds: string[] | null; webSearchConfig: WebSearchConfig | null; provider: string; agent?: string | null; @@ -183,6 +184,7 @@ export async function handlePoliciesState({ ? recordedPolicyPresetsForSupport : null, messagingPolicyPresets, + messagingChannelIds: latestSession?.messagingPlan?.channels.map((c) => c.channelId) ?? null, webSearchConfig, provider, // selectOnboardAgent returns null for the default OpenClaw path (no From 498d24b0b7fad02c98744d54e66bb28e67c0b40f Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 00:18:00 +0700 Subject: [PATCH 09/10] fix(policy): derive active messaging presets from enabled plan entries only Disabled messaging channels could still drive sandbox network policy because getPolicyPresetsFromPlan spread raw networkPolicy.presets (which retains disabled-channel entries after compilation) and handlePoliciesState passed all plan channels as messagingChannelIds regardless of enabled state. - getPolicyPresetsFromPlan now mirrors applyPolicyAtOpenShell: derives presets from filterEnabledPlanEntries(plan, plan.networkPolicy.entries) - Add getEnabledChannelIdsFromPlan helper; use it in handlePoliciesState so disabled same-name channels (e.g. Telegram) are not suggested - Expand REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL to cover all five messaging channels (discord, telegram, wechat, whatsapp) so ALL_MESSAGING_POLICY_PRESET_NAMES enables stale detection for non-Slack stopped channels and pruneDisabledMessagingPolicyPresets works in the rebuild backup-manifest path for all channels - Extract hasDisabledMessagingPreset and filterActiveMessagingPresets into policy-presets.ts; replace inline occurrences in policy-selection.ts to reduce duplication - Tighten parseSandboxMessagingPlan to validate element types in channels[], disabledChannels[], networkPolicy.presets[], and networkPolicy.entries[] before accepting a persisted plan - Add messaging-plan-session.test.ts covering disabled-channel preset exclusion, enabled channel ID derivation, and parse validation - Update rebuild-policy-presets tests to reflect correct behavior for all messaging channel presets (not just Slack) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../messaging/applier/policy-presets.test.ts | 70 +++++- src/lib/messaging/applier/policy-presets.ts | 36 ++- src/lib/onboard/machine/handlers/policies.ts | 3 +- .../onboard/messaging-plan-session.test.ts | 226 ++++++++++++++++++ src/lib/onboard/messaging-plan-session.ts | 32 ++- src/lib/onboard/policy-selection.ts | 24 +- test/rebuild-policy-presets.test.ts | 13 +- 7 files changed, 384 insertions(+), 20 deletions(-) create mode 100644 src/lib/onboard/messaging-plan-session.test.ts diff --git a/src/lib/messaging/applier/policy-presets.test.ts b/src/lib/messaging/applier/policy-presets.test.ts index a7bb8aef02..268f96ca1c 100644 --- a/src/lib/messaging/applier/policy-presets.test.ts +++ b/src/lib/messaging/applier/policy-presets.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it } from "vitest"; import { ALL_MESSAGING_POLICY_PRESET_NAMES, + filterActiveMessagingPresets, + hasDisabledMessagingPreset, pruneDisabledMessagingPolicyPresets, } from "./policy-presets"; @@ -16,10 +18,26 @@ describe("pruneDisabledMessagingPolicyPresets", () => { ]); }); - it("preserves non-required policy presets when a same-named channel is disabled", () => { + it("removes telegram preset when telegram channel is disabled", () => { expect( pruneDisabledMessagingPolicyPresets(["telegram", "npm", "pypi"], ["telegram"]), - ).toEqual(["telegram", "npm", "pypi"]); + ).toEqual(["npm", "pypi"]); + }); + + it("removes discord preset when discord channel is disabled", () => { + expect(pruneDisabledMessagingPolicyPresets(["discord", "npm"], ["discord"])).toEqual(["npm"]); + }); + + it("removes wechat preset when wechat channel is disabled", () => { + expect(pruneDisabledMessagingPolicyPresets(["wechat", "npm"], ["wechat"])).toEqual(["npm"]); + }); + + it("removes whatsapp preset when whatsapp channel is disabled", () => { + expect(pruneDisabledMessagingPolicyPresets(["whatsapp", "npm"], ["whatsapp"])).toEqual(["npm"]); + }); + + it("preserves presets for non-messaging same-named items", () => { + expect(pruneDisabledMessagingPolicyPresets(["npm", "pypi"], ["npm"])).toEqual(["npm", "pypi"]); }); it("returns the original list unchanged when no channels are disabled", () => { @@ -28,13 +46,57 @@ describe("pruneDisabledMessagingPolicyPresets", () => { }); describe("ALL_MESSAGING_POLICY_PRESET_NAMES", () => { - it("includes the slack preset", () => { + it("includes all messaging channel presets", () => { expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("slack")).toBe(true); + expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("telegram")).toBe(true); + expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("discord")).toBe(true); + expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("wechat")).toBe(true); + expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("whatsapp")).toBe(true); }); it("does not include non-messaging presets", () => { expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("npm")).toBe(false); expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("pypi")).toBe(false); - expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("telegram")).toBe(false); + expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("brave")).toBe(false); + }); +}); + +describe("hasDisabledMessagingPreset", () => { + it("returns true when a messaging preset is not in the active set", () => { + expect(hasDisabledMessagingPreset(["slack", "npm"], new Set())).toBe(true); + }); + + it("returns false when all messaging presets are in the active set", () => { + expect(hasDisabledMessagingPreset(["slack", "npm"], new Set(["slack"]))).toBe(false); + }); + + it("returns false for non-messaging presets not in the active set", () => { + expect(hasDisabledMessagingPreset(["npm", "pypi"], new Set())).toBe(false); + }); + + it("detects stale telegram preset", () => { + expect(hasDisabledMessagingPreset(["telegram", "npm"], new Set())).toBe(true); + }); +}); + +describe("filterActiveMessagingPresets", () => { + it("keeps non-messaging presets regardless of plan", () => { + expect(filterActiveMessagingPresets(["npm", "pypi"], new Set())).toEqual(["npm", "pypi"]); + }); + + it("removes messaging presets absent from the plan", () => { + expect(filterActiveMessagingPresets(["npm", "slack", "telegram"], new Set(["slack"]))).toEqual([ + "npm", + "slack", + ]); + }); + + it("retains messaging presets present in the plan", () => { + const active = new Set(["slack", "telegram"]); + expect(filterActiveMessagingPresets(["slack", "telegram", "npm"], active)).toEqual([ + "slack", + "telegram", + "npm", + ]); }); }); diff --git a/src/lib/messaging/applier/policy-presets.ts b/src/lib/messaging/applier/policy-presets.ts index d654174b75..197442b017 100644 --- a/src/lib/messaging/applier/policy-presets.ts +++ b/src/lib/messaging/applier/policy-presets.ts @@ -2,7 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 const REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL: Record = { + discord: ["discord"], slack: ["slack"], + telegram: ["telegram"], + wechat: ["wechat"], + whatsapp: ["whatsapp"], }; /** All preset names that any messaging channel can require. */ @@ -35,8 +39,8 @@ function requiredMessagingChannelPolicyPresets(channels: string[] | null | undef /** * 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. + * plan is available. For plan-aware paths, use getPolicyPresetsFromPlan which + * derives presets from enabled plan entries only. */ export function pruneDisabledMessagingPolicyPresets( selectedPresets: string[], @@ -48,3 +52,31 @@ export function pruneDisabledMessagingPolicyPresets( (preset) => !disabledRequiredPresets.has(preset.trim().toLowerCase()), ); } + +/** + * Returns true if any preset in the list is a messaging policy preset that is + * absent from the active plan preset set. Used to detect stale messaging + * presets still applied in the gateway after a channel stop/disable. + */ +export function hasDisabledMessagingPreset( + presets: readonly string[], + activePlanPresets: ReadonlySet, +): boolean { + return presets.some( + (preset) => ALL_MESSAGING_POLICY_PRESET_NAMES.has(preset) && !activePlanPresets.has(preset), + ); +} + +/** + * Filters a preset list to retain only non-messaging presets and messaging + * presets that appear in the active plan preset set. Used to exclude stale + * gateway presets from the resume/preservation set. + */ +export function filterActiveMessagingPresets( + presets: readonly string[], + activePlanPresets: ReadonlySet, +): string[] { + return presets.filter( + (name) => !ALL_MESSAGING_POLICY_PRESET_NAMES.has(name) || activePlanPresets.has(name), + ); +} diff --git a/src/lib/onboard/machine/handlers/policies.ts b/src/lib/onboard/machine/handlers/policies.ts index 86340d6753..5be5f36f40 100644 --- a/src/lib/onboard/machine/handlers/policies.ts +++ b/src/lib/onboard/machine/handlers/policies.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { Session, SessionUpdates } from "../../../state/onboard-session"; +import { getEnabledChannelIdsFromPlan } from "../../messaging-plan-session"; import { advanceTo, type OnboardStateTransitionResult } from "../result"; // Inlined to avoid pulling sandbox-agent's transitive runner.ts deps into @@ -184,7 +185,7 @@ export async function handlePoliciesState({ ? recordedPolicyPresetsForSupport : null, messagingPolicyPresets, - messagingChannelIds: latestSession?.messagingPlan?.channels.map((c) => c.channelId) ?? null, + messagingChannelIds: getEnabledChannelIdsFromPlan(latestSession?.messagingPlan), webSearchConfig, provider, // selectOnboardAgent returns null for the default OpenClaw path (no diff --git a/src/lib/onboard/messaging-plan-session.test.ts b/src/lib/onboard/messaging-plan-session.test.ts new file mode 100644 index 0000000000..8964d2add2 --- /dev/null +++ b/src/lib/onboard/messaging-plan-session.test.ts @@ -0,0 +1,226 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { + getEnabledChannelIdsFromPlan, + getPolicyPresetsFromPlan, + parseSandboxMessagingPlan, +} from "./messaging-plan-session"; + +function makePlan( + channelIds: readonly string[], + overrides: Partial = {}, +): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + channels: channelIds.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste" as const, + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + })), + disabledChannels: [], + credentialBindings: [], + networkPolicy: { + presets: [...channelIds], + entries: channelIds.map((channelId) => ({ + channelId, + presetName: channelId, + policyKeys: [channelId], + source: "manifest" as const, + })), + }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + ...overrides, + }; +} + +describe("getPolicyPresetsFromPlan", () => { + it("returns empty array for null/undefined", () => { + expect(getPolicyPresetsFromPlan(null)).toEqual([]); + expect(getPolicyPresetsFromPlan(undefined)).toEqual([]); + }); + + it("returns presets for all active channels", () => { + const plan = makePlan(["slack", "telegram"]); + expect(getPolicyPresetsFromPlan(plan).sort()).toEqual(["slack", "telegram"]); + }); + + it("excludes presets for disabled channels", () => { + const plan = makePlan(["slack", "telegram"], { + channels: [ + { + channelId: "slack", + displayName: "Slack", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + { + channelId: "telegram", + displayName: "Telegram", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: true, + inputs: [], + hooks: [], + }, + ], + disabledChannels: ["telegram"], + }); + expect(getPolicyPresetsFromPlan(plan)).toEqual(["slack"]); + }); + + it("ignores networkPolicy.presets — derives from enabled entries only", () => { + // Compiler may leave disabled-channel entries in networkPolicy.presets; + // getPolicyPresetsFromPlan must ignore that field. + const plan = makePlan(["slack"], { + disabledChannels: ["telegram"], + networkPolicy: { + presets: ["slack", "telegram"], + entries: [ + { channelId: "slack", presetName: "slack", policyKeys: ["slack"], source: "manifest" }, + { + channelId: "telegram", + presetName: "telegram", + policyKeys: ["telegram_bot"], + source: "manifest", + }, + ], + }, + }); + expect(getPolicyPresetsFromPlan(plan)).toEqual(["slack"]); + }); + + it("deduplicates preset names", () => { + const plan = makePlan(["slack"], { + networkPolicy: { + presets: ["slack", "slack"], + entries: [ + { channelId: "slack", presetName: "slack", policyKeys: ["slack"], source: "manifest" }, + { channelId: "slack", presetName: "slack", policyKeys: ["slack2"], source: "manifest" }, + ], + }, + }); + expect(getPolicyPresetsFromPlan(plan)).toEqual(["slack"]); + }); +}); + +describe("getEnabledChannelIdsFromPlan", () => { + it("returns null for null/undefined", () => { + expect(getEnabledChannelIdsFromPlan(null)).toBeNull(); + expect(getEnabledChannelIdsFromPlan(undefined)).toBeNull(); + }); + + it("returns only enabled channel IDs", () => { + const plan = makePlan(["slack", "telegram"], { + channels: [ + { + channelId: "slack", + displayName: "Slack", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + { + channelId: "telegram", + displayName: "Telegram", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: true, + inputs: [], + hooks: [], + }, + ], + disabledChannels: ["telegram"], + }); + expect(getEnabledChannelIdsFromPlan(plan)).toEqual(["slack"]); + }); + + it("returns null when all channels are disabled", () => { + const plan = makePlan(["telegram"], { + channels: [ + { + channelId: "telegram", + displayName: "Telegram", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: true, + inputs: [], + hooks: [], + }, + ], + disabledChannels: ["telegram"], + }); + expect(getEnabledChannelIdsFromPlan(plan)).toBeNull(); + }); +}); + +describe("parseSandboxMessagingPlan", () => { + it("accepts a well-formed plan", () => { + const plan = makePlan(["slack"]); + expect(parseSandboxMessagingPlan(plan)).not.toBeNull(); + }); + + it("rejects a plan with non-string channel IDs", () => { + const plan = makePlan(["slack"]) as unknown as Record; + (plan.channels as Array>)[0].channelId = 42; + expect(parseSandboxMessagingPlan(plan)).toBeNull(); + }); + + it("rejects a plan with non-string disabledChannels entries", () => { + const plan = { ...makePlan(["slack"]), disabledChannels: [42] }; + expect(parseSandboxMessagingPlan(plan)).toBeNull(); + }); + + it("rejects a plan with non-string networkPolicy.presets entries", () => { + const plan = { + ...makePlan(["slack"]), + networkPolicy: { presets: [42], entries: [] }, + }; + expect(parseSandboxMessagingPlan(plan)).toBeNull(); + }); + + it("rejects a plan with networkPolicy.entries missing presetName", () => { + const plan = { + ...makePlan(["slack"]), + networkPolicy: { + presets: ["slack"], + entries: [{ channelId: "slack", policyKeys: ["slack"] }], + }, + }; + expect(parseSandboxMessagingPlan(plan)).toBeNull(); + }); + + it("rejects null", () => { + expect(parseSandboxMessagingPlan(null)).toBeNull(); + }); +}); diff --git a/src/lib/onboard/messaging-plan-session.ts b/src/lib/onboard/messaging-plan-session.ts index a87c58e20f..b9b48b8a00 100644 --- a/src/lib/onboard/messaging-plan-session.ts +++ b/src/lib/onboard/messaging-plan-session.ts @@ -3,6 +3,7 @@ import type { MessagingChannelConfig } from "../messaging-channel-config"; import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { enabledPlanChannelIds, filterEnabledPlanEntries } from "../messaging/applier/plan-filter"; export function parseSandboxMessagingPlan(value: unknown): SandboxMessagingPlan | null { if ( @@ -12,9 +13,19 @@ export function parseSandboxMessagingPlan(value: unknown): SandboxMessagingPlan typeof value.agent !== "string" || typeof value.workflow !== "string" || !Array.isArray(value.channels) || + !value.channels.every( + (c) => isObject(c) && typeof c.channelId === "string", + ) || !Array.isArray(value.disabledChannels) || + !value.disabledChannels.every((id) => typeof id === "string") || !Array.isArray(value.credentialBindings) || !isObject(value.networkPolicy) || + !Array.isArray(value.networkPolicy.presets) || + !value.networkPolicy.presets.every((p) => typeof p === "string") || + !Array.isArray(value.networkPolicy.entries) || + !value.networkPolicy.entries.every( + (e) => isObject(e) && typeof e.channelId === "string" && typeof e.presetName === "string", + ) || !Array.isArray(value.agentRender) || !Array.isArray(value.buildSteps) || !Array.isArray(value.stateUpdates) || @@ -35,6 +46,15 @@ export function getChannelsFromPlan(plan: SandboxMessagingPlan | null | undefine return plan.channels.map((c) => c.channelId); } +/** Derive only enabled channel IDs from a plan (excludes disabled channels). */ +export function getEnabledChannelIdsFromPlan( + plan: SandboxMessagingPlan | null | undefined, +): string[] | null { + if (!plan) return null; + const ids = [...enabledPlanChannelIds(plan)]; + return ids.length > 0 ? ids : null; +} + /** Derive the equivalent of session.disabledChannels from a plan. */ export function getDisabledChannelsFromPlan( plan: SandboxMessagingPlan | null | undefined, @@ -45,7 +65,17 @@ export function getDisabledChannelsFromPlan( /** 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] : []; + if (!plan) return []; + const activeEntries = filterEnabledPlanEntries(plan, plan.networkPolicy.entries); + const seen = new Set(); + const result: string[] = []; + for (const entry of activeEntries) { + if (entry.presetName && !seen.has(entry.presetName)) { + seen.add(entry.presetName); + result.push(entry.presetName); + } + } + return result; } diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index b4eac39246..93534a15b1 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -2,7 +2,11 @@ // 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 { + ALL_MESSAGING_POLICY_PRESET_NAMES, + filterActiveMessagingPresets, + hasDisabledMessagingPreset, +} from "../messaging/applier/policy-presets"; import { filterSetupPolicyPresetNamesForAgent, filterSetupPolicyPresetsForAgent, @@ -284,15 +288,16 @@ export function preparePolicyPresetResumeSelection( // 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 = hasDisabledMessagingPreset( + appliedPolicyPresetsForSupport, + planMessagingPresets, ); // 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), + const appliedToPreserve = filterActiveMessagingPresets( + appliedPolicyPresetsForSupport, + planMessagingPresets, ); for (const preset of appliedToPreserve) { if (!policyPresets.includes(preset)) policyPresets.push(preset); @@ -375,10 +380,9 @@ async function setupPoliciesWithSelectionInner( // 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 = filterActiveMessagingPresets( + applied.filter((name) => !isStaleBuiltinBrave(name)), + planMessagingPresets, ); const filterSupportedPresetNames = (presetNames: string[]) => filterSetupPolicyPresetNamesForAgent(presetNames, agent).filter( diff --git a/test/rebuild-policy-presets.test.ts b/test/rebuild-policy-presets.test.ts index 18944cde3e..1b435cdc6c 100644 --- a/test/rebuild-policy-presets.test.ts +++ b/test/rebuild-policy-presets.test.ts @@ -178,13 +178,22 @@ describe("rebuild policy preset restoration (#1952)", () => { expect(savedPresets).toEqual(["npm", "pypi"]); }); - it("preserves non-required channel presets for later start and rebuild", () => { + it("removes telegram preset when telegram channel is disabled", () => { const manifest = { policyPresets: ["telegram", "npm", "pypi"] }; const savedPresets = pruneDisabledMessagingPolicyPresets( manifest.policyPresets || [], ["telegram"], ); - expect(savedPresets).toEqual(["telegram", "npm", "pypi"]); + expect(savedPresets).toEqual(["npm", "pypi"]); + }); + + it("preserves non-messaging custom presets when a channel is disabled", () => { + const manifest = { policyPresets: ["custom-api", "npm", "pypi"] }; + const savedPresets = pruneDisabledMessagingPolicyPresets( + manifest.policyPresets || [], + ["telegram"], + ); + expect(savedPresets).toEqual(["custom-api", "npm", "pypi"]); }); }); }); From b7601f14d05f1920541ed9fc327773100cc5032b Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 01:16:14 +0700 Subject: [PATCH 10/10] fix(onboard): preserve messaging policy plan state --- .../messaging/applier/policy-presets.test.ts | 48 ++++++ src/lib/messaging/applier/policy-presets.ts | 67 ++++++-- .../messaging/channels/telegram/manifest.ts | 8 +- .../compiler/manifest-compiler.test.ts | 6 + src/lib/onboard.ts | 14 +- src/lib/onboard/initial-policy.test.ts | 8 +- src/lib/onboard/initial-policy.ts | 17 +-- .../onboard/machine/handlers/policies.test.ts | 88 +++++++++-- src/lib/onboard/machine/handlers/policies.ts | 39 +++-- .../onboard/messaging-plan-session.test.ts | 12 +- src/lib/onboard/messaging-plan-session.ts | 10 +- .../onboard/messaging-policy-state.test.ts | 127 +++++++++++++++ src/lib/onboard/messaging-policy-state.ts | 144 ++++++++++++++++++ .../openclaw-otel-policy-presets.test.ts | 3 + src/lib/onboard/policy-selection.ts | 125 +++++++-------- test/onboard-messaging.test.ts | 2 +- test/policies.test.ts | 22 +-- test/secret-redaction.test.ts | 2 + 18 files changed, 600 insertions(+), 142 deletions(-) create mode 100644 src/lib/onboard/messaging-policy-state.test.ts create mode 100644 src/lib/onboard/messaging-policy-state.ts diff --git a/src/lib/messaging/applier/policy-presets.test.ts b/src/lib/messaging/applier/policy-presets.test.ts index 268f96ca1c..89e9fe8fa0 100644 --- a/src/lib/messaging/applier/policy-presets.test.ts +++ b/src/lib/messaging/applier/policy-presets.test.ts @@ -3,13 +3,32 @@ import { describe, expect, it } from "vitest"; +import { createChannelManifestRegistry, type ChannelManifest } from "../manifest"; import { ALL_MESSAGING_POLICY_PRESET_NAMES, filterActiveMessagingPresets, hasDisabledMessagingPreset, + messagingPolicyKeysByChannel, pruneDisabledMessagingPolicyPresets, + requiredMessagingChannelPolicyPresets, } from "./policy-presets"; +function manifest(id: string, policyPresets: ChannelManifest["policyPresets"]): ChannelManifest { + return { + schemaVersion: 1, + id, + displayName: id, + supportedAgents: ["openclaw", "hermes"], + auth: { mode: "token-paste" }, + inputs: [], + credentials: [], + policyPresets, + render: [], + state: {}, + hooks: [], + }; +} + describe("pruneDisabledMessagingPolicyPresets", () => { it("removes policy presets for disabled messaging channels", () => { expect(pruneDisabledMessagingPolicyPresets(["npm", "slack", "pypi"], [" Slack "])).toEqual([ @@ -45,6 +64,35 @@ describe("pruneDisabledMessagingPolicyPresets", () => { }); }); +describe("requiredMessagingChannelPolicyPresets", () => { + it("derives fallback channel presets from manifests", () => { + const registry = createChannelManifestRegistry([ + manifest("matrix", [{ name: "matrix-policy", policyKeys: ["matrix_bridge"] }]), + ]); + + expect(requiredMessagingChannelPolicyPresets(["matrix"], registry)).toEqual(["matrix-policy"]); + expect(pruneDisabledMessagingPolicyPresets(["npm", "matrix-policy"], ["matrix"], registry)).toEqual([ + "npm", + ]); + }); + + it("derives agent policy keys from manifest aliases", () => { + const registry = createChannelManifestRegistry([ + manifest("matrix", [ + { + name: "matrix-policy", + policyKeys: ["matrix_default"], + agentPolicyKeys: { hermes: ["matrix_hermes"] }, + }, + ]), + ]); + + expect(messagingPolicyKeysByChannel("hermes", registry)).toEqual({ + matrix: ["matrix_hermes"], + }); + }); +}); + describe("ALL_MESSAGING_POLICY_PRESET_NAMES", () => { it("includes all messaging channel presets", () => { expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("slack")).toBe(true); diff --git a/src/lib/messaging/applier/policy-presets.ts b/src/lib/messaging/applier/policy-presets.ts index 197442b017..a93468e0c8 100644 --- a/src/lib/messaging/applier/policy-presets.ts +++ b/src/lib/messaging/applier/policy-presets.ts @@ -1,17 +1,41 @@ // 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 = { - discord: ["discord"], - slack: ["slack"], - telegram: ["telegram"], - wechat: ["wechat"], - whatsapp: ["whatsapp"], -}; +import { createBuiltInChannelManifestRegistry } from "../channels"; +import type { + ChannelManifest, + ChannelManifestRegistry, + ChannelPolicyPresetReference, + ChannelPolicyPresetSpec, + MessagingAgentId, +} from "../manifest"; + +const BUILT_IN_CHANNEL_MANIFEST_REGISTRY = createBuiltInChannelManifestRegistry(); + +function normalizePolicyPreset(preset: ChannelPolicyPresetReference): ChannelPolicyPresetSpec { + return typeof preset === "string" ? { name: preset } : preset; +} + +function manifestPolicyPresetNames(manifest: ChannelManifest): string[] { + return (manifest.policyPresets ?? []).map((preset) => normalizePolicyPreset(preset).name); +} + +function manifestPolicyKeys(manifest: ChannelManifest, agent: MessagingAgentId): string[] { + return (manifest.policyPresets ?? []).flatMap((preset) => { + const policy = normalizePolicyPreset(preset); + return [...(policy.agentPolicyKeys?.[agent] ?? policy.policyKeys ?? [policy.name])]; + }); +} + +function getRegistry(registry?: ChannelManifestRegistry | null): ChannelManifestRegistry { + return registry ?? BUILT_IN_CHANNEL_MANIFEST_REGISTRY; +} /** All preset names that any messaging channel can require. */ export const ALL_MESSAGING_POLICY_PRESET_NAMES: ReadonlySet = new Set( - Object.values(REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL).flat(), + getRegistry() + .list() + .flatMap((manifest) => manifestPolicyPresetNames(manifest)), ); function normalizedNames(values: string[] | null | undefined): string[] { @@ -26,16 +50,34 @@ function normalizedNames(values: string[] | null | undefined): string[] { return names; } -function requiredMessagingChannelPolicyPresets(channels: string[] | null | undefined): string[] { +export function requiredMessagingChannelPolicyPresets( + channels: string[] | null | undefined, + registry?: ChannelManifestRegistry | null, +): string[] { + const manifestRegistry = getRegistry(registry); const required: string[] = []; for (const channel of normalizedNames(channels)) { - for (const preset of REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL[channel] || []) { + const manifest = manifestRegistry.get(channel); + if (!manifest) continue; + for (const preset of manifestPolicyPresetNames(manifest)) { if (!required.includes(preset)) required.push(preset); } } return required; } +export function messagingPolicyKeysByChannel( + agent: MessagingAgentId, + registry?: ChannelManifestRegistry | null, +): Record { + const result: Record = {}; + for (const manifest of getRegistry(registry).listAvailable({ agent })) { + const keys = manifestPolicyKeys(manifest, agent); + if (keys.length > 0) result[manifest.id] = keys; + } + return result; +} + /** * Removes from selectedPresets any preset exclusively required by a disabled * channel. Used when restoring presets from backup manifests where no compiled @@ -45,8 +87,11 @@ function requiredMessagingChannelPolicyPresets(channels: string[] | null | undef export function pruneDisabledMessagingPolicyPresets( selectedPresets: string[], disabledChannels: string[] | null | undefined, + registry?: ChannelManifestRegistry | null, ): string[] { - const disabledRequiredPresets = new Set(requiredMessagingChannelPolicyPresets(disabledChannels)); + const disabledRequiredPresets = new Set( + requiredMessagingChannelPolicyPresets(disabledChannels, registry), + ); if (disabledRequiredPresets.size === 0) return selectedPresets; return selectedPresets.filter( (preset) => !disabledRequiredPresets.has(preset.trim().toLowerCase()), diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index d0af4167bd..788d8327fa 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -61,7 +61,13 @@ export const telegramManifest = { placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", }, ], - policyPresets: [{ name: "telegram", policyKeys: ["telegram_bot"] }], + policyPresets: [ + { + name: "telegram", + policyKeys: ["telegram_bot"], + agentPolicyKeys: { hermes: ["telegram"] }, + }, + ], render: [ { id: "telegram-openclaw-account", diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 82b5bf8c23..a54b98430b 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -268,6 +268,12 @@ describe("ManifestCompiler", () => { policyKeys: ["wechat_bridge"], source: "manifest", }); + expect(plan.networkPolicy.entries.find((entry) => entry.channelId === "telegram")).toEqual({ + channelId: "telegram", + presetName: "telegram", + policyKeys: ["telegram"], + source: "agent-alias", + }); expect(plan.agentRender.map((render) => `${render.channelId}:${render.target}`)).toEqual([ "telegram:~/.hermes/.env", "telegram:~/.hermes/config.yaml", diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 16a3522832..6e3405eae2 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -508,7 +508,10 @@ import { setupHermesToolGateways, stringSetsEqual, } from "./onboard/hermes-managed-tools"; -import { getPolicyPresetsFromPlan } from "./onboard/messaging-plan-session"; +import { + getEnabledChannelIdsFromPlan, + getPolicyPresetsFromPlan, +} from "./onboard/messaging-plan-session"; import { filterEnabledChannelsByAgent, resolveQrSelectedChannels, @@ -3347,14 +3350,16 @@ async function createSandbox( dockerGpuSandboxCreate.resolveDockerGpuSandboxCreatePlan(effectiveSandboxGpuConfig, { dockerDriverGateway: isLinuxDockerDriverGatewayEnabled(), }); + const activeMessagingPlan = messagingPlan ?? readMessagingPlanFromEnv(); + const planEnabledMessagingChannels = getEnabledChannelIdsFromPlan(activeMessagingPlan); const initialSandboxPolicy = prepareInitialSandboxCreatePolicy( basePolicyPath, - activeMessagingChannels, + planEnabledMessagingChannels ?? activeMessagingChannels, { directGpu: effectiveSandboxGpuConfig.sandboxGpuEnabled, dockerGpuPatch: useDockerGpuPatch, additionalPresets: hermesToolGateways, - messagingPolicyPresets: getPolicyPresetsFromPlan(messagingPlan ?? readMessagingPlanFromEnv()), + messagingPolicyPresets: getPolicyPresetsFromPlan(activeMessagingPlan), }, ); if (initialSandboxPolicy.cleanup) { @@ -6542,13 +6547,14 @@ 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), verifyCompatibleEndpointSandboxSmoke: (options) => verifyCompatibleEndpointSandboxSmoke({ ...options, diff --git a/src/lib/onboard/initial-policy.test.ts b/src/lib/onboard/initial-policy.test.ts index 7a4151d539..00364fa720 100644 --- a/src/lib/onboard/initial-policy.test.ts +++ b/src/lib/onboard/initial-policy.test.ts @@ -163,10 +163,14 @@ network_policies: expect(getNetworkPolicyNames("network_policies: [unterminated")).toBeNull(); }); - it("keeps the base policy when no channel needs a create-time preset", () => { + it("keeps the base policy when the compiled plan has no active messaging preset", () => { const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); - expect(prepareInitialSandboxCreatePolicy(basePolicyPath, ["telegram"])).toEqual({ + expect( + prepareInitialSandboxCreatePolicy(basePolicyPath, ["telegram"], { + messagingPolicyPresets: [], + }), + ).toEqual({ policyPath: basePolicyPath, appliedPresets: [], }); diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts index f824e734e4..1be5bf4378 100644 --- a/src/lib/onboard/initial-policy.ts +++ b/src/lib/onboard/initial-policy.ts @@ -5,6 +5,10 @@ import fs from "node:fs"; import path from "node:path"; import YAML from "yaml"; +import { + messagingPolicyKeysByChannel, + requiredMessagingChannelPolicyPresets, +} from "../messaging/applier/policy-presets"; import * as policies from "../policy"; import { requiredOpenclawOtelPolicyPresets } from "./openclaw-otel-policy-presets"; import { cleanupTempDir, secureTempFile } from "./temp-files"; @@ -15,13 +19,6 @@ export type InitialSandboxPolicy = { cleanup?: () => boolean; }; -const HERMES_MESSAGING_POLICY_KEYS: Record = { - discord: ["discord"], - slack: ["slack"], - telegram: ["telegram"], - wechat: ["wechat_bridge"], -}; - const PROC_PATH = "/proc"; const PROC_COMM_READ_WRITE_PATHS = ["/proc/self/comm", "/proc/self/task/*/comm"]; @@ -181,7 +178,7 @@ function filterHermesInactiveMessagingPolicies( const active = new Set(activeMessagingChannels); let changed = false; - for (const [channel, policyKeys] of Object.entries(HERMES_MESSAGING_POLICY_KEYS)) { + for (const [channel, policyKeys] of Object.entries(messagingPolicyKeysByChannel("hermes"))) { if (active.has(channel)) continue; for (const key of policyKeys) { if (Object.prototype.hasOwnProperty.call(parsed.network_policies, key)) { @@ -210,7 +207,7 @@ export function prepareInitialSandboxCreatePolicy( dockerGpuPatch?: boolean; additionalPresets?: string[]; agentName?: string | null; - messagingPolicyPresets?: string[]; + messagingPolicyPresets?: string[] | null; } = {}, ): InitialSandboxPolicy { const directGpuPolicy = options.directGpu @@ -226,7 +223,7 @@ export function prepareInitialSandboxCreatePolicy( : undefined; const requestedCreateTimePresets = [ ...new Set([ - ...(options.messagingPolicyPresets ?? []), + ...(options.messagingPolicyPresets ?? requiredMessagingChannelPolicyPresets(activeMessagingChannels)), ...requiredOpenclawOtelPolicyPresets(options.agentName ?? "openclaw"), ...(options.additionalPresets || []), ]), diff --git a/src/lib/onboard/machine/handlers/policies.test.ts b/src/lib/onboard/machine/handlers/policies.test.ts index 503cba65c5..c842d6b400 100644 --- a/src/lib/onboard/machine/handlers/policies.test.ts +++ b/src/lib/onboard/machine/handlers/policies.test.ts @@ -3,16 +3,57 @@ import { describe, expect, it, vi } from "vitest"; +import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; import { handlePoliciesState, type PoliciesStateOptions } from "./policies"; type Agent = { name: string } | null; type WebSearchConfig = { fetchEnabled: true }; +function makeMessagingPlan( + channelIds: readonly string[], + overrides: Partial = {}, +): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "my-assistant", + agent: "openclaw", + workflow: "onboard", + channels: channelIds.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + })), + disabledChannels: [], + credentialBindings: [], + networkPolicy: { + presets: [...channelIds], + entries: channelIds.map((channelId) => ({ + channelId, + presetName: channelId, + policyKeys: [channelId], + source: "manifest", + })), + }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + ...overrides, + }; +} + function createDeps(overrides: Partial["deps"]> = {}) { let session = createSession(); const calls = { load: vi.fn(() => session), + activeSandbox: vi.fn(() => null), smoke: vi.fn(), prepareResume: vi.fn( ( @@ -40,6 +81,7 @@ function createDeps(overrides: Partial { const result = await handlePoliciesState({ ...baseOptions(deps), - messagingPolicyPresets: ["slack"], + selectedMessagingChannels: ["slack"], }); expect(calls.smoke).toHaveBeenCalledWith({ @@ -107,7 +149,8 @@ describe("handlePoliciesState", () => { "my-assistant", expect.objectContaining({ selectedPresets: null, - messagingPolicyPresets: ["slack"], + messagingPolicyPresets: null, + messagingChannelIds: ["slack"], provider: "provider", webSearchSupported: true, }), @@ -126,16 +169,43 @@ describe("handlePoliciesState", () => { }); it("passes plan-derived messaging policy presets through to preparePolicyPresetResumeSelection", async () => { - const { deps, calls } = createDeps(); + const { deps, calls, setSession } = createDeps(); + setSession(createSession({ messagingPlan: makeMessagingPlan(["slack"]) })); - await handlePoliciesState({ - ...baseOptions(deps), - messagingPolicyPresets: ["slack"], - }); + await handlePoliciesState(baseOptions(deps)); + + expect(calls.prepareResume).toHaveBeenCalledWith( + "my-assistant", + expect.objectContaining({ + messagingPolicyPresets: ["slack"], + messagingChannelIds: ["slack"], + disabledChannels: [], + }), + ); + }); + + it("uses legacy session messagingChannels when no messagingPlan is available", async () => { + const { deps, calls, setSession } = createDeps(); + setSession(createSession({ messagingChannels: ["slack"], messagingPlan: null })); + + await handlePoliciesState(baseOptions(deps)); + expect(calls.smoke).toHaveBeenCalledWith( + expect.objectContaining({ messagingChannels: ["slack"] }), + ); expect(calls.prepareResume).toHaveBeenCalledWith( "my-assistant", - expect.objectContaining({ messagingPolicyPresets: ["slack"] }), + expect.objectContaining({ + messagingPolicyPresets: null, + messagingChannelIds: ["slack"], + }), + ); + expect(calls.setupPolicies).toHaveBeenCalledWith( + "my-assistant", + expect.objectContaining({ + messagingPolicyPresets: null, + messagingChannelIds: ["slack"], + }), ); }); diff --git a/src/lib/onboard/machine/handlers/policies.ts b/src/lib/onboard/machine/handlers/policies.ts index 5be5f36f40..64d14dfe04 100644 --- a/src/lib/onboard/machine/handlers/policies.ts +++ b/src/lib/onboard/machine/handlers/policies.ts @@ -2,7 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import type { Session, SessionUpdates } from "../../../state/onboard-session"; -import { getEnabledChannelIdsFromPlan } from "../../messaging-plan-session"; +import { + type ActiveSandboxPolicyState, + resolveMessagingPolicyState, +} from "../../messaging-policy-state"; import { advanceTo, type OnboardStateTransitionResult } from "../result"; // Inlined to avoid pulling sandbox-agent's transitive runner.ts deps into @@ -31,13 +34,14 @@ 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; verifyCompatibleEndpointSandboxSmoke(options: { sandboxName: string; provider: string; @@ -52,6 +56,8 @@ export interface PoliciesStateOptions { options: { recordedPolicyPresets: string[] | null; messagingPolicyPresets?: string[] | null; + messagingChannelIds?: string[] | null; + disabledChannels?: string[] | null; hermesToolGateways: string[]; agent?: string | null; webSearchConfig: WebSearchConfig | null; @@ -69,8 +75,9 @@ export interface PoliciesStateOptions { sandboxName: string, options: { selectedPresets: string[] | null; - messagingPolicyPresets: string[]; + messagingPolicyPresets: string[] | null; messagingChannelIds: string[] | null; + disabledChannels?: string[] | null; webSearchConfig: WebSearchConfig | null; provider: string; agent?: string | null; @@ -105,7 +112,7 @@ export async function handlePoliciesState({ model, endpointUrl, credentialEnv, - messagingPolicyPresets, + selectedMessagingChannels, webSearchConfig, webSearchSupported, hermesToolGateways, @@ -116,6 +123,17 @@ 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 messagingPolicyState = resolveMessagingPolicyState({ + plan: latestSession?.messagingPlan, + selectedChannels: selectedMessagingChannels, + recordedChannels: recordedMessagingChannels, + activeSandbox, + sessionDisabledChannels: latestSession?.disabledChannels, + }); deps.verifyCompatibleEndpointSandboxSmoke({ sandboxName, @@ -123,15 +141,15 @@ export async function handlePoliciesState({ 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: messagingPolicyState.messagingChannelIds, agent, }); const policyResumeSelection = deps.preparePolicyPresetResumeSelection(sandboxName, { recordedPolicyPresets, - messagingPolicyPresets, + messagingPolicyPresets: messagingPolicyState.messagingPolicyPresets, + messagingChannelIds: messagingPolicyState.messagingChannelIds, + disabledChannels: messagingPolicyState.disabledChannels, hermesToolGateways, agent: normalizeAgentName((agent as { name?: string } | null)?.name), webSearchConfig, @@ -184,8 +202,9 @@ export async function handlePoliciesState({ selectedPresets: Array.isArray(recordedPolicyPresets) ? recordedPolicyPresetsForSupport : null, - messagingPolicyPresets, - messagingChannelIds: getEnabledChannelIdsFromPlan(latestSession?.messagingPlan), + messagingPolicyPresets: messagingPolicyState.messagingPolicyPresets, + messagingChannelIds: messagingPolicyState.messagingChannelIds, + disabledChannels: messagingPolicyState.disabledChannels, webSearchConfig, provider, // selectOnboardAgent returns null for the default OpenClaw path (no diff --git a/src/lib/onboard/messaging-plan-session.test.ts b/src/lib/onboard/messaging-plan-session.test.ts index 8964d2add2..c9969d5327 100644 --- a/src/lib/onboard/messaging-plan-session.test.ts +++ b/src/lib/onboard/messaging-plan-session.test.ts @@ -50,14 +50,14 @@ function makePlan( } describe("getPolicyPresetsFromPlan", () => { - it("returns empty array for null/undefined", () => { - expect(getPolicyPresetsFromPlan(null)).toEqual([]); - expect(getPolicyPresetsFromPlan(undefined)).toEqual([]); + it("returns null for null/undefined plans", () => { + expect(getPolicyPresetsFromPlan(null)).toBeNull(); + expect(getPolicyPresetsFromPlan(undefined)).toBeNull(); }); it("returns presets for all active channels", () => { const plan = makePlan(["slack", "telegram"]); - expect(getPolicyPresetsFromPlan(plan).sort()).toEqual(["slack", "telegram"]); + expect(getPolicyPresetsFromPlan(plan)?.sort()).toEqual(["slack", "telegram"]); }); it("excludes presets for disabled channels", () => { @@ -163,7 +163,7 @@ describe("getEnabledChannelIdsFromPlan", () => { expect(getEnabledChannelIdsFromPlan(plan)).toEqual(["slack"]); }); - it("returns null when all channels are disabled", () => { + it("returns empty array when a plan has no enabled channels", () => { const plan = makePlan(["telegram"], { channels: [ { @@ -180,7 +180,7 @@ describe("getEnabledChannelIdsFromPlan", () => { ], disabledChannels: ["telegram"], }); - expect(getEnabledChannelIdsFromPlan(plan)).toBeNull(); + expect(getEnabledChannelIdsFromPlan(plan)).toEqual([]); }); }); diff --git a/src/lib/onboard/messaging-plan-session.ts b/src/lib/onboard/messaging-plan-session.ts index b9b48b8a00..29376a8101 100644 --- a/src/lib/onboard/messaging-plan-session.ts +++ b/src/lib/onboard/messaging-plan-session.ts @@ -42,7 +42,7 @@ function isObject(value: unknown): value is Record { /** 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; + if (!plan) return null; return plan.channels.map((c) => c.channelId); } @@ -52,7 +52,7 @@ export function getEnabledChannelIdsFromPlan( ): string[] | null { if (!plan) return null; const ids = [...enabledPlanChannelIds(plan)]; - return ids.length > 0 ? ids : null; + return ids; } /** Derive the equivalent of session.disabledChannels from a plan. */ @@ -60,12 +60,12 @@ export function getDisabledChannelsFromPlan( plan: SandboxMessagingPlan | null | undefined, ): string[] | null { if (!plan) return null; - return plan.disabledChannels.length > 0 ? [...plan.disabledChannels] : null; + return [...plan.disabledChannels]; } /** Derive the messaging network policy presets for active channels from a plan. */ -export function getPolicyPresetsFromPlan(plan: SandboxMessagingPlan | null | undefined): string[] { - if (!plan) return []; +export function getPolicyPresetsFromPlan(plan: SandboxMessagingPlan | null | undefined): string[] | null { + if (!plan) return null; const activeEntries = filterEnabledPlanEntries(plan, plan.networkPolicy.entries); const seen = new Set(); const result: string[] = []; diff --git a/src/lib/onboard/messaging-policy-state.test.ts b/src/lib/onboard/messaging-policy-state.test.ts new file mode 100644 index 0000000000..0dc2d002b1 --- /dev/null +++ b/src/lib/onboard/messaging-policy-state.test.ts @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { + filterMessagingPolicyPresetsForSelection, + mergeRequiredMessagingPolicyPresets, + resolveMessagingPolicyState, +} from "./messaging-policy-state"; + +function makePlan( + channelIds: readonly string[], + overrides: Partial = {}, +): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + channels: channelIds.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + })), + disabledChannels: [], + credentialBindings: [], + networkPolicy: { + presets: [...channelIds], + entries: channelIds.map((channelId) => ({ + channelId, + presetName: channelId, + policyKeys: [channelId], + source: "manifest", + })), + }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + ...overrides, + }; +} + +describe("resolveMessagingPolicyState", () => { + it("keeps no-plan legacy channels as fallback source", () => { + expect( + resolveMessagingPolicyState({ + plan: null, + selectedChannels: [], + recordedChannels: ["slack"], + activeSandbox: null, + }), + ).toEqual({ + messagingPolicyPresets: null, + messagingChannelIds: ["slack"], + disabledChannels: null, + }); + }); + + it("treats a compiled plan with no active entries as authoritative", () => { + const plan = makePlan(["slack"], { + channels: [ + { + channelId: "slack", + displayName: "Slack", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: true, + inputs: [], + hooks: [], + }, + ], + disabledChannels: ["slack"], + }); + + expect( + resolveMessagingPolicyState({ + plan, + recordedChannels: ["slack"], + activeSandbox: { messagingChannels: ["slack"], disabledChannels: [] }, + }), + ).toEqual({ + messagingPolicyPresets: [], + messagingChannelIds: [], + disabledChannels: ["slack"], + }); + }); +}); + +describe("messaging policy selection helpers", () => { + it("adds manifest-derived presets from legacy channel IDs", () => { + expect( + mergeRequiredMessagingPolicyPresets(["npm"], { + messagingPolicyPresets: null, + messagingChannelIds: ["telegram"], + }), + ).toEqual(["npm", "telegram"]); + }); + + it("does not treat no-plan fallback as an all-messaging stale marker", () => { + expect( + filterMessagingPolicyPresetsForSelection(["npm", "slack", "telegram"], { + messagingPolicyPresets: null, + disabledChannels: ["telegram"], + }), + ).toEqual(["npm", "slack"]); + }); + + it("does treat an empty compiled plan preset set as authoritative", () => { + expect( + filterMessagingPolicyPresetsForSelection(["npm", "slack", "telegram"], { + messagingPolicyPresets: [], + disabledChannels: null, + }), + ).toEqual(["npm"]); + }); +}); diff --git a/src/lib/onboard/messaging-policy-state.ts b/src/lib/onboard/messaging-policy-state.ts new file mode 100644 index 0000000000..ecd637e7ea --- /dev/null +++ b/src/lib/onboard/messaging-policy-state.ts @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + filterActiveMessagingPresets, + hasDisabledMessagingPreset, + pruneDisabledMessagingPolicyPresets, + requiredMessagingChannelPolicyPresets, +} from "../messaging/applier/policy-presets"; +import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { + getDisabledChannelsFromPlan, + getEnabledChannelIdsFromPlan, + getPolicyPresetsFromPlan, +} from "./messaging-plan-session"; + +export interface ActiveSandboxPolicyState { + messagingChannels?: string[] | null; + disabledChannels?: string[] | null; +} + +export interface ResolvedMessagingPolicyState { + /** Null means no compiled plan source is available. [] means the plan has no active presets. */ + messagingPolicyPresets: string[] | null; + messagingChannelIds: string[]; + disabledChannels: string[] | null; +} + +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 resolveMessagingPolicyState(options: { + plan?: SandboxMessagingPlan | null; + selectedChannels?: string[] | null; + recordedChannels?: string[] | null; + activeSandbox?: ActiveSandboxPolicyState | null; + sessionDisabledChannels?: string[] | null; +}): ResolvedMessagingPolicyState { + const planPolicyPresets = getPolicyPresetsFromPlan(options.plan); + if (planPolicyPresets !== null) { + return { + messagingPolicyPresets: planPolicyPresets, + messagingChannelIds: getEnabledChannelIdsFromPlan(options.plan) ?? [], + disabledChannels: getDisabledChannelsFromPlan(options.plan), + }; + } + + const disabledChannels = + options.activeSandbox?.disabledChannels ?? options.sessionDisabledChannels ?? null; + return { + messagingPolicyPresets: null, + messagingChannelIds: mergePolicyMessagingChannels( + options.selectedChannels, + options.recordedChannels, + options.activeSandbox?.messagingChannels, + disabledChannels, + ), + disabledChannels, + }; +} + +export function requiredMessagingPolicyPresets(options: { + messagingPolicyPresets?: string[] | null; + messagingChannelIds?: string[] | null; +}): string[] { + const merged: string[] = []; + for (const preset of options.messagingPolicyPresets ?? []) { + if (!merged.includes(preset)) merged.push(preset); + } + for (const preset of requiredMessagingChannelPolicyPresets(options.messagingChannelIds)) { + if (!merged.includes(preset)) merged.push(preset); + } + return merged; +} + +export function mergeRequiredMessagingPolicyPresets( + selectedPresets: string[], + options: { + messagingPolicyPresets?: string[] | null; + messagingChannelIds?: string[] | null; + knownPresetNames?: Iterable | null; + }, +): string[] { + const known = options.knownPresetNames ? new Set(options.knownPresetNames) : null; + const merged = [...selectedPresets]; + for (const preset of requiredMessagingPolicyPresets(options)) { + if (known && !known.has(preset)) continue; + if (merged.includes(preset)) continue; + merged.push(preset); + } + return merged; +} + +export function filterMessagingPolicyPresetsForSelection( + presets: readonly string[], + options: { + messagingPolicyPresets?: string[] | null; + disabledChannels?: string[] | null; + }, +): string[] { + if (Array.isArray(options.messagingPolicyPresets)) { + return filterActiveMessagingPresets(presets, new Set(options.messagingPolicyPresets)); + } + return pruneDisabledMessagingPolicyPresets([...presets], options.disabledChannels); +} + +export function hasMessagingPolicyPresetNeedingReconcile( + presets: readonly string[], + options: { + messagingPolicyPresets?: string[] | null; + disabledChannels?: string[] | null; + }, +): boolean { + if (Array.isArray(options.messagingPolicyPresets)) { + return hasDisabledMessagingPreset(presets, new Set(options.messagingPolicyPresets)); + } + return filterMessagingPolicyPresetsForSelection(presets, options).length !== presets.length; +} diff --git a/src/lib/onboard/openclaw-otel-policy-presets.test.ts b/src/lib/onboard/openclaw-otel-policy-presets.test.ts index 0300e73b79..d4e548889a 100644 --- a/src/lib/onboard/openclaw-otel-policy-presets.test.ts +++ b/src/lib/onboard/openclaw-otel-policy-presets.test.ts @@ -5,7 +5,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("../messaging/applier/policy-presets", () => ({ ALL_MESSAGING_POLICY_PRESET_NAMES: new Set(["slack"]), + filterActiveMessagingPresets: (presets: string[]) => presets, + hasDisabledMessagingPreset: () => false, pruneDisabledMessagingPolicyPresets: (presets: string[]) => presets, + requiredMessagingChannelPolicyPresets: () => [], })); vi.mock("./hermes-managed-tools", () => ({ diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index 93534a15b1..c8ca670702 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -2,11 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import type { WebSearchConfig } from "../inference/web-search"; -import { - ALL_MESSAGING_POLICY_PRESET_NAMES, - filterActiveMessagingPresets, - hasDisabledMessagingPreset, -} from "../messaging/applier/policy-presets"; import { filterSetupPolicyPresetNamesForAgent, filterSetupPolicyPresetsForAgent, @@ -17,6 +12,12 @@ import { HERMES_TOOL_GATEWAY_PRESET_NAMES, mergeRequiredHermesToolGatewayPolicyPresets, } from "./hermes-managed-tools"; +import { + filterMessagingPolicyPresetsForSelection, + hasMessagingPolicyPresetNeedingReconcile, + mergeRequiredMessagingPolicyPresets, + requiredMessagingPolicyPresets, +} from "./messaging-policy-state"; import { isOpenclawAgent, mergeRequiredOpenclawOtelPolicyPresets, @@ -47,6 +48,7 @@ export type SetupPresetSuggestionOptions = { messagingPolicyPresets?: string[] | null; /** Channel IDs whose names map to known policy presets (e.g. "telegram", "discord"). */ messagingChannelIds?: string[] | null; + disabledChannels?: string[] | null; webSearchConfig?: WebSearchConfig | null; provider?: string | null; agent?: string | null; @@ -63,6 +65,7 @@ export type SetupPolicySelectionOptions = { messagingPolicyPresets?: string[] | null; /** Channel IDs whose names map to known policy presets (e.g. "telegram", "discord"). */ messagingChannelIds?: string[] | null; + disabledChannels?: string[] | null; provider?: string | null; agent?: string | null; knownPresetNames?: string[]; @@ -105,6 +108,7 @@ export function mergeRequiredSetupPolicyPresets( policyPresets: string[], options: { messagingPolicyPresets?: string[] | null; + messagingChannelIds?: string[] | null; hermesToolGateways?: string[] | null; agent?: string | null; knownPresetNames?: string[] | Set | null; @@ -112,22 +116,18 @@ export function mergeRequiredSetupPolicyPresets( } = {}, ): 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( + const withMessaging = mergeRequiredMessagingPolicyPresets( mergeRequiredHermesToolGatewayPolicyPresets( agentFilteredPresets, options.hermesToolGateways, options.knownPresetNames, ), - options.messagingPolicyPresets ?? [], - known, + { + messagingPolicyPresets: options.messagingPolicyPresets, + messagingChannelIds: options.messagingChannelIds, + knownPresetNames: options.knownPresetNames, + }, ); - const mergedPresets = mergeRequiredOpenclawOtelPolicyPresets(withMessaging, { agent: options.agent, knownPresetNames: options.knownPresetNames, @@ -136,20 +136,6 @@ export function mergeRequiredSetupPolicyPresets( 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: { @@ -202,35 +188,21 @@ 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 ?? []) { + for (const preset of requiredMessagingPolicyPresets({ + messagingPolicyPresets, + messagingChannelIds: options.messagingChannelIds, + })) { add(preset); } - // Channel IDs that map directly to same-named policy presets (e.g. "telegram", "discord"). - for (const channelId of options.messagingChannelIds ?? []) { - add(channelId); - } if (Array.isArray(options.hermesToolGateways)) { for (const preset of options.hermesToolGateways) { if (HERMES_TOOL_GATEWAY_PRESET_NAMES.has(preset)) add(preset); } } - // When messaging state is explicitly provided, remove messaging presets not - // in the active set (e.g. disabled-channel presets that appear in open tiers). - if (messagingPolicyPresets !== null) { - const activeMessagingPresets = new Set([ - ...messagingPolicyPresets, - ...(options.messagingChannelIds ?? []), - ]); - for (let i = suggestions.length - 1; i >= 0; i--) { - if ( - ALL_MESSAGING_POLICY_PRESET_NAMES.has(suggestions[i]) && - !activeMessagingPresets.has(suggestions[i]) - ) { - suggestions.splice(i, 1); - } - } - } - return suggestions; + return filterMessagingPolicyPresetsForSelection(suggestions, { + messagingPolicyPresets, + disabledChannels: options.disabledChannels, + }); } export function preparePolicyPresetResumeSelection( @@ -239,6 +211,8 @@ export function preparePolicyPresetResumeSelection( options: { recordedPolicyPresets: string[] | null; messagingPolicyPresets?: string[] | null; + messagingChannelIds?: string[] | null; + disabledChannels?: string[] | null; hermesToolGateways?: string[] | null; agent?: string | null; webSearchConfig?: WebSearchConfig | null; @@ -271,7 +245,14 @@ export function preparePolicyPresetResumeSelection( webSearchConfig: options.webSearchConfig, customPresetNames: customPolicyPresetNames, }); - let policyPresets = clampedRecordedPolicyPresets.filter((name) => !isStaleBuiltinBrave(name)); + const messagingSelection = { + messagingPolicyPresets: options.messagingPolicyPresets, + disabledChannels: options.disabledChannels, + }; + let policyPresets = filterMessagingPolicyPresetsForSelection( + clampedRecordedPolicyPresets.filter((name) => !isStaleBuiltinBrave(name)), + messagingSelection, + ); const recordedPolicyPresetsNeedReconcile = Array.isArray(options.recordedPolicyPresets) && policyPresets.length !== options.recordedPolicyPresets.length; @@ -284,20 +265,14 @@ export function preparePolicyPresetResumeSelection( ) .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 = hasDisabledMessagingPreset( + const disabledMessagingPolicyPresetApplied = hasMessagingPolicyPresetNeedingReconcile( appliedPolicyPresetsForSupport, - planMessagingPresets, + messagingSelection, ); - // Merge any applied non-stale presets (minus the stale messaging ones) so the - // policy sync step can diff them out cleanly. - const appliedToPreserve = filterActiveMessagingPresets( + const appliedToPreserve = filterMessagingPolicyPresetsForSelection( appliedPolicyPresetsForSupport, - planMessagingPresets, + messagingSelection, ); for (const preset of appliedToPreserve) { if (!policyPresets.includes(preset)) policyPresets.push(preset); @@ -306,6 +281,7 @@ export function preparePolicyPresetResumeSelection( if (Array.isArray(options.recordedPolicyPresets)) { policyPresets = mergeRequiredSetupPolicyPresets(policyPresets, { messagingPolicyPresets: options.messagingPolicyPresets, + messagingChannelIds: options.messagingChannelIds, hermesToolGateways: options.hermesToolGateways, agent: options.agent, knownPresetNames: selectablePolicyPresets.map((preset) => preset.name), @@ -344,6 +320,7 @@ async function setupPoliciesWithSelectionInner( const messagingChannelIds = Array.isArray(options.messagingChannelIds) ? options.messagingChannelIds : null; + const disabledChannels = Array.isArray(options.disabledChannels) ? options.disabledChannels : null; const provider = options.provider || null; const agent = options.agent || null; const hermesToolGateways = Array.isArray(options.hermesToolGateways) @@ -376,13 +353,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 = filterActiveMessagingPresets( + const messagingSelection = { messagingPolicyPresets, disabledChannels }; + const filterMessagingSelection = (presetNames: readonly string[]) => + filterMessagingPolicyPresetsForSelection(presetNames, messagingSelection); + const appliedForPreservation = filterMessagingPolicyPresetsForSelection( applied.filter((name) => !isStaleBuiltinBrave(name)), - planMessagingPresets, + messagingSelection, ); const filterSupportedPresetNames = (presetNames: string[]) => filterSetupPolicyPresetNamesForAgent(presetNames, agent).filter( @@ -403,11 +379,13 @@ async function setupPoliciesWithSelectionInner( const knownSelectablePresets = new Set(selectablePresets.map((preset) => preset.name)); chosen = mergeRequiredSetupPolicyPresets(chosen, { messagingPolicyPresets, + messagingChannelIds, hermesToolGateways, agent, knownPresetNames: knownSelectablePresets, env: deps.env, }); + chosen = filterMessagingSelection(chosen); } if (selectedPresets !== null) { @@ -427,6 +405,7 @@ async function setupPoliciesWithSelectionInner( const suggestions = computeSetupPresetSuggestions(deps, tierName, { messagingPolicyPresets, messagingChannelIds, + disabledChannels, webSearchConfig, provider, agent, @@ -472,11 +451,13 @@ async function setupPoliciesWithSelectionInner( chosen = mergeRequiredSetupPolicyPresets(chosen, { messagingPolicyPresets, + messagingChannelIds, hermesToolGateways, agent, knownPresetNames: knownPresets, env: deps.env, }); + chosen = filterMessagingSelection(chosen); const invalidPresets = chosen.filter((name) => !knownPresets.has(name)); if (invalidPresets.length > 0) { @@ -517,15 +498,15 @@ 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), - { + const interactiveChoice = filterMessagingSelection( + mergeRequiredSetupPolicyPresets(resolvedPresets.map((preset) => preset.name), { messagingPolicyPresets, + messagingChannelIds, hermesToolGateways, agent, knownPresetNames: knownNames, env: deps.env, - }, + }), ); if (onSelection) onSelection(interactiveChoice); diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index 687d874e14..562d2becb0 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -115,7 +115,7 @@ childProcess.spawn = (...args) => { const { createSandbox, setupMessagingChannels } = require(${onboardPath}); (async () => { - process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.OPENSHELL_GATEWAY = "nemoclaw"; process.env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION = "1"; process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY = "1"; process.env.DISCORD_BOT_TOKEN = "test-discord-token-value"; process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token-value"; diff --git a/test/policies.test.ts b/test/policies.test.ts index 9ece74dc2c..ae98551183 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -794,18 +794,16 @@ exit 1 describe("applyPreset disclosure logging", () => { it("logs egress endpoints before applying", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-log-")); + const previousHome = process.env.HOME; + process.env.HOME = tmpDir; + const resolveSpy = vi + .spyOn(resolveOpenshellModule, "resolveOpenshell") + .mockReturnValue("/bin/true"); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("exit"); - }); try { - try { - policies.applyPreset("test-sandbox", "npm"); - } catch { - /* applyPreset may throw if sandbox not running — we only care about the log */ - } + try { policies.applyPreset("test-sandbox", "npm"); } catch { /* log assertion only */ } const messages = logSpy.mock.calls.map((call) => typeof call[0] === "string" ? call[0] : undefined, ); @@ -814,8 +812,10 @@ exit 1 ).toBe(true); } finally { logSpy.mockRestore(); - errSpy.mockRestore(); - exitSpy.mockRestore(); + resolveSpy.mockRestore(); + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + fs.rmSync(tmpDir, { recursive: true, force: true }); } }); diff --git a/test/secret-redaction.test.ts b/test/secret-redaction.test.ts index 417aada56e..878410466c 100644 --- a/test/secret-redaction.test.ts +++ b/test/secret-redaction.test.ts @@ -81,6 +81,7 @@ describe("secret redaction consistency (#1736)", () => { encoding: "utf-8", env: { ...process.env, + HOME: tmp, NEMOCLAW_NODE: process.execPath, TMPDIR: tmp, PATH: `${fakeBin}:${process.env.PATH || ""}`, @@ -132,6 +133,7 @@ describe("secret redaction consistency (#1736)", () => { encoding: "utf-8", env: { ...process.env, + HOME: tmp, NEMOCLAW_NODE: process.execPath, TMPDIR: tmp, PATH: fakeBin,