Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 84 additions & 1 deletion src/lib/onboard/machine/handlers/sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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 })),
Expand Down Expand Up @@ -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",
Expand Down
87 changes: 66 additions & 21 deletions src/lib/onboard/machine/handlers/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void> => {
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(
Expand All @@ -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<typeof detectMessagingChannelsFromEnv>[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;
Expand Down
46 changes: 45 additions & 1 deletion src/lib/onboard/messaging-channel-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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");
});
});
18 changes: 18 additions & 0 deletions src/lib/onboard/messaging-channel-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading