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 7c8cffb831..ebb54bd7f8 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -89,10 +89,7 @@ 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 }: 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 +6496,9 @@ async function onboard(opts: OnboardOptions = {}): Promise { getSandboxMessagingChannels: (name) => registry.getSandbox(name)?.messagingChannels, setupMessagingChannels, 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 51dbaf2352..b52876f9e8 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 }; @@ -72,6 +90,9 @@ function createDeps(overrides: Partial ["telegram"], setupMessagingChannels: calls.setupMessaging, readMessagingChannelConfigFromEnv: () => null, + readMessagingPlanFromEnv: () => null, + writePlanToEnv: () => undefined, + getRegistrySandboxMessagingPlan: () => null, promptValidatedSandboxName: calls.promptName, selectResourceProfileForSandbox: calls.selectResourceProfile, stopStaleDashboardListenersForSandbox: calls.stopStale, @@ -316,4 +337,67 @@ 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 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(registryPlan); + }); + + 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"] }); + 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" }); + + expect(writePlanToEnv).not.toHaveBeenCalled(); + expect(getSession().messagingPlan).toEqual(rebuiltPlan); + }); + + 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" }); + + expect(writePlanToEnv).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 26a26c08c8..07d6208d7b 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,9 @@ export interface SandboxStateOptions; readMessagingChannelConfigFromEnv(): MessagingChannelConfig | null; + readMessagingPlanFromEnv(): SandboxMessagingPlan | null; + writePlanToEnv(plan: SandboxMessagingPlan): void; + getRegistrySandboxMessagingPlan(sandboxName: string): SandboxMessagingPlan | null; promptValidatedSandboxName(agent: Agent): Promise; selectResourceProfileForSandbox(): Promise; stopStaleDashboardListenersForSandbox(sandboxes: unknown[], sandboxName: string): void; @@ -263,21 +267,44 @@ export async function handleSandboxState 0) { deps.note(` [non-interactive] Reusing messaging channel configuration: ${selectedMessagingChannels.join(", ")}`); + // 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 { + // Registry is always current — updated by stop/start/add/remove. + // Works for plain process-restart resumes and cancel-then-resume + // when sandbox step had previously completed. + const registryPlan = deps.getRegistrySandboxMessagingPlan(sandboxName); + if (registryPlan) { + deps.writePlanToEnv(registryPlan); + messagingPlan = registryPlan; + } + } } } 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/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index c0a3928c7e..97d1e8ce4f 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -3,6 +3,7 @@ import type { AgentDefinition } from "../agent/defs"; import { getCredential, normalizeCredentialValue } from "../credentials/store"; +import * as registry from "../state/registry"; import { type ChannelInputSpec, type ChannelManifest, @@ -10,12 +11,14 @@ import { createBuiltInChannelManifestRegistry, getMessagingManifestAvailabilityContext, hasMessagingManifestRequiredInputs, + MessagingHostStateApplier, MessagingSetupApplier, MessagingWorkflowPlanner, resolveMessagingManifestSeed, type SandboxMessagingPlan, toMessagingAgentId, } from "../messaging"; +export { MessagingHostStateApplier }; import { resolveMessagingChannelConfigEnvValue } from "../messaging-channel-config"; export interface SetupSelectedMessagingChannelsOptions { @@ -317,6 +320,18 @@ function printInSandboxQrStatus(manifest: ChannelManifest): void { } } +export function readMessagingPlanFromEnv(): SandboxMessagingPlan | null { + return MessagingSetupApplier.readPlanFromEnv(); +} + +export function writePlanToEnv(plan: SandboxMessagingPlan): void { + MessagingSetupApplier.writePlanToEnv(plan); +} + +export function getRegistrySandboxMessagingPlan(sandboxName: string): SandboxMessagingPlan | null { + return registry.getSandbox(sandboxName)?.messaging?.plan ?? null; +} + function resolveMessagingSetupSandboxName(options: SetupSelectedMessagingChannelsOptions): string { const explicitName = normalizeSandboxName(options.sandboxName); if (explicitName) return explicitName; diff --git a/src/lib/onboard/messaging-plan-session.ts b/src/lib/onboard/messaging-plan-session.ts new file mode 100644 index 0000000000..bb05f97ea2 --- /dev/null +++ b/src/lib/onboard/messaging-plan-session.ts @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingChannelConfig } from "../messaging-channel-config"; +import type { SandboxMessagingPlan } from "../messaging/manifest"; + +export 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 isObject(value: unknown): value is Record { + 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; +} 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..ce6601cb01 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -18,6 +18,8 @@ import { type MessagingChannelConfig, sanitizeMessagingChannelConfig, } from "../messaging-channel-config"; +import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { parseSandboxMessagingPlan } from "../onboard/messaging-plan-session"; import { createOnboardMachineEvent, emitOnboardMachineEvent, @@ -104,6 +106,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 +185,7 @@ export interface SessionUpdates { policyPresets?: string[] | null; messagingChannels?: string[] | null; messagingChannelConfig?: MessagingChannelConfig | null; + messagingPlan?: SandboxMessagingPlan | null; disabledChannels?: string[] | null; migratedLegacyValueHashes?: Record; gpuPassthrough?: boolean; @@ -459,6 +463,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 +506,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 +955,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)) {