Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 3 additions & 3 deletions ci/test-file-size-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
"nemoclaw/src/commands/migration-state.test.ts": 1566,
"src/lib/inference/nim.test.ts": 2068,
"src/lib/onboard/preflight.test.ts": 1905,
"test/channels-add-preset.test.ts": 1872,
"test/channels-add-preset.test.ts": 1871,
"test/generate-openclaw-config.test.ts": 2091,
"test/install-preflight.test.ts": 4396,
"test/nemoclaw-start.test.ts": 5289,
"test/onboard-messaging.test.ts": 2097,
"test/onboard-messaging.test.ts": 2064,
"test/onboard-selection.test.ts": 6922,
"test/onboard.test.ts": 4783,
"test/onboard.test.ts": 4782,
"test/policies.test.ts": 2763
}
}
4 changes: 1 addition & 3 deletions src/lib/actions/inference-route-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,7 @@ function session(overrides: Partial<Session> = {}): Session {
routerCredentialHash: null,
webSearchConfig: null,
policyPresets: null,
messagingChannels: null,
messagingChannelConfig: null,
disabledChannels: null,
messagingPlan: null,
migratedLegacyValueHashes: null,
hermesToolGateways: null,
gpuPassthrough: false,
Expand Down
4 changes: 1 addition & 3 deletions src/lib/actions/inference-set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,7 @@ function baseSession(overrides: Partial<Session> = {}): Session {
routerCredentialHash: null,
webSearchConfig: null,
policyPresets: null,
messagingChannels: null,
messagingChannelConfig: null,
disabledChannels: null,
messagingPlan: null,
migratedLegacyValueHashes: null,
hermesToolGateways: null,
gpuPassthrough: false,
Expand Down
42 changes: 40 additions & 2 deletions src/lib/actions/sandbox/channel-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ vi.mock("../../policy", () => ({

vi.mock("../../state/registry", () => ({
getSandbox: vi.fn(),
getConfiguredMessagingChannelsFromEntry: vi.fn((entry) => {
const channels = entry?.messaging?.plan?.channels;
return Array.isArray(channels)
? channels
.filter((channel) => channel?.configured === true)
.map((channel) => channel.channelId)
: [];
}),
getDisabledMessagingChannelsFromEntry: vi.fn((entry) => {
const disabled = entry?.messaging?.plan?.disabledChannels;
return Array.isArray(disabled) ? [...disabled] : [];
}),
}));

vi.mock("../../agent/defs", () => ({
Expand Down Expand Up @@ -108,11 +120,37 @@ function entry(
messagingChannels: string[] = ["whatsapp"],
disabledChannels: string[] = [],
): SandboxEntry {
const disabled = new Set(disabledChannels);
return {
name: "alpha",
agent: "openclaw",
messagingChannels,
disabledChannels,
messaging: {
schemaVersion: 1,
plan: {
schemaVersion: 1,
sandboxName: "alpha",
agent: "openclaw",
workflow: "onboard",
channels: messagingChannels.map((channelId) => ({
channelId,
displayName: channelId,
authMode: channelId === "whatsapp" ? "in-sandbox-qr" : "token-paste",
active: !disabled.has(channelId),
selected: true,
configured: true,
disabled: disabled.has(channelId),
inputs: [],
hooks: [],
})),
disabledChannels,
credentialBindings: [],
networkPolicy: { presets: [], entries: [] },
agentRender: [],
buildSteps: [],
stateUpdates: [],
healthChecks: [],
},
},
} as SandboxEntry;
}

Expand Down
17 changes: 10 additions & 7 deletions src/lib/actions/sandbox/channel-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,9 @@ function buildWhatsappProbeInput(
}

const entry = deps.getSandbox(sandboxName);
const channelEnabledInRegistry = (entry?.messagingChannels ?? []).includes("whatsapp");
const channelEnabledInRegistry = registry
.getConfiguredMessagingChannelsFromEntry(entry)
.includes("whatsapp");

const appliedPresets = deps.getAppliedPresets(sandboxName);
const presetInRegistry = appliedPresets.includes("whatsapp");
Expand Down Expand Up @@ -440,8 +442,8 @@ function buildBasicChannelReport(
deps: Required<StatusDeps>,
): ChannelStatusReport {
const entry = deps.getSandbox(sandboxName);
const enabled = (entry?.messagingChannels ?? []).includes(channelName);
const disabled = (entry?.disabledChannels ?? []).includes(channelName);
const enabled = registry.getConfiguredMessagingChannelsFromEntry(entry).includes(channelName);
const disabled = registry.getDisabledMessagingChannelsFromEntry(entry).includes(channelName);
const appliedPresets = deps.getAppliedPresets(sandboxName);
const presetInRegistry = appliedPresets.includes(channelName);
const signals: DiagnosticSignal[] = [];
Expand Down Expand Up @@ -523,11 +525,12 @@ export async function showSandboxChannelStatus(

let channelName = channelArg;
if (!channelName) {
const enabled = (entry.messagingChannels ?? []).filter((name: string) => name === "whatsapp");
const configuredChannels = registry.getConfiguredMessagingChannelsFromEntry(entry);
const enabled = configuredChannels.filter((name: string) => name === "whatsapp");
if (enabled.length > 0) {
channelName = "whatsapp";
} else if ((entry.messagingChannels ?? []).length > 0) {
channelName = entry.messagingChannels?.[0];
} else if (configuredChannels.length > 0) {
channelName = configuredChannels[0];
} else {
channelName = "whatsapp";
}
Expand All @@ -551,7 +554,7 @@ export async function showSandboxChannelStatus(

const agent = deps.loadAgent(entry.agent || "openclaw");

const disabledChannels = new Set(entry.disabledChannels ?? []);
const disabledChannels = new Set(registry.getDisabledMessagingChannelsFromEntry(entry));
const channelIsPaused = disabledChannels.has(channelName);

let report: ChannelStatusReport;
Expand Down
10 changes: 4 additions & 6 deletions src/lib/actions/sandbox/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,8 +423,8 @@ function channelRuntimeDoctorCheck(
}

function messagingDoctorCheck(sandboxName: string, sb: SandboxEntry): DoctorCheck {
const registeredChannels = Array.isArray(sb.messagingChannels) ? sb.messagingChannels : [];
const disabledChannels = new Set(Array.isArray(sb.disabledChannels) ? sb.disabledChannels : []);
const registeredChannels = registry.getConfiguredMessagingChannelsFromEntry(sb);
const disabledChannels = new Set(registry.getDisabledMessagingChannelsFromEntry(sb));
const channels = registeredChannels.filter((channel: string) => !disabledChannels.has(channel));
const pausedChannels = registeredChannels.filter((channel: string) =>
disabledChannels.has(channel),
Expand Down Expand Up @@ -783,10 +783,8 @@ export async function runSandboxDoctor(
// #4156: bridge the gap between "configured" and "runtime-visible" — the
// existing messaging check above probes provider attachment, not whether
// OpenClaw's runtime config actually surfaces each enabled channel.
const registeredChannels = Array.isArray(sb.messagingChannels) ? sb.messagingChannels : [];
const disabledChannelsSet = new Set(
Array.isArray(sb.disabledChannels) ? sb.disabledChannels : [],
);
const registeredChannels = registry.getConfiguredMessagingChannelsFromEntry(sb);
const disabledChannelsSet = new Set(registry.getDisabledMessagingChannelsFromEntry(sb));
const enabledChannels = registeredChannels.filter(
(channel: string) => !disabledChannelsSet.has(channel),
);
Expand Down
66 changes: 27 additions & 39 deletions src/lib/actions/sandbox/policy-channel-conflict.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ function makePlanEntry(
} as unknown as SandboxEntry;
}

function makeEmptyEntry(name: string): SandboxEntry {
return { name } as SandboxEntry;
}

let spies: MockInstance[];
let logSpy: MockInstance;
let errSpy: MockInstance;
Expand Down Expand Up @@ -246,7 +250,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
// Scenario 1
it("interactive matching-token conflict: warns, user continues, add proceeds", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
current: makeEmptyEntry("alpha"),
others: [
makePlanEntry("bob", "telegram", [
{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH },
Expand All @@ -268,7 +272,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
// Scenario 2
it("interactive matching-token conflict: user aborts, nothing is mutated", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
current: makeEmptyEntry("alpha"),
others: [
makePlanEntry("bob", "telegram", [
{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH },
Expand All @@ -288,7 +292,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {

it("interactive matching-token conflict: empty answer (default N) aborts", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
current: makeEmptyEntry("alpha"),
others: [
makePlanEntry("bob", "telegram", [
{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH },
Expand All @@ -307,7 +311,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
// Scenario 3
it("non-interactive matching-token conflict: aborts with exit(1) and guidance", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
current: makeEmptyEntry("alpha"),
others: [
makePlanEntry("bob", "telegram", [
{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH },
Expand Down Expand Up @@ -335,7 +339,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
// Scenario 4
it("--force bypasses the conflict even in non-interactive mode", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
current: makeEmptyEntry("alpha"),
others: [
makePlanEntry("bob", "telegram", [
{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH },
Expand All @@ -359,8 +363,8 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
// Scenario 5a
it("unknown-token wording when the other sandbox has the channel but no hash", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
others: [{ name: "bob", messagingChannels: ["telegram"] }], // no plan — legacy entry, unknown-token
current: makeEmptyEntry("alpha"),
others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN" }])],
});
getCredentialMock.mockReturnValue(TELEGRAM_TOKEN);
promptMock.mockResolvedValue("y");
Expand All @@ -375,7 +379,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
// Scenario 5b
it("different hash on the other sandbox is NOT a conflict (no warning, add proceeds)", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
current: makeEmptyEntry("alpha"),
others: [
makePlanEntry("bob", "telegram", [
{
Expand Down Expand Up @@ -421,7 +425,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
// Scenario 7
it("--dry-run never runs the conflict check or touches credentials", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
current: makeEmptyEntry("alpha"),
others: [
makePlanEntry("bob", "telegram", [
{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH },
Expand All @@ -447,7 +451,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
const wechatToken = "wx-secret-token-abc";
const wechatHash = hashCredential(wechatToken) as string;
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
current: makeEmptyEntry("alpha"),
others: [
makePlanEntry("bob", "wechat", [
{ providerEnvKey: "WECHAT_BOT_TOKEN", credentialHash: wechatHash },
Expand All @@ -473,8 +477,8 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
// acquired and skips the credential conflict check entirely.
it("in-sandbox-qr whatsapp skips the credential conflict check", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
others: [{ name: "bob", messagingChannels: ["whatsapp"] }],
current: makeEmptyEntry("alpha"),
others: [makePlanEntry("bob", "whatsapp", [])],
});
process.env.NEMOCLAW_NON_INTERACTIVE = "1";

Expand All @@ -488,48 +492,32 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
});

// Scenario 9
it("probe + backfill failure is swallowed; a pre-recorded matching hash still warns", async () => {
it("entries without messaging plans are ignored while plan-backed conflicts still warn", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
current: makeEmptyEntry("alpha"),
others: [
makePlanEntry("bob", "telegram", [
{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH },
]),
// Legacy entry with NO messagingChannels field — backfill probes the
// (alive) gateway, gets "absent" for every provider, then writes
// messagingChannels:[] for it. We make THAT write throw to genuinely
// exercise the try/catch around backfillMessagingChannels.
{ name: "legacy" },
makeEmptyEntry("legacy"),
],
});
getCredentialMock.mockReturnValue(TELEGRAM_TOKEN);
// Gateway alive (status 0) + every provider absent, so backfill reaches the
// updateSandbox("legacy") write — which throws below.
runOpenshellMock.mockReturnValue({ status: 0, stdout: "", stderr: "" });
updateSandboxMock.mockImplementation((name: string, _updates: Partial<SandboxEntry>) => {
if (name === "legacy") throw new Error("backfill boom");
return true;
});
process.env.NEMOCLAW_NON_INTERACTIVE = "1";

// bob already has messagingChannels + a matching hash, so the conflict is
// still found -> non-interactive abort. Key guarantee: a throw inside
// backfillMessagingChannels is swallowed; the only exit is the conflict
// exit(1), not an unhandled exception.
await expect(addSandboxChannel("alpha", { channel: "telegram" })).rejects.toThrow(
"process.exit(1)",
);
expect(exitMock).toHaveBeenCalledWith(1);
expect(loggedText()).toContain("same telegram credential");
});

it("probe + backfill failure with no pre-recorded conflict lets the add proceed", async () => {
it("entries without messaging plans do not block an add", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
others: [], // no other sandbox -> no conflict resolvable
current: makeEmptyEntry("alpha"),
others: [makeEmptyEntry("legacy")],
});
getCredentialMock.mockReturnValue(TELEGRAM_TOKEN);
runOpenshellMock.mockReturnValue({ status: 1, stdout: "", stderr: "down" });

await addSandboxChannel("alpha", { channel: "telegram" });

Expand All @@ -539,7 +527,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
});

it("non-interactive add aborts when the conflict check throws", async () => {
arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, others: [] });
arrangeRegistry({ current: makeEmptyEntry("alpha"), others: [] });
getCredentialMock.mockReturnValue(TELEGRAM_TOKEN);
listSandboxesMock.mockImplementation(() => {
throw new Error("malformed messaging plan");
Expand All @@ -557,7 +545,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
});

it("--force proceeds when the conflict check throws", async () => {
arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, others: [] });
arrangeRegistry({ current: makeEmptyEntry("alpha"), others: [] });
getCredentialMock.mockReturnValue(TELEGRAM_TOKEN);
listSandboxesMock.mockImplementation(() => {
throw new Error("malformed messaging plan");
Expand All @@ -575,7 +563,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
// Scenario 10
it("never prints the raw token value in any conflict output (proceed path)", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
current: makeEmptyEntry("alpha"),
others: [
makePlanEntry("bob", "telegram", [
{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH },
Expand All @@ -595,7 +583,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {

it("non-interactive abort path also keeps the raw token out of output", async () => {
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
current: makeEmptyEntry("alpha"),
others: [
makePlanEntry("bob", "telegram", [
{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH },
Expand All @@ -618,7 +606,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
const slackApp = "xapp-test-slack-app-token";
const slackBotHash = hashCredential(slackBot) as string;
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
current: makeEmptyEntry("alpha"),
// only bot token stored — app token unknown → conservative unknown-token OR
// matching-token if bot token matches; test verifies the conflict is surfaced.
others: [
Expand Down
Loading
Loading