diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 23ed0e5f6ac..aef3562ab16 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -1,13 +1,20 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import { hashCredential } from "../../../security/credential-hash"; import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; +import { detectMessagingChannelsFromEnv } from "../../messaging-channel-setup"; import { handleSandboxState, type SandboxStateOptions } from "./sandbox"; +vi.mock("../../messaging-channel-setup", () => ({ + detectMessagingChannelsFromEnv: vi.fn(() => []), +})); + +const detectMessagingChannelsFromEnvMock = vi.mocked(detectMessagingChannelsFromEnv); + function makeMinimalPlan( sandboxName: string, agent = "openclaw", @@ -217,6 +224,10 @@ function baseOptions( } describe("handleSandboxState", () => { + beforeEach(() => { + detectMessagingChannelsFromEnvMock.mockReturnValue([]); + }); + it("creates a sandbox and records messaging/web search state", async () => { const { deps, calls } = createDeps({ configureWebSearch: vi.fn(async () => ({ fetchEnabled: true as const })), @@ -625,6 +636,78 @@ describe("handleSandboxState", () => { expect(getSession().messagingPlan).toBeNull(); }); + it("refreshes a reused empty registry messaging plan when env supplies new channel inputs", async () => { + // Reporter scenario (#5680): a fresh non-interactive onboard targets an + // existing sandbox whose registry messaging plan has no active channels, but + // the process now exports TELEGRAM_BOT_TOKEN. The empty plan must not be + // accepted as authoritative; messaging setup must run so the Telegram + // reachability check executes instead of being silently bypassed. + detectMessagingChannelsFromEnvMock.mockReturnValue(["telegram"]); + // Reused registry plan has no ACTIVE channels but records a previously + // configured in-sandbox-QR channel (whatsapp, disabled) with no host token. + // The rebuild must seed `existing` from this authoritative registry plan, + // not from the session plan, so whatsapp is preserved across the refresh. + const registryPlan = makeMinimalPlan("my-assistant", "openclaw", ["whatsapp"], ["whatsapp"]); + const refreshedPlan = makeMinimalPlan("my-assistant", "openclaw", ["telegram"], ["telegram"]); + const session = createSession({ + sandboxName: "my-assistant", + // A divergent/stale session plan that must NOT be used as the seed source. + messagingPlan: makeMinimalPlan("my-assistant", "openclaw", ["slack"]), + }); + const writePlanToEnv = vi.fn(); + const readMessagingPlanFromEnv = vi + .fn() + .mockReturnValueOnce(null) + .mockReturnValue(refreshedPlan); + const { deps, calls, getSession } = createDeps({ + getRecordedMessagingChannelsForResume: vi.fn(() => null), + writePlanToEnv, + readMessagingPlanFromEnv, + getRegistrySandboxMessagingPlan: () => registryPlan, + }); + // Fake-token rejection disables Telegram, so no channel survives setup. + calls.setupMessaging.mockResolvedValue([]); + + const result = await handleSandboxState({ + ...baseOptions(deps, session), + sandboxName: "my-assistant", + }); + + expect(calls.setupMessaging).toHaveBeenCalledWith(null, ["whatsapp"], "my-assistant"); + expect(writePlanToEnv).not.toHaveBeenCalled(); + expect(calls.note).toHaveBeenCalledWith( + " [non-interactive] Detected messaging channel inputs for telegram; refreshing reused sandbox messaging plan.", + ); + expect(result.selectedMessagingChannels).toEqual([]); + expect(getSession().messagingPlan).toEqual(refreshedPlan); + }); + + it("preserves an active registry channel without refresh when env adds a different channel", async () => { + // Regression guard: a reused plan with an active channel (slack) must not be + // rebuilt just because a new token (telegram) now appears in env. Rebuilding + // non-interactively would re-derive the plan from env and drop slack when + // its token is absent from this run, so active plans are preserved as-is. + detectMessagingChannelsFromEnvMock.mockReturnValue(["telegram"]); + const registryPlan = makeMinimalPlan("my-assistant", "openclaw", ["slack"]); + const session = createSession({ sandboxName: "my-assistant", messagingPlan: registryPlan }); + const writePlanToEnv = vi.fn(); + const { deps, calls } = createDeps({ + getRecordedMessagingChannelsForResume: vi.fn(() => null), + writePlanToEnv, + readMessagingPlanFromEnv: () => null, + getRegistrySandboxMessagingPlan: () => registryPlan, + }); + + const result = await handleSandboxState({ + ...baseOptions(deps, session), + sandboxName: "my-assistant", + }); + + expect(calls.setupMessaging).not.toHaveBeenCalled(); + expect(writePlanToEnv).toHaveBeenCalledWith(registryPlan); + expect(result.selectedMessagingChannels).toEqual(["slack"]); + }); + it("does not restore plan to env when registry has no entry", async () => { const session = createSession({ sandboxName: "my-assistant", diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index ef51b159bba..dafa4a9b967 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -5,6 +5,7 @@ import { isMessagingSupportedAgent, tryGetMessagingAgentId } from "../../../mess import type { MessagingAgentId, SandboxMessagingPlan } from "../../../messaging/manifest"; import { hashCredential } from "../../../security/credential-hash"; import type { Session, SessionUpdates } from "../../../state/onboard-session"; +import { detectMessagingChannelsFromEnv } from "../../messaging-channel-setup"; import { getActiveChannelsFromPlan, getChannelsFromPlan } from "../../messaging-plan-session"; import { withSandboxPhaseTrace } from "../../tracing"; import { branchTo, type OnboardStateTransitionResult } from "../result"; @@ -420,6 +421,38 @@ export async function handleSandboxState< deps.writePlanToEnv(filtered); } }; + // Run messaging channel setup, then adopt the plan it stages in env: + // filter both the selected channels and the plan for the current agent, + // clearing env when no channel is supported and writing the filtered plan + // back when it changed. Shared by the registry-refresh branch (#5680) and + // the normal setup branch so plan adoption stays consistent across both. + const setupAndAdoptMessagingPlan = async ( + existingChannels: string[] | null, + targetSandboxName: string, + ): Promise => { + const existing = existingChannels + ? filterChannelNamesForCurrentAgent(existingChannels, agent) + : existingChannels; + let selected = filterChannelNamesForCurrentAgent( + await deps.setupMessagingChannels(agent, existing, targetSandboxName), + agent, + ); + let plan = deps.readMessagingPlanFromEnv(); + if (plan) { + const filtered = filterMessagingPlanForCurrentAgent(plan, agent); + if (!filtered) { + deps.clearPlanEnv(); + plan = null; + selected = []; + } else if (filtered !== plan) { + plan = filtered; + selected = getActiveChannelsFromPlan(plan) ?? []; + deps.writePlanToEnv(filtered); + } + } + messagingPlan = plan; + selectedMessagingChannels = selected; + }; if (recordedMessagingChannels) { selectedMessagingChannels = filterChannelNamesForCurrentAgent( @@ -439,30 +472,42 @@ export async function handleSandboxState< } else if (envMessagingPlan) { reuseMessagingPlan(envMessagingPlan, false); } else if (registryMessagingPlan) { - reuseMessagingPlan(registryMessagingPlan, true); - } else { - const existingChannels = getChannelsFromPlan(session?.messagingPlan); - const existing = existingChannels - ? filterChannelNamesForCurrentAgent(existingChannels, agent) - : existingChannels; - selectedMessagingChannels = await deps.setupMessagingChannels(agent, existing, sandboxName); - selectedMessagingChannels = filterChannelNamesForCurrentAgent( - selectedMessagingChannels, + // Honor newly supplied messaging env inputs when the reused registry plan + // has no active channels for the current agent (the reporter's empty/stale + // "Messaging: none" case). Rebuild via setupMessagingChannels so newly + // supplied channels (e.g. Telegram via TELEGRAM_BOT_TOKEN) are discovered + // and run their reachability checks instead of being silently bypassed + // (#5680). When the reused plan already has active channels, preserve it + // as-is so we never drop an existing channel whose token is absent from + // this run's env. The explicit env-staged branch above stays authoritative. + const registryActiveChannels = filterChannelNamesForCurrentAgent( + getActiveChannelsFromPlan(registryMessagingPlan) ?? [], agent, ); - messagingPlan = deps.readMessagingPlanFromEnv(); - if (messagingPlan) { - const filtered = filterMessagingPlanForCurrentAgent(messagingPlan, agent); - if (!filtered) { - deps.clearPlanEnv(); - messagingPlan = null; - selectedMessagingChannels = []; - } else if (filtered !== messagingPlan) { - messagingPlan = filtered; - selectedMessagingChannels = getActiveChannelsFromPlan(messagingPlan) ?? []; - deps.writePlanToEnv(filtered); - } + const envDetectedChannels = filterChannelNamesForCurrentAgent( + detectMessagingChannelsFromEnv( + agent as Parameters[0], + ), + agent, + ); + if (registryActiveChannels.length === 0 && envDetectedChannels.length > 0) { + deps.note( + ` [non-interactive] Detected messaging channel inputs for ${envDetectedChannels.join(", ")}; refreshing reused sandbox messaging plan.`, + ); + // Seed previously-configured channels from the authoritative reused + // registry plan, not session?.messagingPlan (which may be null or stale + // on a fresh non-interactive run). This preserves channels configured on + // the sandbox whose inputs aren't re-derivable from env this run — e.g. + // an in-sandbox-QR channel like WhatsApp that has no host-side token. + await setupAndAdoptMessagingPlan( + getChannelsFromPlan(registryMessagingPlan) ?? getChannelsFromPlan(session?.messagingPlan), + sandboxName, + ); + } else { + reuseMessagingPlan(registryMessagingPlan, true); } + } else { + await setupAndAdoptMessagingPlan(getChannelsFromPlan(session?.messagingPlan), sandboxName); } session = deps.updateSession((current) => { current.messagingPlan = messagingPlan; diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index 29de917a366..35590537262 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -8,7 +8,11 @@ import { createBuiltInChannelManifestRegistry, MessagingSetupApplier } from "../ import { MESSAGING_SETUP_APPLIER_ENV_KEY } from "../messaging/applier/types"; import { validateSlackCredentials } from "../messaging/channels/slack/hooks/credential-validation"; import { runWechatHostQrLogin } from "../messaging/channels/wechat/login"; -import { setupMessagingChannels, setupSelectedMessagingChannels } from "./messaging-channel-setup"; +import { + detectMessagingChannelsFromEnv, + setupMessagingChannels, + setupSelectedMessagingChannels, +} from "./messaging-channel-setup"; vi.mock("../credentials/store", () => ({ getCredential: vi.fn(() => null), @@ -569,3 +573,43 @@ describe("setupMessagingChannels", () => { expect(output).toContain("slack — already configured"); }); }); + +describe("detectMessagingChannelsFromEnv", () => { + function clearMessagingEnv(): void { + const envKeys = manifestRegistry + .listAvailable({ agent: "openclaw", supportedChannelIds: null }) + .flatMap((manifest) => manifest.inputs) + .map((input) => input.envKey) + .filter((envKey): envKey is string => Boolean(envKey)); + for (const envKey of envKeys) delete process.env[envKey]; + delete process.env.NEMOCLAW_POLICY_PRESETS; + } + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.clearAllMocks(); + vi.mocked(getCredential).mockReturnValue(null); + clearMessagingEnv(); + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.restoreAllMocks(); + }); + + it("returns no telegram channel when no messaging env inputs are present", () => { + expect(detectMessagingChannelsFromEnv(null)).not.toContain("telegram"); + }); + + it("detects telegram when TELEGRAM_BOT_TOKEN is supplied", () => { + process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-token"; + + expect(detectMessagingChannelsFromEnv(null)).toContain("telegram"); + }); + + it("does not select telegram from NEMOCLAW_POLICY_PRESETS alone", () => { + process.env.NEMOCLAW_POLICY_PRESETS = "telegram"; + + expect(detectMessagingChannelsFromEnv(null)).not.toContain("telegram"); + }); +}); diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index 556c89a0c80..2e1e5c81309 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -55,6 +55,24 @@ const getMessagingInputValue = (input: ChannelInputSpec): string | null => { return normalizeCredentialValue(process.env[input.envKey]) || null; }; +/** + * Detect which built-in messaging channels currently have complete required + * inputs in the process environment, using the same manifest input rules as + * {@link setupMessagingChannels}. Pure and side-effect free: it only reads env + * via the manifest input resolvers so callers can compare current env inputs + * against a reused/stale sandbox messaging plan before treating that plan as + * authoritative. NEMOCLAW_POLICY_PRESETS is intentionally ignored — policy + * presets are not messaging channel selection. + */ +export function detectMessagingChannelsFromEnv(agent: AgentDefinition | null = null): string[] { + const manifestRegistry = createBuiltInChannelManifestRegistry(); + const availabilityContext = getMessagingManifestAvailabilityContext(agent); + const availableChannels = manifestRegistry.listAvailable(availabilityContext); + return availableChannels + .filter((manifest) => hasMessagingManifestRequiredInputs(manifest, getMessagingInputValue)) + .map((manifest) => manifest.id); +} + export async function setupMessagingChannels( agent: AgentDefinition | null = null, existingChannels: string[] | null = null,