Skip to content
10 changes: 7 additions & 3 deletions src/lib/actions/sandbox/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
MessagingWorkflowPlanner,
toMessagingAgentId,
} from "../../messaging";
import type { SandboxMessagingPlan } from "../../messaging/manifest";
import { pruneDisabledMessagingPolicyPresets } from "../../onboard/messaging-policy-presets";
import {
captureSandboxListWithGatewayRecovery,
Expand Down Expand Up @@ -176,7 +177,7 @@ async function stageMessagingManifestPlanForRebuild(
sandboxEntry: registry.SandboxEntry,
rebuildAgent: string | null,
log: (msg: string) => void,
): Promise<void> {
): Promise<SandboxMessagingPlan | null> {
const agent = loadAgent(rebuildAgent || "openclaw");
const planner = new MessagingWorkflowPlanner(createBuiltInChannelManifestRegistry());
const plan = await planner.buildRebuildPlanFromSandboxEntry({
Expand All @@ -188,14 +189,15 @@ 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(
`Messaging manifest rebuild plan staged: ${plan.channels
.map((channel) => channel.channelId)
.join(",")}`,
);
return plan;
}

/**
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -6499,6 +6496,9 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
getSandboxMessagingChannels: (name) => registry.getSandbox(name)?.messagingChannels,
setupMessagingChannels,
readMessagingChannelConfigFromEnv,
readMessagingPlanFromEnv,
writePlanToEnv,
getRegistrySandboxMessagingPlan,
promptValidatedSandboxName,
selectResourceProfileForSandbox: () => selectResourceProfileForSandbox({ isNonInteractive, note, prompt, promptOrDefault }),
stopStaleDashboardListenersForSandbox,
Expand Down
84 changes: 84 additions & 0 deletions src/lib/onboard/machine/handlers/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -72,6 +90,9 @@ function createDeps(overrides: Partial<SandboxStateOptions<Gpu, Agent, WebSearch
getSandboxMessagingChannels: () => ["telegram"],
setupMessagingChannels: calls.setupMessaging,
readMessagingChannelConfigFromEnv: () => null,
readMessagingPlanFromEnv: () => null,
writePlanToEnv: () => undefined,
getRegistrySandboxMessagingPlan: () => null,
promptValidatedSandboxName: calls.promptName,
selectResourceProfileForSandbox: calls.selectResourceProfile,
stopStaleDashboardListenersForSandbox: calls.stopStale,
Expand Down Expand Up @@ -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();
});
});
27 changes: 27 additions & 0 deletions src/lib/onboard/machine/handlers/sandbox.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -62,6 +63,9 @@ export interface SandboxStateOptions<Gpu, Agent, WebSearchConfig, MessagingChann
sandboxName: string,
): Promise<string[]>;
readMessagingChannelConfigFromEnv(): MessagingChannelConfig | null;
readMessagingPlanFromEnv(): SandboxMessagingPlan | null;
writePlanToEnv(plan: SandboxMessagingPlan): void;
getRegistrySandboxMessagingPlan(sandboxName: string): SandboxMessagingPlan | null;
promptValidatedSandboxName(agent: Agent): Promise<string>;
selectResourceProfileForSandbox(): Promise<ResourceProfile | null>;
stopStaleDashboardListenersForSandbox(sandboxes: unknown[], sandboxName: string): void;
Expand Down Expand Up @@ -263,21 +267,44 @@ export async function handleSandboxState<Gpu, Agent, WebSearchConfig, MessagingC
await deps.startRecordedStep("sandbox", { provider, model });
if (!sandboxName) sandboxName = await deps.promptValidatedSandboxName(agent);
const recordedMessagingChannels = deps.getRecordedMessagingChannelsForResume(resume, session, sandboxName);
let messagingPlan: SandboxMessagingPlan | null = null;
if (recordedMessagingChannels) {
selectedMessagingChannels = recordedMessagingChannels;
if (selectedMessagingChannels.length > 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;
});

Expand Down
15 changes: 15 additions & 0 deletions src/lib/onboard/messaging-channel-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,22 @@

import type { AgentDefinition } from "../agent/defs";
import { getCredential, normalizeCredentialValue } from "../credentials/store";
import * as registry from "../state/registry";
import {
type ChannelInputSpec,
type ChannelManifest,
createBuiltInMessagingHookRegistry,
createBuiltInChannelManifestRegistry,
getMessagingManifestAvailabilityContext,
hasMessagingManifestRequiredInputs,
MessagingHostStateApplier,
MessagingSetupApplier,
MessagingWorkflowPlanner,
resolveMessagingManifestSeed,
type SandboxMessagingPlan,
toMessagingAgentId,
} from "../messaging";
export { MessagingHostStateApplier };
import { resolveMessagingChannelConfigEnvValue } from "../messaging-channel-config";

export interface SetupSelectedMessagingChannelsOptions {
Expand Down Expand Up @@ -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;
Expand Down
64 changes: 64 additions & 0 deletions src/lib/onboard/messaging-plan-session.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, string> = {};
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;
}
3 changes: 3 additions & 0 deletions src/lib/onboard/session-updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,6 +19,7 @@ export interface OnboardSessionUpdateInput {
policyPresets?: string[] | null;
messagingChannels?: string[] | null;
messagingChannelConfig?: MessagingChannelConfig | null;
messagingPlan?: SandboxMessagingPlan | null;
hermesToolGateways?: string[] | null;
}

Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading