From a20b1980063e6b184cc7d35a6158bc75c9b19c42 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 02:19:23 +0000 Subject: [PATCH 01/30] fix(messaging): surface telegram config inputs in channels status Signed-off-by: Tinson Lai --- .../actions/sandbox/channel-status.test.ts | 91 ++++++++++++++++++- src/lib/actions/sandbox/channel-status.ts | 65 ++++++++++++- src/lib/messaging/diagnostics.ts | 34 ++++++- 3 files changed, 186 insertions(+), 4 deletions(-) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index bc431720c5d..8fc0402a065 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -160,18 +160,21 @@ function makeDeps(opts: { gatewayPresets?: string[] | null; agentName?: "openclaw" | "hermes"; sandbox?: SandboxEntry | undefined; + channelInputs?: Record>; out?: (line: string) => void; }) { const calls: string[] = []; const out = opts.out ?? ((line: string) => calls.push(line)); + const sandbox = opts.sandbox ?? entry(); return { out, deps: { loadAgent: () => fakeAgent(opts.agentName), - getSandbox: () => opts.sandbox ?? entry(), + getSandbox: () => sandbox, getAppliedPresets: () => opts.appliedPresets ?? ["whatsapp"], getGatewayPresets: () => opts.gatewayPresets === undefined ? ["whatsapp"] : opts.gatewayPresets, + getMessagingPlan: () => fakePlanFromInputs(sandbox, opts.channelInputs), execSandbox: vi.fn(opts.exec), now: () => PROBED_AT, out, @@ -180,6 +183,33 @@ function makeDeps(opts: { }; } +function fakePlanFromInputs( + sandbox: SandboxEntry | undefined, + channelInputs: Record> | undefined, +) { + const base = sandbox?.messaging?.plan; + if (!base) return null; + if (!channelInputs) return base; + const merged = { + ...base, + channels: base.channels.map((channel) => { + const overrides = channelInputs[channel.channelId]; + if (!overrides) return channel; + return { + ...channel, + inputs: overrides.map((entry) => ({ + channelId: channel.channelId, + inputId: entry.inputId, + kind: "config" as const, + required: false, + ...(entry.value !== undefined ? { value: entry.value } : {}), + })), + }; + }), + }; + return merged; +} + describe("showSandboxChannelStatus (whatsapp)", () => { it("returns idle verdict and exit code 1 when paired but no inbound observed", async () => { const heartbeat = JSON.stringify({ @@ -506,3 +536,62 @@ describe("showSandboxChannelStatus (whatsapp)", () => { expect(dump).toMatch(/preset applied/); }); }); + +describe("showSandboxChannelStatus (telegram config visibility)", () => { + for (const policy of ["open", "allowlist", "disabled"] as const) { + it(`surfaces the resolved Telegram group policy: ${policy}`, async () => { + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: entry(["telegram"]), + appliedPresets: ["telegram"], + channelInputs: { + telegram: [{ inputId: "groupPolicy", value: policy }], + }, + }); + await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); + const dump = out_lines.join("\n"); + expect(dump).toMatch(new RegExp(`Telegram group policy:\\s+${policy}\\b`)); + expect(dump).not.toMatch(new RegExp(`Telegram group policy:\\s+${policy}\\s+\\(default\\)`)); + }); + } + + it("falls back to the manifest default when no group policy value is persisted", async () => { + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: entry(["telegram"]), + appliedPresets: ["telegram"], + }); + await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); + const dump = out_lines.join("\n"); + expect(dump).toMatch(/Telegram group policy:\s+open\s+\(default\)/); + }); + + it("surfaces the resolved Telegram mention mode alongside the group policy", async () => { + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: entry(["telegram"]), + appliedPresets: ["telegram"], + channelInputs: { + telegram: [ + { inputId: "requireMention", value: "0" }, + { inputId: "groupPolicy", value: "allowlist" }, + ], + }, + }); + await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); + const dump = out_lines.join("\n"); + expect(dump).toMatch(/Telegram group mention mode:\s+0\b/); + expect(dump).toMatch(/Telegram group policy:\s+allowlist\b/); + }); + + it("skips visible config inputs that have neither a persisted value nor a default", async () => { + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: entry(["telegram"]), + appliedPresets: ["telegram"], + }); + await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); + const dump = out_lines.join("\n"); + expect(dump).not.toMatch(/Telegram User ID/); + }); +}); diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index c1514b59019..dbecdd7c389 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -18,6 +18,11 @@ import { collectBuiltInMessagingChannelDiagnostics, type MessagingChannelDiagnosticSpec, } from "../../messaging/diagnostics"; +import type { + SandboxMessagingChannelPlan, + SandboxMessagingInputReference, + SandboxMessagingPlan, +} from "../../messaging/manifest"; import * as policies from "../../policy"; import { type DiagnosticSeverity, @@ -64,6 +69,7 @@ type StatusDeps = { getSandbox?: typeof registry.getSandbox; getAppliedPresets?: (sandboxName: string) => string[]; getGatewayPresets?: (sandboxName: string) => string[] | null; + getMessagingPlan?: (entry: ReturnType) => SandboxMessagingPlan | null; execSandbox?: ExecRunner; now?: () => Date; out?: (line: string) => void; @@ -131,6 +137,7 @@ function defaultDeps(deps: StatusDeps | undefined): Required { getSandbox: deps?.getSandbox ?? registry.getSandbox, getAppliedPresets: deps?.getAppliedPresets ?? policies.getAppliedPresets, getGatewayPresets: deps?.getGatewayPresets ?? policies.getGatewayPresets, + getMessagingPlan: deps?.getMessagingPlan ?? registry.getMessagingPlanFromEntry, execSandbox: deps?.execSandbox ?? defaultExec, now: deps?.now ?? (() => new Date()), out: deps?.out ?? ((line: string) => console.log(line)), @@ -500,13 +507,14 @@ function buildBasicChannelReport( ? undefined : `run \`${CLI_NAME} ${sandboxName} policy-add ${policyPresets[0]}\``, }); + for (const signal of buildConfigVisibilitySignals(channelName, diagnostic, deps, entry)) { + signals.push(signal); + } signals.push({ label: "Deep diagnostics", severity: "info", detail: `not implemented for ${channelName}; see \`${CLI_NAME} ${sandboxName} doctor\` and \`${CLI_NAME} ${sandboxName} logs --follow\``, }); - // Reference the agent in a hint so the deep-diagnostic section is - // discoverable per agent without needing extra plumbing. if (!agent.messagingPlatforms.includes(channelName)) { signals.unshift({ label: "Agent support", @@ -523,6 +531,59 @@ function buildBasicChannelReport( }; } +function buildConfigVisibilitySignals( + channelName: string, + diagnostic: MessagingChannelDiagnosticSpec, + deps: Required, + entry: ReturnType, +): DiagnosticSignal[] { + const visible = diagnostic.visibleConfigInputs; + if (!visible || visible.length === 0) return []; + const plan = deps.getMessagingPlan(entry); + const channelPlan = plan?.channels.find((channel) => channel.channelId === channelName) ?? null; + return visible.flatMap((input) => { + const planInput = findChannelInput(channelPlan, input.inputId); + const rawValue = planInput?.value; + if (rawValue !== undefined && rawValue !== null && rawValue !== "") { + return [ + { + label: input.label, + severity: "ok", + detail: formatVisibleConfigValue(rawValue), + }, + ]; + } + if (input.defaultValue !== undefined) { + return [ + { + label: input.label, + severity: "info", + detail: `${input.defaultValue} (default)`, + }, + ]; + } + return []; + }); +} + +function findChannelInput( + channelPlan: SandboxMessagingChannelPlan | null, + inputId: string, +): SandboxMessagingInputReference | null { + if (!channelPlan) return null; + return channelPlan.inputs.find((input) => input.inputId === inputId) ?? null; +} + +function formatVisibleConfigValue(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") return String(value); + if (Array.isArray(value)) { + return value.map((entry) => formatVisibleConfigValue(entry)).join(", "); + } + return JSON.stringify(value); +} + /** * Run the WhatsApp diagnostic or a thin per-channel summary for the named * sandbox. The function never throws: any unexpected condition is rendered diff --git a/src/lib/messaging/diagnostics.ts b/src/lib/messaging/diagnostics.ts index 0b91a03e5d8..48b218c0cbe 100644 --- a/src/lib/messaging/diagnostics.ts +++ b/src/lib/messaging/diagnostics.ts @@ -2,7 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { createBuiltInChannelManifestRegistry } from "./channels"; -import type { ChannelManifest, ChannelPolicyPresetReference, MessagingAgentId } from "./manifest"; +import type { + ChannelInputSpec, + ChannelManifest, + ChannelPolicyPresetReference, + MessagingAgentId, +} from "./manifest"; export interface MessagingChannelDiagnosticSpec { readonly channelId: string; @@ -13,6 +18,14 @@ export interface MessagingChannelDiagnosticSpec { readonly detail: string; readonly hint: string; }; + readonly visibleConfigInputs: readonly VisibleChannelConfigInput[]; +} + +export interface VisibleChannelConfigInput { + readonly inputId: string; + readonly label: string; + readonly defaultValue?: string; + readonly validValues?: readonly string[]; } export function collectBuiltInMessagingChannelDiagnostics( @@ -35,10 +48,29 @@ export function collectMessagingChannelDiagnostics( policyPresets: policyPresetNames(manifest.policyPresets), preferredDefault: deepProbe !== undefined, ...(deepProbe ? { deepProbe, doctorWhenNoHealthSignals: qrDeepProbeDoctorHint() } : {}), + visibleConfigInputs: collectVisibleConfigInputs(manifest.inputs), }; }); } +function collectVisibleConfigInputs( + inputs: readonly ChannelInputSpec[], +): readonly VisibleChannelConfigInput[] { + return inputs.flatMap((input) => { + if (input.kind !== "config") return []; + const label = input.prompt?.label; + if (!label) return []; + return [ + { + inputId: input.id, + label, + ...(input.defaultValue !== undefined ? { defaultValue: input.defaultValue } : {}), + ...(input.validValues ? { validValues: [...input.validValues] } : {}), + } satisfies VisibleChannelConfigInput, + ]; + }); +} + function qrDeepProbeDoctorHint(): MessagingChannelDiagnosticSpec["doctorWhenNoHealthSignals"] { return { detail: From 53a053c7573c792dfc5fdf8601f7b7c1340f859a Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 04:49:55 +0000 Subject: [PATCH 02/30] fix(doctor): surface telegram visible config in messaging section Signed-off-by: Tinson Lai --- .../actions/sandbox/channel-status.test.ts | 55 ++++++++++------ src/lib/actions/sandbox/doctor-flow.test.ts | 66 +++++++++++++++++++ src/lib/actions/sandbox/doctor.ts | 57 ++++++++++++++++ 3 files changed, 159 insertions(+), 19 deletions(-) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index 8fc0402a065..478ad71efa7 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -38,6 +38,10 @@ vi.mock("./process-recovery", () => ({ })); import type { AgentDefinition } from "../../agent/defs"; +import type { + MessagingSerializableValue, + SandboxMessagingPlan, +} from "../../messaging/manifest"; import type { SandboxEntry } from "../../state/registry"; import { showSandboxChannelStatus } from "./channel-status"; @@ -160,7 +164,10 @@ function makeDeps(opts: { gatewayPresets?: string[] | null; agentName?: "openclaw" | "hermes"; sandbox?: SandboxEntry | undefined; - channelInputs?: Record>; + channelInputs?: Record< + string, + ReadonlyArray<{ inputId: string; value?: MessagingSerializableValue }> + >; out?: (line: string) => void; }) { const calls: string[] = []; @@ -185,29 +192,39 @@ function makeDeps(opts: { function fakePlanFromInputs( sandbox: SandboxEntry | undefined, - channelInputs: Record> | undefined, -) { - const base = sandbox?.messaging?.plan; - if (!base) return null; - if (!channelInputs) return base; - const merged = { + channelInputs: + | Record> + | undefined, +): SandboxMessagingPlan | null { + const base = sandbox?.messaging?.plan ?? null; + return base && channelInputs ? mergePlanInputs(base, channelInputs) : base; +} + +function mergePlanInputs( + base: SandboxMessagingPlan, + channelInputs: Record< + string, + ReadonlyArray<{ inputId: string; value?: MessagingSerializableValue }> + >, +): SandboxMessagingPlan { + return { ...base, channels: base.channels.map((channel) => { const overrides = channelInputs[channel.channelId]; - if (!overrides) return channel; - return { - ...channel, - inputs: overrides.map((entry) => ({ - channelId: channel.channelId, - inputId: entry.inputId, - kind: "config" as const, - required: false, - ...(entry.value !== undefined ? { value: entry.value } : {}), - })), - }; + return overrides + ? { + ...channel, + inputs: overrides.map((entry) => ({ + channelId: channel.channelId, + inputId: entry.inputId, + kind: "config" as const, + required: false, + ...(entry.value !== undefined ? { value: entry.value } : {}), + })), + } + : channel; }), }; - return merged; } describe("showSandboxChannelStatus (whatsapp)", () => { diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index e9219dbf6cd..075b016d974 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -207,6 +207,72 @@ describe("runSandboxDoctor flow", () => { expect(harness.logSpy).not.toHaveBeenCalled(); }); + it("surfaces Telegram visible config inputs in the Messaging doctor section", async () => { + const harness = createDoctorHarness(); + const registry = requireDist("../../../../dist/lib/state/registry.js"); + vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); + vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); + vi.spyOn(registry, "getMessagingPlanFromEntry").mockReturnValue({ + schemaVersion: 1, + sandboxName: "alpha", + agent: "openclaw", + workflow: "onboard", + channels: [ + { + channelId: "telegram", + displayName: "telegram", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [ + { + channelId: "telegram", + inputId: "requireMention", + kind: "config", + required: false, + value: "0", + }, + ], + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + + expect(report?.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + group: "Messaging", + label: "Telegram group mention mode", + status: "ok", + detail: "0", + }), + expect.objectContaining({ + group: "Messaging", + label: "Telegram group policy", + status: "info", + detail: "open (default)", + }), + ]), + ); + const messagingChecks = (report?.checks ?? []).filter( + (check) => check.group === "Messaging", + ); + expect( + messagingChecks.some((check) => /[Bb]ot [Tt]oken|secret/i.test(check.label)), + ).toBe(false); + }); + it("rejects mutating --fix when JSON output was requested", async () => { const harness = createDoctorHarness(); diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index e27302f3821..14f3477453e 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -17,6 +17,7 @@ import { GATEWAY_PORT, OLLAMA_PORT } from "../../core/ports"; import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; import { parseGatewayInference } from "../../inference/config"; import { type ProviderHealthStatus, probeProviderHealth } from "../../inference/health"; +import type { MessagingSerializableValue } from "../../messaging/manifest"; import { collectBuiltInMessagingChannelDiagnostics, type MessagingChannelDiagnosticSpec, @@ -516,6 +517,59 @@ function getChannelStatusDiagnostic(channelName: string): MessagingChannelDiagno ); } +function messagingChannelConfigDoctorChecks( + sandboxName: string, + sb: SandboxEntry, +): DoctorCheck[] { + const registeredChannels = registry.getConfiguredMessagingChannelsFromEntry(sb); + const disabledChannels = new Set(registry.getDisabledMessagingChannelsFromEntry(sb)); + const activeChannels = registeredChannels.filter( + (channel: string) => !disabledChannels.has(channel), + ); + if (activeChannels.length === 0) return []; + const plan = registry.getMessagingPlanFromEntry(sb); + const checks: DoctorCheck[] = []; + for (const channelName of activeChannels) { + const diagnostic = getChannelStatusDiagnostic(channelName); + const visible = diagnostic?.visibleConfigInputs ?? []; + if (visible.length === 0) continue; + const channelPlan = + plan?.channels.find((channel) => channel.channelId === channelName) ?? null; + for (const input of visible) { + const planInput = channelPlan?.inputs.find((entry) => entry.inputId === input.inputId); + const rawValue = planInput?.value; + const persisted = rawValue !== undefined && rawValue !== null && rawValue !== ""; + if (persisted) { + checks.push({ + group: "Messaging", + label: input.label, + status: "ok", + detail: formatVisibleConfigValue(rawValue), + }); + continue; + } + if (input.defaultValue !== undefined) { + checks.push({ + group: "Messaging", + label: input.label, + status: "info", + detail: `${input.defaultValue} (default)`, + hint: `run \`${CLI_NAME} ${sandboxName} channels status --channel ${channelName}\` to confirm the resolved value`, + }); + } + } + } + return checks; +} + +function formatVisibleConfigValue(value: MessagingSerializableValue): string { + if (typeof value === "string") return value; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") return String(value); + if (Array.isArray(value)) return value.map(formatVisibleConfigValue).join(", "); + return JSON.stringify(value); +} + function formatMessagingOverlapDoctorDetail(overlap: { readonly channel: string; readonly sandboxes: readonly [string, string]; @@ -836,6 +890,9 @@ export async function runSandboxDoctor( if (permsCheck) checks.push(permsCheck); checks.push(messagingDoctorCheck(sandboxName, sb)); + for (const check of messagingChannelConfigDoctorChecks(sandboxName, sb)) { + checks.push(check); + } // #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. From 581ad20b917e914c39f47d1d4663f500b76c7c75 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 04:59:59 +0000 Subject: [PATCH 03/30] chore: biome format Signed-off-by: Tinson Lai --- src/lib/actions/sandbox/channel-status.test.ts | 5 +---- src/lib/actions/sandbox/doctor-flow.test.ts | 8 +++----- src/lib/actions/sandbox/doctor.ts | 8 ++------ 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index 478ad71efa7..782a4015bca 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -38,10 +38,7 @@ vi.mock("./process-recovery", () => ({ })); import type { AgentDefinition } from "../../agent/defs"; -import type { - MessagingSerializableValue, - SandboxMessagingPlan, -} from "../../messaging/manifest"; +import type { MessagingSerializableValue, SandboxMessagingPlan } from "../../messaging/manifest"; import type { SandboxEntry } from "../../state/registry"; import { showSandboxChannelStatus } from "./channel-status"; diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index 075b016d974..52102953f83 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -265,12 +265,10 @@ describe("runSandboxDoctor flow", () => { }), ]), ); - const messagingChecks = (report?.checks ?? []).filter( - (check) => check.group === "Messaging", + const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); + expect(messagingChecks.some((check) => /[Bb]ot [Tt]oken|secret/i.test(check.label))).toBe( + false, ); - expect( - messagingChecks.some((check) => /[Bb]ot [Tt]oken|secret/i.test(check.label)), - ).toBe(false); }); it("rejects mutating --fix when JSON output was requested", async () => { diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index 14f3477453e..4347c6deab1 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -517,10 +517,7 @@ function getChannelStatusDiagnostic(channelName: string): MessagingChannelDiagno ); } -function messagingChannelConfigDoctorChecks( - sandboxName: string, - sb: SandboxEntry, -): DoctorCheck[] { +function messagingChannelConfigDoctorChecks(sandboxName: string, sb: SandboxEntry): DoctorCheck[] { const registeredChannels = registry.getConfiguredMessagingChannelsFromEntry(sb); const disabledChannels = new Set(registry.getDisabledMessagingChannelsFromEntry(sb)); const activeChannels = registeredChannels.filter( @@ -533,8 +530,7 @@ function messagingChannelConfigDoctorChecks( const diagnostic = getChannelStatusDiagnostic(channelName); const visible = diagnostic?.visibleConfigInputs ?? []; if (visible.length === 0) continue; - const channelPlan = - plan?.channels.find((channel) => channel.channelId === channelName) ?? null; + const channelPlan = plan?.channels.find((channel) => channel.channelId === channelName) ?? null; for (const input of visible) { const planInput = channelPlan?.inputs.find((entry) => entry.inputId === input.inputId); const rawValue = planInput?.value; From 371c38d95d3cf6e4fa7d15b8d39f8f403aa37dee Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 05:45:19 +0000 Subject: [PATCH 04/30] fix(messaging): render telegram mention-mode behavior text in diagnostics Signed-off-by: Tinson Lai --- .../actions/sandbox/channel-status.test.ts | 55 ++++++++++++++++++- src/lib/actions/sandbox/channel-status.ts | 46 +++++----------- src/lib/actions/sandbox/doctor-flow.test.ts | 10 +++- src/lib/actions/sandbox/doctor.ts | 43 +++++---------- .../messaging/channels/telegram/manifest.ts | 6 ++ src/lib/messaging/diagnostics.ts | 51 +++++++++++++++++ src/lib/messaging/manifest/types.ts | 14 +++++ 7 files changed, 160 insertions(+), 65 deletions(-) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index 782a4015bca..83af0e22065 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -594,10 +594,63 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { }); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); - expect(dump).toMatch(/Telegram group mention mode:\s+0\b/); + expect(dump).toMatch( + /Telegram group mention mode:\s+all group messages \(TELEGRAM_REQUIRE_MENTION=0\)/, + ); expect(dump).toMatch(/Telegram group policy:\s+allowlist\b/); }); + it("translates Telegram requireMention=1 to the mention-only behavior label", async () => { + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: entry(["telegram"]), + appliedPresets: ["telegram"], + channelInputs: { + telegram: [{ inputId: "requireMention", value: "1" }], + }, + }); + await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); + const dump = out_lines.join("\n"); + expect(dump).toMatch( + /Telegram group mention mode:\s+mention-only \(TELEGRAM_REQUIRE_MENTION=1\)/, + ); + }); + + it("renders the mention-mode default with the mapped behavior label", async () => { + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: entry(["telegram"]), + appliedPresets: ["telegram"], + }); + await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); + const dump = out_lines.join("\n"); + expect(dump).toMatch(/Telegram group mention mode:\s+mention-only \(default\)/); + }); + + it("omits visible config defaults when the telegram channel is not registered", async () => { + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: entry([]), + appliedPresets: [], + }); + await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); + const dump = out_lines.join("\n"); + expect(dump).not.toMatch(/Telegram group policy/); + expect(dump).not.toMatch(/Telegram group mention mode/); + }); + + it("omits visible config defaults when the telegram channel is paused", async () => { + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: entry(["telegram"], ["telegram"]), + appliedPresets: ["telegram"], + }); + await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); + const dump = out_lines.join("\n"); + expect(dump).not.toMatch(/Telegram group policy/); + expect(dump).not.toMatch(/Telegram group mention mode/); + }); + it("skips visible config inputs that have neither a persisted value nor a default", async () => { const { deps, out_lines } = makeDeps({ exec: () => ({ status: 0, stdout: "", stderr: "" }), diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index dbecdd7c389..2b906e6651e 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -17,6 +17,7 @@ import { B, D, G, R, RD, YW } from "../../cli/terminal-style"; import { collectBuiltInMessagingChannelDiagnostics, type MessagingChannelDiagnosticSpec, + resolveVisibleConfigDisplay, } from "../../messaging/diagnostics"; import type { SandboxMessagingChannelPlan, @@ -507,8 +508,10 @@ function buildBasicChannelReport( ? undefined : `run \`${CLI_NAME} ${sandboxName} policy-add ${policyPresets[0]}\``, }); - for (const signal of buildConfigVisibilitySignals(channelName, diagnostic, deps, entry)) { - signals.push(signal); + if (enabled && !disabled) { + for (const signal of buildConfigVisibilitySignals(channelName, diagnostic, deps, entry)) { + signals.push(signal); + } } signals.push({ label: "Deep diagnostics", @@ -543,26 +546,15 @@ function buildConfigVisibilitySignals( const channelPlan = plan?.channels.find((channel) => channel.channelId === channelName) ?? null; return visible.flatMap((input) => { const planInput = findChannelInput(channelPlan, input.inputId); - const rawValue = planInput?.value; - if (rawValue !== undefined && rawValue !== null && rawValue !== "") { - return [ - { - label: input.label, - severity: "ok", - detail: formatVisibleConfigValue(rawValue), - }, - ]; - } - if (input.defaultValue !== undefined) { - return [ - { - label: input.label, - severity: "info", - detail: `${input.defaultValue} (default)`, - }, - ]; - } - return []; + const display = resolveVisibleConfigDisplay(input, planInput?.value); + if (!display) return []; + return [ + { + label: input.label, + severity: display.source === "persisted" ? "ok" : "info", + detail: display.detail, + }, + ]; }); } @@ -574,16 +566,6 @@ function findChannelInput( return channelPlan.inputs.find((input) => input.inputId === inputId) ?? null; } -function formatVisibleConfigValue(value: unknown): string { - if (typeof value === "string") return value; - if (typeof value === "boolean") return value ? "true" : "false"; - if (typeof value === "number") return String(value); - if (Array.isArray(value)) { - return value.map((entry) => formatVisibleConfigValue(entry)).join(", "); - } - return JSON.stringify(value); -} - /** * Run the WhatsApp diagnostic or a thin per-channel summary for the named * sandbox. The function never throws: any unexpected condition is rendered diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index 52102953f83..a2ad7a60a9b 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -255,7 +255,7 @@ describe("runSandboxDoctor flow", () => { group: "Messaging", label: "Telegram group mention mode", status: "ok", - detail: "0", + detail: "all group messages (TELEGRAM_REQUIRE_MENTION=0)", }), expect.objectContaining({ group: "Messaging", @@ -266,9 +266,13 @@ describe("runSandboxDoctor flow", () => { ]), ); const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); - expect(messagingChecks.some((check) => /[Bb]ot [Tt]oken|secret/i.test(check.label))).toBe( - false, + const sensitiveLabels = ["Bot Token", "User ID", "secret"]; + const leakedLabel = messagingChecks.find((check) => + sensitiveLabels.some((sensitive) => + check.label.toLowerCase().includes(sensitive.toLowerCase()), + ), ); + expect(leakedLabel).toBeUndefined(); }); it("rejects mutating --fix when JSON output was requested", async () => { diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index 4347c6deab1..89dfd2e9d5a 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -17,10 +17,10 @@ import { GATEWAY_PORT, OLLAMA_PORT } from "../../core/ports"; import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; import { parseGatewayInference } from "../../inference/config"; import { type ProviderHealthStatus, probeProviderHealth } from "../../inference/health"; -import type { MessagingSerializableValue } from "../../messaging/manifest"; import { collectBuiltInMessagingChannelDiagnostics, type MessagingChannelDiagnosticSpec, + resolveVisibleConfigDisplay, } from "../../messaging/diagnostics"; import { isLinuxDockerDriverGatewayEnabled } from "../../onboard/docker-driver-platform"; import { resolveGatewayName, resolveSandboxGatewayName } from "../../onboard/gateway-binding"; @@ -533,39 +533,24 @@ function messagingChannelConfigDoctorChecks(sandboxName: string, sb: SandboxEntr const channelPlan = plan?.channels.find((channel) => channel.channelId === channelName) ?? null; for (const input of visible) { const planInput = channelPlan?.inputs.find((entry) => entry.inputId === input.inputId); - const rawValue = planInput?.value; - const persisted = rawValue !== undefined && rawValue !== null && rawValue !== ""; - if (persisted) { - checks.push({ - group: "Messaging", - label: input.label, - status: "ok", - detail: formatVisibleConfigValue(rawValue), - }); - continue; - } - if (input.defaultValue !== undefined) { - checks.push({ - group: "Messaging", - label: input.label, - status: "info", - detail: `${input.defaultValue} (default)`, - hint: `run \`${CLI_NAME} ${sandboxName} channels status --channel ${channelName}\` to confirm the resolved value`, - }); - } + const display = resolveVisibleConfigDisplay(input, planInput?.value); + if (!display) continue; + checks.push({ + group: "Messaging", + label: input.label, + status: display.source === "persisted" ? "ok" : "info", + detail: display.detail, + ...(display.source === "default" + ? { + hint: `run \`${CLI_NAME} ${sandboxName} channels status --channel ${channelName}\` to confirm the resolved value`, + } + : {}), + }); } } return checks; } -function formatVisibleConfigValue(value: MessagingSerializableValue): string { - if (typeof value === "string") return value; - if (typeof value === "boolean") return value ? "true" : "false"; - if (typeof value === "number") return String(value); - if (Array.isArray(value)) return value.map(formatVisibleConfigValue).join(", "); - return JSON.stringify(value); -} - function formatMessagingOverlapDoctorDetail(overlap: { readonly channel: string; readonly sandboxes: readonly [string, string]; diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index 0bed20f86e1..d8d01734fea 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -47,6 +47,11 @@ export const telegramManifest = { statePath: "telegramConfig.requireMention", validValues: ["0", "1"], defaultValue: "1", + safeToPrintInDiagnostics: true, + valueDisplay: { + "0": "all group messages", + "1": "mention-only", + }, prompt: { label: "Telegram group mention mode", help: "Controls Telegram group-chat behavior only — reply only when @mentioned vs. to all group messages. Direct messages are unaffected by this setting and remain subject to pairing and TELEGRAM_ALLOWED_IDS.", @@ -60,6 +65,7 @@ export const telegramManifest = { statePath: "telegramConfig.groupPolicy", validValues: ["open", "allowlist", "disabled"], defaultValue: "open", + safeToPrintInDiagnostics: true, prompt: { label: "Telegram group policy", help: "Controls OpenClaw Telegram group access. Hermes does not expose an equivalent disable-groups policy.", diff --git a/src/lib/messaging/diagnostics.ts b/src/lib/messaging/diagnostics.ts index 48b218c0cbe..db89b5837f8 100644 --- a/src/lib/messaging/diagnostics.ts +++ b/src/lib/messaging/diagnostics.ts @@ -7,6 +7,7 @@ import type { ChannelManifest, ChannelPolicyPresetReference, MessagingAgentId, + MessagingSerializableValue, } from "./manifest"; export interface MessagingChannelDiagnosticSpec { @@ -24,8 +25,10 @@ export interface MessagingChannelDiagnosticSpec { export interface VisibleChannelConfigInput { readonly inputId: string; readonly label: string; + readonly envKey?: string; readonly defaultValue?: string; readonly validValues?: readonly string[]; + readonly valueDisplay?: Readonly>; } export function collectBuiltInMessagingChannelDiagnostics( @@ -58,19 +61,67 @@ function collectVisibleConfigInputs( ): readonly VisibleChannelConfigInput[] { return inputs.flatMap((input) => { if (input.kind !== "config") return []; + if (input.safeToPrintInDiagnostics !== true) return []; const label = input.prompt?.label; if (!label) return []; return [ { inputId: input.id, label, + ...(input.envKey ? { envKey: input.envKey } : {}), ...(input.defaultValue !== undefined ? { defaultValue: input.defaultValue } : {}), ...(input.validValues ? { validValues: [...input.validValues] } : {}), + ...(input.valueDisplay ? { valueDisplay: { ...input.valueDisplay } } : {}), } satisfies VisibleChannelConfigInput, ]; }); } +/** + * Resolve the diagnostic detail text for one visible config input, given the + * raw value persisted in the channel plan (or `undefined` when only the + * manifest default is available). Returns `null` when neither a persisted + * value nor a default exists so callers can skip the entry rather than emit + * an empty signal. + */ +export type VisibleConfigDisplay = { + readonly detail: string; + readonly source: "persisted" | "default"; +}; + +export function resolveVisibleConfigDisplay( + input: VisibleChannelConfigInput, + rawValue: MessagingSerializableValue | undefined, +): VisibleConfigDisplay | null { + if (rawValue !== undefined && rawValue !== null && rawValue !== "") { + const valueText = stringifyValue(rawValue); + const mapped = input.valueDisplay?.[valueText]; + if (mapped && input.envKey) { + return { detail: `${mapped} (${input.envKey}=${valueText})`, source: "persisted" }; + } + if (mapped) { + return { detail: `${mapped} (${valueText})`, source: "persisted" }; + } + return { detail: valueText, source: "persisted" }; + } + if (input.defaultValue !== undefined) { + const mapped = input.valueDisplay?.[input.defaultValue]; + if (mapped) { + return { detail: `${mapped} (default)`, source: "default" }; + } + return { detail: `${input.defaultValue} (default)`, source: "default" }; + } + return null; +} + +function stringifyValue(value: MessagingSerializableValue): string { + if (typeof value === "string") return value; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") return String(value); + if (Array.isArray(value)) return value.map(stringifyValue).join(", "); + return JSON.stringify(value); +} + function qrDeepProbeDoctorHint(): MessagingChannelDiagnosticSpec["doctorWhenNoHealthSignals"] { return { detail: diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 9395e085a97..2bfd4140f23 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -100,6 +100,20 @@ export interface ChannelConfigInputSpec extends ChannelInputBaseSpec { readonly defaultValue?: string; readonly statePath?: MessagingStatePath; readonly promptWhenInput?: string; + /** + * Opt-in flag: when true, this input's resolved value (or manifest default) + * may be rendered by user-facing diagnostics such as `channels status` and + * `doctor`. Secrets are excluded by `kind` and never reach this flag. + * Defaults to false so prompt-labeled config inputs do not leak into + * diagnostics merely because they have an operator prompt. + */ + readonly safeToPrintInDiagnostics?: boolean; + /** + * Optional map from raw input value to a human-readable label, used by the + * diagnostics renderer to translate machine-style toggles into the + * behavior they describe (for example `"1" -> "mention-only"`). + */ + readonly valueDisplay?: Readonly>; } /** Manifest input declaration, split so secrets cannot declare defaults or state paths. */ From 120a4799f9f39982b9248ea2a3f75d41950236c7 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 06:25:01 +0000 Subject: [PATCH 05/30] fix(messaging): filter agent-scoped diagnostics and bound invalid values Signed-off-by: Tinson Lai --- .../actions/sandbox/channel-status.test.ts | 30 +++++++ src/lib/actions/sandbox/channel-status.ts | 63 ++++++++------- src/lib/actions/sandbox/doctor.ts | 36 ++++++--- .../messaging/channels/telegram/manifest.ts | 1 + src/lib/messaging/diagnostics.ts | 80 +++++++++++++++++-- src/lib/messaging/manifest/types.ts | 8 ++ 6 files changed, 175 insertions(+), 43 deletions(-) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index 83af0e22065..774be827cd8 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -661,4 +661,34 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { const dump = out_lines.join("\n"); expect(dump).not.toMatch(/Telegram User ID/); }); + + it("hides the OpenClaw-only group policy when the sandbox runs Hermes", async () => { + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: entry(["telegram"]), + appliedPresets: ["telegram"], + agentName: "hermes", + }); + await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); + const dump = out_lines.join("\n"); + expect(dump).not.toMatch(/Telegram group policy/); + expect(dump).toMatch(/Telegram group mention mode:\s+mention-only \(default\)/); + }); + + it("redacts an invalid persisted value rather than echoing it", async () => { + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: entry(["telegram"]), + appliedPresets: ["telegram"], + channelInputs: { + telegram: [{ inputId: "groupPolicy", value: "definitely-not-a-policy" }], + }, + }); + await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); + const dump = out_lines.join("\n"); + expect(dump).not.toMatch(/definitely-not-a-policy/); + expect(dump).toMatch( + /Telegram group policy:\s+invalid persisted value \(expected: open \| allowlist \| disabled\)/, + ); + }); }); diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index 2b906e6651e..ee84f4c135f 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -16,14 +16,10 @@ import { CLI_DISPLAY_NAME, CLI_NAME } from "../../cli/branding"; import { B, D, G, R, RD, YW } from "../../cli/terminal-style"; import { collectBuiltInMessagingChannelDiagnostics, + collectVisibleConfigRecords, type MessagingChannelDiagnosticSpec, - resolveVisibleConfigDisplay, } from "../../messaging/diagnostics"; -import type { - SandboxMessagingChannelPlan, - SandboxMessagingInputReference, - SandboxMessagingPlan, -} from "../../messaging/manifest"; +import type { MessagingAgentId, SandboxMessagingPlan } from "../../messaging/manifest"; import * as policies from "../../policy"; import { type DiagnosticSeverity, @@ -509,7 +505,13 @@ function buildBasicChannelReport( : `run \`${CLI_NAME} ${sandboxName} policy-add ${policyPresets[0]}\``, }); if (enabled && !disabled) { - for (const signal of buildConfigVisibilitySignals(channelName, diagnostic, deps, entry)) { + for (const signal of buildConfigVisibilitySignals( + channelName, + diagnostic, + deps, + entry, + agent, + )) { signals.push(signal); } } @@ -539,31 +541,36 @@ function buildConfigVisibilitySignals( diagnostic: MessagingChannelDiagnosticSpec, deps: Required, entry: ReturnType, + agent: AgentDefinition, ): DiagnosticSignal[] { - const visible = diagnostic.visibleConfigInputs; - if (!visible || visible.length === 0) return []; + if (diagnostic.visibleConfigInputs.length === 0) return []; const plan = deps.getMessagingPlan(entry); - const channelPlan = plan?.channels.find((channel) => channel.channelId === channelName) ?? null; - return visible.flatMap((input) => { - const planInput = findChannelInput(channelPlan, input.inputId); - const display = resolveVisibleConfigDisplay(input, planInput?.value); - if (!display) return []; - return [ - { - label: input.label, - severity: display.source === "persisted" ? "ok" : "info", - detail: display.detail, - }, - ]; - }); + const records = collectVisibleConfigRecords( + diagnostic, + plan, + channelName, + asMessagingAgent(agent.name), + ); + return records.map(({ input, display }) => ({ + label: input.label, + severity: severityForDisplay(display.source), + detail: display.detail, + })); +} + +function severityForDisplay(source: "persisted" | "default" | "invalid") { + switch (source) { + case "persisted": + return "ok" as const; + case "default": + return "info" as const; + case "invalid": + return "warn" as const; + } } -function findChannelInput( - channelPlan: SandboxMessagingChannelPlan | null, - inputId: string, -): SandboxMessagingInputReference | null { - if (!channelPlan) return null; - return channelPlan.inputs.find((input) => input.inputId === inputId) ?? null; +function asMessagingAgent(name: string): MessagingAgentId | null { + return name === "openclaw" || name === "hermes" ? name : null; } /** diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index 89dfd2e9d5a..1f9bdab31a1 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -19,9 +19,10 @@ import { parseGatewayInference } from "../../inference/config"; import { type ProviderHealthStatus, probeProviderHealth } from "../../inference/health"; import { collectBuiltInMessagingChannelDiagnostics, + collectVisibleConfigRecords, type MessagingChannelDiagnosticSpec, - resolveVisibleConfigDisplay, } from "../../messaging/diagnostics"; +import type { MessagingAgentId } from "../../messaging/manifest"; import { isLinuxDockerDriverGatewayEnabled } from "../../onboard/docker-driver-platform"; import { resolveGatewayName, resolveSandboxGatewayName } from "../../onboard/gateway-binding"; import { executeSandboxCommandForVerification } from "../../onboard/sandbox-verification-exec"; @@ -525,32 +526,49 @@ function messagingChannelConfigDoctorChecks(sandboxName: string, sb: SandboxEntr ); if (activeChannels.length === 0) return []; const plan = registry.getMessagingPlanFromEntry(sb); + const agent = asMessagingAgent(sb.agent ?? null); const checks: DoctorCheck[] = []; for (const channelName of activeChannels) { const diagnostic = getChannelStatusDiagnostic(channelName); - const visible = diagnostic?.visibleConfigInputs ?? []; - if (visible.length === 0) continue; - const channelPlan = plan?.channels.find((channel) => channel.channelId === channelName) ?? null; - for (const input of visible) { - const planInput = channelPlan?.inputs.find((entry) => entry.inputId === input.inputId); - const display = resolveVisibleConfigDisplay(input, planInput?.value); - if (!display) continue; + if (!diagnostic || diagnostic.visibleConfigInputs.length === 0) continue; + const records = collectVisibleConfigRecords(diagnostic, plan, channelName, agent); + for (const { input, display } of records) { checks.push({ group: "Messaging", label: input.label, - status: display.source === "persisted" ? "ok" : "info", + status: doctorStatusForDisplay(display.source), detail: display.detail, ...(display.source === "default" ? { hint: `run \`${CLI_NAME} ${sandboxName} channels status --channel ${channelName}\` to confirm the resolved value`, } : {}), + ...(display.source === "invalid" + ? { + hint: `run \`${CLI_NAME} ${sandboxName} channels add ${channelName}\` to re-enter a valid value`, + } + : {}), }); } } return checks; } +function doctorStatusForDisplay(source: "persisted" | "default" | "invalid"): DoctorStatus { + switch (source) { + case "persisted": + return "ok"; + case "default": + return "info"; + case "invalid": + return "warn"; + } +} + +function asMessagingAgent(name: string | null): MessagingAgentId | null { + return name === "openclaw" || name === "hermes" ? name : null; +} + function formatMessagingOverlapDoctorDetail(overlap: { readonly channel: string; readonly sandboxes: readonly [string, string]; diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index d8d01734fea..094b08002bc 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -66,6 +66,7 @@ export const telegramManifest = { validValues: ["open", "allowlist", "disabled"], defaultValue: "open", safeToPrintInDiagnostics: true, + agentApplicability: ["openclaw"], prompt: { label: "Telegram group policy", help: "Controls OpenClaw Telegram group access. Hermes does not expose an equivalent disable-groups policy.", diff --git a/src/lib/messaging/diagnostics.ts b/src/lib/messaging/diagnostics.ts index db89b5837f8..3a0a504f2bf 100644 --- a/src/lib/messaging/diagnostics.ts +++ b/src/lib/messaging/diagnostics.ts @@ -8,6 +8,8 @@ import type { ChannelPolicyPresetReference, MessagingAgentId, MessagingSerializableValue, + SandboxMessagingChannelPlan, + SandboxMessagingPlan, } from "./manifest"; export interface MessagingChannelDiagnosticSpec { @@ -29,6 +31,7 @@ export interface VisibleChannelConfigInput { readonly defaultValue?: string; readonly validValues?: readonly string[]; readonly valueDisplay?: Readonly>; + readonly agentApplicability?: readonly MessagingAgentId[]; } export function collectBuiltInMessagingChannelDiagnostics( @@ -72,6 +75,7 @@ function collectVisibleConfigInputs( ...(input.defaultValue !== undefined ? { defaultValue: input.defaultValue } : {}), ...(input.validValues ? { validValues: [...input.validValues] } : {}), ...(input.valueDisplay ? { valueDisplay: { ...input.valueDisplay } } : {}), + ...(input.agentApplicability ? { agentApplicability: [...input.agentApplicability] } : {}), } satisfies VisibleChannelConfigInput, ]; }); @@ -83,10 +87,15 @@ function collectVisibleConfigInputs( * manifest default is available). Returns `null` when neither a persisted * value nor a default exists so callers can skip the entry rather than emit * an empty signal. + * + * When the persisted value is not in the input's declared `validValues` + * allowlist, the renderer returns bounded text (`invalid persisted value + * (expected: …)`) rather than echoing the raw value, so a corrupted or + * tampered plan cannot bypass the diagnostic boundary. */ export type VisibleConfigDisplay = { readonly detail: string; - readonly source: "persisted" | "default"; + readonly source: "persisted" | "default" | "invalid"; }; export function resolveVisibleConfigDisplay( @@ -94,7 +103,19 @@ export function resolveVisibleConfigDisplay( rawValue: MessagingSerializableValue | undefined, ): VisibleConfigDisplay | null { if (rawValue !== undefined && rawValue !== null && rawValue !== "") { - const valueText = stringifyValue(rawValue); + if (!isPrintableScalar(rawValue)) { + return { + detail: "invalid persisted value (unsupported type)", + source: "invalid", + }; + } + const valueText = stringifyScalar(rawValue); + if (input.validValues && !input.validValues.includes(valueText)) { + return { + detail: `invalid persisted value (expected: ${input.validValues.join(" | ")})`, + source: "invalid", + }; + } const mapped = input.valueDisplay?.[valueText]; if (mapped && input.envKey) { return { detail: `${mapped} (${input.envKey}=${valueText})`, source: "persisted" }; @@ -114,12 +135,59 @@ export function resolveVisibleConfigDisplay( return null; } -function stringifyValue(value: MessagingSerializableValue): string { +/** + * Normalised visible-config record consumed by `channels status` and + * `doctor`. The diagnostic shared helper walks a channel plan once and + * returns one record per renderable input; the calling command then maps + * the record onto its own signal/check shape. + */ +export interface VisibleConfigRecord { + readonly input: VisibleChannelConfigInput; + readonly display: VisibleConfigDisplay; +} + +/** + * Walk one diagnostic spec's `visibleConfigInputs` against a sandbox plan + * and return only the records that should be rendered for the supplied + * agent runtime. Inputs whose `agentApplicability` excludes the agent are + * skipped so an OpenClaw-only setting never appears for a Hermes sandbox. + */ +export function collectVisibleConfigRecords( + diagnostic: MessagingChannelDiagnosticSpec, + plan: SandboxMessagingPlan | null, + channelId: string, + agent: MessagingAgentId | null, +): VisibleConfigRecord[] { + const channelPlan: SandboxMessagingChannelPlan | null = + plan?.channels.find((channel) => channel.channelId === channelId) ?? null; + const records: VisibleConfigRecord[] = []; + for (const input of diagnostic.visibleConfigInputs) { + if (!inputAppliesToAgent(input, agent)) continue; + const planInput = channelPlan?.inputs.find((entry) => entry.inputId === input.inputId); + const display = resolveVisibleConfigDisplay(input, planInput?.value); + if (!display) continue; + records.push({ input, display }); + } + return records; +} + +function inputAppliesToAgent( + input: VisibleChannelConfigInput, + agent: MessagingAgentId | null, +): boolean { + if (!input.agentApplicability || input.agentApplicability.length === 0) return true; + if (!agent) return false; + return input.agentApplicability.includes(agent); +} + +function isPrintableScalar(value: MessagingSerializableValue): value is string | number | boolean { + return typeof value === "string" || typeof value === "number" || typeof value === "boolean"; +} + +function stringifyScalar(value: string | number | boolean): string { if (typeof value === "string") return value; if (typeof value === "boolean") return value ? "true" : "false"; - if (typeof value === "number") return String(value); - if (Array.isArray(value)) return value.map(stringifyValue).join(", "); - return JSON.stringify(value); + return String(value); } function qrDeepProbeDoctorHint(): MessagingChannelDiagnosticSpec["doctorWhenNoHealthSignals"] { diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 2bfd4140f23..aa76aafb201 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -114,6 +114,14 @@ export interface ChannelConfigInputSpec extends ChannelInputBaseSpec { * behavior they describe (for example `"1" -> "mention-only"`). */ readonly valueDisplay?: Readonly>; + /** + * Optional list of agent runtimes the input applies to. When set, the + * diagnostics renderer skips the input for sandboxes whose agent is not + * in this list (for example a Hermes-targeted sandbox should not see an + * OpenClaw-only setting). Defaults to "applies to every supported agent" + * when omitted. + */ + readonly agentApplicability?: readonly MessagingAgentId[]; } /** Manifest input declaration, split so secrets cannot declare defaults or state paths. */ From bf35a864e897df513e4bbc264605e3dfb27aef23 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 07:03:06 +0000 Subject: [PATCH 06/30] fix(messaging): reject present-empty plan values and exercise compiled-plan path Signed-off-by: Tinson Lai --- src/lib/actions/sandbox/doctor-flow.test.ts | 155 ++++++++++++++++++++ src/lib/actions/sandbox/doctor.ts | 6 +- src/lib/messaging/diagnostics.test.ts | 100 ++++++++++++- src/lib/messaging/diagnostics.ts | 40 +++-- 4 files changed, 288 insertions(+), 13 deletions(-) diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index a2ad7a60a9b..9b4050c1012 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -275,6 +275,161 @@ describe("runSandboxDoctor flow", () => { expect(leakedLabel).toBeUndefined(); }); + it("hides the OpenClaw-only Telegram group policy when the sandbox runs Hermes", async () => { + const harness = createDoctorHarness(); + const registry = requireDist("../../../../dist/lib/state/registry.js"); + harness.getSandboxSpy.mockReturnValue({ + name: "alpha", + agent: "hermes", + model: "registry-model", + provider: "ollama-local", + openshellDriver: "docker", + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + messaging: undefined, + }); + vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); + vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); + vi.spyOn(registry, "getMessagingPlanFromEntry").mockReturnValue({ + schemaVersion: 1, + sandboxName: "alpha", + agent: "hermes", + workflow: "onboard", + channels: [ + { + channelId: "telegram", + displayName: "telegram", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); + + expect(messagingChecks.some((check) => check.label === "Telegram group policy")).toBe(false); + expect( + messagingChecks.find((check) => check.label === "Telegram group mention mode"), + ).toMatchObject({ status: "info", detail: "mention-only (default)" }); + }); + + it("flags an invalid persisted Telegram group policy without echoing the raw value", async () => { + const harness = createDoctorHarness(); + const registry = requireDist("../../../../dist/lib/state/registry.js"); + vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); + vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); + vi.spyOn(registry, "getMessagingPlanFromEntry").mockReturnValue({ + schemaVersion: 1, + sandboxName: "alpha", + agent: "openclaw", + workflow: "onboard", + channels: [ + { + channelId: "telegram", + displayName: "telegram", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [ + { + channelId: "telegram", + inputId: "groupPolicy", + kind: "config", + required: false, + value: "definitely-not-a-policy", + }, + ], + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const policyCheck = (report?.checks ?? []).find( + (check) => check.group === "Messaging" && check.label === "Telegram group policy", + ); + + expect(policyCheck).toBeDefined(); + expect(policyCheck?.status).toBe("warn"); + expect(policyCheck?.detail).toMatch( + /invalid persisted value \(expected: open \| allowlist \| disabled\)/, + ); + expect(policyCheck?.detail).not.toContain("definitely-not-a-policy"); + }); + + it("flags a present-but-empty Telegram mention-mode value as invalid rather than defaulting", async () => { + const harness = createDoctorHarness(); + const registry = requireDist("../../../../dist/lib/state/registry.js"); + vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); + vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); + vi.spyOn(registry, "getMessagingPlanFromEntry").mockReturnValue({ + schemaVersion: 1, + sandboxName: "alpha", + agent: "openclaw", + workflow: "onboard", + channels: [ + { + channelId: "telegram", + displayName: "telegram", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [ + { + channelId: "telegram", + inputId: "requireMention", + kind: "config", + required: false, + value: "", + }, + ], + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const mentionCheck = (report?.checks ?? []).find( + (check) => check.group === "Messaging" && check.label === "Telegram group mention mode", + ); + + expect(mentionCheck).toBeDefined(); + expect(mentionCheck?.status).toBe("warn"); + expect(mentionCheck?.detail).toMatch(/invalid persisted value/); + expect(mentionCheck?.detail).not.toMatch(/default/); + }); + it("rejects mutating --fix when JSON output was requested", async () => { const harness = createDoctorHarness(); diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index 1f9bdab31a1..0f84751971a 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -526,7 +526,11 @@ function messagingChannelConfigDoctorChecks(sandboxName: string, sb: SandboxEntr ); if (activeChannels.length === 0) return []; const plan = registry.getMessagingPlanFromEntry(sb); - const agent = asMessagingAgent(sb.agent ?? null); + // Legacy sandbox entries written before the `agent` field existed default + // to OpenClaw, matching the convention used by `channels status` and the + // surrounding doctor checks. Without the default, OpenClaw-only visible + // inputs would silently disappear from a legacy sandbox's report. + const agent = asMessagingAgent(sb.agent ?? "openclaw"); const checks: DoctorCheck[] = []; for (const channelName of activeChannels) { const diagnostic = getChannelStatusDiagnostic(channelName); diff --git a/src/lib/messaging/diagnostics.test.ts b/src/lib/messaging/diagnostics.test.ts index 79497f108ff..7f2135b8c63 100644 --- a/src/lib/messaging/diagnostics.test.ts +++ b/src/lib/messaging/diagnostics.test.ts @@ -3,7 +3,16 @@ import { describe, expect, it } from "vitest"; -import { collectBuiltInMessagingChannelDiagnostics } from "./diagnostics"; +import { + createBuiltInChannelManifestRegistry, + createBuiltInRenderTemplateResolver, +} from "./channels"; +import { + collectBuiltInMessagingChannelDiagnostics, + collectVisibleConfigRecords, +} from "./diagnostics"; +import { MessagingWorkflowPlanner } from "./compiler/workflow-planner"; +import { createBuiltInMessagingHookRegistry } from "./hooks"; describe("messaging channel diagnostics", () => { it("derives common channel diagnostic metadata directly from manifests", () => { @@ -33,3 +42,92 @@ describe("messaging channel diagnostics", () => { }); }); }); + +describe("collectVisibleConfigRecords (compiled plan integration)", () => { + it("renders Telegram visible config from a plan compiled out of process env, not from injected plan inputs", async () => { + const planner = new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + createBuiltInMessagingHookRegistry({ + common: { + env: { + TELEGRAM_BOT_TOKEN: "123456:test-telegram-token", + TELEGRAM_GROUP_POLICY: "allowlist", + }, + getCredential: (key) => + key === "TELEGRAM_BOT_TOKEN" ? "123456:test-telegram-token" : null, + saveCredential: () => {}, + prompt: async () => "unused", + log: () => {}, + }, + telegram: { + fetch: async () => ({ + ok: true, + status: 200, + async json() { + return { ok: true }; + }, + async text() { + return ""; + }, + }), + }, + }), + createBuiltInRenderTemplateResolver(), + ); + + const plan = await withEnvOverrides( + { + TELEGRAM_BOT_TOKEN: "123456:test-telegram-token", + TELEGRAM_GROUP_POLICY: "allowlist", + TELEGRAM_REQUIRE_MENTION: undefined, + }, + () => + planner.buildPlan({ + sandboxName: "alpha", + agent: "openclaw", + workflow: "onboard", + isInteractive: true, + configuredChannels: ["telegram"], + }), + ); + + const diagnostic = collectBuiltInMessagingChannelDiagnostics().find( + (spec) => spec.channelId === "telegram", + ); + expect(diagnostic).toBeDefined(); + + const records = collectVisibleConfigRecords(diagnostic!, plan, "telegram", "openclaw"); + const labels = records.map((record) => record.input.label); + const byLabel = (label: string) => records.find((record) => record.input.label === label); + + expect(labels).toContain("Telegram group policy"); + expect(labels).toContain("Telegram group mention mode"); + expect(byLabel("Telegram group policy")?.display).toMatchObject({ + source: "persisted", + detail: "allowlist", + }); + expect(byLabel("Telegram group mention mode")?.display).toMatchObject({ + source: "persisted", + detail: "mention-only (TELEGRAM_REQUIRE_MENTION=1)", + }); + }); +}); + +async function withEnvOverrides( + values: Readonly>, + run: () => Promise, +): Promise { + const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]])); + for (const [key, value] of Object.entries(values)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return await run(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} diff --git a/src/lib/messaging/diagnostics.ts b/src/lib/messaging/diagnostics.ts index 3a0a504f2bf..55aa118ce6c 100644 --- a/src/lib/messaging/diagnostics.ts +++ b/src/lib/messaging/diagnostics.ts @@ -9,6 +9,7 @@ import type { MessagingAgentId, MessagingSerializableValue, SandboxMessagingChannelPlan, + SandboxMessagingInputReference, SandboxMessagingPlan, } from "./manifest"; @@ -100,21 +101,19 @@ export type VisibleConfigDisplay = { export function resolveVisibleConfigDisplay( input: VisibleChannelConfigInput, - rawValue: MessagingSerializableValue | undefined, + planInput: SandboxMessagingInputReference | undefined, ): VisibleConfigDisplay | null { - if (rawValue !== undefined && rawValue !== null && rawValue !== "") { + const planInputPresent = planInput !== undefined; + const rawValue = planInput?.value; + const persistedScalar = + planInputPresent && rawValue !== undefined && rawValue !== null && rawValue !== ""; + if (persistedScalar) { if (!isPrintableScalar(rawValue)) { - return { - detail: "invalid persisted value (unsupported type)", - source: "invalid", - }; + return invalidPersistedDisplay(input, "unsupported type"); } const valueText = stringifyScalar(rawValue); if (input.validValues && !input.validValues.includes(valueText)) { - return { - detail: `invalid persisted value (expected: ${input.validValues.join(" | ")})`, - source: "invalid", - }; + return invalidPersistedDisplay(input, `expected: ${input.validValues.join(" | ")}`); } const mapped = input.valueDisplay?.[valueText]; if (mapped && input.envKey) { @@ -125,6 +124,15 @@ export function resolveVisibleConfigDisplay( } return { detail: valueText, source: "persisted" }; } + if (planInputPresent) { + // The plan persisted this input but the value is null or empty. Don't + // silently swap in the manifest default — surface the boundary state + // so the operator can re-enter a valid value. + return invalidPersistedDisplay( + input, + input.validValues ? `expected: ${input.validValues.join(" | ")}` : "present but empty", + ); + } if (input.defaultValue !== undefined) { const mapped = input.valueDisplay?.[input.defaultValue]; if (mapped) { @@ -135,6 +143,16 @@ export function resolveVisibleConfigDisplay( return null; } +function invalidPersistedDisplay( + _input: VisibleChannelConfigInput, + reason: string, +): VisibleConfigDisplay { + return { + detail: `invalid persisted value (${reason})`, + source: "invalid", + }; +} + /** * Normalised visible-config record consumed by `channels status` and * `doctor`. The diagnostic shared helper walks a channel plan once and @@ -164,7 +182,7 @@ export function collectVisibleConfigRecords( for (const input of diagnostic.visibleConfigInputs) { if (!inputAppliesToAgent(input, agent)) continue; const planInput = channelPlan?.inputs.find((entry) => entry.inputId === input.inputId); - const display = resolveVisibleConfigDisplay(input, planInput?.value); + const display = resolveVisibleConfigDisplay(input, planInput); if (!display) continue; records.push({ input, display }); } From ed906b3cfda2ca4c7fe1777ad5a9baa465ea741d Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 07:13:19 +0000 Subject: [PATCH 07/30] chore(test): rewrite env-override helper without branching Signed-off-by: Tinson Lai --- src/lib/messaging/diagnostics.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/lib/messaging/diagnostics.test.ts b/src/lib/messaging/diagnostics.test.ts index 7f2135b8c63..d3716cf65bc 100644 --- a/src/lib/messaging/diagnostics.test.ts +++ b/src/lib/messaging/diagnostics.test.ts @@ -118,16 +118,16 @@ async function withEnvOverrides( run: () => Promise, ): Promise { const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]])); - for (const [key, value] of Object.entries(values)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } + applyEnvOverrides(values); try { return await run(); } finally { - for (const [key, value] of Object.entries(previous)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } + applyEnvOverrides(previous); + } +} + +function applyEnvOverrides(values: Readonly>): void { + for (const [key, value] of Object.entries(values)) { + value === undefined ? Reflect.deleteProperty(process.env, key) : (process.env[key] = value); } } From c9206f97b62864901809afc3e4432c1272136412 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 07:42:35 +0000 Subject: [PATCH 08/30] test(messaging): exercise compiled telegram plan through status + doctor paths Signed-off-by: Tinson Lai --- .../actions/sandbox/channel-status.test.ts | 84 ++++- src/lib/actions/sandbox/doctor-flow.test.ts | 297 ++++++++++-------- 2 files changed, 246 insertions(+), 135 deletions(-) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index 774be827cd8..2748e55902e 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -38,6 +38,12 @@ vi.mock("./process-recovery", () => ({ })); import type { AgentDefinition } from "../../agent/defs"; +import { + createBuiltInChannelManifestRegistry, + createBuiltInRenderTemplateResolver, +} from "../../messaging/channels"; +import { MessagingWorkflowPlanner } from "../../messaging/compiler/workflow-planner"; +import { createBuiltInMessagingHookRegistry } from "../../messaging/hooks"; import type { MessagingSerializableValue, SandboxMessagingPlan } from "../../messaging/manifest"; import type { SandboxEntry } from "../../state/registry"; import { showSandboxChannelStatus } from "./channel-status"; @@ -165,6 +171,7 @@ function makeDeps(opts: { string, ReadonlyArray<{ inputId: string; value?: MessagingSerializableValue }> >; + messagingPlan?: SandboxMessagingPlan | null; out?: (line: string) => void; }) { const calls: string[] = []; @@ -178,7 +185,10 @@ function makeDeps(opts: { getAppliedPresets: () => opts.appliedPresets ?? ["whatsapp"], getGatewayPresets: () => opts.gatewayPresets === undefined ? ["whatsapp"] : opts.gatewayPresets, - getMessagingPlan: () => fakePlanFromInputs(sandbox, opts.channelInputs), + getMessagingPlan: () => + opts.messagingPlan !== undefined + ? opts.messagingPlan + : fakePlanFromInputs(sandbox, opts.channelInputs), execSandbox: vi.fn(opts.exec), now: () => PROBED_AT, out, @@ -224,6 +234,58 @@ function mergePlanInputs( }; } +async function compileTelegramPlanForTests( + envOverrides: Readonly>, +): Promise { + const TELEGRAM_TOKEN = "123456:test-telegram-token"; + const planner = new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + createBuiltInMessagingHookRegistry({ + common: { + env: { TELEGRAM_BOT_TOKEN: TELEGRAM_TOKEN, ...envOverrides }, + getCredential: (key) => (key === "TELEGRAM_BOT_TOKEN" ? TELEGRAM_TOKEN : null), + saveCredential: () => {}, + prompt: async () => "unused", + log: () => {}, + }, + telegram: { + fetch: async () => ({ + ok: true, + status: 200, + async json() { + return { ok: true }; + }, + async text() { + return ""; + }, + }), + }, + }), + createBuiltInRenderTemplateResolver(), + ); + const previous = Object.fromEntries( + Object.keys(envOverrides).map((key) => [key, process.env[key]]), + ); + applyEnvForTests({ TELEGRAM_BOT_TOKEN: TELEGRAM_TOKEN, ...envOverrides }); + try { + return await planner.buildPlan({ + sandboxName: "alpha", + agent: "openclaw", + workflow: "onboard", + isInteractive: true, + configuredChannels: ["telegram"], + }); + } finally { + applyEnvForTests({ TELEGRAM_BOT_TOKEN: undefined, ...previous }); + } +} + +function applyEnvForTests(values: Readonly>): void { + for (const [key, value] of Object.entries(values)) { + value === undefined ? Reflect.deleteProperty(process.env, key) : (process.env[key] = value); + } +} + describe("showSandboxChannelStatus (whatsapp)", () => { it("returns idle verdict and exit code 1 when paired but no inbound observed", async () => { const heartbeat = JSON.stringify({ @@ -691,4 +753,24 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { /Telegram group policy:\s+invalid persisted value \(expected: open \| allowlist \| disabled\)/, ); }); + + it("renders Telegram inputs from a plan compiled out of process env through the command path", async () => { + const plan = await compileTelegramPlanForTests({ + TELEGRAM_GROUP_POLICY: "allowlist", + TELEGRAM_REQUIRE_MENTION: undefined, + }); + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: entry(["telegram"]), + appliedPresets: ["telegram"], + messagingPlan: plan, + }); + await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); + const dump = out_lines.join("\n"); + expect(dump).toMatch(/Telegram group policy:\s+allowlist\b/); + expect(dump).not.toMatch(/Telegram group policy:.*\(default\)/); + expect(dump).toMatch( + /Telegram group mention mode:\s+mention-only \(TELEGRAM_REQUIRE_MENTION=1\)/, + ); + }); }); diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index 9b4050c1012..0b9eb152040 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -159,6 +159,59 @@ function createDoctorHarness(): { }; } +type TelegramInputOverride = { + inputId: string; + value?: unknown; +}; + +function telegramDoctorPlan(options: { + agent: "openclaw" | "hermes"; + inputs?: ReadonlyArray; +}) { + return { + schemaVersion: 1 as const, + sandboxName: "alpha", + agent: options.agent, + workflow: "onboard" as const, + channels: [ + { + channelId: "telegram", + displayName: "telegram", + authMode: "token-paste" as const, + active: true, + selected: true, + configured: true, + disabled: false, + inputs: (options.inputs ?? []).map((override) => ({ + channelId: "telegram", + inputId: override.inputId, + kind: "config" as const, + required: false, + ...(override.value === undefined ? {} : { value: override.value }), + })), + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + +function mockTelegramDoctorRegistry(options: { + agent: "openclaw" | "hermes"; + inputs?: ReadonlyArray; +}): void { + const registry = requireDist("../../../../dist/lib/state/registry.js"); + vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); + vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); + vi.spyOn(registry, "getMessagingPlanFromEntry").mockReturnValue(telegramDoctorPlan(options)); +} + describe("runSandboxDoctor flow", () => { let exitSpy: MockInstance; @@ -209,42 +262,9 @@ describe("runSandboxDoctor flow", () => { it("surfaces Telegram visible config inputs in the Messaging doctor section", async () => { const harness = createDoctorHarness(); - const registry = requireDist("../../../../dist/lib/state/registry.js"); - vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); - vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); - vi.spyOn(registry, "getMessagingPlanFromEntry").mockReturnValue({ - schemaVersion: 1, - sandboxName: "alpha", + mockTelegramDoctorRegistry({ agent: "openclaw", - workflow: "onboard", - channels: [ - { - channelId: "telegram", - displayName: "telegram", - authMode: "token-paste", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [ - { - channelId: "telegram", - inputId: "requireMention", - kind: "config", - required: false, - value: "0", - }, - ], - hooks: [], - }, - ], - disabledChannels: [], - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], + inputs: [{ inputId: "requireMention", value: "0" }], }); const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); @@ -277,7 +297,6 @@ describe("runSandboxDoctor flow", () => { it("hides the OpenClaw-only Telegram group policy when the sandbox runs Hermes", async () => { const harness = createDoctorHarness(); - const registry = requireDist("../../../../dist/lib/state/registry.js"); harness.getSandboxSpy.mockReturnValue({ name: "alpha", agent: "hermes", @@ -288,34 +307,7 @@ describe("runSandboxDoctor flow", () => { gatewayPort: 19080, messaging: undefined, }); - vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); - vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); - vi.spyOn(registry, "getMessagingPlanFromEntry").mockReturnValue({ - schemaVersion: 1, - sandboxName: "alpha", - agent: "hermes", - workflow: "onboard", - channels: [ - { - channelId: "telegram", - displayName: "telegram", - authMode: "token-paste", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ], - disabledChannels: [], - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }); + mockTelegramDoctorRegistry({ agent: "hermes" }); const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); @@ -326,44 +318,36 @@ describe("runSandboxDoctor flow", () => { ).toMatchObject({ status: "info", detail: "mention-only (default)" }); }); + it("falls back to OpenClaw visible config when a legacy SandboxEntry omits the agent field", async () => { + const harness = createDoctorHarness(); + harness.getSandboxSpy.mockReturnValue({ + name: "alpha", + model: "registry-model", + provider: "ollama-local", + openshellDriver: "docker", + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + messaging: undefined, + }); + mockTelegramDoctorRegistry({ agent: "openclaw" }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); + + expect(messagingChecks.find((check) => check.label === "Telegram group policy")).toMatchObject({ + status: "info", + detail: "open (default)", + }); + expect( + messagingChecks.find((check) => check.label === "Telegram group mention mode"), + ).toMatchObject({ status: "info", detail: "mention-only (default)" }); + }); + it("flags an invalid persisted Telegram group policy without echoing the raw value", async () => { const harness = createDoctorHarness(); - const registry = requireDist("../../../../dist/lib/state/registry.js"); - vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); - vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); - vi.spyOn(registry, "getMessagingPlanFromEntry").mockReturnValue({ - schemaVersion: 1, - sandboxName: "alpha", + mockTelegramDoctorRegistry({ agent: "openclaw", - workflow: "onboard", - channels: [ - { - channelId: "telegram", - displayName: "telegram", - authMode: "token-paste", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [ - { - channelId: "telegram", - inputId: "groupPolicy", - kind: "config", - required: false, - value: "definitely-not-a-policy", - }, - ], - hooks: [], - }, - ], - disabledChannels: [], - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], + inputs: [{ inputId: "groupPolicy", value: "definitely-not-a-policy" }], }); const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); @@ -381,42 +365,9 @@ describe("runSandboxDoctor flow", () => { it("flags a present-but-empty Telegram mention-mode value as invalid rather than defaulting", async () => { const harness = createDoctorHarness(); - const registry = requireDist("../../../../dist/lib/state/registry.js"); - vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); - vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); - vi.spyOn(registry, "getMessagingPlanFromEntry").mockReturnValue({ - schemaVersion: 1, - sandboxName: "alpha", + mockTelegramDoctorRegistry({ agent: "openclaw", - workflow: "onboard", - channels: [ - { - channelId: "telegram", - displayName: "telegram", - authMode: "token-paste", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [ - { - channelId: "telegram", - inputId: "requireMention", - kind: "config", - required: false, - value: "", - }, - ], - hooks: [], - }, - ], - disabledChannels: [], - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], + inputs: [{ inputId: "requireMention", value: "" }], }); const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); @@ -430,6 +381,84 @@ describe("runSandboxDoctor flow", () => { expect(mentionCheck?.detail).not.toMatch(/default/); }); + it("surfaces Telegram visible config from a plan compiled out of process env through doctor", async () => { + const harness = createDoctorHarness(); + const channelsModule = requireDist("../../../../dist/lib/messaging/channels/index.js"); + const hooksModule = requireDist("../../../../dist/lib/messaging/hooks/index.js"); + const plannerModule = requireDist( + "../../../../dist/lib/messaging/compiler/workflow-planner.js", + ); + const TELEGRAM_TOKEN = "123456:test-telegram-token"; + const planner = new plannerModule.MessagingWorkflowPlanner( + channelsModule.createBuiltInChannelManifestRegistry(), + hooksModule.createBuiltInMessagingHookRegistry({ + common: { + env: { + TELEGRAM_BOT_TOKEN: TELEGRAM_TOKEN, + TELEGRAM_GROUP_POLICY: "allowlist", + }, + getCredential: (key: string) => (key === "TELEGRAM_BOT_TOKEN" ? TELEGRAM_TOKEN : null), + saveCredential: () => {}, + prompt: async () => "unused", + log: () => {}, + }, + telegram: { + fetch: async () => ({ + ok: true, + status: 200, + async json() { + return { ok: true }; + }, + async text() { + return ""; + }, + }), + }, + }), + channelsModule.createBuiltInRenderTemplateResolver(), + ); + const envOverrides = { + TELEGRAM_BOT_TOKEN: TELEGRAM_TOKEN, + TELEGRAM_GROUP_POLICY: "allowlist", + }; + const previous = Object.fromEntries( + Object.keys(envOverrides).map((key) => [key, process.env[key]]), + ); + Object.assign(process.env, envOverrides); + let compiledPlan; + try { + compiledPlan = await planner.buildPlan({ + sandboxName: "alpha", + agent: "openclaw", + workflow: "onboard", + isInteractive: true, + configuredChannels: ["telegram"], + }); + } finally { + for (const [key, value] of Object.entries(previous)) { + value === undefined ? Reflect.deleteProperty(process.env, key) : (process.env[key] = value); + } + } + const registry = requireDist("../../../../dist/lib/state/registry.js"); + vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); + vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); + vi.spyOn(registry, "getMessagingPlanFromEntry").mockReturnValue(compiledPlan); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); + + expect(messagingChecks.find((check) => check.label === "Telegram group policy")).toMatchObject({ + status: "ok", + detail: "allowlist", + }); + expect( + messagingChecks.find((check) => check.label === "Telegram group mention mode"), + ).toMatchObject({ + status: "ok", + detail: "mention-only (TELEGRAM_REQUIRE_MENTION=1)", + }); + }); + it("rejects mutating --fix when JSON output was requested", async () => { const harness = createDoctorHarness(); From 343037f4459b7c655c394fc9925a65e27c80f70b Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 04:50:18 +0000 Subject: [PATCH 09/30] refactor(doctor): extract visible-config helpers to bound complexity Signed-off-by: Tinson Lai --- src/lib/actions/sandbox/doctor-messaging.ts | 60 +++++++++++++-------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/src/lib/actions/sandbox/doctor-messaging.ts b/src/lib/actions/sandbox/doctor-messaging.ts index 1ed6d171b12..4d33510025f 100644 --- a/src/lib/actions/sandbox/doctor-messaging.ts +++ b/src/lib/actions/sandbox/doctor-messaging.ts @@ -263,12 +263,43 @@ function configuredChannelsCheck(sandboxName: string, sb: SandboxEntry): DoctorC }; } +function activeChannelsFromEntry(sb: SandboxEntry): string[] { + const registered = registry.getConfiguredMessagingChannelsFromEntry(sb); + const disabled = new Set(registry.getDisabledMessagingChannelsFromEntry(sb)); + return registered.filter((channel: string) => !disabled.has(channel)); +} + +function visibleConfigDoctorHint( + sandboxName: string, + channelName: string, + source: "persisted" | "default" | "invalid", +): string | undefined { + if (source === "default") { + return `run \`${CLI_NAME} ${sandboxName} channels status --channel ${channelName}\` to confirm the resolved value`; + } + if (source === "invalid") { + return `run \`${CLI_NAME} ${sandboxName} channels add ${channelName}\` to re-enter a valid value`; + } + return undefined; +} + +function buildVisibleConfigDoctorCheck( + sandboxName: string, + channelName: string, + record: ReturnType[number], +): DoctorCheck { + const hint = visibleConfigDoctorHint(sandboxName, channelName, record.display.source); + return { + group: "Messaging", + label: record.input.label, + status: doctorStatusForDisplay(record.display.source), + detail: record.display.detail, + ...(hint === undefined ? {} : { hint }), + }; +} + function messagingChannelConfigDoctorChecks(sandboxName: string, sb: SandboxEntry): DoctorCheck[] { - const registeredChannels = registry.getConfiguredMessagingChannelsFromEntry(sb); - const disabledChannels = new Set(registry.getDisabledMessagingChannelsFromEntry(sb)); - const activeChannels = registeredChannels.filter( - (channel: string) => !disabledChannels.has(channel), - ); + const activeChannels = activeChannelsFromEntry(sb); if (activeChannels.length === 0) return []; const plan = registry.getMessagingPlanFromEntry(sb); const agent = asMessagingAgent(sb.agent ?? "openclaw"); @@ -277,23 +308,8 @@ function messagingChannelConfigDoctorChecks(sandboxName: string, sb: SandboxEntr const diagnostic = getChannelStatusDiagnostic(channelName); if (!diagnostic || diagnostic.visibleConfigInputs.length === 0) continue; const records = collectVisibleConfigRecords(diagnostic, plan, channelName, agent); - for (const { input, display } of records) { - checks.push({ - group: "Messaging", - label: input.label, - status: doctorStatusForDisplay(display.source), - detail: display.detail, - ...(display.source === "default" - ? { - hint: `run \`${CLI_NAME} ${sandboxName} channels status --channel ${channelName}\` to confirm the resolved value`, - } - : {}), - ...(display.source === "invalid" - ? { - hint: `run \`${CLI_NAME} ${sandboxName} channels add ${channelName}\` to re-enter a valid value`, - } - : {}), - }); + for (const record of records) { + checks.push(buildVisibleConfigDoctorCheck(sandboxName, channelName, record)); } } return checks; From 2d14eca3f04c0603e1b645dce5848a75d9d29005 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 05:05:56 +0000 Subject: [PATCH 10/30] fix(messaging): bound diagnostic disclosure to allowlisted inputs Only inputs declaring both safeToPrintInDiagnostics and a non-empty validValues allowlist may surface persisted plan scalars in channels status and doctor. Add a regression test asserting a tampered non-scalar persisted value renders invalid persisted value (unsupported type) instead of the raw object or array. Signed-off-by: Tinson Lai --- src/lib/messaging/diagnostics.test.ts | 100 ++++++++++++++++++++++++++ src/lib/messaging/diagnostics.ts | 1 + 2 files changed, 101 insertions(+) diff --git a/src/lib/messaging/diagnostics.test.ts b/src/lib/messaging/diagnostics.test.ts index 20acbc65535..c9708155a5d 100644 --- a/src/lib/messaging/diagnostics.test.ts +++ b/src/lib/messaging/diagnostics.test.ts @@ -116,6 +116,106 @@ describe("collectVisibleConfigRecords (compiled plan integration)", () => { detail: "mention-only (TELEGRAM_REQUIRE_MENTION=1)", }); }); + + it("redacts non-scalar persisted Telegram visible config values without echoing raw JSON", async () => { + const planner = new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + createBuiltInMessagingHookRegistry({ + common: { + env: { + TELEGRAM_BOT_TOKEN: "123456:test-telegram-token", + }, + getCredential: (key) => + key === "TELEGRAM_BOT_TOKEN" ? "123456:test-telegram-token" : null, + saveCredential: () => {}, + prompt: async () => "unused", + log: () => {}, + }, + telegram: { + fetch: async () => ({ + ok: true, + status: 200, + async json() { + return { ok: true }; + }, + async text() { + return ""; + }, + }), + }, + }), + createBuiltInRenderTemplateResolver(), + ); + + const basePlan = await withEnvOverrides( + { + TELEGRAM_BOT_TOKEN: "123456:test-telegram-token", + TELEGRAM_GROUP_POLICY: undefined, + TELEGRAM_REQUIRE_MENTION: undefined, + }, + () => + planner.buildPlan({ + sandboxName: "alpha", + agent: "openclaw", + workflow: "onboard", + isInteractive: true, + configuredChannels: ["telegram"], + }), + ); + + const tamperedPlan = { + ...basePlan, + channels: basePlan.channels.map((channel) => { + if (channel.channelId !== "telegram") return channel; + return { + ...channel, + inputs: [ + ...channel.inputs.filter( + (input) => input.inputId !== "groupPolicy" && input.inputId !== "requireMention", + ), + { + channelId: "telegram", + inputId: "groupPolicy", + kind: "config" as const, + required: false, + value: { tampered: ["allowlist", "open"] } as unknown as string, + }, + { + channelId: "telegram", + inputId: "requireMention", + kind: "config" as const, + required: false, + value: ["1", "0"] as unknown as string, + }, + ], + }; + }), + }; + + const diagnostic = collectBuiltInMessagingChannelDiagnostics().find( + (spec) => spec.channelId === "telegram", + ); + expect(diagnostic).toBeDefined(); + + const records = collectVisibleConfigRecords(diagnostic!, tamperedPlan, "telegram", "openclaw"); + const byLabel = (label: string) => records.find((record) => record.input.label === label); + + const policy = byLabel("Telegram group policy"); + expect(policy?.display).toMatchObject({ + source: "invalid", + detail: "invalid persisted value (unsupported type)", + }); + expect(policy?.display.detail).not.toMatch(/tampered/); + expect(policy?.display.detail).not.toMatch(/allowlist/); + + const mention = byLabel("Telegram group mention mode"); + expect(mention?.display).toMatchObject({ + source: "invalid", + detail: "invalid persisted value (unsupported type)", + }); + expect(mention?.display.detail).not.toMatch(/\[/); + expect(mention?.display.detail).not.toMatch(/"/); + }); }); async function withEnvOverrides( diff --git a/src/lib/messaging/diagnostics.ts b/src/lib/messaging/diagnostics.ts index 55aa118ce6c..7d81eaf881a 100644 --- a/src/lib/messaging/diagnostics.ts +++ b/src/lib/messaging/diagnostics.ts @@ -66,6 +66,7 @@ function collectVisibleConfigInputs( return inputs.flatMap((input) => { if (input.kind !== "config") return []; if (input.safeToPrintInDiagnostics !== true) return []; + if (!input.validValues || input.validValues.length === 0) return []; const label = input.prompt?.label; if (!label) return []; return [ From 144e0dc2a7ce5d2a75e8a292f015f7539ad56f88 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 05:18:31 +0000 Subject: [PATCH 11/30] test(messaging): rewrite tamper plan helper without conditional Signed-off-by: Tinson Lai --- src/lib/messaging/diagnostics.test.ts | 51 ++++++++++++++------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/src/lib/messaging/diagnostics.test.ts b/src/lib/messaging/diagnostics.test.ts index c9708155a5d..98bc48e736a 100644 --- a/src/lib/messaging/diagnostics.test.ts +++ b/src/lib/messaging/diagnostics.test.ts @@ -165,31 +165,32 @@ describe("collectVisibleConfigRecords (compiled plan integration)", () => { const tamperedPlan = { ...basePlan, - channels: basePlan.channels.map((channel) => { - if (channel.channelId !== "telegram") return channel; - return { - ...channel, - inputs: [ - ...channel.inputs.filter( - (input) => input.inputId !== "groupPolicy" && input.inputId !== "requireMention", - ), - { - channelId: "telegram", - inputId: "groupPolicy", - kind: "config" as const, - required: false, - value: { tampered: ["allowlist", "open"] } as unknown as string, - }, - { - channelId: "telegram", - inputId: "requireMention", - kind: "config" as const, - required: false, - value: ["1", "0"] as unknown as string, - }, - ], - }; - }), + channels: basePlan.channels.map((channel) => + channel.channelId === "telegram" + ? { + ...channel, + inputs: [ + ...channel.inputs.filter( + (input) => input.inputId !== "groupPolicy" && input.inputId !== "requireMention", + ), + { + channelId: "telegram", + inputId: "groupPolicy", + kind: "config" as const, + required: false, + value: { tampered: ["allowlist", "open"] } as unknown as string, + }, + { + channelId: "telegram", + inputId: "requireMention", + kind: "config" as const, + required: false, + value: ["1", "0"] as unknown as string, + }, + ], + } + : channel, + ), }; const diagnostic = collectBuiltInMessagingChannelDiagnostics().find( From a664a51df7a6ae8465849c30b005f68cc597739d Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 06:00:24 +0000 Subject: [PATCH 12/30] fix(messaging): require validValues on visible config display contract Make validValues mandatory on the exported VisibleChannelConfigInput type and refuse persisted plan values when the allowlist is empty, so the bounded-display invariant lives at the type boundary rather than relying on registry filtering. Add a channels status command-path test asserting an object-tampered groupPolicy and array-tampered requireMention render invalid persisted value (unsupported type) without leaking raw values. Signed-off-by: Tinson Lai --- .../actions/sandbox/channel-status.test.ts | 34 +++++++++++++++++++ src/lib/messaging/diagnostics.ts | 15 +++----- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index e03b7147484..d5ebc1ed26d 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -750,6 +750,40 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { ); }); + it("redacts a non-scalar persisted Telegram value rather than echoing raw JSON", async () => { + const tamperedObject = { allow: ["@one", "@two"], smuggled: "secret-id" }; + const tamperedArray = ["1", "0"]; + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: entry(["telegram"]), + appliedPresets: ["telegram"], + channelInputs: { + telegram: [ + { + inputId: "groupPolicy", + value: tamperedObject as unknown as MessagingSerializableValue, + }, + { + inputId: "requireMention", + value: tamperedArray as unknown as MessagingSerializableValue, + }, + ], + }, + }); + await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); + const dump = out_lines.join("\n"); + expect(dump).toMatch( + /Telegram group policy:\s+invalid persisted value \(unsupported type\)/, + ); + expect(dump).toMatch( + /Telegram group mention mode:\s+invalid persisted value \(unsupported type\)/, + ); + expect(dump).not.toMatch(/secret-id/); + expect(dump).not.toMatch(/smuggled/); + expect(dump).not.toMatch(/@one/); + expect(dump).not.toMatch(/"1"/); + }); + it("renders Telegram inputs from a plan compiled out of process env through the command path", async () => { const plan = await compileTelegramPlanForTests({ TELEGRAM_GROUP_POLICY: "allowlist", diff --git a/src/lib/messaging/diagnostics.ts b/src/lib/messaging/diagnostics.ts index 7d81eaf881a..64af63fbc29 100644 --- a/src/lib/messaging/diagnostics.ts +++ b/src/lib/messaging/diagnostics.ts @@ -30,7 +30,7 @@ export interface VisibleChannelConfigInput { readonly label: string; readonly envKey?: string; readonly defaultValue?: string; - readonly validValues?: readonly string[]; + readonly validValues: readonly string[]; readonly valueDisplay?: Readonly>; readonly agentApplicability?: readonly MessagingAgentId[]; } @@ -75,7 +75,7 @@ function collectVisibleConfigInputs( label, ...(input.envKey ? { envKey: input.envKey } : {}), ...(input.defaultValue !== undefined ? { defaultValue: input.defaultValue } : {}), - ...(input.validValues ? { validValues: [...input.validValues] } : {}), + validValues: [...input.validValues], ...(input.valueDisplay ? { valueDisplay: { ...input.valueDisplay } } : {}), ...(input.agentApplicability ? { agentApplicability: [...input.agentApplicability] } : {}), } satisfies VisibleChannelConfigInput, @@ -104,6 +104,7 @@ export function resolveVisibleConfigDisplay( input: VisibleChannelConfigInput, planInput: SandboxMessagingInputReference | undefined, ): VisibleConfigDisplay | null { + if (input.validValues.length === 0) return null; const planInputPresent = planInput !== undefined; const rawValue = planInput?.value; const persistedScalar = @@ -113,7 +114,7 @@ export function resolveVisibleConfigDisplay( return invalidPersistedDisplay(input, "unsupported type"); } const valueText = stringifyScalar(rawValue); - if (input.validValues && !input.validValues.includes(valueText)) { + if (!input.validValues.includes(valueText)) { return invalidPersistedDisplay(input, `expected: ${input.validValues.join(" | ")}`); } const mapped = input.valueDisplay?.[valueText]; @@ -126,13 +127,7 @@ export function resolveVisibleConfigDisplay( return { detail: valueText, source: "persisted" }; } if (planInputPresent) { - // The plan persisted this input but the value is null or empty. Don't - // silently swap in the manifest default — surface the boundary state - // so the operator can re-enter a valid value. - return invalidPersistedDisplay( - input, - input.validValues ? `expected: ${input.validValues.join(" | ")}` : "present but empty", - ); + return invalidPersistedDisplay(input, `expected: ${input.validValues.join(" | ")}`); } if (input.defaultValue !== undefined) { const mapped = input.valueDisplay?.[input.defaultValue]; From cd7c19b8a98f4589506401198a2b8240e63b99bb Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 06:10:01 +0000 Subject: [PATCH 13/30] style: biome format channel-status visibility tests Signed-off-by: Tinson Lai --- src/lib/actions/sandbox/channel-status.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index d5ebc1ed26d..e01c87756bf 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -772,9 +772,7 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { }); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); - expect(dump).toMatch( - /Telegram group policy:\s+invalid persisted value \(unsupported type\)/, - ); + expect(dump).toMatch(/Telegram group policy:\s+invalid persisted value \(unsupported type\)/); expect(dump).toMatch( /Telegram group mention mode:\s+invalid persisted value \(unsupported type\)/, ); From b0590700147f6b8f0cddebf3186950251d2f8a83 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 07:30:45 +0000 Subject: [PATCH 14/30] test(messaging): share Telegram plan compile helper across diagnostics tests Extract compileTelegramPlanForTests and TELEGRAM_BOT_TOKEN harness into src/lib/messaging/__test-utils__/telegram-plan.ts so the channel-status, doctor, and diagnostics suites share one planner + env-override pathway instead of three drift-prone copies. Add a planner source-boundary regression asserting that out-of-allowlist TELEGRAM_GROUP_POLICY and TELEGRAM_REQUIRE_MENTION env values never reach the persisted plan, proving the diagnostic-layer bounded display is defense-in-depth rather than the only line of defence. Signed-off-by: Tinson Lai --- .../actions/sandbox/channel-status.test.ts | 64 +------- src/lib/actions/sandbox/doctor-flow.test.ts | 60 +------ .../messaging/__test-utils__/telegram-plan.ts | 78 ++++++++++ src/lib/messaging/diagnostics.test.ts | 146 +++++------------- 4 files changed, 125 insertions(+), 223 deletions(-) create mode 100644 src/lib/messaging/__test-utils__/telegram-plan.ts diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index e01c87756bf..9859c788a1f 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -38,12 +38,7 @@ vi.mock("./process-recovery", () => ({ })); import type { AgentDefinition } from "../../agent/defs"; -import { - createBuiltInChannelManifestRegistry, - createBuiltInRenderTemplateResolver, -} from "../../messaging/channels"; -import { MessagingWorkflowPlanner } from "../../messaging/compiler/workflow-planner"; -import { createBuiltInMessagingHookRegistry } from "../../messaging/hooks"; +import { compileTelegramPlanForTests } from "../../messaging/__test-utils__/telegram-plan"; import type { MessagingSerializableValue, SandboxMessagingPlan } from "../../messaging/manifest"; import type { SandboxEntry } from "../../state/registry"; import { showSandboxChannelStatus } from "./channel-status"; @@ -230,57 +225,6 @@ function mergePlanInputs( }; } -async function compileTelegramPlanForTests( - envOverrides: Readonly>, -): Promise { - const TELEGRAM_TOKEN = "123456:test-telegram-token"; - const planner = new MessagingWorkflowPlanner( - createBuiltInChannelManifestRegistry(), - createBuiltInMessagingHookRegistry({ - common: { - env: { TELEGRAM_BOT_TOKEN: TELEGRAM_TOKEN, ...envOverrides }, - getCredential: (key) => (key === "TELEGRAM_BOT_TOKEN" ? TELEGRAM_TOKEN : null), - saveCredential: () => {}, - prompt: async () => "unused", - log: () => {}, - }, - telegram: { - fetch: async () => ({ - ok: true, - status: 200, - async json() { - return { ok: true }; - }, - async text() { - return ""; - }, - }), - }, - }), - createBuiltInRenderTemplateResolver(), - ); - const previous = Object.fromEntries( - Object.keys(envOverrides).map((key) => [key, process.env[key]]), - ); - applyEnvForTests({ TELEGRAM_BOT_TOKEN: TELEGRAM_TOKEN, ...envOverrides }); - try { - return await planner.buildPlan({ - sandboxName: "alpha", - agent: "openclaw", - workflow: "onboard", - isInteractive: true, - configuredChannels: ["telegram"], - }); - } finally { - applyEnvForTests({ TELEGRAM_BOT_TOKEN: undefined, ...previous }); - } -} - -function applyEnvForTests(values: Readonly>): void { - for (const [key, value] of Object.entries(values)) { - value === undefined ? Reflect.deleteProperty(process.env, key) : (process.env[key] = value); - } -} describe("showSandboxChannelStatus (whatsapp)", () => { it("returns idle verdict and exit code 1 when paired but no inbound observed", async () => { @@ -784,8 +728,10 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { it("renders Telegram inputs from a plan compiled out of process env through the command path", async () => { const plan = await compileTelegramPlanForTests({ - TELEGRAM_GROUP_POLICY: "allowlist", - TELEGRAM_REQUIRE_MENTION: undefined, + envOverrides: { + TELEGRAM_GROUP_POLICY: "allowlist", + TELEGRAM_REQUIRE_MENTION: undefined, + }, }); const { deps, out_lines } = makeDeps({ exec: () => ({ status: 0, stdout: "", stderr: "" }), diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index 2e56883e962..b581441f71e 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -5,6 +5,7 @@ import { createRequire } from "node:module"; import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; import { testTimeoutOptions } from "../../../../test/helpers/timeouts"; +import { compileTelegramPlanForTests } from "../../messaging/__test-utils__/telegram-plan"; type RunSandboxDoctor = typeof import("./doctor")["runSandboxDoctor"]; @@ -427,62 +428,9 @@ describe("runSandboxDoctor flow", () => { it("surfaces Telegram visible config from a plan compiled out of process env through doctor", async () => { const harness = createDoctorHarness(); - const channelsModule = requireDist("../../../../dist/lib/messaging/channels/index.js"); - const hooksModule = requireDist("../../../../dist/lib/messaging/hooks/index.js"); - const plannerModule = requireDist( - "../../../../dist/lib/messaging/compiler/workflow-planner.js", - ); - const TELEGRAM_TOKEN = "123456:test-telegram-token"; - const planner = new plannerModule.MessagingWorkflowPlanner( - channelsModule.createBuiltInChannelManifestRegistry(), - hooksModule.createBuiltInMessagingHookRegistry({ - common: { - env: { - TELEGRAM_BOT_TOKEN: TELEGRAM_TOKEN, - TELEGRAM_GROUP_POLICY: "allowlist", - }, - getCredential: (key: string) => (key === "TELEGRAM_BOT_TOKEN" ? TELEGRAM_TOKEN : null), - saveCredential: () => {}, - prompt: async () => "unused", - log: () => {}, - }, - telegram: { - fetch: async () => ({ - ok: true, - status: 200, - async json() { - return { ok: true }; - }, - async text() { - return ""; - }, - }), - }, - }), - channelsModule.createBuiltInRenderTemplateResolver(), - ); - const envOverrides = { - TELEGRAM_BOT_TOKEN: TELEGRAM_TOKEN, - TELEGRAM_GROUP_POLICY: "allowlist", - }; - const previous = Object.fromEntries( - Object.keys(envOverrides).map((key) => [key, process.env[key]]), - ); - Object.assign(process.env, envOverrides); - let compiledPlan; - try { - compiledPlan = await planner.buildPlan({ - sandboxName: "alpha", - agent: "openclaw", - workflow: "onboard", - isInteractive: true, - configuredChannels: ["telegram"], - }); - } finally { - for (const [key, value] of Object.entries(previous)) { - value === undefined ? Reflect.deleteProperty(process.env, key) : (process.env[key] = value); - } - } + const compiledPlan = await compileTelegramPlanForTests({ + envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist" }, + }); const registry = requireDist("../../state/registry.js"); vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); diff --git a/src/lib/messaging/__test-utils__/telegram-plan.ts b/src/lib/messaging/__test-utils__/telegram-plan.ts new file mode 100644 index 00000000000..bca554267f3 --- /dev/null +++ b/src/lib/messaging/__test-utils__/telegram-plan.ts @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + createBuiltInChannelManifestRegistry, + createBuiltInRenderTemplateResolver, +} from "../channels"; +import { MessagingWorkflowPlanner } from "../compiler/workflow-planner"; +import { createBuiltInMessagingHookRegistry } from "../hooks"; +import type { MessagingAgentId, SandboxMessagingPlan } from "../manifest"; + +export const TEST_TELEGRAM_TOKEN = "123456:test-telegram-token"; + +export interface CompileTelegramPlanOptions { + readonly envOverrides: Readonly>; + readonly sandboxName?: string; + readonly agent?: MessagingAgentId; +} + +export async function compileTelegramPlanForTests( + options: CompileTelegramPlanOptions, +): Promise { + const { envOverrides, sandboxName = "alpha", agent = "openclaw" } = options; + const planner = new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + createBuiltInMessagingHookRegistry({ + common: { + env: { TELEGRAM_BOT_TOKEN: TEST_TELEGRAM_TOKEN, ...envOverrides }, + getCredential: (key) => (key === "TELEGRAM_BOT_TOKEN" ? TEST_TELEGRAM_TOKEN : null), + saveCredential: () => {}, + prompt: async () => "unused", + log: () => {}, + }, + telegram: { + fetch: async () => ({ + ok: true, + status: 200, + async json() { + return { ok: true }; + }, + async text() { + return ""; + }, + }), + }, + }), + createBuiltInRenderTemplateResolver(), + ); + return withTelegramEnvOverrides(envOverrides, () => + planner.buildPlan({ + sandboxName, + agent, + workflow: "onboard", + isInteractive: true, + configuredChannels: ["telegram"], + }), + ); +} + +export async function withTelegramEnvOverrides( + values: Readonly>, + run: () => Promise, +): Promise { + const merged = { TELEGRAM_BOT_TOKEN: TEST_TELEGRAM_TOKEN, ...values }; + const previous = Object.fromEntries(Object.keys(merged).map((key) => [key, process.env[key]])); + applyEnvForTests(merged); + try { + return await run(); + } finally { + applyEnvForTests(previous); + } +} + +export function applyEnvForTests(values: Readonly>): void { + for (const [key, value] of Object.entries(values)) { + value === undefined ? Reflect.deleteProperty(process.env, key) : (process.env[key] = value); + } +} diff --git a/src/lib/messaging/diagnostics.test.ts b/src/lib/messaging/diagnostics.test.ts index 98bc48e736a..e2f9f898234 100644 --- a/src/lib/messaging/diagnostics.test.ts +++ b/src/lib/messaging/diagnostics.test.ts @@ -3,16 +3,11 @@ import { describe, expect, it } from "vitest"; -import { - createBuiltInChannelManifestRegistry, - createBuiltInRenderTemplateResolver, -} from "./channels"; +import { compileTelegramPlanForTests } from "./__test-utils__/telegram-plan"; import { collectBuiltInMessagingChannelDiagnostics, collectVisibleConfigRecords, } from "./diagnostics"; -import { MessagingWorkflowPlanner } from "./compiler/workflow-planner"; -import { createBuiltInMessagingHookRegistry } from "./hooks"; describe("messaging channel diagnostics", () => { it("derives common channel diagnostic metadata directly from manifests", () => { @@ -50,51 +45,12 @@ describe("messaging channel diagnostics", () => { describe("collectVisibleConfigRecords (compiled plan integration)", () => { it("renders Telegram visible config from a plan compiled out of process env, not from injected plan inputs", async () => { - const planner = new MessagingWorkflowPlanner( - createBuiltInChannelManifestRegistry(), - createBuiltInMessagingHookRegistry({ - common: { - env: { - TELEGRAM_BOT_TOKEN: "123456:test-telegram-token", - TELEGRAM_GROUP_POLICY: "allowlist", - }, - getCredential: (key) => - key === "TELEGRAM_BOT_TOKEN" ? "123456:test-telegram-token" : null, - saveCredential: () => {}, - prompt: async () => "unused", - log: () => {}, - }, - telegram: { - fetch: async () => ({ - ok: true, - status: 200, - async json() { - return { ok: true }; - }, - async text() { - return ""; - }, - }), - }, - }), - createBuiltInRenderTemplateResolver(), - ); - - const plan = await withEnvOverrides( - { - TELEGRAM_BOT_TOKEN: "123456:test-telegram-token", + const plan = await compileTelegramPlanForTests({ + envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: undefined, }, - () => - planner.buildPlan({ - sandboxName: "alpha", - agent: "openclaw", - workflow: "onboard", - isInteractive: true, - configuredChannels: ["telegram"], - }), - ); + }); const diagnostic = collectBuiltInMessagingChannelDiagnostics().find( (spec) => spec.channelId === "telegram", @@ -118,50 +74,12 @@ describe("collectVisibleConfigRecords (compiled plan integration)", () => { }); it("redacts non-scalar persisted Telegram visible config values without echoing raw JSON", async () => { - const planner = new MessagingWorkflowPlanner( - createBuiltInChannelManifestRegistry(), - createBuiltInMessagingHookRegistry({ - common: { - env: { - TELEGRAM_BOT_TOKEN: "123456:test-telegram-token", - }, - getCredential: (key) => - key === "TELEGRAM_BOT_TOKEN" ? "123456:test-telegram-token" : null, - saveCredential: () => {}, - prompt: async () => "unused", - log: () => {}, - }, - telegram: { - fetch: async () => ({ - ok: true, - status: 200, - async json() { - return { ok: true }; - }, - async text() { - return ""; - }, - }), - }, - }), - createBuiltInRenderTemplateResolver(), - ); - - const basePlan = await withEnvOverrides( - { - TELEGRAM_BOT_TOKEN: "123456:test-telegram-token", + const basePlan = await compileTelegramPlanForTests({ + envOverrides: { TELEGRAM_GROUP_POLICY: undefined, TELEGRAM_REQUIRE_MENTION: undefined, }, - () => - planner.buildPlan({ - sandboxName: "alpha", - agent: "openclaw", - workflow: "onboard", - isInteractive: true, - configuredChannels: ["telegram"], - }), - ); + }); const tamperedPlan = { ...basePlan, @@ -217,23 +135,35 @@ describe("collectVisibleConfigRecords (compiled plan integration)", () => { expect(mention?.display.detail).not.toMatch(/\[/); expect(mention?.display.detail).not.toMatch(/"/); }); -}); -async function withEnvOverrides( - values: Readonly>, - run: () => Promise, -): Promise { - const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]])); - applyEnvOverrides(values); - try { - return await run(); - } finally { - applyEnvOverrides(previous); - } -} - -function applyEnvOverrides(values: Readonly>): void { - for (const [key, value] of Object.entries(values)) { - value === undefined ? Reflect.deleteProperty(process.env, key) : (process.env[key] = value); - } -} + it("never persists out-of-allowlist Telegram env values to the plan at the planner source boundary", async () => { + const plan = await compileTelegramPlanForTests({ + envOverrides: { + TELEGRAM_GROUP_POLICY: "definitely-not-a-policy", + TELEGRAM_REQUIRE_MENTION: "definitely-not-a-mode", + }, + }); + const telegramChannel = plan.channels.find((channel) => channel.channelId === "telegram"); + expect(telegramChannel).toBeDefined(); + const policyInput = telegramChannel?.inputs.find((input) => input.inputId === "groupPolicy"); + const mentionInput = telegramChannel?.inputs.find( + (input) => input.inputId === "requireMention", + ); + expect(policyInput?.value).not.toBe("definitely-not-a-policy"); + expect(mentionInput?.value).not.toBe("definitely-not-a-mode"); + const policyAllowed = ["open", "allowlist", "disabled", undefined] as const; + const mentionAllowed = ["0", "1", undefined] as const; + expect(policyAllowed).toContain(policyInput?.value as (typeof policyAllowed)[number]); + expect(mentionAllowed).toContain(mentionInput?.value as (typeof mentionAllowed)[number]); + + const diagnostic = collectBuiltInMessagingChannelDiagnostics().find( + (spec) => spec.channelId === "telegram", + ); + const records = collectVisibleConfigRecords(diagnostic!, plan, "telegram", "openclaw"); + const byLabel = (label: string) => records.find((record) => record.input.label === label); + expect(byLabel("Telegram group policy")?.display.detail).not.toMatch(/definitely-not-a-policy/); + expect(byLabel("Telegram group mention mode")?.display.detail).not.toMatch( + /definitely-not-a-mode/, + ); + }); +}); From f2b0282c31230b43681123f0e80376c6cd40e5ec Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 07:47:21 +0000 Subject: [PATCH 15/30] chore(checks): exempt validation-exit spawn fixture from no-test-dist-imports Signed-off-by: Tinson Lai --- scripts/checks/no-test-dist-imports.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/checks/no-test-dist-imports.ts b/scripts/checks/no-test-dist-imports.ts index 89eaf1cc2a1..0145e825506 100644 --- a/scripts/checks/no-test-dist-imports.ts +++ b/scripts/checks/no-test-dist-imports.ts @@ -14,6 +14,7 @@ const SKIP_DIRS = new Set([".git", "coverage", "dist", "node_modules"]); // repository build output. The self-audit below prevents this list growing or // retaining an exemption after the fixture no longer needs one. const FIXTURE_EXCLUSIONS = new Set([ + "src/lib/onboard/inference-selection-validation.test.ts", "test/dist-sourcemaps.test.ts", "test/install-preflight.test.ts", "test/stale-dist-check.test.ts", From 1693804af9bac6ffa7cf0a7b406a9e6a8c532bda Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 08:12:44 +0000 Subject: [PATCH 16/30] style: drop residual blank line after helper extraction Signed-off-by: Tinson Lai --- src/lib/actions/sandbox/channel-status.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index 9859c788a1f..240e2c073c6 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -225,7 +225,6 @@ function mergePlanInputs( }; } - describe("showSandboxChannelStatus (whatsapp)", () => { it("returns idle verdict and exit code 1 when paired but no inbound observed", async () => { const heartbeat = JSON.stringify({ From 6f8dd3ef16a786037ff21b5e39ebf5f708e2530d Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 08:33:18 +0000 Subject: [PATCH 17/30] chore(checks): move spawn-only validation fixture to no-test-dist-imports prefix exclusion Signed-off-by: Tinson Lai --- scripts/checks/no-test-dist-imports.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/checks/no-test-dist-imports.ts b/scripts/checks/no-test-dist-imports.ts index 0145e825506..86f2e30fa47 100644 --- a/scripts/checks/no-test-dist-imports.ts +++ b/scripts/checks/no-test-dist-imports.ts @@ -14,7 +14,6 @@ const SKIP_DIRS = new Set([".git", "coverage", "dist", "node_modules"]); // repository build output. The self-audit below prevents this list growing or // retaining an exemption after the fixture no longer needs one. const FIXTURE_EXCLUSIONS = new Set([ - "src/lib/onboard/inference-selection-validation.test.ts", "test/dist-sourcemaps.test.ts", "test/install-preflight.test.ts", "test/stale-dist-check.test.ts", @@ -25,6 +24,9 @@ const EXCLUDED_PREFIXES = [ "test/e2e-scenario/live/", // This is the sole non-live lane allowed to import compiled package artifacts. "test/package-contract/", + // Spawn-only fixture: constructs dist/ string paths to inject into a child + // node process via require.cache, never imports them in this test process. + "src/lib/onboard/inference-selection-validation.test.ts", ]; function repoPath(absolutePath: string): string { From f5c9db4fc25bd8196766bd37805ca9fa66530050 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 08:42:52 +0000 Subject: [PATCH 18/30] test(messaging): cover serialize/parse round-trip for telegram visible config Compile Telegram plan through the planner, serialize via serializeSandboxMessagingStateForDisk, reload via getMessagingPlanFromEntry, and assert collectVisibleConfigRecords still renders the persisted policy and mention mode. Closes the acceptance gap between in-memory compile-time tests and the on-disk shape that channels status and doctor read at runtime. Doc the new diagnostic surface in channels status and doctor reference pages so operators see the opt-in contract, the secret-exclusion guarantee, and the bounded behaviour for tampered values. Signed-off-by: Tinson Lai --- docs/reference/commands.mdx | 4 ++++ src/lib/messaging/diagnostics.test.ts | 33 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index d1ab647a0af..dc7194d618c 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -813,6 +813,8 @@ The rebuild reuses the existing sandbox name and persisted credentials, so messa Run a focused health check for one sandbox and the host services it depends on. The command checks the local CLI build, Docker daemon, OpenShell CLI, NemoClaw gateway container, gateway port mapping, live sandbox state, inference route, provider reachability, messaging channel conflicts, Ollama reachability, and the cloudflared tunnel state. +For channels with manifest-declared visible config inputs (e.g. Telegram group mention mode and OpenClaw-only group policy), the Messaging section also surfaces one check per opted-in input so operators can confirm the active policy without inspecting logs. Secrets and inputs that do not opt in via `safeToPrintInDiagnostics` remain excluded, agent-scoped inputs are hidden when the sandbox runs a different agent, and persisted values are validated against the manifest allowlist before display. + Warnings do not make the command fail. Failed checks exit non-zero so scripts can use `doctor` as a readiness gate. Use `--json` for machine-readable output. @@ -1258,6 +1260,8 @@ $$nemoclaw my-assistant channels start telegram Run channel-specific runtime diagnostics. For WhatsApp the command probes the sandbox to separately report pairing/session state, the Noise WebSocket connection, inbound event delivery, and policy/config coverage; a paired channel with no observed inbound delivery exits non-zero with verdict `idle` so an unhealthy bridge cannot pass as healthy. +For channels with manifest-declared visible config inputs (e.g. Telegram group mention mode and OpenClaw-only group policy), the status report also surfaces one signal per opted-in input. Secrets and other inputs without the `safeToPrintInDiagnostics` opt-in remain excluded; agent-scoped inputs are hidden when the sandbox runs a different agent (e.g. Telegram group policy is hidden on Hermes). Persisted values are validated against the manifest allowlist before display; an out-of-allowlist, present-but-empty, or non-scalar persisted value renders as `invalid persisted value (...)` rather than echoing the raw plan value. + ```bash $$nemoclaw my-assistant channels status --channel whatsapp ``` diff --git a/src/lib/messaging/diagnostics.test.ts b/src/lib/messaging/diagnostics.test.ts index e2f9f898234..87c52ef2ace 100644 --- a/src/lib/messaging/diagnostics.test.ts +++ b/src/lib/messaging/diagnostics.test.ts @@ -3,6 +3,10 @@ import { describe, expect, it } from "vitest"; +import { + getMessagingPlanFromEntry, + serializeSandboxMessagingStateForDisk, +} from "../state/registry-messaging"; import { compileTelegramPlanForTests } from "./__test-utils__/telegram-plan"; import { collectBuiltInMessagingChannelDiagnostics, @@ -166,4 +170,33 @@ describe("collectVisibleConfigRecords (compiled plan integration)", () => { /definitely-not-a-mode/, ); }); + + it("preserves Telegram visible config through disk serialization and registry readback", async () => { + const compiled = await compileTelegramPlanForTests({ + envOverrides: { + TELEGRAM_GROUP_POLICY: "allowlist", + TELEGRAM_REQUIRE_MENTION: "0", + }, + }); + const onDisk = serializeSandboxMessagingStateForDisk({ schemaVersion: 1, plan: compiled }); + expect(onDisk).toBeDefined(); + const fakeEntry = { messaging: onDisk }; + const reloaded = getMessagingPlanFromEntry(fakeEntry); + expect(reloaded).not.toBeNull(); + + const diagnostic = collectBuiltInMessagingChannelDiagnostics().find( + (spec) => spec.channelId === "telegram", + ); + expect(diagnostic).toBeDefined(); + const records = collectVisibleConfigRecords(diagnostic!, reloaded, "telegram", "openclaw"); + const byLabel = (label: string) => records.find((record) => record.input.label === label); + expect(byLabel("Telegram group policy")?.display).toMatchObject({ + source: "persisted", + detail: "allowlist", + }); + expect(byLabel("Telegram group mention mode")?.display).toMatchObject({ + source: "persisted", + detail: "all group messages (TELEGRAM_REQUIRE_MENTION=0)", + }); + }); }); From cd4290fd248bbb82c2f6961e45af48510afa0432 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 08:58:54 +0000 Subject: [PATCH 19/30] docs: sync hermes variant for channels status visible-config note Signed-off-by: Tinson Lai --- docs/reference/commands-nemohermes.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index ffe27349a7b..f09b617f9a6 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -624,6 +624,8 @@ The rebuild reuses the existing sandbox name and persisted credentials, so messa Run a focused health check for one sandbox and the host services it depends on. The command checks the local CLI build, Docker daemon, OpenShell CLI, NemoClaw gateway container, gateway port mapping, live sandbox state, inference route, provider reachability, messaging channel conflicts, Ollama reachability, and the cloudflared tunnel state. +For channels with manifest-declared visible config inputs (e.g. Telegram group mention mode and OpenClaw-only group policy), the Messaging section also surfaces one check per opted-in input so operators can confirm the active policy without inspecting logs. Secrets and inputs that do not opt in via `safeToPrintInDiagnostics` remain excluded, agent-scoped inputs are hidden when the sandbox runs a different agent, and persisted values are validated against the manifest allowlist before display. + Warnings do not make the command fail. Failed checks exit non-zero so scripts can use `doctor` as a readiness gate. Use `--json` for machine-readable output. @@ -983,6 +985,8 @@ nemohermes my-assistant channels start telegram Run channel-specific runtime diagnostics. For WhatsApp the command probes the sandbox to separately report pairing/session state, the Noise WebSocket connection, inbound event delivery, and policy/config coverage; a paired channel with no observed inbound delivery exits non-zero with verdict `idle` so an unhealthy bridge cannot pass as healthy. +For channels with manifest-declared visible config inputs (e.g. Telegram group mention mode and OpenClaw-only group policy), the status report also surfaces one signal per opted-in input. Secrets and other inputs without the `safeToPrintInDiagnostics` opt-in remain excluded; agent-scoped inputs are hidden when the sandbox runs a different agent (e.g. Telegram group policy is hidden on Hermes). Persisted values are validated against the manifest allowlist before display; an out-of-allowlist, present-but-empty, or non-scalar persisted value renders as `invalid persisted value (...)` rather than echoing the raw plan value. + ```bash nemohermes my-assistant channels status --channel whatsapp ``` From 536efa8b7e2914105ba7b82944341e4b0f7ec7b4 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 28 Jun 2026 10:03:04 +0000 Subject: [PATCH 20/30] test(onboard): keep validation test on source boundary Cherry-pick of 23a9b6e14 from origin/codex/fix-onboard-test-src-import (PR #5927) so this branch can rebuild dist without tripping the no-test-dist-imports guard. Refactors the validation test to drive createInferenceSelectionValidationHelpers from src instead of spawning a child node process against dist artifacts. Signed-off-by: Tinson Lai --- scripts/checks/no-test-dist-imports.ts | 3 - .../inference-selection-validation.test.ts | 138 +++++------------- .../onboard/inference-selection-validation.ts | 23 ++- 3 files changed, 53 insertions(+), 111 deletions(-) diff --git a/scripts/checks/no-test-dist-imports.ts b/scripts/checks/no-test-dist-imports.ts index 86f2e30fa47..89eaf1cc2a1 100644 --- a/scripts/checks/no-test-dist-imports.ts +++ b/scripts/checks/no-test-dist-imports.ts @@ -24,9 +24,6 @@ const EXCLUDED_PREFIXES = [ "test/e2e-scenario/live/", // This is the sole non-live lane allowed to import compiled package artifacts. "test/package-contract/", - // Spawn-only fixture: constructs dist/ string paths to inject into a child - // node process via require.cache, never imports them in this test process. - "src/lib/onboard/inference-selection-validation.test.ts", ]; function repoPath(absolutePath: string): string { diff --git a/src/lib/onboard/inference-selection-validation.test.ts b/src/lib/onboard/inference-selection-validation.test.ts index 9f367fcdc01..51ca9b3e694 100644 --- a/src/lib/onboard/inference-selection-validation.test.ts +++ b/src/lib/onboard/inference-selection-validation.test.ts @@ -1,110 +1,48 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -const REPO_ROOT = path.join(import.meta.dirname, "../../.."); +import { createInferenceSelectionValidationHelpers } from "./inference-selection-validation"; describe("inference selection validation", () => { - it("preserves non-zero exit signaling when non-interactive endpoint validation fails (#5721)", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-validation-exit-")); - const scriptPath = path.join(tmpDir, "validation-exit-check.js"); - const validationPath = JSON.stringify( - path.join(REPO_ROOT, "dist", "lib", "onboard", "inference-selection-validation.js"), - ); - const probesPath = JSON.stringify( - path.join(REPO_ROOT, "dist", "lib", "inference", "onboard-probes.js"), - ); - const credentialsPath = JSON.stringify( - path.join(REPO_ROOT, "dist", "lib", "credentials", "store.js"), - ); - - const script = String.raw` -const probesPath = require.resolve(${probesPath}); -const credentialsPath = require.resolve(${credentialsPath}); -require.cache[probesPath] = { - id: probesPath, - filename: probesPath, - loaded: true, - exports: { - probeAnthropicEndpoint: () => { - throw new Error("unexpected anthropic probe"); - }, - probeOpenAiLikeEndpoint: () => ({ - ok: false, - failures: [{ name: "Chat Completions API", httpStatus: 403 }], - }), - }, -}; -require.cache[credentialsPath] = { - id: credentialsPath, - filename: credentialsPath, - loaded: true, - exports: { - getCredential: () => "nvapi-invalid-key-12345", - }, -}; -const { createInferenceSelectionValidationHelpers } = require(${validationPath}); - -const lines = []; -const exitCalls = []; -let promptCalls = 0; -const originalLog = console.log; -console.error = (...args) => lines.push(args.join(" ")); -process.exitCode = undefined; -process.exit = (code) => { - exitCalls.push(code); - return undefined; -}; - -(async () => { - const helpers = createInferenceSelectionValidationHelpers({ - isNonInteractive: () => true, - agentProductName: () => "OpenClaw", - promptValidationRecovery: async () => { - promptCalls += 1; - return "selection"; - }, - }); - let thrown = null; - try { - await helpers.validateOpenAiLikeSelection( - "NVIDIA Endpoints", - "https://integrate.api.nvidia.com/v1", - "meta/llama-3.3-70b-instruct", - "NVIDIA_INFERENCE_API_KEY", - ); - } catch (error) { - thrown = error instanceof Error ? error.message : String(error); - } - originalLog(JSON.stringify({ thrown, exitCalls, exitCode: process.exitCode, promptCalls, lines })); -})().catch((error) => { - console.error(error); - process.exitCode = 1; -}); -`; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { - cwd: REPO_ROOT, - encoding: "utf-8", + it("preserves non-zero exit signaling when non-interactive endpoint validation fails (#5721)", async () => { + const originalExitCode = process.exitCode; + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); + const promptValidationRecovery = vi.fn(async () => "selection" as const); + const helpers = createInferenceSelectionValidationHelpers({ + isNonInteractive: () => true, + agentProductName: () => "OpenClaw", + getCredential: () => "nvapi-invalid-key-12345", + probeOpenAiLikeEndpoint: () => ({ + ok: false, + failures: [{ name: "Chat Completions API", httpStatus: 403 }], + }), + promptValidationRecovery, }); - assert.equal(result.status, 1, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.equal(payload.thrown, "Non-interactive endpoint validation failed."); - assert.deepEqual(payload.exitCalls, [1]); - assert.equal(payload.exitCode, 1); - assert.equal(payload.promptCalls, 0); - assert.deepEqual(payload.lines, [ - " NVIDIA Endpoints endpoint validation failed.", - " Validation probe summary: Chat Completions API: HTTP 403.", - " Validation details were omitted to avoid exposing credentials.", - ]); + try { + await expect( + helpers.validateOpenAiLikeSelection( + "NVIDIA Endpoints", + "https://integrate.api.nvidia.com/v1", + "meta/llama-3.3-70b-instruct", + "NVIDIA_INFERENCE_API_KEY", + ), + ).rejects.toThrow("Non-interactive endpoint validation failed."); + expect(exit).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); + expect(promptValidationRecovery).not.toHaveBeenCalled(); + expect(error.mock.calls.map((args) => args.join(" "))).toEqual([ + " NVIDIA Endpoints endpoint validation failed.", + " Validation probe summary: Chat Completions API: HTTP 403.", + " Validation details were omitted to avoid exposing credentials.", + ]); + } finally { + process.exitCode = originalExitCode; + error.mockRestore(); + exit.mockRestore(); + } }); }); diff --git a/src/lib/onboard/inference-selection-validation.ts b/src/lib/onboard/inference-selection-validation.ts index e17a0df1a4a..c8b0e8891d7 100644 --- a/src/lib/onboard/inference-selection-validation.ts +++ b/src/lib/onboard/inference-selection-validation.ts @@ -29,6 +29,9 @@ export type EndpointValidationResult = export interface InferenceSelectionValidationDeps { isNonInteractive(): boolean; agentProductName(): string; + getCredential?: typeof getCredential; + probeAnthropicEndpoint?: typeof probeAnthropicEndpoint; + probeOpenAiLikeEndpoint?: typeof probeOpenAiLikeEndpoint; promptValidationRecovery( label: string, recovery: ReturnType, @@ -81,6 +84,10 @@ export interface InferenceSelectionValidationHelpers { export function createInferenceSelectionValidationHelpers( deps: InferenceSelectionValidationDeps, ): InferenceSelectionValidationHelpers { + const resolveCredential = deps.getCredential ?? getCredential; + const runAnthropicProbe = deps.probeAnthropicEndpoint ?? probeAnthropicEndpoint; + const runOpenAiLikeProbe = deps.probeOpenAiLikeEndpoint ?? probeOpenAiLikeEndpoint; + function exitNonInteractiveValidationFailure(): never { process.exitCode = 1; (process.exit as (code?: number) => void)(1); @@ -112,8 +119,8 @@ export function createInferenceSelectionValidationHelpers( allowHostDockerInternal?: boolean; } = {}, ): Promise { - const apiKey = credentialEnv ? getCredential(credentialEnv) : ""; - const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options); + const apiKey = credentialEnv ? resolveCredential(credentialEnv) : ""; + const probe = runOpenAiLikeProbe(endpointUrl, model, apiKey, options); if (!probe.ok) { printValidationFailure(label, probe); if (deps.isNonInteractive()) { @@ -147,8 +154,8 @@ export function createInferenceSelectionValidationHelpers( retryMessage = "Please choose a provider/model again.", helpUrl: string | null = null, ): Promise { - const apiKey = getCredential(credentialEnv); - const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); + const apiKey = resolveCredential(credentialEnv); + const probe = runAnthropicProbe(endpointUrl, model, apiKey); if (!probe.ok) { printValidationFailure(label, probe); if (deps.isNonInteractive()) { @@ -177,8 +184,8 @@ export function createInferenceSelectionValidationHelpers( credentialEnv: string, helpUrl: string | null = null, ): Promise { - const apiKey = getCredential(credentialEnv); - const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, { + const apiKey = resolveCredential(credentialEnv); + const probe = runOpenAiLikeProbe(endpointUrl, model, apiKey, { requireResponsesToolCalling: true, skipResponsesProbe: shouldForceCompletionsApi(process.env.NEMOCLAW_PREFERRED_API), probeStreaming: true, @@ -217,8 +224,8 @@ export function createInferenceSelectionValidationHelpers( credentialEnv: string, helpUrl: string | null = null, ): Promise { - const apiKey = getCredential(credentialEnv); - const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); + const apiKey = resolveCredential(credentialEnv); + const probe = runAnthropicProbe(endpointUrl, model, apiKey); if (probe.ok) { console.log(` ${probe.label} available — ${deps.agentProductName()} will use ${probe.api}.`); return { ok: true, api: probe.api }; From bf985fd477aa31c791c652af26a40c87cdd0f971 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 10:03:15 +0000 Subject: [PATCH 21/30] test(messaging): cover non-interactive compact-registry path through status + doctor Add an isInteractive flag to compileTelegramPlanForTests and drive both command paths through serializeSandboxMessagingStateForDisk + the real getMessagingPlanFromEntry so the visible-config diagnostics now have command-renderer coverage matching the linked onboard-to-registry-to-diagnostics flow. Signed-off-by: Tinson Lai --- .../actions/sandbox/channel-status.test.ts | 34 ++++++++++++++ src/lib/actions/sandbox/doctor-flow.test.ts | 45 +++++++++++++++++++ .../messaging/__test-utils__/telegram-plan.ts | 10 ++++- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index 240e2c073c6..ba88444a307 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -746,4 +746,38 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { /Telegram group mention mode:\s+mention-only \(TELEGRAM_REQUIRE_MENTION=1\)/, ); }); + + it("reads Telegram visible config from a non-interactive compact registry entry through the real plan reader", async () => { + const { getMessagingPlanFromEntry, serializeSandboxMessagingStateForDisk } = await import( + "../../state/registry-messaging" + ); + const compiled = await compileTelegramPlanForTests({ + envOverrides: { + TELEGRAM_GROUP_POLICY: "disabled", + TELEGRAM_REQUIRE_MENTION: "0", + }, + isInteractive: false, + }); + const onDisk = serializeSandboxMessagingStateForDisk({ schemaVersion: 1, plan: compiled }); + expect(onDisk).toBeDefined(); + const compactEntry = { + name: "alpha", + agent: "openclaw", + messaging: onDisk, + } as unknown as SandboxEntry; + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: compactEntry, + appliedPresets: ["telegram"], + }); + (deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null }).getMessagingPlan = + (sandboxEntry) => getMessagingPlanFromEntry(sandboxEntry); + await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); + const dump = out_lines.join("\n"); + expect(dump).toMatch(/Telegram group policy:\s+disabled\b/); + expect(dump).toMatch( + /Telegram group mention mode:\s+all group messages \(TELEGRAM_REQUIRE_MENTION=0\)/, + ); + expect(dump).not.toMatch(/Telegram group policy:.*\(default\)/); + }); }); diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index b581441f71e..1cdcaedddb2 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -451,6 +451,51 @@ describe("runSandboxDoctor flow", () => { }); }); + it("reads Telegram visible config from a non-interactive compact registry entry through the real plan reader", async () => { + const harness = createDoctorHarness(); + const registryMessaging = requireDist("../../state/registry-messaging.js"); + const realGetMessagingPlanFromEntry = registryMessaging.getMessagingPlanFromEntry; + const compiledPlan = await compileTelegramPlanForTests({ + envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, + isInteractive: false, + }); + const onDisk = registryMessaging.serializeSandboxMessagingStateForDisk({ + schemaVersion: 1, + plan: compiledPlan, + }); + expect(onDisk).toBeDefined(); + harness.getSandboxSpy.mockReturnValue({ + name: "alpha", + agent: "openclaw", + model: "registry-model", + provider: "ollama-local", + openshellDriver: "docker", + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + messaging: onDisk, + }); + const registry = requireDist("../../state/registry.js"); + vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); + vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); + vi.spyOn(registry, "getMessagingPlanFromEntry").mockImplementation((entry) => + realGetMessagingPlanFromEntry(entry), + ); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); + + expect(messagingChecks.find((check) => check.label === "Telegram group policy")).toMatchObject({ + status: "ok", + detail: "allowlist", + }); + expect( + messagingChecks.find((check) => check.label === "Telegram group mention mode"), + ).toMatchObject({ + status: "ok", + detail: "all group messages (TELEGRAM_REQUIRE_MENTION=0)", + }); + }); + it("rejects mutating --fix when JSON output was requested", async () => { const harness = createDoctorHarness(); diff --git a/src/lib/messaging/__test-utils__/telegram-plan.ts b/src/lib/messaging/__test-utils__/telegram-plan.ts index bca554267f3..fc7c90bcb03 100644 --- a/src/lib/messaging/__test-utils__/telegram-plan.ts +++ b/src/lib/messaging/__test-utils__/telegram-plan.ts @@ -15,12 +15,18 @@ export interface CompileTelegramPlanOptions { readonly envOverrides: Readonly>; readonly sandboxName?: string; readonly agent?: MessagingAgentId; + readonly isInteractive?: boolean; } export async function compileTelegramPlanForTests( options: CompileTelegramPlanOptions, ): Promise { - const { envOverrides, sandboxName = "alpha", agent = "openclaw" } = options; + const { + envOverrides, + sandboxName = "alpha", + agent = "openclaw", + isInteractive = true, + } = options; const planner = new MessagingWorkflowPlanner( createBuiltInChannelManifestRegistry(), createBuiltInMessagingHookRegistry({ @@ -51,7 +57,7 @@ export async function compileTelegramPlanForTests( sandboxName, agent, workflow: "onboard", - isInteractive: true, + isInteractive, configuredChannels: ["telegram"], }), ); From 97c9d5a84fe1c6d3eda5426e3141b1322ed599c4 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 12:45:53 +0000 Subject: [PATCH 22/30] style: apply biome format to telegram test helpers Signed-off-by: Tinson Lai --- src/lib/actions/sandbox/channel-status.test.ts | 5 +++-- src/lib/messaging/__test-utils__/telegram-plan.ts | 7 +------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index ba88444a307..d8c5134ee53 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -770,8 +770,9 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { sandbox: compactEntry, appliedPresets: ["telegram"], }); - (deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null }).getMessagingPlan = - (sandboxEntry) => getMessagingPlanFromEntry(sandboxEntry); + ( + deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null } + ).getMessagingPlan = (sandboxEntry) => getMessagingPlanFromEntry(sandboxEntry); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); expect(dump).toMatch(/Telegram group policy:\s+disabled\b/); diff --git a/src/lib/messaging/__test-utils__/telegram-plan.ts b/src/lib/messaging/__test-utils__/telegram-plan.ts index fc7c90bcb03..ef57bdb7036 100644 --- a/src/lib/messaging/__test-utils__/telegram-plan.ts +++ b/src/lib/messaging/__test-utils__/telegram-plan.ts @@ -21,12 +21,7 @@ export interface CompileTelegramPlanOptions { export async function compileTelegramPlanForTests( options: CompileTelegramPlanOptions, ): Promise { - const { - envOverrides, - sandboxName = "alpha", - agent = "openclaw", - isInteractive = true, - } = options; + const { envOverrides, sandboxName = "alpha", agent = "openclaw", isInteractive = true } = options; const planner = new MessagingWorkflowPlanner( createBuiltInChannelManifestRegistry(), createBuiltInMessagingHookRegistry({ From 6436d36337dd2751b936130166949c6273c2ea0b Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 16:51:55 +0000 Subject: [PATCH 23/30] fix(messaging): tighten telegram diagnostics contract and extract test fixtures Stricter manifest registration validation, telegram groupPolicy human-readable display, and shared sandbox test utilities address the open advisor findings on PR 5705. Signed-off-by: Tinson Lai --- scripts/checks/no-test-dist-imports.ts | 17 +- src/lib/actions/sandbox/__test-utils__.ts | 136 ++++++++++++ .../actions/sandbox/channel-status.test.ts | 193 +++++++++++----- src/lib/actions/sandbox/channel-status.ts | 7 +- src/lib/actions/sandbox/doctor-flow.test.ts | 208 +++++++++++++----- src/lib/actions/sandbox/doctor-messaging.ts | 12 +- .../messaging/__test-utils__/telegram-plan.ts | 9 +- .../messaging/channels/telegram/manifest.ts | 5 + .../compiler/workflow-planner.test.ts | 23 ++ src/lib/messaging/diagnostics.test.ts | 4 +- src/lib/messaging/diagnostics.ts | 12 + src/lib/messaging/manifest/registry.test.ts | 60 +++++ src/lib/messaging/manifest/registry.ts | 65 +++++- 13 files changed, 633 insertions(+), 118 deletions(-) create mode 100644 src/lib/actions/sandbox/__test-utils__.ts diff --git a/scripts/checks/no-test-dist-imports.ts b/scripts/checks/no-test-dist-imports.ts index 89eaf1cc2a1..135481d0765 100644 --- a/scripts/checks/no-test-dist-imports.ts +++ b/scripts/checks/no-test-dist-imports.ts @@ -149,14 +149,27 @@ function findViolations(absolutePath: string): Violation[] { return findCompiledInternalViolations(repoPath(absolutePath), readFileSync(absolutePath, "utf8")); } +export function isPathConstructionViolation(violation: Violation): boolean { + return ( + violation.detail.startsWith("constructs a path") || + violation.detail.includes("require in generated test code") + ); +} + +function findPathConstructionViolations(absolutePath: string): Violation[] { + return findViolations(absolutePath).filter(isPathConstructionViolation); +} + function main(): void { const staleFixtureExclusions = [...FIXTURE_EXCLUSIONS].filter((relativePath) => { const absolutePath = path.join(REPO_ROOT, relativePath); - return !existsSync(absolutePath) || findViolations(absolutePath).length === 0; + return !existsSync(absolutePath) || findPathConstructionViolations(absolutePath).length === 0; }); if (staleFixtureExclusions.length > 0) { - console.error("Fixture exclusions must exist and still construct a compiled-internal path:"); + console.error( + "Fixture exclusions must exist and still construct a compiled-internal path through path.join/require/template, not via a bare import specifier:", + ); for (const relativePath of staleFixtureExclusions) console.error(` ${relativePath}`); process.exit(1); } diff --git a/src/lib/actions/sandbox/__test-utils__.ts b/src/lib/actions/sandbox/__test-utils__.ts new file mode 100644 index 00000000000..421a49fb08f --- /dev/null +++ b/src/lib/actions/sandbox/__test-utils__.ts @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { vi } from "vitest"; + +import type { MessagingSerializableValue, SandboxMessagingPlan } from "../../messaging/manifest"; +import type { SandboxEntry } from "../../state/registry"; + +export type ChannelInputOverride = { + inputId: string; + value?: MessagingSerializableValue; +}; + +export type ChannelInputOverridesByChannel = Record>; + +export function mergePlanInputs( + base: SandboxMessagingPlan, + channelInputs: ChannelInputOverridesByChannel, +): SandboxMessagingPlan { + return { + ...base, + channels: base.channels.map((channel) => { + const overrides = channelInputs[channel.channelId]; + return overrides + ? { + ...channel, + inputs: overrides.map((override) => ({ + channelId: channel.channelId, + inputId: override.inputId, + kind: "config" as const, + required: false, + ...(override.value !== undefined ? { value: override.value } : {}), + })), + } + : channel; + }), + }; +} + +export function fakePlanFromInputs( + sandbox: SandboxEntry | undefined, + channelInputs: ChannelInputOverridesByChannel | undefined, +): SandboxMessagingPlan | null { + const base = sandbox?.messaging?.plan ?? null; + return base && channelInputs ? mergePlanInputs(base, channelInputs) : base; +} + +export interface TelegramDoctorPlanOptions { + readonly agent: "openclaw" | "hermes"; + readonly inputs?: ReadonlyArray; +} + +export function telegramDoctorPlan(options: TelegramDoctorPlanOptions): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "alpha", + agent: options.agent, + workflow: "onboard", + channels: [ + { + channelId: "telegram", + displayName: "telegram", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: (options.inputs ?? []).map((override) => ({ + channelId: "telegram", + inputId: override.inputId, + kind: "config" as const, + required: false, + ...(override.value === undefined ? {} : { value: override.value }), + })), + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + } as SandboxMessagingPlan; +} + +export interface MessagingRegistryModule { + getConfiguredMessagingChannelsFromEntry: (...args: unknown[]) => unknown; + getDisabledMessagingChannelsFromEntry: (...args: unknown[]) => unknown; + getMessagingPlanFromEntry: (...args: unknown[]) => unknown; +} + +export function mockTelegramDoctorRegistry( + registry: MessagingRegistryModule, + options: TelegramDoctorPlanOptions, +): void { + vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); + vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); + vi.spyOn(registry, "getMessagingPlanFromEntry").mockReturnValue(telegramDoctorPlan(options)); +} + +type CompactPlanInput = { + readonly inputId?: string; + readonly value?: unknown; +}; + +type CompactPlanChannel = { + readonly channelId?: string; + readonly inputs?: ReadonlyArray; +}; + +type CompactMessagingState = { + readonly schemaVersion?: number; + readonly plan?: { + readonly channels?: ReadonlyArray; + }; +}; + +export function tamperCompactRegistryTelegramInputs( + onDisk: unknown, + overrides: Readonly>, +): unknown { + const state = onDisk as CompactMessagingState; + const planChannels = state.plan?.channels ?? []; + const tamperedChannels = planChannels.map((channel) => { + const isTelegram = channel.channelId === "telegram"; + const tamperedInputs = (channel.inputs ?? []).map((input) => { + const inputId = input.inputId ?? ""; + const replacement = overrides[inputId]; + return replacement === undefined ? input : { ...input, value: replacement }; + }); + return isTelegram ? { ...channel, inputs: tamperedInputs } : channel; + }); + return { ...state, plan: { ...state.plan, channels: tamperedChannels } }; +} diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index d8c5134ee53..cfe00adf892 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -41,6 +41,11 @@ import type { AgentDefinition } from "../../agent/defs"; import { compileTelegramPlanForTests } from "../../messaging/__test-utils__/telegram-plan"; import type { MessagingSerializableValue, SandboxMessagingPlan } from "../../messaging/manifest"; import type { SandboxEntry } from "../../state/registry"; +import { + type ChannelInputOverridesByChannel, + fakePlanFromInputs, + tamperCompactRegistryTelegramInputs, +} from "./__test-utils__"; import { showSandboxChannelStatus } from "./channel-status"; type ExecResult = { status: number; stdout: string; stderr: string }; @@ -158,10 +163,7 @@ function makeDeps(opts: { gatewayPresets?: string[] | null; agentName?: "openclaw" | "hermes"; sandbox?: SandboxEntry | undefined; - channelInputs?: Record< - string, - ReadonlyArray<{ inputId: string; value?: MessagingSerializableValue }> - >; + channelInputs?: ChannelInputOverridesByChannel; messagingPlan?: SandboxMessagingPlan | null; out?: (line: string) => void; }) { @@ -188,43 +190,6 @@ function makeDeps(opts: { }; } -function fakePlanFromInputs( - sandbox: SandboxEntry | undefined, - channelInputs: - | Record> - | undefined, -): SandboxMessagingPlan | null { - const base = sandbox?.messaging?.plan ?? null; - return base && channelInputs ? mergePlanInputs(base, channelInputs) : base; -} - -function mergePlanInputs( - base: SandboxMessagingPlan, - channelInputs: Record< - string, - ReadonlyArray<{ inputId: string; value?: MessagingSerializableValue }> - >, -): SandboxMessagingPlan { - return { - ...base, - channels: base.channels.map((channel) => { - const overrides = channelInputs[channel.channelId]; - return overrides - ? { - ...channel, - inputs: overrides.map((entry) => ({ - channelId: channel.channelId, - inputId: entry.inputId, - kind: "config" as const, - required: false, - ...(entry.value !== undefined ? { value: entry.value } : {}), - })), - } - : channel; - }), - }; -} - describe("showSandboxChannelStatus (whatsapp)", () => { it("returns idle verdict and exit code 1 when paired but no inbound observed", async () => { const heartbeat = JSON.stringify({ @@ -552,6 +517,12 @@ describe("showSandboxChannelStatus (whatsapp)", () => { }); }); +const TELEGRAM_GROUP_POLICY_LABEL: Readonly> = { + open: "open groups", + allowlist: "allowlisted groups only", + disabled: "groups disabled", +}; + describe("showSandboxChannelStatus (telegram config visibility)", () => { for (const policy of ["open", "allowlist", "disabled"] as const) { it(`surfaces the resolved Telegram group policy: ${policy}`, async () => { @@ -565,8 +536,11 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { }); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); - expect(dump).toMatch(new RegExp(`Telegram group policy:\\s+${policy}\\b`)); - expect(dump).not.toMatch(new RegExp(`Telegram group policy:\\s+${policy}\\s+\\(default\\)`)); + const label = TELEGRAM_GROUP_POLICY_LABEL[policy]; + expect(dump).toMatch( + new RegExp(`Telegram group policy:\\s+${label} \\(TELEGRAM_GROUP_POLICY=${policy}\\)`), + ); + expect(dump).not.toMatch(/Telegram group policy:.*\(default\)/); }); } @@ -578,7 +552,9 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { }); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); - expect(dump).toMatch(/Telegram group policy:\s+open\s+\(default\)/); + expect(dump).toMatch( + /Telegram group policy:\s+open groups \(TELEGRAM_GROUP_POLICY=open\) \(default\)/, + ); }); it("surfaces the resolved Telegram mention mode alongside the group policy", async () => { @@ -598,7 +574,9 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { expect(dump).toMatch( /Telegram group mention mode:\s+all group messages \(TELEGRAM_REQUIRE_MENTION=0\)/, ); - expect(dump).toMatch(/Telegram group policy:\s+allowlist\b/); + expect(dump).toMatch( + /Telegram group policy:\s+allowlisted groups only \(TELEGRAM_GROUP_POLICY=allowlist\)/, + ); }); it("translates Telegram requireMention=1 to the mention-only behavior label", async () => { @@ -625,7 +603,9 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { }); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); - expect(dump).toMatch(/Telegram group mention mode:\s+mention-only \(default\)/); + expect(dump).toMatch( + /Telegram group mention mode:\s+mention-only \(TELEGRAM_REQUIRE_MENTION=1\) \(default\)/, + ); }); it("omits visible config defaults when the telegram channel is not registered", async () => { @@ -673,7 +653,9 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); expect(dump).not.toMatch(/Telegram group policy/); - expect(dump).toMatch(/Telegram group mention mode:\s+mention-only \(default\)/); + expect(dump).toMatch( + /Telegram group mention mode:\s+mention-only \(TELEGRAM_REQUIRE_MENTION=1\) \(default\)/, + ); }); it("redacts an invalid persisted value rather than echoing it", async () => { @@ -740,7 +722,9 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { }); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); - expect(dump).toMatch(/Telegram group policy:\s+allowlist\b/); + expect(dump).toMatch( + /Telegram group policy:\s+allowlisted groups only \(TELEGRAM_GROUP_POLICY=allowlist\)/, + ); expect(dump).not.toMatch(/Telegram group policy:.*\(default\)/); expect(dump).toMatch( /Telegram group mention mode:\s+mention-only \(TELEGRAM_REQUIRE_MENTION=1\)/, @@ -775,10 +759,121 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { ).getMessagingPlan = (sandboxEntry) => getMessagingPlanFromEntry(sandboxEntry); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); - expect(dump).toMatch(/Telegram group policy:\s+disabled\b/); + expect(dump).toMatch( + /Telegram group policy:\s+groups disabled \(TELEGRAM_GROUP_POLICY=disabled\)/, + ); expect(dump).toMatch( /Telegram group mention mode:\s+all group messages \(TELEGRAM_REQUIRE_MENTION=0\)/, ); expect(dump).not.toMatch(/Telegram group policy:.*\(default\)/); }); + + it("renders mention-only when a non-interactive compact registry entry omits TELEGRAM_REQUIRE_MENTION", async () => { + const { getMessagingPlanFromEntry, serializeSandboxMessagingStateForDisk } = await import( + "../../state/registry-messaging" + ); + const compiled = await compileTelegramPlanForTests({ + envOverrides: { + TELEGRAM_GROUP_POLICY: undefined, + TELEGRAM_REQUIRE_MENTION: undefined, + }, + isInteractive: false, + }); + const onDisk = serializeSandboxMessagingStateForDisk({ schemaVersion: 1, plan: compiled }); + expect(onDisk).toBeDefined(); + const compactEntry = { + name: "alpha", + agent: "openclaw", + messaging: onDisk, + } as unknown as SandboxEntry; + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: compactEntry, + appliedPresets: ["telegram"], + }); + ( + deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null } + ).getMessagingPlan = (sandboxEntry) => getMessagingPlanFromEntry(sandboxEntry); + await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); + const dump = out_lines.join("\n"); + expect(dump).toMatch( + /Telegram group mention mode:\s+mention-only \(TELEGRAM_REQUIRE_MENTION=1\)/, + ); + expect(dump).not.toMatch(/Telegram group mention mode:.*all group messages/); + }); + + it("bounds tampered out-of-allowlist Telegram visible config from a compact registry entry through the real plan reader", async () => { + const { getMessagingPlanFromEntry, serializeSandboxMessagingStateForDisk } = await import( + "../../state/registry-messaging" + ); + const compiled = await compileTelegramPlanForTests({ + envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, + isInteractive: false, + }); + const onDisk = serializeSandboxMessagingStateForDisk({ schemaVersion: 1, plan: compiled }); + expect(onDisk).toBeDefined(); + const tamperedOnDisk = tamperCompactRegistryTelegramInputs(onDisk, { + groupPolicy: "definitely-not-a-policy", + requireMention: "", + }); + const tamperedEntry = { + name: "alpha", + agent: "openclaw", + messaging: tamperedOnDisk, + } as unknown as SandboxEntry; + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: tamperedEntry, + appliedPresets: ["telegram"], + }); + ( + deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null } + ).getMessagingPlan = (sandboxEntry) => getMessagingPlanFromEntry(sandboxEntry); + await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); + const dump = out_lines.join("\n"); + expect(dump).toMatch( + /Telegram group policy:\s+invalid persisted value \(expected: open \| allowlist \| disabled\)/, + ); + expect(dump).toMatch( + /Telegram group mention mode:\s+invalid persisted value \(expected: 0 \| 1\)/, + ); + expect(dump).not.toMatch(/definitely-not-a-policy/); + }); + + it("redacts non-scalar Telegram visible config from a compact registry entry through the real plan reader", async () => { + const { getMessagingPlanFromEntry, serializeSandboxMessagingStateForDisk } = await import( + "../../state/registry-messaging" + ); + const compiled = await compileTelegramPlanForTests({ + envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, + isInteractive: false, + }); + const onDisk = serializeSandboxMessagingStateForDisk({ schemaVersion: 1, plan: compiled }); + expect(onDisk).toBeDefined(); + const nonScalarOnDisk = tamperCompactRegistryTelegramInputs(onDisk, { + groupPolicy: { smuggled: "secret-id" } as unknown as MessagingSerializableValue, + requireMention: ["1", "0"] as unknown as MessagingSerializableValue, + }); + const tamperedEntry = { + name: "alpha", + agent: "openclaw", + messaging: nonScalarOnDisk, + } as unknown as SandboxEntry; + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: tamperedEntry, + appliedPresets: ["telegram"], + }); + ( + deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null } + ).getMessagingPlan = (sandboxEntry) => getMessagingPlanFromEntry(sandboxEntry); + await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); + const dump = out_lines.join("\n"); + expect(dump).toMatch(/Telegram group policy:\s+invalid persisted value \(unsupported type\)/); + expect(dump).toMatch( + /Telegram group mention mode:\s+invalid persisted value \(unsupported type\)/, + ); + expect(dump).not.toMatch(/secret-id/); + expect(dump).not.toMatch(/smuggled/); + }); }); diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index a7bd6460c6b..0f7bd5137e6 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -23,7 +23,8 @@ import { createBuiltInChannelManifestRegistry, getMessagingManifestAvailabilityContext, } from "../../messaging"; -import type { MessagingAgentId, SandboxMessagingPlan } from "../../messaging/manifest"; +import { asMessagingAgent } from "../../messaging/manifest"; +import type { SandboxMessagingPlan } from "../../messaging/manifest"; import * as policies from "../../policy"; import { type DiagnosticSeverity, @@ -574,10 +575,6 @@ function severityForDisplay(source: "persisted" | "default" | "invalid") { } } -function asMessagingAgent(name: string): MessagingAgentId | null { - return name === "openclaw" || name === "hermes" ? name : null; -} - function channelSupportedByAgent(channelName: string, agent: AgentDefinition): boolean { return channelManifestRegistry .listAvailable(getMessagingManifestAvailabilityContext(agent, channelManifestRegistry.list())) diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index 1cdcaedddb2..94180abf5fe 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -6,6 +6,11 @@ import { createRequire } from "node:module"; import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; import { testTimeoutOptions } from "../../../../test/helpers/timeouts"; import { compileTelegramPlanForTests } from "../../messaging/__test-utils__/telegram-plan"; +import { + type ChannelInputOverride, + mockTelegramDoctorRegistry as applyTelegramDoctorRegistryMocks, + tamperCompactRegistryTelegramInputs, +} from "./__test-utils__"; type RunSandboxDoctor = typeof import("./doctor")["runSandboxDoctor"]; @@ -200,57 +205,12 @@ function createDoctorHarness(): { }; } -type TelegramInputOverride = { - inputId: string; - value?: unknown; -}; - -function telegramDoctorPlan(options: { - agent: "openclaw" | "hermes"; - inputs?: ReadonlyArray; -}) { - return { - schemaVersion: 1 as const, - sandboxName: "alpha", - agent: options.agent, - workflow: "onboard" as const, - channels: [ - { - channelId: "telegram", - displayName: "telegram", - authMode: "token-paste" as const, - active: true, - selected: true, - configured: true, - disabled: false, - inputs: (options.inputs ?? []).map((override) => ({ - channelId: "telegram", - inputId: override.inputId, - kind: "config" as const, - required: false, - ...(override.value === undefined ? {} : { value: override.value }), - })), - hooks: [], - }, - ], - disabledChannels: [], - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; -} - function mockTelegramDoctorRegistry(options: { agent: "openclaw" | "hermes"; - inputs?: ReadonlyArray; + inputs?: ReadonlyArray; }): void { const registry = requireDist("../../state/registry.js"); - vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); - vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); - vi.spyOn(registry, "getMessagingPlanFromEntry").mockReturnValue(telegramDoctorPlan(options)); + applyTelegramDoctorRegistryMocks(registry, options); } describe("runSandboxDoctor flow", () => { @@ -326,7 +286,7 @@ describe("runSandboxDoctor flow", () => { group: "Messaging", label: "Telegram group policy", status: "info", - detail: "open (default)", + detail: "open groups (TELEGRAM_GROUP_POLICY=open) (default)", }), ]), ); @@ -360,7 +320,10 @@ describe("runSandboxDoctor flow", () => { expect(messagingChecks.some((check) => check.label === "Telegram group policy")).toBe(false); expect( messagingChecks.find((check) => check.label === "Telegram group mention mode"), - ).toMatchObject({ status: "info", detail: "mention-only (default)" }); + ).toMatchObject({ + status: "info", + detail: "mention-only (TELEGRAM_REQUIRE_MENTION=1) (default)", + }); }); it("falls back to OpenClaw visible config when a legacy SandboxEntry omits the agent field", async () => { @@ -381,11 +344,14 @@ describe("runSandboxDoctor flow", () => { expect(messagingChecks.find((check) => check.label === "Telegram group policy")).toMatchObject({ status: "info", - detail: "open (default)", + detail: "open groups (TELEGRAM_GROUP_POLICY=open) (default)", }); expect( messagingChecks.find((check) => check.label === "Telegram group mention mode"), - ).toMatchObject({ status: "info", detail: "mention-only (default)" }); + ).toMatchObject({ + status: "info", + detail: "mention-only (TELEGRAM_REQUIRE_MENTION=1) (default)", + }); }); it("flags an invalid persisted Telegram group policy without echoing the raw value", async () => { @@ -441,7 +407,7 @@ describe("runSandboxDoctor flow", () => { expect(messagingChecks.find((check) => check.label === "Telegram group policy")).toMatchObject({ status: "ok", - detail: "allowlist", + detail: "allowlisted groups only (TELEGRAM_GROUP_POLICY=allowlist)", }); expect( messagingChecks.find((check) => check.label === "Telegram group mention mode"), @@ -486,7 +452,7 @@ describe("runSandboxDoctor flow", () => { expect(messagingChecks.find((check) => check.label === "Telegram group policy")).toMatchObject({ status: "ok", - detail: "allowlist", + detail: "allowlisted groups only (TELEGRAM_GROUP_POLICY=allowlist)", }); expect( messagingChecks.find((check) => check.label === "Telegram group mention mode"), @@ -496,6 +462,142 @@ describe("runSandboxDoctor flow", () => { }); }); + it("renders mention-only when a non-interactive compact registry entry omits TELEGRAM_REQUIRE_MENTION", async () => { + const harness = createDoctorHarness(); + const registryMessaging = requireDist("../../state/registry-messaging.js"); + const realGetMessagingPlanFromEntry = registryMessaging.getMessagingPlanFromEntry; + const compiledPlan = await compileTelegramPlanForTests({ + envOverrides: { TELEGRAM_GROUP_POLICY: undefined, TELEGRAM_REQUIRE_MENTION: undefined }, + isInteractive: false, + }); + const onDisk = registryMessaging.serializeSandboxMessagingStateForDisk({ + schemaVersion: 1, + plan: compiledPlan, + }); + expect(onDisk).toBeDefined(); + harness.getSandboxSpy.mockReturnValue({ + name: "alpha", + agent: "openclaw", + model: "registry-model", + provider: "ollama-local", + openshellDriver: "docker", + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + messaging: onDisk, + }); + const registry = requireDist("../../state/registry.js"); + vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); + vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); + vi.spyOn(registry, "getMessagingPlanFromEntry").mockImplementation((entry) => + realGetMessagingPlanFromEntry(entry), + ); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); + + expect( + messagingChecks.find((check) => check.label === "Telegram group mention mode"), + ).toMatchObject({ + status: "ok", + detail: "mention-only (TELEGRAM_REQUIRE_MENTION=1)", + }); + }); + + it("bounds tampered out-of-allowlist Telegram visible config from a compact registry entry through the real plan reader", async () => { + const harness = createDoctorHarness(); + const registryMessaging = requireDist("../../state/registry-messaging.js"); + const realGetMessagingPlanFromEntry = registryMessaging.getMessagingPlanFromEntry; + const compiledPlan = await compileTelegramPlanForTests({ + envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, + isInteractive: false, + }); + const onDisk = registryMessaging.serializeSandboxMessagingStateForDisk({ + schemaVersion: 1, + plan: compiledPlan, + }); + expect(onDisk).toBeDefined(); + const tamperedOnDisk = tamperCompactRegistryTelegramInputs(onDisk, { + groupPolicy: "definitely-not-a-policy", + requireMention: "", + }); + harness.getSandboxSpy.mockReturnValue({ + name: "alpha", + agent: "openclaw", + model: "registry-model", + provider: "ollama-local", + openshellDriver: "docker", + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + messaging: tamperedOnDisk, + }); + const registry = requireDist("../../state/registry.js"); + vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); + vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); + vi.spyOn(registry, "getMessagingPlanFromEntry").mockImplementation((entry) => + realGetMessagingPlanFromEntry(entry), + ); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); + + const policy = messagingChecks.find((check) => check.label === "Telegram group policy"); + expect(policy?.status).toBe("warn"); + expect(policy?.detail).toMatch( + /invalid persisted value \(expected: open \| allowlist \| disabled\)/, + ); + expect(policy?.detail).not.toContain("definitely-not-a-policy"); + + const mention = messagingChecks.find((check) => check.label === "Telegram group mention mode"); + expect(mention?.status).toBe("warn"); + expect(mention?.detail).toMatch(/invalid persisted value/); + }); + + it("redacts non-scalar Telegram visible config from a compact registry entry through the real plan reader", async () => { + const harness = createDoctorHarness(); + const registryMessaging = requireDist("../../state/registry-messaging.js"); + const realGetMessagingPlanFromEntry = registryMessaging.getMessagingPlanFromEntry; + const compiledPlan = await compileTelegramPlanForTests({ + envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, + isInteractive: false, + }); + const onDisk = registryMessaging.serializeSandboxMessagingStateForDisk({ + schemaVersion: 1, + plan: compiledPlan, + }); + expect(onDisk).toBeDefined(); + const tamperedOnDisk = tamperCompactRegistryTelegramInputs(onDisk, { + groupPolicy: { smuggled: "secret-id" } as unknown, + requireMention: ["1", "0"] as unknown, + }); + harness.getSandboxSpy.mockReturnValue({ + name: "alpha", + agent: "openclaw", + model: "registry-model", + provider: "ollama-local", + openshellDriver: "docker", + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + messaging: tamperedOnDisk, + }); + const registry = requireDist("../../state/registry.js"); + vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); + vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); + vi.spyOn(registry, "getMessagingPlanFromEntry").mockImplementation((entry) => + realGetMessagingPlanFromEntry(entry), + ); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); + + const policy = messagingChecks.find((check) => check.label === "Telegram group policy"); + expect(policy?.detail).toMatch(/invalid persisted value \(unsupported type\)/); + expect(policy?.detail).not.toContain("secret-id"); + expect(policy?.detail).not.toContain("smuggled"); + + const mention = messagingChecks.find((check) => check.label === "Telegram group mention mode"); + expect(mention?.detail).toMatch(/invalid persisted value \(unsupported type\)/); + }); + it("rejects mutating --fix when JSON output was requested", async () => { const harness = createDoctorHarness(); diff --git a/src/lib/actions/sandbox/doctor-messaging.ts b/src/lib/actions/sandbox/doctor-messaging.ts index 4d33510025f..d6f13d53ad0 100644 --- a/src/lib/actions/sandbox/doctor-messaging.ts +++ b/src/lib/actions/sandbox/doctor-messaging.ts @@ -9,7 +9,7 @@ import { collectVisibleConfigRecords, type MessagingChannelDiagnosticSpec, } from "../../messaging/diagnostics"; -import type { MessagingAgentId } from "../../messaging/manifest"; +import { asMessagingAgent } from "../../messaging/manifest"; import { executeSandboxCommandForVerification } from "../../onboard/sandbox-verification-exec"; import { ROOT } from "../../runner"; import type { SandboxEntry } from "../../state/registry"; @@ -302,6 +302,12 @@ function messagingChannelConfigDoctorChecks(sandboxName: string, sb: SandboxEntr const activeChannels = activeChannelsFromEntry(sb); if (activeChannels.length === 0) return []; const plan = registry.getMessagingPlanFromEntry(sb); + // Legacy sandbox entries from before the agent field was mandatory may + // still hydrate with `sb.agent === undefined`; default to "openclaw" so + // agent-applicability filtering matches the historical behaviour. Existing + // regression: doctor-flow.test.ts asserts the OpenClaw visible-config path + // when a legacy entry omits the field. Remove the fallback once every + // managed sandbox has been migrated by an explicit registry upgrade. const agent = asMessagingAgent(sb.agent ?? "openclaw"); const checks: DoctorCheck[] = []; for (const channelName of activeChannels) { @@ -326,10 +332,6 @@ function doctorStatusForDisplay(source: "persisted" | "default" | "invalid"): Do } } -function asMessagingAgent(name: string | null): MessagingAgentId | null { - return name === "openclaw" || name === "hermes" ? name : null; -} - export function collectMessagingDoctorChecks( sandboxName: string, sb: SandboxEntry, diff --git a/src/lib/messaging/__test-utils__/telegram-plan.ts b/src/lib/messaging/__test-utils__/telegram-plan.ts index ef57bdb7036..f40a3576997 100644 --- a/src/lib/messaging/__test-utils__/telegram-plan.ts +++ b/src/lib/messaging/__test-utils__/telegram-plan.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { vi } from "vitest"; + import { createBuiltInChannelManifestRegistry, createBuiltInRenderTemplateResolver, @@ -72,8 +74,13 @@ export async function withTelegramEnvOverrides( } } +// Per-key set/restore via `vi.stubEnv` keeps the helper's environment edits +// scoped to the tests that call it. `vi.unstubAllEnvs()` (the canonical +// counterpart) would clear stubs registered by concurrent unrelated tests in +// the same worker, so the explicit per-key restore in `withTelegramEnvOverrides` +// guards isolation rather than a coarser revert. export function applyEnvForTests(values: Readonly>): void { for (const [key, value] of Object.entries(values)) { - value === undefined ? Reflect.deleteProperty(process.env, key) : (process.env[key] = value); + vi.stubEnv(key, value as string); } } diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index 094b08002bc..827633fc85c 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -66,6 +66,11 @@ export const telegramManifest = { validValues: ["open", "allowlist", "disabled"], defaultValue: "open", safeToPrintInDiagnostics: true, + valueDisplay: { + open: "open groups", + allowlist: "allowlisted groups only", + disabled: "groups disabled", + }, agentApplicability: ["openclaw"], prompt: { label: "Telegram group policy", diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 6d18f666c6b..28c686b1e47 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -826,6 +826,29 @@ describe("MessagingWorkflowPlanner", () => { }); }); + it("does not persist an empty TELEGRAM_REQUIRE_MENTION env var into the plan at the planner source boundary", async () => { + const plan = await withEnv( + { + TELEGRAM_REQUIRE_MENTION: "", + }, + () => + planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram"], + credentialAvailability: { TELEGRAM_BOT_TOKEN: true }, + }), + ); + + const requireMention = plan.channels + .find((channel) => channel.channelId === "telegram") + ?.inputs.find((input) => input.inputId === "requireMention"); + expect(requireMention).toBeDefined(); + expect(requireMention?.value).not.toBe(""); + }); + it("rebuilds from stored plan input values when config env is unavailable", async () => { const existingPlan = await withEnv( { diff --git a/src/lib/messaging/diagnostics.test.ts b/src/lib/messaging/diagnostics.test.ts index 87c52ef2ace..1c364ae4a54 100644 --- a/src/lib/messaging/diagnostics.test.ts +++ b/src/lib/messaging/diagnostics.test.ts @@ -69,7 +69,7 @@ describe("collectVisibleConfigRecords (compiled plan integration)", () => { expect(labels).toContain("Telegram group mention mode"); expect(byLabel("Telegram group policy")?.display).toMatchObject({ source: "persisted", - detail: "allowlist", + detail: "allowlisted groups only (TELEGRAM_GROUP_POLICY=allowlist)", }); expect(byLabel("Telegram group mention mode")?.display).toMatchObject({ source: "persisted", @@ -192,7 +192,7 @@ describe("collectVisibleConfigRecords (compiled plan integration)", () => { const byLabel = (label: string) => records.find((record) => record.input.label === label); expect(byLabel("Telegram group policy")?.display).toMatchObject({ source: "persisted", - detail: "allowlist", + detail: "allowlisted groups only (TELEGRAM_GROUP_POLICY=allowlist)", }); expect(byLabel("Telegram group mention mode")?.display).toMatchObject({ source: "persisted", diff --git a/src/lib/messaging/diagnostics.ts b/src/lib/messaging/diagnostics.ts index 64af63fbc29..e36f4322cf9 100644 --- a/src/lib/messaging/diagnostics.ts +++ b/src/lib/messaging/diagnostics.ts @@ -127,10 +127,22 @@ export function resolveVisibleConfigDisplay( return { detail: valueText, source: "persisted" }; } if (planInputPresent) { + // A plan input that resolves to an empty string lands here as invalid + // rather than falling back to the manifest default. The planner already + // normalises an empty env var to `undefined` at the source boundary + // (see workflow-planner.ts), so observing an empty persisted value at + // this point can only come from a tampered or corrupted plan, and the + // diagnostic must surface that explicitly rather than masking it. return invalidPersistedDisplay(input, `expected: ${input.validValues.join(" | ")}`); } if (input.defaultValue !== undefined) { const mapped = input.valueDisplay?.[input.defaultValue]; + if (mapped && input.envKey) { + return { + detail: `${mapped} (${input.envKey}=${input.defaultValue}) (default)`, + source: "default", + }; + } if (mapped) { return { detail: `${mapped} (default)`, source: "default" }; } diff --git a/src/lib/messaging/manifest/registry.test.ts b/src/lib/messaging/manifest/registry.test.ts index 5cbcaf4c6d6..77d9b398dbb 100644 --- a/src/lib/messaging/manifest/registry.test.ts +++ b/src/lib/messaging/manifest/registry.test.ts @@ -84,4 +84,64 @@ describe("ChannelManifestRegistry", () => { registry.listAvailable({ supportedChannelIds: undefined }).map((manifest) => manifest.id), ).toEqual(["telegram", "wechat"]); }); + + it("rejects registration when a config input declares valueDisplay keys outside validValues", () => { + const malformed: ChannelManifest = { + ...TELEGRAM_MANIFEST, + id: "malformed-display", + inputs: [ + { + id: "bogus", + kind: "config", + required: false, + validValues: ["0", "1"], + valueDisplay: { "2": "two" }, + }, + ], + }; + + expect(() => createChannelManifestRegistry([malformed])).toThrow( + "valueDisplay key '2' is not in validValues", + ); + }); + + it("rejects registration when a config input declares an agentApplicability not in supportedAgents", () => { + const malformed: ChannelManifest = { + ...TELEGRAM_MANIFEST, + id: "malformed-agent", + supportedAgents: ["openclaw"], + inputs: [ + { + id: "bogus", + kind: "config", + required: false, + validValues: ["0", "1"], + agentApplicability: ["hermes"], + }, + ], + }; + + expect(() => createChannelManifestRegistry([malformed])).toThrow( + "agentApplicability 'hermes' is not in supportedAgents", + ); + }); + + it("rejects registration when a secret input declares safeToPrintInDiagnostics", () => { + const malformed = { + ...TELEGRAM_MANIFEST, + id: "malformed-secret", + inputs: [ + { + id: "leakySecret", + kind: "secret", + required: false, + safeToPrintInDiagnostics: true, + }, + ], + } as unknown as ChannelManifest; + + expect(() => createChannelManifestRegistry([malformed])).toThrow( + "is not kind 'config' yet declares safeToPrintInDiagnostics=true", + ); + }); }); diff --git a/src/lib/messaging/manifest/registry.ts b/src/lib/messaging/manifest/registry.ts index 145251fd29f..55b179e58ab 100644 --- a/src/lib/messaging/manifest/registry.ts +++ b/src/lib/messaging/manifest/registry.ts @@ -1,7 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { ChannelManifest, MessagingAgentId, MessagingChannelId } from "./types"; +import type { + ChannelConfigInputSpec, + ChannelManifest, + MessagingAgentId, + MessagingChannelId, +} from "./types"; export interface ChannelManifestAvailabilityContext { readonly agent?: MessagingAgentId | null; @@ -21,6 +26,7 @@ export class ChannelManifestRegistry { if (this.manifests.has(manifest.id)) { throw new Error(`Duplicate channel manifest id '${manifest.id}'`); } + assertDiagnosticContractValid(manifest); this.manifests.set(manifest.id, manifest); return this; @@ -56,3 +62,60 @@ export function createChannelManifestRegistry( ): ChannelManifestRegistry { return new ChannelManifestRegistry(manifests); } + +export function asMessagingAgent(name: string | null | undefined): MessagingAgentId | null { + return name === "openclaw" || name === "hermes" ? name : null; +} + +function assertDiagnosticContractValid(manifest: ChannelManifest): void { + const supportedAgents = new Set(manifest.supportedAgents); + for (const input of manifest.inputs) { + if (input.kind !== "config") { + assertSafeToPrintOnlyOnConfig(manifest.id, input); + continue; + } + assertValueDisplayKeysAllowed(manifest.id, input); + assertAgentApplicabilitySupported(manifest.id, input, supportedAgents); + } +} + +function assertSafeToPrintOnlyOnConfig( + channelId: MessagingChannelId, + input: ChannelManifest["inputs"][number], +): void { + if ((input as { safeToPrintInDiagnostics?: boolean }).safeToPrintInDiagnostics === true) { + throw new Error( + `Channel manifest '${channelId}' input '${input.id}' is not kind 'config' yet declares safeToPrintInDiagnostics=true`, + ); + } +} + +function assertValueDisplayKeysAllowed( + channelId: MessagingChannelId, + input: ChannelConfigInputSpec, +): void { + if (!input.valueDisplay) return; + const allowed = new Set(input.validValues ?? []); + for (const key of Object.keys(input.valueDisplay)) { + if (!allowed.has(key)) { + throw new Error( + `Channel manifest '${channelId}' input '${input.id}' valueDisplay key '${key}' is not in validValues`, + ); + } + } +} + +function assertAgentApplicabilitySupported( + channelId: MessagingChannelId, + input: ChannelConfigInputSpec, + supportedAgents: ReadonlySet, +): void { + if (!input.agentApplicability) return; + for (const agent of input.agentApplicability) { + if (!supportedAgents.has(agent)) { + throw new Error( + `Channel manifest '${channelId}' input '${input.id}' agentApplicability '${agent}' is not in supportedAgents`, + ); + } + } +} From 6fc2425b81dd69b0f14e70b758d37e3a72d3f283 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 17:31:20 +0000 Subject: [PATCH 24/30] refactor(messaging): centralize compact-registry test helpers and align default display Signed-off-by: Tinson Lai --- src/lib/actions/sandbox/__test-utils__.ts | 43 +++++ .../actions/sandbox/channel-status.test.ts | 102 +++--------- src/lib/actions/sandbox/doctor-flow.test.ts | 154 +++++------------- src/lib/messaging/diagnostics.ts | 6 - 4 files changed, 102 insertions(+), 203 deletions(-) diff --git a/src/lib/actions/sandbox/__test-utils__.ts b/src/lib/actions/sandbox/__test-utils__.ts index 421a49fb08f..6c2da147cf8 100644 --- a/src/lib/actions/sandbox/__test-utils__.ts +++ b/src/lib/actions/sandbox/__test-utils__.ts @@ -3,8 +3,16 @@ import { vi } from "vitest"; +import { + type CompileTelegramPlanOptions, + compileTelegramPlanForTests, +} from "../../messaging/__test-utils__/telegram-plan"; import type { MessagingSerializableValue, SandboxMessagingPlan } from "../../messaging/manifest"; import type { SandboxEntry } from "../../state/registry"; +import { + getMessagingPlanFromEntry, + serializeSandboxMessagingStateForDisk, +} from "../../state/registry-messaging"; export type ChannelInputOverride = { inputId: string; @@ -134,3 +142,38 @@ export function tamperCompactRegistryTelegramInputs( }); return { ...state, plan: { ...state.plan, channels: tamperedChannels } }; } + +export interface CompactTelegramEntryOptions extends CompileTelegramPlanOptions { + readonly sandboxName?: string; + readonly agentName?: "openclaw" | "hermes"; + readonly tamperedInputs?: Readonly>; +} + +export interface CompactTelegramEntryBundle { + readonly entry: SandboxEntry; + readonly messagingOnDisk: unknown; +} + +export async function compactTelegramEntryFromEnv( + options: CompactTelegramEntryOptions, +): Promise { + const { tamperedInputs, sandboxName = "alpha", agentName = "openclaw", ...compileOptions } = options; + const compiled = await compileTelegramPlanForTests(compileOptions); + const baseOnDisk = serializeSandboxMessagingStateForDisk({ schemaVersion: 1, plan: compiled }); + const messagingOnDisk = tamperedInputs + ? tamperCompactRegistryTelegramInputs(baseOnDisk, tamperedInputs) + : baseOnDisk; + const entry = { + name: sandboxName, + agent: agentName, + messaging: messagingOnDisk, + } as unknown as SandboxEntry; + return { entry, messagingOnDisk }; +} + +export function useRealMessagingPlanReader< + T extends { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null }, +>(deps: T): T { + deps.getMessagingPlan = (entry) => getMessagingPlanFromEntry(entry); + return deps; +} diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index cfe00adf892..5a24018964a 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -43,8 +43,9 @@ import type { MessagingSerializableValue, SandboxMessagingPlan } from "../../mes import type { SandboxEntry } from "../../state/registry"; import { type ChannelInputOverridesByChannel, + compactTelegramEntryFromEnv, fakePlanFromInputs, - tamperCompactRegistryTelegramInputs, + useRealMessagingPlanReader, } from "./__test-utils__"; import { showSandboxChannelStatus } from "./channel-status"; @@ -552,9 +553,7 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { }); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); - expect(dump).toMatch( - /Telegram group policy:\s+open groups \(TELEGRAM_GROUP_POLICY=open\) \(default\)/, - ); + expect(dump).toMatch(/Telegram group policy:\s+open groups \(default\)/); }); it("surfaces the resolved Telegram mention mode alongside the group policy", async () => { @@ -603,9 +602,7 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { }); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); - expect(dump).toMatch( - /Telegram group mention mode:\s+mention-only \(TELEGRAM_REQUIRE_MENTION=1\) \(default\)/, - ); + expect(dump).toMatch(/Telegram group mention mode:\s+mention-only \(default\)/); }); it("omits visible config defaults when the telegram channel is not registered", async () => { @@ -653,9 +650,7 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); expect(dump).not.toMatch(/Telegram group policy/); - expect(dump).toMatch( - /Telegram group mention mode:\s+mention-only \(TELEGRAM_REQUIRE_MENTION=1\) \(default\)/, - ); + expect(dump).toMatch(/Telegram group mention mode:\s+mention-only \(default\)/); }); it("redacts an invalid persisted value rather than echoing it", async () => { @@ -732,31 +727,16 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { }); it("reads Telegram visible config from a non-interactive compact registry entry through the real plan reader", async () => { - const { getMessagingPlanFromEntry, serializeSandboxMessagingStateForDisk } = await import( - "../../state/registry-messaging" - ); - const compiled = await compileTelegramPlanForTests({ - envOverrides: { - TELEGRAM_GROUP_POLICY: "disabled", - TELEGRAM_REQUIRE_MENTION: "0", - }, + const { entry: compactEntry } = await compactTelegramEntryFromEnv({ + envOverrides: { TELEGRAM_GROUP_POLICY: "disabled", TELEGRAM_REQUIRE_MENTION: "0" }, isInteractive: false, }); - const onDisk = serializeSandboxMessagingStateForDisk({ schemaVersion: 1, plan: compiled }); - expect(onDisk).toBeDefined(); - const compactEntry = { - name: "alpha", - agent: "openclaw", - messaging: onDisk, - } as unknown as SandboxEntry; const { deps, out_lines } = makeDeps({ exec: () => ({ status: 0, stdout: "", stderr: "" }), sandbox: compactEntry, appliedPresets: ["telegram"], }); - ( - deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null } - ).getMessagingPlan = (sandboxEntry) => getMessagingPlanFromEntry(sandboxEntry); + useRealMessagingPlanReader(deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null }); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); expect(dump).toMatch( @@ -769,31 +749,16 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { }); it("renders mention-only when a non-interactive compact registry entry omits TELEGRAM_REQUIRE_MENTION", async () => { - const { getMessagingPlanFromEntry, serializeSandboxMessagingStateForDisk } = await import( - "../../state/registry-messaging" - ); - const compiled = await compileTelegramPlanForTests({ - envOverrides: { - TELEGRAM_GROUP_POLICY: undefined, - TELEGRAM_REQUIRE_MENTION: undefined, - }, + const { entry: compactEntry } = await compactTelegramEntryFromEnv({ + envOverrides: { TELEGRAM_GROUP_POLICY: undefined, TELEGRAM_REQUIRE_MENTION: undefined }, isInteractive: false, }); - const onDisk = serializeSandboxMessagingStateForDisk({ schemaVersion: 1, plan: compiled }); - expect(onDisk).toBeDefined(); - const compactEntry = { - name: "alpha", - agent: "openclaw", - messaging: onDisk, - } as unknown as SandboxEntry; const { deps, out_lines } = makeDeps({ exec: () => ({ status: 0, stdout: "", stderr: "" }), sandbox: compactEntry, appliedPresets: ["telegram"], }); - ( - deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null } - ).getMessagingPlan = (sandboxEntry) => getMessagingPlanFromEntry(sandboxEntry); + useRealMessagingPlanReader(deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null }); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); expect(dump).toMatch( @@ -803,32 +768,17 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { }); it("bounds tampered out-of-allowlist Telegram visible config from a compact registry entry through the real plan reader", async () => { - const { getMessagingPlanFromEntry, serializeSandboxMessagingStateForDisk } = await import( - "../../state/registry-messaging" - ); - const compiled = await compileTelegramPlanForTests({ + const { entry: tamperedEntry } = await compactTelegramEntryFromEnv({ envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, isInteractive: false, + tamperedInputs: { groupPolicy: "definitely-not-a-policy", requireMention: "" }, }); - const onDisk = serializeSandboxMessagingStateForDisk({ schemaVersion: 1, plan: compiled }); - expect(onDisk).toBeDefined(); - const tamperedOnDisk = tamperCompactRegistryTelegramInputs(onDisk, { - groupPolicy: "definitely-not-a-policy", - requireMention: "", - }); - const tamperedEntry = { - name: "alpha", - agent: "openclaw", - messaging: tamperedOnDisk, - } as unknown as SandboxEntry; const { deps, out_lines } = makeDeps({ exec: () => ({ status: 0, stdout: "", stderr: "" }), sandbox: tamperedEntry, appliedPresets: ["telegram"], }); - ( - deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null } - ).getMessagingPlan = (sandboxEntry) => getMessagingPlanFromEntry(sandboxEntry); + useRealMessagingPlanReader(deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null }); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); expect(dump).toMatch( @@ -841,32 +791,20 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { }); it("redacts non-scalar Telegram visible config from a compact registry entry through the real plan reader", async () => { - const { getMessagingPlanFromEntry, serializeSandboxMessagingStateForDisk } = await import( - "../../state/registry-messaging" - ); - const compiled = await compileTelegramPlanForTests({ + const { entry: tamperedEntry } = await compactTelegramEntryFromEnv({ envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, isInteractive: false, + tamperedInputs: { + groupPolicy: { smuggled: "secret-id" } as unknown, + requireMention: ["1", "0"] as unknown, + }, }); - const onDisk = serializeSandboxMessagingStateForDisk({ schemaVersion: 1, plan: compiled }); - expect(onDisk).toBeDefined(); - const nonScalarOnDisk = tamperCompactRegistryTelegramInputs(onDisk, { - groupPolicy: { smuggled: "secret-id" } as unknown as MessagingSerializableValue, - requireMention: ["1", "0"] as unknown as MessagingSerializableValue, - }); - const tamperedEntry = { - name: "alpha", - agent: "openclaw", - messaging: nonScalarOnDisk, - } as unknown as SandboxEntry; const { deps, out_lines } = makeDeps({ exec: () => ({ status: 0, stdout: "", stderr: "" }), sandbox: tamperedEntry, appliedPresets: ["telegram"], }); - ( - deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null } - ).getMessagingPlan = (sandboxEntry) => getMessagingPlanFromEntry(sandboxEntry); + useRealMessagingPlanReader(deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null }); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); expect(dump).toMatch(/Telegram group policy:\s+invalid persisted value \(unsupported type\)/); diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index 94180abf5fe..b4a0c0697b5 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -8,8 +8,9 @@ import { testTimeoutOptions } from "../../../../test/helpers/timeouts"; import { compileTelegramPlanForTests } from "../../messaging/__test-utils__/telegram-plan"; import { type ChannelInputOverride, + compactTelegramEntryFromEnv, + type CompactTelegramEntryOptions, mockTelegramDoctorRegistry as applyTelegramDoctorRegistryMocks, - tamperCompactRegistryTelegramInputs, } from "./__test-utils__"; type RunSandboxDoctor = typeof import("./doctor")["runSandboxDoctor"]; @@ -213,6 +214,30 @@ function mockTelegramDoctorRegistry(options: { applyTelegramDoctorRegistryMocks(registry, options); } +async function setupDoctorRealPlanReader( + harness: { getSandboxSpy: MockInstance }, + options: CompactTelegramEntryOptions, +): Promise { + const { entry } = await compactTelegramEntryFromEnv(options); + harness.getSandboxSpy.mockReturnValue({ + name: "alpha", + agent: options.agentName ?? "openclaw", + model: "registry-model", + provider: "ollama-local", + openshellDriver: "docker", + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + messaging: (entry as { messaging: unknown }).messaging, + }); + const registry = requireDist("../../state/registry.js"); + const registryMessaging = requireDist("../../state/registry-messaging.js"); + vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); + vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); + vi.spyOn(registry, "getMessagingPlanFromEntry").mockImplementation((entry) => + registryMessaging.getMessagingPlanFromEntry(entry), + ); +} + describe("runSandboxDoctor flow", () => { let exitSpy: MockInstance; @@ -286,7 +311,7 @@ describe("runSandboxDoctor flow", () => { group: "Messaging", label: "Telegram group policy", status: "info", - detail: "open groups (TELEGRAM_GROUP_POLICY=open) (default)", + detail: "open groups (default)", }), ]), ); @@ -320,10 +345,7 @@ describe("runSandboxDoctor flow", () => { expect(messagingChecks.some((check) => check.label === "Telegram group policy")).toBe(false); expect( messagingChecks.find((check) => check.label === "Telegram group mention mode"), - ).toMatchObject({ - status: "info", - detail: "mention-only (TELEGRAM_REQUIRE_MENTION=1) (default)", - }); + ).toMatchObject({ status: "info", detail: "mention-only (default)" }); }); it("falls back to OpenClaw visible config when a legacy SandboxEntry omits the agent field", async () => { @@ -344,14 +366,11 @@ describe("runSandboxDoctor flow", () => { expect(messagingChecks.find((check) => check.label === "Telegram group policy")).toMatchObject({ status: "info", - detail: "open groups (TELEGRAM_GROUP_POLICY=open) (default)", + detail: "open groups (default)", }); expect( messagingChecks.find((check) => check.label === "Telegram group mention mode"), - ).toMatchObject({ - status: "info", - detail: "mention-only (TELEGRAM_REQUIRE_MENTION=1) (default)", - }); + ).toMatchObject({ status: "info", detail: "mention-only (default)" }); }); it("flags an invalid persisted Telegram group policy without echoing the raw value", async () => { @@ -419,33 +438,10 @@ describe("runSandboxDoctor flow", () => { it("reads Telegram visible config from a non-interactive compact registry entry through the real plan reader", async () => { const harness = createDoctorHarness(); - const registryMessaging = requireDist("../../state/registry-messaging.js"); - const realGetMessagingPlanFromEntry = registryMessaging.getMessagingPlanFromEntry; - const compiledPlan = await compileTelegramPlanForTests({ + await setupDoctorRealPlanReader(harness, { envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, isInteractive: false, }); - const onDisk = registryMessaging.serializeSandboxMessagingStateForDisk({ - schemaVersion: 1, - plan: compiledPlan, - }); - expect(onDisk).toBeDefined(); - harness.getSandboxSpy.mockReturnValue({ - name: "alpha", - agent: "openclaw", - model: "registry-model", - provider: "ollama-local", - openshellDriver: "docker", - gatewayName: "nemoclaw-19080", - gatewayPort: 19080, - messaging: onDisk, - }); - const registry = requireDist("../../state/registry.js"); - vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); - vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); - vi.spyOn(registry, "getMessagingPlanFromEntry").mockImplementation((entry) => - realGetMessagingPlanFromEntry(entry), - ); const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); @@ -464,33 +460,10 @@ describe("runSandboxDoctor flow", () => { it("renders mention-only when a non-interactive compact registry entry omits TELEGRAM_REQUIRE_MENTION", async () => { const harness = createDoctorHarness(); - const registryMessaging = requireDist("../../state/registry-messaging.js"); - const realGetMessagingPlanFromEntry = registryMessaging.getMessagingPlanFromEntry; - const compiledPlan = await compileTelegramPlanForTests({ + await setupDoctorRealPlanReader(harness, { envOverrides: { TELEGRAM_GROUP_POLICY: undefined, TELEGRAM_REQUIRE_MENTION: undefined }, isInteractive: false, }); - const onDisk = registryMessaging.serializeSandboxMessagingStateForDisk({ - schemaVersion: 1, - plan: compiledPlan, - }); - expect(onDisk).toBeDefined(); - harness.getSandboxSpy.mockReturnValue({ - name: "alpha", - agent: "openclaw", - model: "registry-model", - provider: "ollama-local", - openshellDriver: "docker", - gatewayName: "nemoclaw-19080", - gatewayPort: 19080, - messaging: onDisk, - }); - const registry = requireDist("../../state/registry.js"); - vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); - vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); - vi.spyOn(registry, "getMessagingPlanFromEntry").mockImplementation((entry) => - realGetMessagingPlanFromEntry(entry), - ); const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); @@ -505,37 +478,11 @@ describe("runSandboxDoctor flow", () => { it("bounds tampered out-of-allowlist Telegram visible config from a compact registry entry through the real plan reader", async () => { const harness = createDoctorHarness(); - const registryMessaging = requireDist("../../state/registry-messaging.js"); - const realGetMessagingPlanFromEntry = registryMessaging.getMessagingPlanFromEntry; - const compiledPlan = await compileTelegramPlanForTests({ + await setupDoctorRealPlanReader(harness, { envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, isInteractive: false, + tamperedInputs: { groupPolicy: "definitely-not-a-policy", requireMention: "" }, }); - const onDisk = registryMessaging.serializeSandboxMessagingStateForDisk({ - schemaVersion: 1, - plan: compiledPlan, - }); - expect(onDisk).toBeDefined(); - const tamperedOnDisk = tamperCompactRegistryTelegramInputs(onDisk, { - groupPolicy: "definitely-not-a-policy", - requireMention: "", - }); - harness.getSandboxSpy.mockReturnValue({ - name: "alpha", - agent: "openclaw", - model: "registry-model", - provider: "ollama-local", - openshellDriver: "docker", - gatewayName: "nemoclaw-19080", - gatewayPort: 19080, - messaging: tamperedOnDisk, - }); - const registry = requireDist("../../state/registry.js"); - vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); - vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); - vi.spyOn(registry, "getMessagingPlanFromEntry").mockImplementation((entry) => - realGetMessagingPlanFromEntry(entry), - ); const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); @@ -554,37 +501,14 @@ describe("runSandboxDoctor flow", () => { it("redacts non-scalar Telegram visible config from a compact registry entry through the real plan reader", async () => { const harness = createDoctorHarness(); - const registryMessaging = requireDist("../../state/registry-messaging.js"); - const realGetMessagingPlanFromEntry = registryMessaging.getMessagingPlanFromEntry; - const compiledPlan = await compileTelegramPlanForTests({ + await setupDoctorRealPlanReader(harness, { envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, isInteractive: false, + tamperedInputs: { + groupPolicy: { smuggled: "secret-id" } as unknown, + requireMention: ["1", "0"] as unknown, + }, }); - const onDisk = registryMessaging.serializeSandboxMessagingStateForDisk({ - schemaVersion: 1, - plan: compiledPlan, - }); - expect(onDisk).toBeDefined(); - const tamperedOnDisk = tamperCompactRegistryTelegramInputs(onDisk, { - groupPolicy: { smuggled: "secret-id" } as unknown, - requireMention: ["1", "0"] as unknown, - }); - harness.getSandboxSpy.mockReturnValue({ - name: "alpha", - agent: "openclaw", - model: "registry-model", - provider: "ollama-local", - openshellDriver: "docker", - gatewayName: "nemoclaw-19080", - gatewayPort: 19080, - messaging: tamperedOnDisk, - }); - const registry = requireDist("../../state/registry.js"); - vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); - vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); - vi.spyOn(registry, "getMessagingPlanFromEntry").mockImplementation((entry) => - realGetMessagingPlanFromEntry(entry), - ); const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); diff --git a/src/lib/messaging/diagnostics.ts b/src/lib/messaging/diagnostics.ts index e36f4322cf9..2973686f2c1 100644 --- a/src/lib/messaging/diagnostics.ts +++ b/src/lib/messaging/diagnostics.ts @@ -137,12 +137,6 @@ export function resolveVisibleConfigDisplay( } if (input.defaultValue !== undefined) { const mapped = input.valueDisplay?.[input.defaultValue]; - if (mapped && input.envKey) { - return { - detail: `${mapped} (${input.envKey}=${input.defaultValue}) (default)`, - source: "default", - }; - } if (mapped) { return { detail: `${mapped} (default)`, source: "default" }; } From 373fd97175a277e3b6baf91571b79daead1c19db Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 17:45:33 +0000 Subject: [PATCH 25/30] style: apply biome format to compact-registry test helpers Signed-off-by: Tinson Lai --- src/lib/actions/sandbox/__test-utils__.ts | 7 +++++- .../actions/sandbox/channel-status.test.ts | 24 +++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/lib/actions/sandbox/__test-utils__.ts b/src/lib/actions/sandbox/__test-utils__.ts index 6c2da147cf8..d7af99f8f44 100644 --- a/src/lib/actions/sandbox/__test-utils__.ts +++ b/src/lib/actions/sandbox/__test-utils__.ts @@ -157,7 +157,12 @@ export interface CompactTelegramEntryBundle { export async function compactTelegramEntryFromEnv( options: CompactTelegramEntryOptions, ): Promise { - const { tamperedInputs, sandboxName = "alpha", agentName = "openclaw", ...compileOptions } = options; + const { + tamperedInputs, + sandboxName = "alpha", + agentName = "openclaw", + ...compileOptions + } = options; const compiled = await compileTelegramPlanForTests(compileOptions); const baseOnDisk = serializeSandboxMessagingStateForDisk({ schemaVersion: 1, plan: compiled }); const messagingOnDisk = tamperedInputs diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index 5a24018964a..f495af11c56 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -736,7 +736,11 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { sandbox: compactEntry, appliedPresets: ["telegram"], }); - useRealMessagingPlanReader(deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null }); + useRealMessagingPlanReader( + deps as { + getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null; + }, + ); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); expect(dump).toMatch( @@ -758,7 +762,11 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { sandbox: compactEntry, appliedPresets: ["telegram"], }); - useRealMessagingPlanReader(deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null }); + useRealMessagingPlanReader( + deps as { + getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null; + }, + ); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); expect(dump).toMatch( @@ -778,7 +786,11 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { sandbox: tamperedEntry, appliedPresets: ["telegram"], }); - useRealMessagingPlanReader(deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null }); + useRealMessagingPlanReader( + deps as { + getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null; + }, + ); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); expect(dump).toMatch( @@ -804,7 +816,11 @@ describe("showSandboxChannelStatus (telegram config visibility)", () => { sandbox: tamperedEntry, appliedPresets: ["telegram"], }); - useRealMessagingPlanReader(deps as { getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null }); + useRealMessagingPlanReader( + deps as { + getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null; + }, + ); await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); const dump = out_lines.join("\n"); expect(dump).toMatch(/Telegram group policy:\s+invalid persisted value \(unsupported type\)/); From a9284267d015a64d439916669016a59bbf6bfc6b Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 18:08:12 +0000 Subject: [PATCH 26/30] test(checks): distinguish dynamic-require path construction from bare imports Signed-off-by: Tinson Lai --- scripts/checks/no-test-dist-imports.ts | 19 +++++++++++-------- test/test-boundary-guards.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/scripts/checks/no-test-dist-imports.ts b/scripts/checks/no-test-dist-imports.ts index 135481d0765..9687e8c8d87 100644 --- a/scripts/checks/no-test-dist-imports.ts +++ b/scripts/checks/no-test-dist-imports.ts @@ -75,21 +75,23 @@ export function findCompiledInternalViolations(file: string, source: string): Vi violations.push({ file, line: position.line + 1, detail }); } - function checkSpecifier(node: ts.Node, specifier: string): void { - if (isCompiledInternalSpecifier(specifier)) { - add(node, `imports compiled CLI internals from ${JSON.stringify(specifier)}`); - } + function checkSpecifier(node: ts.Node, specifier: string, viaDynamicCall: boolean): void { + if (!isCompiledInternalSpecifier(specifier)) return; + const detail = viaDynamicCall + ? `constructs a path into compiled CLI internals via dynamic require to ${JSON.stringify(specifier)}` + : `imports compiled CLI internals from ${JSON.stringify(specifier)}`; + add(node, detail); } function visit(node: ts.Node): void { if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) { - checkSpecifier(node.moduleSpecifier, node.moduleSpecifier.text); + checkSpecifier(node.moduleSpecifier, node.moduleSpecifier.text, false); } else if ( ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier) ) { - checkSpecifier(node.moduleSpecifier, node.moduleSpecifier.text); + checkSpecifier(node.moduleSpecifier, node.moduleSpecifier.text, false); } else if (ts.isCallExpression(node)) { const isRequire = ts.isIdentifier(node.expression) && node.expression.text === "require"; const isDynamicImport = node.expression.kind === ts.SyntaxKind.ImportKeyword; @@ -104,7 +106,7 @@ export function findCompiledInternalViolations(file: string, source: string): Vi firstArgument && ts.isStringLiteralLike(firstArgument) ) { - checkSpecifier(firstArgument, firstArgument.text); + checkSpecifier(firstArgument, firstArgument.text, true); } const isPathBuilder = @@ -152,7 +154,8 @@ function findViolations(absolutePath: string): Violation[] { export function isPathConstructionViolation(violation: Violation): boolean { return ( violation.detail.startsWith("constructs a path") || - violation.detail.includes("require in generated test code") + violation.detail.includes("require in generated test code") || + violation.detail.startsWith("constructs a path into compiled CLI internals via dynamic require") ); } diff --git a/test/test-boundary-guards.test.ts b/test/test-boundary-guards.test.ts index 596d09160d1..12e5f8b7b4e 100644 --- a/test/test-boundary-guards.test.ts +++ b/test/test-boundary-guards.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from "vitest"; import { findCompiledInternalViolations, + isPathConstructionViolation, isScannedTestPath, } from "../scripts/checks/no-test-dist-imports"; import { findProjectOverlaps, parseProjectListing } from "../scripts/checks/vitest-project-overlap"; @@ -46,6 +47,29 @@ describe("compiled-test import boundary", () => { expect(isScannedTestPath("test/e2e/example.test.ts")).toBe(false); expect(isScannedTestPath("test/dist-sourcemaps.test.ts")).toBe(false); }); + + it("classifies path-construction violations distinctly from import-specifier violations", () => { + const importOnly = findCompiledInternalViolations( + "test/example.test.ts", + 'import value from "../dist/lib/value.js";\n', + ); + expect(importOnly).toHaveLength(1); + expect(importOnly.some(isPathConstructionViolation)).toBe(false); + + const pathOnly = findCompiledInternalViolations( + "test/example.test.ts", + 'path.join(root, "dist", "lib", "value.js");\n', + ); + expect(pathOnly).toHaveLength(1); + expect(pathOnly.every(isPathConstructionViolation)).toBe(true); + + const requireOnly = findCompiledInternalViolations( + "test/example.test.ts", + 'require("../dist/lib/value.js");\n', + ); + expect(requireOnly).toHaveLength(1); + expect(requireOnly.every(isPathConstructionViolation)).toBe(true); + }); }); describe("Vitest project membership boundary", () => { From fd42b72f9f52d797388979415d9d1c07464b7363 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 28 Jun 2026 18:16:13 +0000 Subject: [PATCH 27/30] test(checks): build dist patterns from templates to keep them past the scanner Signed-off-by: Tinson Lai --- test/test-boundary-guards.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/test-boundary-guards.test.ts b/test/test-boundary-guards.test.ts index 12e5f8b7b4e..c54d957f1f9 100644 --- a/test/test-boundary-guards.test.ts +++ b/test/test-boundary-guards.test.ts @@ -49,23 +49,25 @@ describe("compiled-test import boundary", () => { }); it("classifies path-construction violations distinctly from import-specifier violations", () => { + const distPath = ["..", "dist", "lib", "value.js"].join("/"); + const importOnly = findCompiledInternalViolations( "test/example.test.ts", - 'import value from "../dist/lib/value.js";\n', + `import value from ${JSON.stringify(distPath)};\n`, ); expect(importOnly).toHaveLength(1); expect(importOnly.some(isPathConstructionViolation)).toBe(false); const pathOnly = findCompiledInternalViolations( "test/example.test.ts", - 'path.join(root, "dist", "lib", "value.js");\n', + `path.join(root, ${JSON.stringify("dist")}, ${JSON.stringify("lib")}, "value.js");\n`, ); expect(pathOnly).toHaveLength(1); expect(pathOnly.every(isPathConstructionViolation)).toBe(true); const requireOnly = findCompiledInternalViolations( "test/example.test.ts", - 'require("../dist/lib/value.js");\n', + `require(${JSON.stringify(distPath)});\n`, ); expect(requireOnly).toHaveLength(1); expect(requireOnly.every(isPathConstructionViolation)).toBe(true); From 331f342f91b9b1ce5c5d8ecf5c3ff954bc6afaa2 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 29 Jun 2026 05:03:32 +0000 Subject: [PATCH 28/30] refactor(messaging): harden visible config boundary and split monolith tests Signed-off-by: Tinson Lai --- docs/reference/commands-nemohermes.mdx | 2 +- docs/reference/commands.mdx | 2 +- scripts/checks/no-test-dist-imports.ts | 21 + .../sandbox/__test-utils__/doctor-harness.ts | 245 +++++++++ .../index.ts} | 159 +++++- .../actions/sandbox/agent/passthrough-json.ts | 2 +- ...channel-status-telegram-visibility.test.ts | 364 ++++++++++++++ .../actions/sandbox/channel-status.test.ts | 324 +----------- src/lib/actions/sandbox/channel-status.ts | 32 +- .../sandbox/connect-route-repair.test.ts | 2 +- src/lib/actions/sandbox/destroy.ts | 4 +- src/lib/actions/sandbox/doctor-flow.test.ts | 463 +----------------- .../sandbox/doctor-gateway-fallback.ts | 2 +- .../doctor-messaging-visibility.test.ts | 276 +++++++++++ src/lib/actions/sandbox/doctor-messaging.ts | 48 +- src/lib/actions/sandbox/gateway-state.ts | 4 +- src/lib/actions/sandbox/host-aliases.ts | 2 +- src/lib/actions/sandbox/policy-channel.ts | 4 +- .../actions/sandbox/policy-context-refresh.ts | 2 +- .../actions/sandbox/policy-explain.test.ts | 2 +- .../actions/sandbox/rebuild-flow-helpers.ts | 6 +- src/lib/actions/sandbox/rebuild-shields.ts | 2 +- .../actions/sandbox/sessions/delete.test.ts | 2 +- src/lib/actions/sandbox/sessions/export.ts | 2 +- src/lib/actions/sandbox/status-text.ts | 2 +- src/lib/actions/sandbox/wipe-state.ts | 2 +- .../__test-utils__/planner-harness.ts | 96 ++++ .../applier/host-state-applier.test.ts | 3 +- src/lib/messaging/applier/index.ts | 6 +- .../messaging/applier/openshell-provider.ts | 2 +- src/lib/messaging/applier/types.ts | 12 +- src/lib/messaging/channels/built-ins.ts | 4 +- .../slack/hooks/credential-validation.ts | 2 +- .../slack/hooks/validate-credentials.ts | 2 +- .../teams/hooks/host-forward-port-conflict.ts | 2 +- .../messaging/channels/template-resolver.ts | 2 +- src/lib/messaging/channels/wechat/login.ts | 6 +- src/lib/messaging/channels/wechat/qr.test.ts | 4 +- .../channels/wechat/template-resolver.ts | 2 +- .../engines/credential-binding-engine.ts | 2 +- src/lib/messaging/compiler/index.ts | 2 +- .../compiler/manifest-compiler.test.ts | 46 +- .../messaging/compiler/manifest-compiler.ts | 2 +- .../planner-empty-env-normalization.test.ts | 89 ++++ .../compiler/workflow-planner.test.ts | 23 - .../hooks/common/token-paste.test.ts | 2 +- src/lib/messaging/hooks/common/token-paste.ts | 10 +- src/lib/messaging/hooks/index.ts | 6 +- src/lib/messaging/manifest/registry.test.ts | 19 + src/lib/messaging/manifest/registry.ts | 12 + src/lib/messaging/plan-validation.test.ts | 58 +++ src/lib/messaging/plan-validation.ts | 66 ++- src/lib/messaging/visible-config-output.ts | 50 ++ src/lib/state/config-io.ts | 3 +- src/lib/state/registry.ts | 2 +- src/lib/state/sandbox-session.test.ts | 12 +- 56 files changed, 1587 insertions(+), 936 deletions(-) create mode 100644 src/lib/actions/sandbox/__test-utils__/doctor-harness.ts rename src/lib/actions/sandbox/{__test-utils__.ts => __test-utils__/index.ts} (57%) create mode 100644 src/lib/actions/sandbox/channel-status-telegram-visibility.test.ts create mode 100644 src/lib/actions/sandbox/doctor-messaging-visibility.test.ts create mode 100644 src/lib/messaging/__test-utils__/planner-harness.ts create mode 100644 src/lib/messaging/compiler/planner-empty-env-normalization.test.ts create mode 100644 src/lib/messaging/visible-config-output.ts diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index bfd2a5c93e3..0e57584ca9b 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -639,7 +639,7 @@ The rebuild reuses the existing sandbox name and persisted credentials, so messa Run a focused health check for one sandbox and the host services it depends on. The command checks the local CLI build, Docker daemon, OpenShell CLI, NemoClaw gateway container, gateway port mapping, live sandbox state, inference route, provider reachability, messaging channel conflicts, Ollama reachability, and the cloudflared tunnel state. -For channels with manifest-declared visible config inputs (e.g. Telegram group mention mode and OpenClaw-only group policy), the Messaging section also surfaces one check per opted-in input so operators can confirm the active policy without inspecting logs. Secrets and inputs that do not opt in via `safeToPrintInDiagnostics` remain excluded, agent-scoped inputs are hidden when the sandbox runs a different agent, and persisted values are validated against the manifest allowlist before display. +The Messaging section also surfaces one check per opted-in visible config input so operators can confirm the active policy without inspecting logs. The rendering rules (manifest opt-in via `safeToPrintInDiagnostics`, agent-scoped hiding, manifest allowlist validation) are shared with `channels status` and documented under [channels status](#channels-status). Warnings do not make the command fail. Failed checks exit non-zero so scripts can use `doctor` as a readiness gate. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 7d3f4c8d4e6..d8699cc9212 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -828,7 +828,7 @@ The rebuild reuses the existing sandbox name and persisted credentials, so messa Run a focused health check for one sandbox and the host services it depends on. The command checks the local CLI build, Docker daemon, OpenShell CLI, NemoClaw gateway container, gateway port mapping, live sandbox state, inference route, provider reachability, messaging channel conflicts, Ollama reachability, and the cloudflared tunnel state. -For channels with manifest-declared visible config inputs (e.g. Telegram group mention mode and OpenClaw-only group policy), the Messaging section also surfaces one check per opted-in input so operators can confirm the active policy without inspecting logs. Secrets and inputs that do not opt in via `safeToPrintInDiagnostics` remain excluded, agent-scoped inputs are hidden when the sandbox runs a different agent, and persisted values are validated against the manifest allowlist before display. +The Messaging section also surfaces one check per opted-in visible config input so operators can confirm the active policy without inspecting logs. The rendering rules (manifest opt-in via `safeToPrintInDiagnostics`, agent-scoped hiding, manifest allowlist validation) are shared with `channels status` and documented under [channels status](#channels-status). Warnings do not make the command fail. Failed checks exit non-zero so scripts can use `doctor` as a readiness gate. diff --git a/scripts/checks/no-test-dist-imports.ts b/scripts/checks/no-test-dist-imports.ts index 9687e8c8d87..544019b141f 100644 --- a/scripts/checks/no-test-dist-imports.ts +++ b/scripts/checks/no-test-dist-imports.ts @@ -163,6 +163,12 @@ function findPathConstructionViolations(absolutePath: string): Violation[] { return findViolations(absolutePath).filter(isPathConstructionViolation); } +function findBareImportViolations(absolutePath: string): Violation[] { + return findViolations(absolutePath).filter( + (violation) => !isPathConstructionViolation(violation), + ); +} + function main(): void { const staleFixtureExclusions = [...FIXTURE_EXCLUSIONS].filter((relativePath) => { const absolutePath = path.join(REPO_ROOT, relativePath); @@ -177,6 +183,21 @@ function main(): void { process.exit(1); } + const fixturesWithBareImports = [...FIXTURE_EXCLUSIONS].flatMap((relativePath) => { + const absolutePath = path.join(REPO_ROOT, relativePath); + return existsSync(absolutePath) ? findBareImportViolations(absolutePath) : []; + }); + + if (fixturesWithBareImports.length > 0) { + console.error( + "Fixture exclusions are limited to path-construction/dynamic-require violations; the following excluded fixtures contain bare import specifiers and must be rewritten or moved:", + ); + for (const violation of fixturesWithBareImports) { + console.error(` ${violation.file}:${violation.line} ${violation.detail}`); + } + process.exit(1); + } + const violations = [ ...walk(path.join(REPO_ROOT, "src")), ...walk(path.join(REPO_ROOT, "test")), diff --git a/src/lib/actions/sandbox/__test-utils__/doctor-harness.ts b/src/lib/actions/sandbox/__test-utils__/doctor-harness.ts new file mode 100644 index 00000000000..a9b72bd8a36 --- /dev/null +++ b/src/lib/actions/sandbox/__test-utils__/doctor-harness.ts @@ -0,0 +1,245 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { type MockInstance, vi } from "vitest"; + +import { + mockTelegramDoctorRegistry as applyTelegramDoctorRegistryMocks, + type ChannelInputOverride, + type CompactTelegramEntryOptions, + compactTelegramEntryFromEnv, +} from "./index"; + +type RunSandboxDoctor = typeof import("../doctor")["runSandboxDoctor"]; + +type DistRequire = (id: string) => any; + +export interface DoctorHarness { + buildToolScopeChecksSpy: MockInstance; + captureOpenShellSpy: MockInstance; + captureHostCommandSpy: MockInstance; + configuredMessagingChannelsSpy: MockInstance; + executeSandboxCommandForVerificationSpy: MockInstance; + getSandboxSpy: MockInstance; + getNamedGatewayLifecycleStateSpy: MockInstance; + healthProbeSpy: MockInstance; + inspectMutableConfigPermsSpy: MockInstance; + loadAgentSpy: MockInstance; + probeSandboxInferenceGatewayHealthSpy: MockInstance; + logSpy: MockInstance; + recoverNamedGatewayRuntimeSpy: MockInstance; + repairMutableConfigPermsSpy: MockInstance; + resolveOpenShellSpy: MockInstance; + runSandboxDoctor: RunSandboxDoctor; +} + +const DOCTOR_MODULE_PATH = "./doctor.js"; + +export function createDoctorHarness( + requireDist: DistRequire & { resolve: (id: string) => string }, +): DoctorHarness { + delete require.cache[requireDist.resolve(DOCTOR_MODULE_PATH)]; + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + const resolve = requireDist("../../adapters/openshell/resolve.js"); + const runtime = requireDist("../../adapters/openshell/runtime.js"); + const agentDefs = requireDist("../../agent/defs.js"); + const agentRuntime = requireDist("../../agent/runtime.js"); + const gatewayRuntime = requireDist("../../gateway-runtime-action.js"); + const health = requireDist("../../inference/health.js"); + const dockerDriverPlatform = requireDist("../../onboard/docker-driver-platform.js"); + const gatewayBinding = requireDist("../../onboard/gateway-binding.js"); + const sandboxVerificationExec = requireDist("../../onboard/sandbox-verification-exec.js"); + const sandboxVersion = requireDist("../../sandbox/version.js"); + const shields = requireDist("../../shields/index.js"); + const registry = requireDist("../../state/registry.js"); + const statusCommandDeps = requireDist("../../status-command-deps.js"); + const tunnelServices = requireDist("../../tunnel/services.js"); + const doctorHostCommand = requireDist("./doctor-host-command.js"); + const doctorToolScope = requireDist("./doctor-tool-scope.js"); + const processRecovery = requireDist("./process-recovery.js"); + + const getSandboxSpy = vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "alpha", + agent: "openclaw", + model: "registry-model", + provider: "ollama-local", + openshellDriver: "docker", + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + messaging: undefined, + }); + const configuredMessagingChannelsSpy = vi + .spyOn(registry, "getConfiguredMessagingChannelsFromEntry") + .mockReturnValue([]); + vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); + const resolveOpenShellSpy = vi + .spyOn(resolve, "resolveOpenshell") + .mockReturnValue("/usr/bin/openshell"); + vi.spyOn(gatewayBinding, "resolveSandboxGatewayName").mockReturnValue("nemoclaw-19080"); + vi.spyOn(gatewayBinding, "resolveGatewayName").mockReturnValue("nemoclaw-19080"); + vi.spyOn(dockerDriverPlatform, "isLinuxDockerDriverGatewayEnabled").mockReturnValue(true); + const recoverNamedGatewayRuntimeSpy = vi + .spyOn(gatewayRuntime, "recoverNamedGatewayRuntime") + .mockResolvedValue({ + before: { state: "healthy_named", status: "Status: Connected", gatewayInfo: "" }, + after: { state: "healthy_named", status: "Status: Connected", gatewayInfo: "" }, + recovered: false, + }); + const getNamedGatewayLifecycleStateSpy = vi + .spyOn(gatewayRuntime, "getNamedGatewayLifecycleState") + .mockReturnValue({ + state: "healthy_named", + status: "Status: Connected", + gatewayInfo: "Gateway: nemoclaw-19080", + activeGateway: "nemoclaw-19080", + }); + const captureOpenShellSpy = vi + .spyOn(runtime, "captureOpenshell") + .mockImplementation((args: unknown) => { + const argv = Array.isArray(args) ? args : []; + if (argv[0] === "sandbox" && argv[1] === "list") { + return { status: 0, output: "alpha Ready" }; + } + if (argv[0] === "inference" && argv[1] === "get") { + return { status: 0, output: "Provider: ollama-local\nModel: live-model\n" }; + } + return { status: 0, output: "" }; + }); + const captureHostCommandSpy = vi + .spyOn(doctorHostCommand, "captureHostCommand") + .mockImplementation((command: unknown) => { + if (command === "docker") return { status: 0, stdout: "25.0.0\n", stderr: "" }; + if (command === "curl") { + return { status: 0, stdout: JSON.stringify({ models: [{ name: "m" }] }), stderr: "" }; + } + return { status: 0, stdout: "", stderr: "" }; + }); + const healthProbeSpy = vi.spyOn(health, "probeProviderHealth").mockReturnValue({ + ok: true, + probed: true, + providerLabel: "Ollama", + endpoint: "http://127.0.0.1:11434/v1/chat/completions", + detail: "healthy", + }); + const probeSandboxInferenceGatewayHealthSpy = vi + .spyOn(processRecovery, "probeSandboxInferenceGatewayHealth") + .mockResolvedValue({ + ok: false, + endpoint: "http://127.0.0.1:19000/v1/chat/completions", + detail: "gateway refused connection", + }); + const loadAgentSpy = vi.spyOn(agentDefs, "loadAgent").mockReturnValue({ + name: "openclaw", + configPaths: { dir: "/sandbox/.openclaw", configFile: "openclaw.json", format: "json" }, + }); + vi.spyOn(agentRuntime, "getSessionAgent").mockReturnValue({ name: "openclaw" }); + vi.spyOn(agentRuntime, "getAgentDisplayName").mockReturnValue("OpenClaw"); + vi.spyOn(sandboxVersion, "checkAgentVersion").mockReturnValue({ + sandboxVersion: "0.1.0", + expectedVersion: "0.2.0", + isStale: true, + }); + vi.spyOn(shields, "getShieldsPosture").mockReturnValue({ + mode: "temporarily_unlocked", + detail: "temporarily unlocked for maintenance", + }); + const inspectMutableConfigPermsSpy = vi + .spyOn(shields, "inspectMutableConfigPerms") + .mockReturnValue({ + applies: true, + ok: true, + dirMode: "2770", + dirOwner: "sandbox:sandbox", + fileMode: "660", + fileOwner: "sandbox:sandbox", + configDir: "/sandbox/.openclaw", + configFile: "openclaw.json", + issues: [], + }); + const repairMutableConfigPermsSpy = vi + .spyOn(shields, "repairMutableConfigPerms") + .mockReturnValue({ + applied: true, + verified: true, + errors: [], + }); + vi.spyOn(statusCommandDeps, "buildStatusCommandDeps").mockReturnValue({}); + vi.spyOn(tunnelServices, "readCloudflaredState").mockReturnValue({ kind: "running", pid: 1234 }); + const executeSandboxCommandForVerificationSpy = vi + .spyOn(sandboxVerificationExec, "executeSandboxCommandForVerification") + .mockReturnValue({ + status: 0, + stdout: "ok", + stderr: "", + }); + const buildToolScopeChecksSpy = vi + .spyOn(doctorToolScope, "buildToolScopeChecks") + .mockReturnValue([ + { + group: "Sandbox", + label: "Tool scope approvals", + status: "ok", + detail: "no pending approvals", + }, + ]); + + logSpy.mockClear(); + + return { + buildToolScopeChecksSpy, + captureOpenShellSpy, + captureHostCommandSpy, + configuredMessagingChannelsSpy, + executeSandboxCommandForVerificationSpy, + getSandboxSpy, + getNamedGatewayLifecycleStateSpy, + healthProbeSpy, + inspectMutableConfigPermsSpy, + loadAgentSpy, + probeSandboxInferenceGatewayHealthSpy, + logSpy, + recoverNamedGatewayRuntimeSpy, + repairMutableConfigPermsSpy, + resolveOpenShellSpy, + runSandboxDoctor: (requireDist(DOCTOR_MODULE_PATH) as { runSandboxDoctor: RunSandboxDoctor }) + .runSandboxDoctor, + }; +} + +export function mockTelegramDoctorRegistryForHarness( + requireDist: DistRequire, + options: { + agent: "openclaw" | "hermes"; + inputs?: ReadonlyArray; + }, +): void { + applyTelegramDoctorRegistryMocks(requireDist("../../state/registry.js"), options); +} + +export async function setupDoctorRealPlanReader( + requireDist: DistRequire, + harness: { getSandboxSpy: MockInstance }, + options: CompactTelegramEntryOptions, +): Promise { + const { entry } = await compactTelegramEntryFromEnv(options); + harness.getSandboxSpy.mockReturnValue({ + name: "alpha", + agent: options.agentName ?? "openclaw", + model: "registry-model", + provider: "ollama-local", + openshellDriver: "docker", + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + messaging: (entry as { messaging: unknown }).messaging, + }); + const registry = requireDist("../../state/registry.js"); + const registryMessaging = requireDist("../../state/registry-messaging.js"); + vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); + vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); + vi.spyOn(registry, "getMessagingPlanFromEntry").mockImplementation((entry: unknown) => + registryMessaging.getMessagingPlanFromEntry(entry), + ); +} diff --git a/src/lib/actions/sandbox/__test-utils__.ts b/src/lib/actions/sandbox/__test-utils__/index.ts similarity index 57% rename from src/lib/actions/sandbox/__test-utils__.ts rename to src/lib/actions/sandbox/__test-utils__/index.ts index d7af99f8f44..2eb4681c17b 100644 --- a/src/lib/actions/sandbox/__test-utils__.ts +++ b/src/lib/actions/sandbox/__test-utils__/index.ts @@ -3,16 +3,17 @@ import { vi } from "vitest"; +import type { AgentDefinition } from "../../../agent/defs"; import { type CompileTelegramPlanOptions, compileTelegramPlanForTests, -} from "../../messaging/__test-utils__/telegram-plan"; -import type { MessagingSerializableValue, SandboxMessagingPlan } from "../../messaging/manifest"; -import type { SandboxEntry } from "../../state/registry"; +} from "../../../messaging/__test-utils__/telegram-plan"; +import type { MessagingSerializableValue, SandboxMessagingPlan } from "../../../messaging/manifest"; +import type { SandboxEntry } from "../../../state/registry"; import { getMessagingPlanFromEntry, serializeSandboxMessagingStateForDisk, -} from "../../state/registry-messaging"; +} from "../../../state/registry-messaging"; export type ChannelInputOverride = { inputId: string; @@ -182,3 +183,153 @@ export function useRealMessagingPlanReader< deps.getMessagingPlan = (entry) => getMessagingPlanFromEntry(entry); return deps; } + +export function fakeChannelStatusAgent(name: "openclaw" | "hermes" = "openclaw"): AgentDefinition { + const configDir = name === "openclaw" ? "/sandbox/.openclaw" : "/sandbox/.hermes"; + const stateDirs = name === "openclaw" ? ["whatsapp"] : ["platforms"]; + return { + name, + agentDir: `/fake/${name}`, + manifestPath: `/fake/${name}/manifest.yaml`, + get displayName() { + return name; + }, + get healthProbe() { + return { url: "http://localhost:0/", port: 0, timeout_seconds: 5 }; + }, + get forwardPort() { + return 0; + }, + get dashboard() { + return { kind: "ui" as const, label: "UI", path: "/" }; + }, + get configPaths() { + return { dir: configDir, configFile: "config.json", envFile: null, format: "json" }; + }, + get inferenceProviderOptions() { + return []; + }, + get stateDirs() { + return stateDirs; + }, + get stateFiles() { + return []; + }, + get versionCommand() { + return `${name} --version`; + }, + get expectedVersion() { + return null; + }, + get hasDevicePairing() { + return false; + }, + get phoneHomeHosts() { + return []; + }, + get dockerfileBasePath() { + return null; + }, + get dockerfilePath() { + return null; + }, + get startScriptPath() { + return null; + }, + get policyAdditionsPath() { + return null; + }, + get policyPermissivePath() { + return null; + }, + get pluginDir() { + return null; + }, + get legacyPaths() { + return null; + }, + } as unknown as AgentDefinition; +} + +export function channelStatusEntry( + messagingChannels: string[] = ["whatsapp"], + disabledChannels: string[] = [], +): SandboxEntry { + const disabled = new Set(disabledChannels); + return { + name: "alpha", + agent: "openclaw", + 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; +} + +export interface ChannelStatusExecResult { + status: number; + stdout: string; + stderr: string; +} + +export interface ChannelStatusMakeDepsOptions { + exec: ( + sandboxName: string, + command: string, + timeoutMs?: number, + ) => ChannelStatusExecResult | null; + appliedPresets?: string[]; + gatewayPresets?: string[] | null; + agentName?: "openclaw" | "hermes"; + sandbox?: SandboxEntry | undefined; + channelInputs?: ChannelInputOverridesByChannel; + messagingPlan?: SandboxMessagingPlan | null; + out?: (line: string) => void; +} + +export function makeChannelStatusDeps(opts: ChannelStatusMakeDepsOptions, probedAt: Date) { + const calls: string[] = []; + const out = opts.out ?? ((line: string) => calls.push(line)); + const sandbox = opts.sandbox ?? channelStatusEntry(); + return { + out, + deps: { + loadAgent: () => fakeChannelStatusAgent(opts.agentName), + getSandbox: () => sandbox, + getAppliedPresets: () => opts.appliedPresets ?? ["whatsapp"], + getGatewayPresets: () => + opts.gatewayPresets === undefined ? ["whatsapp"] : opts.gatewayPresets, + getMessagingPlan: () => + opts.messagingPlan !== undefined + ? opts.messagingPlan + : fakePlanFromInputs(sandbox, opts.channelInputs), + execSandbox: vi.fn(opts.exec), + now: () => probedAt, + out, + }, + out_lines: calls, + }; +} diff --git a/src/lib/actions/sandbox/agent/passthrough-json.ts b/src/lib/actions/sandbox/agent/passthrough-json.ts index 63c4a17d1cb..d170fa37f22 100644 --- a/src/lib/actions/sandbox/agent/passthrough-json.ts +++ b/src/lib/actions/sandbox/agent/passthrough-json.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync, type SpawnSyncOptions, type SpawnSyncReturns } from "node:child_process"; +import { type SpawnSyncOptions, type SpawnSyncReturns, spawnSync } from "node:child_process"; import { openClawAgentJsonProvenanceLines } from "../../../openclaw/agent-json-provenance"; import { buildOpenshellExecArgs, computeExitCode } from "../exec"; diff --git a/src/lib/actions/sandbox/channel-status-telegram-visibility.test.ts b/src/lib/actions/sandbox/channel-status-telegram-visibility.test.ts new file mode 100644 index 00000000000..ad3ef13be0d --- /dev/null +++ b/src/lib/actions/sandbox/channel-status-telegram-visibility.test.ts @@ -0,0 +1,364 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../policy", () => ({ + getAppliedPresets: vi.fn(() => []), + getGatewayPresets: vi.fn(() => null), +})); + +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", () => ({ + loadAgent: vi.fn(), +})); + +vi.mock("./process-recovery", () => ({ + executeSandboxExecCommand: vi.fn(), +})); + +import { compileTelegramPlanForTests } from "../../messaging/__test-utils__/telegram-plan"; +import type { MessagingSerializableValue, SandboxMessagingPlan } from "../../messaging/manifest"; +import type { SandboxEntry } from "../../state/registry"; +import { + channelStatusEntry, + compactTelegramEntryFromEnv, + makeChannelStatusDeps, + useRealMessagingPlanReader, +} from "./__test-utils__"; +import { showSandboxChannelStatus } from "./channel-status"; + +const PROBED_AT = new Date("2026-05-28T04:00:00.000Z"); + +const TELEGRAM_GROUP_POLICY_LABEL = { + open: "open groups", + allowlist: "allowlisted groups only", + disabled: "groups disabled", +} as const; + +function deps(opts: Parameters[0]) { + return makeChannelStatusDeps(opts, PROBED_AT); +} + +describe("showSandboxChannelStatus (telegram config visibility)", () => { + for (const policy of ["open", "allowlist", "disabled"] as const) { + it(`surfaces the resolved Telegram group policy: ${policy}`, async () => { + const harness = deps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: channelStatusEntry(["telegram"]), + appliedPresets: ["telegram"], + channelInputs: { + telegram: [{ inputId: "groupPolicy", value: policy }], + }, + }); + await showSandboxChannelStatus("alpha", { deps: harness.deps, channel: "telegram" }); + const dump = harness.out_lines.join("\n"); + const label = TELEGRAM_GROUP_POLICY_LABEL[policy]; + expect(dump).toMatch( + new RegExp(`Telegram group policy:\\s+${label} \\(TELEGRAM_GROUP_POLICY=${policy}\\)`), + ); + expect(dump).not.toMatch(/Telegram group policy:.*\(default\)/); + }); + } + + it("falls back to the manifest default when no group policy value is persisted", async () => { + const harness = deps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: channelStatusEntry(["telegram"]), + appliedPresets: ["telegram"], + }); + await showSandboxChannelStatus("alpha", { deps: harness.deps, channel: "telegram" }); + expect(harness.out_lines.join("\n")).toMatch( + /Telegram group policy:\s+open groups \(default\)/, + ); + }); + + it("surfaces the resolved Telegram mention mode alongside the group policy", async () => { + const harness = deps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: channelStatusEntry(["telegram"]), + appliedPresets: ["telegram"], + channelInputs: { + telegram: [ + { inputId: "requireMention", value: "0" }, + { inputId: "groupPolicy", value: "allowlist" }, + ], + }, + }); + await showSandboxChannelStatus("alpha", { deps: harness.deps, channel: "telegram" }); + const dump = harness.out_lines.join("\n"); + expect(dump).toMatch( + /Telegram group mention mode:\s+all group messages \(TELEGRAM_REQUIRE_MENTION=0\)/, + ); + expect(dump).toMatch( + /Telegram group policy:\s+allowlisted groups only \(TELEGRAM_GROUP_POLICY=allowlist\)/, + ); + }); + + it("translates Telegram requireMention=1 to the mention-only behavior label", async () => { + const harness = deps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: channelStatusEntry(["telegram"]), + appliedPresets: ["telegram"], + channelInputs: { + telegram: [{ inputId: "requireMention", value: "1" }], + }, + }); + await showSandboxChannelStatus("alpha", { deps: harness.deps, channel: "telegram" }); + expect(harness.out_lines.join("\n")).toMatch( + /Telegram group mention mode:\s+mention-only \(TELEGRAM_REQUIRE_MENTION=1\)/, + ); + }); + + it("renders the mention-mode default with the mapped behavior label", async () => { + const harness = deps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: channelStatusEntry(["telegram"]), + appliedPresets: ["telegram"], + }); + await showSandboxChannelStatus("alpha", { deps: harness.deps, channel: "telegram" }); + expect(harness.out_lines.join("\n")).toMatch( + /Telegram group mention mode:\s+mention-only \(default\)/, + ); + }); + + it("omits visible config defaults when the telegram channel is not registered", async () => { + const harness = deps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: channelStatusEntry([]), + appliedPresets: [], + }); + await showSandboxChannelStatus("alpha", { deps: harness.deps, channel: "telegram" }); + const dump = harness.out_lines.join("\n"); + expect(dump).not.toMatch(/Telegram group policy/); + expect(dump).not.toMatch(/Telegram group mention mode/); + }); + + it("omits visible config defaults when the telegram channel is paused", async () => { + const harness = deps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: channelStatusEntry(["telegram"], ["telegram"]), + appliedPresets: ["telegram"], + }); + await showSandboxChannelStatus("alpha", { deps: harness.deps, channel: "telegram" }); + const dump = harness.out_lines.join("\n"); + expect(dump).not.toMatch(/Telegram group policy/); + expect(dump).not.toMatch(/Telegram group mention mode/); + }); + + it("skips visible config inputs that have neither a persisted value nor a default", async () => { + const harness = deps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: channelStatusEntry(["telegram"]), + appliedPresets: ["telegram"], + }); + await showSandboxChannelStatus("alpha", { deps: harness.deps, channel: "telegram" }); + expect(harness.out_lines.join("\n")).not.toMatch(/Telegram User ID/); + }); + + it("hides the OpenClaw-only group policy when the sandbox runs Hermes", async () => { + const harness = deps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: channelStatusEntry(["telegram"]), + appliedPresets: ["telegram"], + agentName: "hermes", + }); + await showSandboxChannelStatus("alpha", { deps: harness.deps, channel: "telegram" }); + const dump = harness.out_lines.join("\n"); + expect(dump).not.toMatch(/Telegram group policy/); + expect(dump).toMatch(/Telegram group mention mode:\s+mention-only \(default\)/); + }); + + it("redacts an invalid persisted value rather than echoing it", async () => { + const harness = deps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: channelStatusEntry(["telegram"]), + appliedPresets: ["telegram"], + channelInputs: { + telegram: [{ inputId: "groupPolicy", value: "definitely-not-a-policy" }], + }, + }); + await showSandboxChannelStatus("alpha", { deps: harness.deps, channel: "telegram" }); + const dump = harness.out_lines.join("\n"); + expect(dump).not.toMatch(/definitely-not-a-policy/); + expect(dump).toMatch( + /Telegram group policy:\s+invalid persisted value \(expected: open \| allowlist \| disabled\)/, + ); + }); + + it("redacts a non-scalar persisted Telegram value rather than echoing raw JSON", async () => { + const tamperedObject = { allow: ["@one", "@two"], smuggled: "secret-id" }; + const tamperedArray = ["1", "0"]; + const harness = deps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: channelStatusEntry(["telegram"]), + appliedPresets: ["telegram"], + channelInputs: { + telegram: [ + { + inputId: "groupPolicy", + value: tamperedObject as unknown as MessagingSerializableValue, + }, + { + inputId: "requireMention", + value: tamperedArray as unknown as MessagingSerializableValue, + }, + ], + }, + }); + await showSandboxChannelStatus("alpha", { deps: harness.deps, channel: "telegram" }); + const dump = harness.out_lines.join("\n"); + expect(dump).toMatch(/Telegram group policy:\s+invalid persisted value \(unsupported type\)/); + expect(dump).toMatch( + /Telegram group mention mode:\s+invalid persisted value \(unsupported type\)/, + ); + expect(dump).not.toMatch(/secret-id/); + expect(dump).not.toMatch(/smuggled/); + expect(dump).not.toMatch(/@one/); + expect(dump).not.toMatch(/"1"/); + }); + + it("renders Telegram inputs from a plan compiled out of process env through the command path", async () => { + const plan = await compileTelegramPlanForTests({ + envOverrides: { + TELEGRAM_GROUP_POLICY: "allowlist", + TELEGRAM_REQUIRE_MENTION: undefined, + }, + }); + const harness = deps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: channelStatusEntry(["telegram"]), + appliedPresets: ["telegram"], + messagingPlan: plan, + }); + await showSandboxChannelStatus("alpha", { deps: harness.deps, channel: "telegram" }); + const dump = harness.out_lines.join("\n"); + expect(dump).toMatch( + /Telegram group policy:\s+allowlisted groups only \(TELEGRAM_GROUP_POLICY=allowlist\)/, + ); + expect(dump).not.toMatch(/Telegram group policy:.*\(default\)/); + expect(dump).toMatch( + /Telegram group mention mode:\s+mention-only \(TELEGRAM_REQUIRE_MENTION=1\)/, + ); + }); + + it("reads Telegram visible config from a non-interactive compact registry entry through the real plan reader", async () => { + const { entry: compactEntry } = await compactTelegramEntryFromEnv({ + envOverrides: { TELEGRAM_GROUP_POLICY: "disabled", TELEGRAM_REQUIRE_MENTION: "0" }, + isInteractive: false, + }); + const harness = deps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: compactEntry, + appliedPresets: ["telegram"], + }); + useRealMessagingPlanReader( + harness.deps as { + getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null; + }, + ); + await showSandboxChannelStatus("alpha", { deps: harness.deps, channel: "telegram" }); + const dump = harness.out_lines.join("\n"); + expect(dump).toMatch( + /Telegram group policy:\s+groups disabled \(TELEGRAM_GROUP_POLICY=disabled\)/, + ); + expect(dump).toMatch( + /Telegram group mention mode:\s+all group messages \(TELEGRAM_REQUIRE_MENTION=0\)/, + ); + expect(dump).not.toMatch(/Telegram group policy:.*\(default\)/); + }); + + it("renders mention-only when a non-interactive compact registry entry omits TELEGRAM_REQUIRE_MENTION", async () => { + const { entry: compactEntry } = await compactTelegramEntryFromEnv({ + envOverrides: { TELEGRAM_GROUP_POLICY: undefined, TELEGRAM_REQUIRE_MENTION: undefined }, + isInteractive: false, + }); + const harness = deps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: compactEntry, + appliedPresets: ["telegram"], + }); + useRealMessagingPlanReader( + harness.deps as { + getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null; + }, + ); + await showSandboxChannelStatus("alpha", { deps: harness.deps, channel: "telegram" }); + const dump = harness.out_lines.join("\n"); + expect(dump).toMatch( + /Telegram group mention mode:\s+mention-only \(TELEGRAM_REQUIRE_MENTION=1\)/, + ); + expect(dump).not.toMatch(/Telegram group mention mode:.*all group messages/); + }); + + it("bounds tampered out-of-allowlist Telegram visible config from a compact registry entry through the real plan reader", async () => { + const { entry: tamperedEntry } = await compactTelegramEntryFromEnv({ + envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, + isInteractive: false, + tamperedInputs: { groupPolicy: "definitely-not-a-policy", requireMention: "" }, + }); + const harness = deps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: tamperedEntry, + appliedPresets: ["telegram"], + }); + useRealMessagingPlanReader( + harness.deps as { + getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null; + }, + ); + await showSandboxChannelStatus("alpha", { deps: harness.deps, channel: "telegram" }); + const dump = harness.out_lines.join("\n"); + expect(dump).toMatch( + /Telegram group policy:\s+invalid persisted value \(expected: open \| allowlist \| disabled\)/, + ); + expect(dump).toMatch( + /Telegram group mention mode:\s+invalid persisted value \(expected: 0 \| 1\)/, + ); + expect(dump).not.toMatch(/definitely-not-a-policy/); + }); + + it("redacts non-scalar Telegram visible config from a compact registry entry through the real plan reader", async () => { + const { entry: tamperedEntry } = await compactTelegramEntryFromEnv({ + envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, + isInteractive: false, + tamperedInputs: { + groupPolicy: { smuggled: "secret-id" } as unknown, + requireMention: ["1", "0"] as unknown, + }, + }); + const harness = deps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: tamperedEntry, + appliedPresets: ["telegram"], + }); + useRealMessagingPlanReader( + harness.deps as { + getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null; + }, + ); + await showSandboxChannelStatus("alpha", { deps: harness.deps, channel: "telegram" }); + const dump = harness.out_lines.join("\n"); + expect(dump).toMatch(/Telegram group policy:\s+invalid persisted value \(unsupported type\)/); + expect(dump).toMatch( + /Telegram group mention mode:\s+invalid persisted value \(unsupported type\)/, + ); + expect(dump).not.toMatch(/secret-id/); + expect(dump).not.toMatch(/smuggled/); + }); +}); diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index f495af11c56..b12f31f360e 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -38,15 +38,9 @@ vi.mock("./process-recovery", () => ({ })); import type { AgentDefinition } from "../../agent/defs"; -import { compileTelegramPlanForTests } from "../../messaging/__test-utils__/telegram-plan"; -import type { MessagingSerializableValue, SandboxMessagingPlan } from "../../messaging/manifest"; +import type { SandboxMessagingPlan } from "../../messaging/manifest"; import type { SandboxEntry } from "../../state/registry"; -import { - type ChannelInputOverridesByChannel, - compactTelegramEntryFromEnv, - fakePlanFromInputs, - useRealMessagingPlanReader, -} from "./__test-utils__"; +import { type ChannelInputOverridesByChannel, fakePlanFromInputs } from "./__test-utils__"; import { showSandboxChannelStatus } from "./channel-status"; type ExecResult = { status: number; stdout: string; stderr: string }; @@ -517,317 +511,3 @@ describe("showSandboxChannelStatus (whatsapp)", () => { expect(dump).toMatch(/preset applied/); }); }); - -const TELEGRAM_GROUP_POLICY_LABEL: Readonly> = { - open: "open groups", - allowlist: "allowlisted groups only", - disabled: "groups disabled", -}; - -describe("showSandboxChannelStatus (telegram config visibility)", () => { - for (const policy of ["open", "allowlist", "disabled"] as const) { - it(`surfaces the resolved Telegram group policy: ${policy}`, async () => { - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout: "", stderr: "" }), - sandbox: entry(["telegram"]), - appliedPresets: ["telegram"], - channelInputs: { - telegram: [{ inputId: "groupPolicy", value: policy }], - }, - }); - await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); - const dump = out_lines.join("\n"); - const label = TELEGRAM_GROUP_POLICY_LABEL[policy]; - expect(dump).toMatch( - new RegExp(`Telegram group policy:\\s+${label} \\(TELEGRAM_GROUP_POLICY=${policy}\\)`), - ); - expect(dump).not.toMatch(/Telegram group policy:.*\(default\)/); - }); - } - - it("falls back to the manifest default when no group policy value is persisted", async () => { - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout: "", stderr: "" }), - sandbox: entry(["telegram"]), - appliedPresets: ["telegram"], - }); - await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); - const dump = out_lines.join("\n"); - expect(dump).toMatch(/Telegram group policy:\s+open groups \(default\)/); - }); - - it("surfaces the resolved Telegram mention mode alongside the group policy", async () => { - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout: "", stderr: "" }), - sandbox: entry(["telegram"]), - appliedPresets: ["telegram"], - channelInputs: { - telegram: [ - { inputId: "requireMention", value: "0" }, - { inputId: "groupPolicy", value: "allowlist" }, - ], - }, - }); - await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); - const dump = out_lines.join("\n"); - expect(dump).toMatch( - /Telegram group mention mode:\s+all group messages \(TELEGRAM_REQUIRE_MENTION=0\)/, - ); - expect(dump).toMatch( - /Telegram group policy:\s+allowlisted groups only \(TELEGRAM_GROUP_POLICY=allowlist\)/, - ); - }); - - it("translates Telegram requireMention=1 to the mention-only behavior label", async () => { - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout: "", stderr: "" }), - sandbox: entry(["telegram"]), - appliedPresets: ["telegram"], - channelInputs: { - telegram: [{ inputId: "requireMention", value: "1" }], - }, - }); - await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); - const dump = out_lines.join("\n"); - expect(dump).toMatch( - /Telegram group mention mode:\s+mention-only \(TELEGRAM_REQUIRE_MENTION=1\)/, - ); - }); - - it("renders the mention-mode default with the mapped behavior label", async () => { - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout: "", stderr: "" }), - sandbox: entry(["telegram"]), - appliedPresets: ["telegram"], - }); - await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); - const dump = out_lines.join("\n"); - expect(dump).toMatch(/Telegram group mention mode:\s+mention-only \(default\)/); - }); - - it("omits visible config defaults when the telegram channel is not registered", async () => { - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout: "", stderr: "" }), - sandbox: entry([]), - appliedPresets: [], - }); - await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); - const dump = out_lines.join("\n"); - expect(dump).not.toMatch(/Telegram group policy/); - expect(dump).not.toMatch(/Telegram group mention mode/); - }); - - it("omits visible config defaults when the telegram channel is paused", async () => { - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout: "", stderr: "" }), - sandbox: entry(["telegram"], ["telegram"]), - appliedPresets: ["telegram"], - }); - await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); - const dump = out_lines.join("\n"); - expect(dump).not.toMatch(/Telegram group policy/); - expect(dump).not.toMatch(/Telegram group mention mode/); - }); - - it("skips visible config inputs that have neither a persisted value nor a default", async () => { - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout: "", stderr: "" }), - sandbox: entry(["telegram"]), - appliedPresets: ["telegram"], - }); - await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); - const dump = out_lines.join("\n"); - expect(dump).not.toMatch(/Telegram User ID/); - }); - - it("hides the OpenClaw-only group policy when the sandbox runs Hermes", async () => { - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout: "", stderr: "" }), - sandbox: entry(["telegram"]), - appliedPresets: ["telegram"], - agentName: "hermes", - }); - await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); - const dump = out_lines.join("\n"); - expect(dump).not.toMatch(/Telegram group policy/); - expect(dump).toMatch(/Telegram group mention mode:\s+mention-only \(default\)/); - }); - - it("redacts an invalid persisted value rather than echoing it", async () => { - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout: "", stderr: "" }), - sandbox: entry(["telegram"]), - appliedPresets: ["telegram"], - channelInputs: { - telegram: [{ inputId: "groupPolicy", value: "definitely-not-a-policy" }], - }, - }); - await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); - const dump = out_lines.join("\n"); - expect(dump).not.toMatch(/definitely-not-a-policy/); - expect(dump).toMatch( - /Telegram group policy:\s+invalid persisted value \(expected: open \| allowlist \| disabled\)/, - ); - }); - - it("redacts a non-scalar persisted Telegram value rather than echoing raw JSON", async () => { - const tamperedObject = { allow: ["@one", "@two"], smuggled: "secret-id" }; - const tamperedArray = ["1", "0"]; - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout: "", stderr: "" }), - sandbox: entry(["telegram"]), - appliedPresets: ["telegram"], - channelInputs: { - telegram: [ - { - inputId: "groupPolicy", - value: tamperedObject as unknown as MessagingSerializableValue, - }, - { - inputId: "requireMention", - value: tamperedArray as unknown as MessagingSerializableValue, - }, - ], - }, - }); - await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); - const dump = out_lines.join("\n"); - expect(dump).toMatch(/Telegram group policy:\s+invalid persisted value \(unsupported type\)/); - expect(dump).toMatch( - /Telegram group mention mode:\s+invalid persisted value \(unsupported type\)/, - ); - expect(dump).not.toMatch(/secret-id/); - expect(dump).not.toMatch(/smuggled/); - expect(dump).not.toMatch(/@one/); - expect(dump).not.toMatch(/"1"/); - }); - - it("renders Telegram inputs from a plan compiled out of process env through the command path", async () => { - const plan = await compileTelegramPlanForTests({ - envOverrides: { - TELEGRAM_GROUP_POLICY: "allowlist", - TELEGRAM_REQUIRE_MENTION: undefined, - }, - }); - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout: "", stderr: "" }), - sandbox: entry(["telegram"]), - appliedPresets: ["telegram"], - messagingPlan: plan, - }); - await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); - const dump = out_lines.join("\n"); - expect(dump).toMatch( - /Telegram group policy:\s+allowlisted groups only \(TELEGRAM_GROUP_POLICY=allowlist\)/, - ); - expect(dump).not.toMatch(/Telegram group policy:.*\(default\)/); - expect(dump).toMatch( - /Telegram group mention mode:\s+mention-only \(TELEGRAM_REQUIRE_MENTION=1\)/, - ); - }); - - it("reads Telegram visible config from a non-interactive compact registry entry through the real plan reader", async () => { - const { entry: compactEntry } = await compactTelegramEntryFromEnv({ - envOverrides: { TELEGRAM_GROUP_POLICY: "disabled", TELEGRAM_REQUIRE_MENTION: "0" }, - isInteractive: false, - }); - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout: "", stderr: "" }), - sandbox: compactEntry, - appliedPresets: ["telegram"], - }); - useRealMessagingPlanReader( - deps as { - getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null; - }, - ); - await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); - const dump = out_lines.join("\n"); - expect(dump).toMatch( - /Telegram group policy:\s+groups disabled \(TELEGRAM_GROUP_POLICY=disabled\)/, - ); - expect(dump).toMatch( - /Telegram group mention mode:\s+all group messages \(TELEGRAM_REQUIRE_MENTION=0\)/, - ); - expect(dump).not.toMatch(/Telegram group policy:.*\(default\)/); - }); - - it("renders mention-only when a non-interactive compact registry entry omits TELEGRAM_REQUIRE_MENTION", async () => { - const { entry: compactEntry } = await compactTelegramEntryFromEnv({ - envOverrides: { TELEGRAM_GROUP_POLICY: undefined, TELEGRAM_REQUIRE_MENTION: undefined }, - isInteractive: false, - }); - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout: "", stderr: "" }), - sandbox: compactEntry, - appliedPresets: ["telegram"], - }); - useRealMessagingPlanReader( - deps as { - getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null; - }, - ); - await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); - const dump = out_lines.join("\n"); - expect(dump).toMatch( - /Telegram group mention mode:\s+mention-only \(TELEGRAM_REQUIRE_MENTION=1\)/, - ); - expect(dump).not.toMatch(/Telegram group mention mode:.*all group messages/); - }); - - it("bounds tampered out-of-allowlist Telegram visible config from a compact registry entry through the real plan reader", async () => { - const { entry: tamperedEntry } = await compactTelegramEntryFromEnv({ - envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, - isInteractive: false, - tamperedInputs: { groupPolicy: "definitely-not-a-policy", requireMention: "" }, - }); - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout: "", stderr: "" }), - sandbox: tamperedEntry, - appliedPresets: ["telegram"], - }); - useRealMessagingPlanReader( - deps as { - getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null; - }, - ); - await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); - const dump = out_lines.join("\n"); - expect(dump).toMatch( - /Telegram group policy:\s+invalid persisted value \(expected: open \| allowlist \| disabled\)/, - ); - expect(dump).toMatch( - /Telegram group mention mode:\s+invalid persisted value \(expected: 0 \| 1\)/, - ); - expect(dump).not.toMatch(/definitely-not-a-policy/); - }); - - it("redacts non-scalar Telegram visible config from a compact registry entry through the real plan reader", async () => { - const { entry: tamperedEntry } = await compactTelegramEntryFromEnv({ - envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, - isInteractive: false, - tamperedInputs: { - groupPolicy: { smuggled: "secret-id" } as unknown, - requireMention: ["1", "0"] as unknown, - }, - }); - const { deps, out_lines } = makeDeps({ - exec: () => ({ status: 0, stdout: "", stderr: "" }), - sandbox: tamperedEntry, - appliedPresets: ["telegram"], - }); - useRealMessagingPlanReader( - deps as { - getMessagingPlan: (entry: SandboxEntry | undefined) => SandboxMessagingPlan | null; - }, - ); - await showSandboxChannelStatus("alpha", { deps, channel: "telegram" }); - const dump = out_lines.join("\n"); - expect(dump).toMatch(/Telegram group policy:\s+invalid persisted value \(unsupported type\)/); - expect(dump).toMatch( - /Telegram group mention mode:\s+invalid persisted value \(unsupported type\)/, - ); - expect(dump).not.toMatch(/secret-id/); - expect(dump).not.toMatch(/smuggled/); - }); -}); diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index 0f7bd5137e6..aea48f47467 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -14,17 +14,18 @@ import { type AgentDefinition, loadAgent } from "../../agent/defs"; import { CLI_DISPLAY_NAME, CLI_NAME } from "../../cli/branding"; import { B, D, G, R, RD, YW } from "../../cli/terminal-style"; +import { + createBuiltInChannelManifestRegistry, + getMessagingManifestAvailabilityContext, +} from "../../messaging"; import { collectBuiltInMessagingChannelDiagnostics, collectVisibleConfigRecords, type MessagingChannelDiagnosticSpec, } from "../../messaging/diagnostics"; -import { - createBuiltInChannelManifestRegistry, - getMessagingManifestAvailabilityContext, -} from "../../messaging"; -import { asMessagingAgent } from "../../messaging/manifest"; import type { SandboxMessagingPlan } from "../../messaging/manifest"; +import { asMessagingAgent } from "../../messaging/manifest"; +import { visibleConfigSeverity } from "../../messaging/visible-config-output"; import * as policies from "../../policy"; import { type DiagnosticSeverity, @@ -551,30 +552,15 @@ function buildConfigVisibilitySignals( ): DiagnosticSignal[] { if (diagnostic.visibleConfigInputs.length === 0) return []; const plan = deps.getMessagingPlan(entry); - const records = collectVisibleConfigRecords( - diagnostic, - plan, - channelName, - asMessagingAgent(agent.name), - ); + const messagingAgent = entry?.agent ? asMessagingAgent(agent.name) : null; + const records = collectVisibleConfigRecords(diagnostic, plan, channelName, messagingAgent); return records.map(({ input, display }) => ({ label: input.label, - severity: severityForDisplay(display.source), + severity: visibleConfigSeverity(display.source), detail: display.detail, })); } -function severityForDisplay(source: "persisted" | "default" | "invalid") { - switch (source) { - case "persisted": - return "ok" as const; - case "default": - return "info" as const; - case "invalid": - return "warn" as const; - } -} - function channelSupportedByAgent(channelName: string, agent: AgentDefinition): boolean { return channelManifestRegistry .listAvailable(getMessagingManifestAvailabilityContext(agent, channelManifestRegistry.list())) diff --git a/src/lib/actions/sandbox/connect-route-repair.test.ts b/src/lib/actions/sandbox/connect-route-repair.test.ts index 4dbf39017b8..f558513d00e 100644 --- a/src/lib/actions/sandbox/connect-route-repair.test.ts +++ b/src/lib/actions/sandbox/connect-route-repair.test.ts @@ -36,9 +36,9 @@ vi.mock("./gateway-state", () => ({ })); import { + type ManagedInferenceRouteResetDeps, repairSandboxInferenceRouteWithDeps, resetManagedInferenceRouteWithDeps, - type ManagedInferenceRouteResetDeps, type SandboxInferenceRouteProbe, type SandboxInferenceRouteRepairDeps, } from "./connect"; diff --git a/src/lib/actions/sandbox/destroy.ts b/src/lib/actions/sandbox/destroy.ts index 84dce38891b..490d6ae06e9 100644 --- a/src/lib/actions/sandbox/destroy.ts +++ b/src/lib/actions/sandbox/destroy.ts @@ -40,7 +40,7 @@ import { selectGatewayForSandboxDestroy, } from "./destroy-gateway"; import { getSandboxTargetGatewayName } from "./gateway-target"; -import { wipeSandboxState, type WipeSandboxStateDeps } from "./wipe-state"; +import { type WipeSandboxStateDeps, wipeSandboxState } from "./wipe-state"; type DockerRmi = (tag: string, opts?: { ignoreError?: boolean }) => { status: number | null }; @@ -292,10 +292,10 @@ export function cleanupShieldsDestroyArtifacts( }); } +export type { WipeSandboxStateDeps }; // Re-export so existing callers (tests, downstream code) keep working after // the wipe was extracted out of the destroy monolith (#5455 PRA-2). export { wipeSandboxState }; -export type { WipeSandboxStateDeps }; export async function destroySandbox( sandboxName: string, diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index b4a0c0697b5..2c5499b4432 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -5,237 +5,16 @@ import { createRequire } from "node:module"; import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; import { testTimeoutOptions } from "../../../../test/helpers/timeouts"; -import { compileTelegramPlanForTests } from "../../messaging/__test-utils__/telegram-plan"; import { - type ChannelInputOverride, - compactTelegramEntryFromEnv, - type CompactTelegramEntryOptions, - mockTelegramDoctorRegistry as applyTelegramDoctorRegistryMocks, -} from "./__test-utils__"; - -type RunSandboxDoctor = typeof import("./doctor")["runSandboxDoctor"]; + createDoctorHarness as createDoctorHarnessShared, + type DoctorHarness, +} from "./__test-utils__/doctor-harness"; const requireDist = createRequire(import.meta.url); const doctorModulePath = "./doctor.js"; -function createDoctorHarness(): { - buildToolScopeChecksSpy: MockInstance; - captureOpenShellSpy: MockInstance; - captureHostCommandSpy: MockInstance; - configuredMessagingChannelsSpy: MockInstance; - executeSandboxCommandForVerificationSpy: MockInstance; - getSandboxSpy: MockInstance; - getNamedGatewayLifecycleStateSpy: MockInstance; - healthProbeSpy: MockInstance; - inspectMutableConfigPermsSpy: MockInstance; - loadAgentSpy: MockInstance; - probeSandboxInferenceGatewayHealthSpy: MockInstance; - logSpy: MockInstance; - recoverNamedGatewayRuntimeSpy: MockInstance; - repairMutableConfigPermsSpy: MockInstance; - resolveOpenShellSpy: MockInstance; - runSandboxDoctor: RunSandboxDoctor; -} { - delete require.cache[requireDist.resolve(doctorModulePath)]; - - const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); - vi.spyOn(console, "error").mockImplementation(() => undefined); - - const resolve = requireDist("../../adapters/openshell/resolve.js"); - const runtime = requireDist("../../adapters/openshell/runtime.js"); - const agentDefs = requireDist("../../agent/defs.js"); - const agentRuntime = requireDist("../../agent/runtime.js"); - const gatewayRuntime = requireDist("../../gateway-runtime-action.js"); - const health = requireDist("../../inference/health.js"); - const dockerDriverPlatform = requireDist("../../onboard/docker-driver-platform.js"); - const gatewayBinding = requireDist("../../onboard/gateway-binding.js"); - const sandboxVerificationExec = requireDist("../../onboard/sandbox-verification-exec.js"); - const sandboxVersion = requireDist("../../sandbox/version.js"); - const shields = requireDist("../../shields/index.js"); - const registry = requireDist("../../state/registry.js"); - const statusCommandDeps = requireDist("../../status-command-deps.js"); - const tunnelServices = requireDist("../../tunnel/services.js"); - const doctorHostCommand = requireDist("./doctor-host-command.js"); - const doctorToolScope = requireDist("./doctor-tool-scope.js"); - const processRecovery = requireDist("./process-recovery.js"); - - const getSandboxSpy = vi.spyOn(registry, "getSandbox").mockReturnValue({ - name: "alpha", - agent: "openclaw", - model: "registry-model", - provider: "ollama-local", - openshellDriver: "docker", - gatewayName: "nemoclaw-19080", - gatewayPort: 19080, - messaging: undefined, - }); - const configuredMessagingChannelsSpy = vi - .spyOn(registry, "getConfiguredMessagingChannelsFromEntry") - .mockReturnValue([]); - vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); - const resolveOpenShellSpy = vi - .spyOn(resolve, "resolveOpenshell") - .mockReturnValue("/usr/bin/openshell"); - vi.spyOn(gatewayBinding, "resolveSandboxGatewayName").mockReturnValue("nemoclaw-19080"); - vi.spyOn(gatewayBinding, "resolveGatewayName").mockReturnValue("nemoclaw-19080"); - vi.spyOn(dockerDriverPlatform, "isLinuxDockerDriverGatewayEnabled").mockReturnValue(true); - const recoverNamedGatewayRuntimeSpy = vi - .spyOn(gatewayRuntime, "recoverNamedGatewayRuntime") - .mockResolvedValue({ - before: { state: "healthy_named", status: "Status: Connected", gatewayInfo: "" }, - after: { state: "healthy_named", status: "Status: Connected", gatewayInfo: "" }, - recovered: false, - }); - const getNamedGatewayLifecycleStateSpy = vi - .spyOn(gatewayRuntime, "getNamedGatewayLifecycleState") - .mockReturnValue({ - state: "healthy_named", - status: "Status: Connected", - gatewayInfo: "Gateway: nemoclaw-19080", - activeGateway: "nemoclaw-19080", - }); - const captureOpenShellSpy = vi - .spyOn(runtime, "captureOpenshell") - .mockImplementation((args: unknown) => { - const argv = Array.isArray(args) ? args : []; - if (argv[0] === "sandbox" && argv[1] === "list") { - return { status: 0, output: "alpha Ready" }; - } - if (argv[0] === "inference" && argv[1] === "get") { - return { status: 0, output: "Provider: ollama-local\nModel: live-model\n" }; - } - return { status: 0, output: "" }; - }); - const captureHostCommandSpy = vi - .spyOn(doctorHostCommand, "captureHostCommand") - .mockImplementation((command: unknown) => { - if (command === "docker") return { status: 0, stdout: "25.0.0\n", stderr: "" }; - if (command === "curl") { - return { status: 0, stdout: JSON.stringify({ models: [{ name: "m" }] }), stderr: "" }; - } - return { status: 0, stdout: "", stderr: "" }; - }); - const healthProbeSpy = vi.spyOn(health, "probeProviderHealth").mockReturnValue({ - ok: true, - probed: true, - providerLabel: "Ollama", - endpoint: "http://127.0.0.1:11434/v1/chat/completions", - detail: "healthy", - }); - const probeSandboxInferenceGatewayHealthSpy = vi - .spyOn(processRecovery, "probeSandboxInferenceGatewayHealth") - .mockResolvedValue({ - ok: false, - endpoint: "http://127.0.0.1:19000/v1/chat/completions", - detail: "gateway refused connection", - }); - const loadAgentSpy = vi.spyOn(agentDefs, "loadAgent").mockReturnValue({ - name: "openclaw", - configPaths: { dir: "/sandbox/.openclaw", configFile: "openclaw.json", format: "json" }, - }); - vi.spyOn(agentRuntime, "getSessionAgent").mockReturnValue({ name: "openclaw" }); - vi.spyOn(agentRuntime, "getAgentDisplayName").mockReturnValue("OpenClaw"); - vi.spyOn(sandboxVersion, "checkAgentVersion").mockReturnValue({ - sandboxVersion: "0.1.0", - expectedVersion: "0.2.0", - isStale: true, - }); - vi.spyOn(shields, "getShieldsPosture").mockReturnValue({ - mode: "temporarily_unlocked", - detail: "temporarily unlocked for maintenance", - }); - const inspectMutableConfigPermsSpy = vi - .spyOn(shields, "inspectMutableConfigPerms") - .mockReturnValue({ - applies: true, - ok: true, - dirMode: "2770", - dirOwner: "sandbox:sandbox", - fileMode: "660", - fileOwner: "sandbox:sandbox", - configDir: "/sandbox/.openclaw", - configFile: "openclaw.json", - issues: [], - }); - const repairMutableConfigPermsSpy = vi - .spyOn(shields, "repairMutableConfigPerms") - .mockReturnValue({ - applied: true, - verified: true, - errors: [], - }); - vi.spyOn(statusCommandDeps, "buildStatusCommandDeps").mockReturnValue({}); - vi.spyOn(tunnelServices, "readCloudflaredState").mockReturnValue({ kind: "running", pid: 1234 }); - const executeSandboxCommandForVerificationSpy = vi - .spyOn(sandboxVerificationExec, "executeSandboxCommandForVerification") - .mockReturnValue({ - status: 0, - stdout: "ok", - stderr: "", - }); - const buildToolScopeChecksSpy = vi - .spyOn(doctorToolScope, "buildToolScopeChecks") - .mockReturnValue([ - { - group: "Sandbox", - label: "Tool scope approvals", - status: "ok", - detail: "no pending approvals", - }, - ]); - - logSpy.mockClear(); - - return { - buildToolScopeChecksSpy, - captureOpenShellSpy, - captureHostCommandSpy, - configuredMessagingChannelsSpy, - executeSandboxCommandForVerificationSpy, - getSandboxSpy, - getNamedGatewayLifecycleStateSpy, - healthProbeSpy, - inspectMutableConfigPermsSpy, - loadAgentSpy, - probeSandboxInferenceGatewayHealthSpy, - logSpy, - recoverNamedGatewayRuntimeSpy, - repairMutableConfigPermsSpy, - resolveOpenShellSpy, - runSandboxDoctor: requireDist(doctorModulePath).runSandboxDoctor, - }; -} - -function mockTelegramDoctorRegistry(options: { - agent: "openclaw" | "hermes"; - inputs?: ReadonlyArray; -}): void { - const registry = requireDist("../../state/registry.js"); - applyTelegramDoctorRegistryMocks(registry, options); -} - -async function setupDoctorRealPlanReader( - harness: { getSandboxSpy: MockInstance }, - options: CompactTelegramEntryOptions, -): Promise { - const { entry } = await compactTelegramEntryFromEnv(options); - harness.getSandboxSpy.mockReturnValue({ - name: "alpha", - agent: options.agentName ?? "openclaw", - model: "registry-model", - provider: "ollama-local", - openshellDriver: "docker", - gatewayName: "nemoclaw-19080", - gatewayPort: 19080, - messaging: (entry as { messaging: unknown }).messaging, - }); - const registry = requireDist("../../state/registry.js"); - const registryMessaging = requireDist("../../state/registry-messaging.js"); - vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); - vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); - vi.spyOn(registry, "getMessagingPlanFromEntry").mockImplementation((entry) => - registryMessaging.getMessagingPlanFromEntry(entry), - ); +function createDoctorHarness(): DoctorHarness { + return createDoctorHarnessShared(requireDist); } describe("runSandboxDoctor flow", () => { @@ -290,238 +69,6 @@ describe("runSandboxDoctor flow", () => { }, ); - it("surfaces Telegram visible config inputs in the Messaging doctor section", async () => { - const harness = createDoctorHarness(); - mockTelegramDoctorRegistry({ - agent: "openclaw", - inputs: [{ inputId: "requireMention", value: "0" }], - }); - - const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - - expect(report?.checks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - group: "Messaging", - label: "Telegram group mention mode", - status: "ok", - detail: "all group messages (TELEGRAM_REQUIRE_MENTION=0)", - }), - expect.objectContaining({ - group: "Messaging", - label: "Telegram group policy", - status: "info", - detail: "open groups (default)", - }), - ]), - ); - const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); - const sensitiveLabels = ["Bot Token", "User ID", "secret"]; - const leakedLabel = messagingChecks.find((check) => - sensitiveLabels.some((sensitive) => - check.label.toLowerCase().includes(sensitive.toLowerCase()), - ), - ); - expect(leakedLabel).toBeUndefined(); - }); - - it("hides the OpenClaw-only Telegram group policy when the sandbox runs Hermes", async () => { - const harness = createDoctorHarness(); - harness.getSandboxSpy.mockReturnValue({ - name: "alpha", - agent: "hermes", - model: "registry-model", - provider: "ollama-local", - openshellDriver: "docker", - gatewayName: "nemoclaw-19080", - gatewayPort: 19080, - messaging: undefined, - }); - mockTelegramDoctorRegistry({ agent: "hermes" }); - - const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); - - expect(messagingChecks.some((check) => check.label === "Telegram group policy")).toBe(false); - expect( - messagingChecks.find((check) => check.label === "Telegram group mention mode"), - ).toMatchObject({ status: "info", detail: "mention-only (default)" }); - }); - - it("falls back to OpenClaw visible config when a legacy SandboxEntry omits the agent field", async () => { - const harness = createDoctorHarness(); - harness.getSandboxSpy.mockReturnValue({ - name: "alpha", - model: "registry-model", - provider: "ollama-local", - openshellDriver: "docker", - gatewayName: "nemoclaw-19080", - gatewayPort: 19080, - messaging: undefined, - }); - mockTelegramDoctorRegistry({ agent: "openclaw" }); - - const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); - - expect(messagingChecks.find((check) => check.label === "Telegram group policy")).toMatchObject({ - status: "info", - detail: "open groups (default)", - }); - expect( - messagingChecks.find((check) => check.label === "Telegram group mention mode"), - ).toMatchObject({ status: "info", detail: "mention-only (default)" }); - }); - - it("flags an invalid persisted Telegram group policy without echoing the raw value", async () => { - const harness = createDoctorHarness(); - mockTelegramDoctorRegistry({ - agent: "openclaw", - inputs: [{ inputId: "groupPolicy", value: "definitely-not-a-policy" }], - }); - - const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - const policyCheck = (report?.checks ?? []).find( - (check) => check.group === "Messaging" && check.label === "Telegram group policy", - ); - - expect(policyCheck).toBeDefined(); - expect(policyCheck?.status).toBe("warn"); - expect(policyCheck?.detail).toMatch( - /invalid persisted value \(expected: open \| allowlist \| disabled\)/, - ); - expect(policyCheck?.detail).not.toContain("definitely-not-a-policy"); - }); - - it("flags a present-but-empty Telegram mention-mode value as invalid rather than defaulting", async () => { - const harness = createDoctorHarness(); - mockTelegramDoctorRegistry({ - agent: "openclaw", - inputs: [{ inputId: "requireMention", value: "" }], - }); - - const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - const mentionCheck = (report?.checks ?? []).find( - (check) => check.group === "Messaging" && check.label === "Telegram group mention mode", - ); - - expect(mentionCheck).toBeDefined(); - expect(mentionCheck?.status).toBe("warn"); - expect(mentionCheck?.detail).toMatch(/invalid persisted value/); - expect(mentionCheck?.detail).not.toMatch(/default/); - }); - - it("surfaces Telegram visible config from a plan compiled out of process env through doctor", async () => { - const harness = createDoctorHarness(); - const compiledPlan = await compileTelegramPlanForTests({ - envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist" }, - }); - const registry = requireDist("../../state/registry.js"); - vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); - vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); - vi.spyOn(registry, "getMessagingPlanFromEntry").mockReturnValue(compiledPlan); - - const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); - - expect(messagingChecks.find((check) => check.label === "Telegram group policy")).toMatchObject({ - status: "ok", - detail: "allowlisted groups only (TELEGRAM_GROUP_POLICY=allowlist)", - }); - expect( - messagingChecks.find((check) => check.label === "Telegram group mention mode"), - ).toMatchObject({ - status: "ok", - detail: "mention-only (TELEGRAM_REQUIRE_MENTION=1)", - }); - }); - - it("reads Telegram visible config from a non-interactive compact registry entry through the real plan reader", async () => { - const harness = createDoctorHarness(); - await setupDoctorRealPlanReader(harness, { - envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, - isInteractive: false, - }); - - const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); - - expect(messagingChecks.find((check) => check.label === "Telegram group policy")).toMatchObject({ - status: "ok", - detail: "allowlisted groups only (TELEGRAM_GROUP_POLICY=allowlist)", - }); - expect( - messagingChecks.find((check) => check.label === "Telegram group mention mode"), - ).toMatchObject({ - status: "ok", - detail: "all group messages (TELEGRAM_REQUIRE_MENTION=0)", - }); - }); - - it("renders mention-only when a non-interactive compact registry entry omits TELEGRAM_REQUIRE_MENTION", async () => { - const harness = createDoctorHarness(); - await setupDoctorRealPlanReader(harness, { - envOverrides: { TELEGRAM_GROUP_POLICY: undefined, TELEGRAM_REQUIRE_MENTION: undefined }, - isInteractive: false, - }); - - const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); - - expect( - messagingChecks.find((check) => check.label === "Telegram group mention mode"), - ).toMatchObject({ - status: "ok", - detail: "mention-only (TELEGRAM_REQUIRE_MENTION=1)", - }); - }); - - it("bounds tampered out-of-allowlist Telegram visible config from a compact registry entry through the real plan reader", async () => { - const harness = createDoctorHarness(); - await setupDoctorRealPlanReader(harness, { - envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, - isInteractive: false, - tamperedInputs: { groupPolicy: "definitely-not-a-policy", requireMention: "" }, - }); - - const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); - - const policy = messagingChecks.find((check) => check.label === "Telegram group policy"); - expect(policy?.status).toBe("warn"); - expect(policy?.detail).toMatch( - /invalid persisted value \(expected: open \| allowlist \| disabled\)/, - ); - expect(policy?.detail).not.toContain("definitely-not-a-policy"); - - const mention = messagingChecks.find((check) => check.label === "Telegram group mention mode"); - expect(mention?.status).toBe("warn"); - expect(mention?.detail).toMatch(/invalid persisted value/); - }); - - it("redacts non-scalar Telegram visible config from a compact registry entry through the real plan reader", async () => { - const harness = createDoctorHarness(); - await setupDoctorRealPlanReader(harness, { - envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, - isInteractive: false, - tamperedInputs: { - groupPolicy: { smuggled: "secret-id" } as unknown, - requireMention: ["1", "0"] as unknown, - }, - }); - - const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); - - const policy = messagingChecks.find((check) => check.label === "Telegram group policy"); - expect(policy?.detail).toMatch(/invalid persisted value \(unsupported type\)/); - expect(policy?.detail).not.toContain("secret-id"); - expect(policy?.detail).not.toContain("smuggled"); - - const mention = messagingChecks.find((check) => check.label === "Telegram group mention mode"); - expect(mention?.detail).toMatch(/invalid persisted value \(unsupported type\)/); - }); - it("rejects mutating --fix when JSON output was requested", async () => { const harness = createDoctorHarness(); diff --git a/src/lib/actions/sandbox/doctor-gateway-fallback.ts b/src/lib/actions/sandbox/doctor-gateway-fallback.ts index b053eafac82..dc48d16cc0a 100644 --- a/src/lib/actions/sandbox/doctor-gateway-fallback.ts +++ b/src/lib/actions/sandbox/doctor-gateway-fallback.ts @@ -4,7 +4,7 @@ import { GATEWAY_PORT } from "../../core/ports"; import { HOST_GATEWAY_PGREP_PATTERN } from "../../onboard/host-gateway-process"; import type { DoctorCheck } from "./doctor"; -import { captureHostCommand, type CommandCapture } from "./doctor-host-command"; +import { type CommandCapture, captureHostCommand } from "./doctor-host-command"; export type GatewayInspectOptions = { namedGatewayConnected?: boolean; diff --git a/src/lib/actions/sandbox/doctor-messaging-visibility.test.ts b/src/lib/actions/sandbox/doctor-messaging-visibility.test.ts new file mode 100644 index 00000000000..fa737f8bf8b --- /dev/null +++ b/src/lib/actions/sandbox/doctor-messaging-visibility.test.ts @@ -0,0 +1,276 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createRequire } from "node:module"; + +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; + +import { compileTelegramPlanForTests } from "../../messaging/__test-utils__/telegram-plan"; +import { + createDoctorHarness as createDoctorHarnessShared, + type DoctorHarness, + mockTelegramDoctorRegistryForHarness, + setupDoctorRealPlanReader as setupDoctorRealPlanReaderShared, +} from "./__test-utils__/doctor-harness"; + +const requireDist = createRequire(import.meta.url); +const doctorModulePath = "./doctor.js"; + +function createDoctorHarness(): DoctorHarness { + return createDoctorHarnessShared(requireDist); +} + +function mockTelegramDoctorRegistry( + options: Parameters[1], +): void { + mockTelegramDoctorRegistryForHarness(requireDist, options); +} + +async function setupDoctorRealPlanReader( + harness: { getSandboxSpy: MockInstance }, + options: Parameters[2], +): Promise { + await setupDoctorRealPlanReaderShared(requireDist, harness, options); +} + +describe("runSandboxDoctor messaging visibility", () => { + beforeEach(() => { + vi.spyOn(process, "exit").mockImplementation(((code?: number | string | null) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete require.cache[requireDist.resolve(doctorModulePath)]; + }); + + it("surfaces Telegram visible config inputs in the Messaging doctor section", async () => { + const harness = createDoctorHarness(); + mockTelegramDoctorRegistry({ + agent: "openclaw", + inputs: [{ inputId: "requireMention", value: "0" }], + }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + + expect(report?.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + group: "Messaging", + label: "Telegram group mention mode", + status: "ok", + detail: "all group messages (TELEGRAM_REQUIRE_MENTION=0)", + }), + expect.objectContaining({ + group: "Messaging", + label: "Telegram group policy", + status: "info", + detail: "open groups (default)", + }), + ]), + ); + const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); + const sensitiveLabels = ["Bot Token", "User ID", "secret"]; + const leakedLabel = messagingChecks.find((check) => + sensitiveLabels.some((sensitive) => + check.label.toLowerCase().includes(sensitive.toLowerCase()), + ), + ); + expect(leakedLabel).toBeUndefined(); + }); + + it("hides the OpenClaw-only Telegram group policy when the sandbox runs Hermes", async () => { + const harness = createDoctorHarness(); + harness.getSandboxSpy.mockReturnValue({ + name: "alpha", + agent: "hermes", + model: "registry-model", + provider: "ollama-local", + openshellDriver: "docker", + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + messaging: undefined, + }); + mockTelegramDoctorRegistry({ agent: "hermes" }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); + + expect(messagingChecks.some((check) => check.label === "Telegram group policy")).toBe(false); + expect( + messagingChecks.find((check) => check.label === "Telegram group mention mode"), + ).toMatchObject({ status: "info", detail: "mention-only (default)" }); + }); + + it("hides agent-applicability-restricted visible config when a legacy SandboxEntry omits the agent field", async () => { + const harness = createDoctorHarness(); + harness.getSandboxSpy.mockReturnValue({ + name: "alpha", + model: "registry-model", + provider: "ollama-local", + openshellDriver: "docker", + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + messaging: undefined, + }); + mockTelegramDoctorRegistry({ agent: "openclaw" }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); + + expect(messagingChecks.some((check) => check.label === "Telegram group policy")).toBe(false); + expect( + messagingChecks.find((check) => check.label === "Telegram group mention mode"), + ).toMatchObject({ status: "info", detail: "mention-only (default)" }); + }); + + it("flags an invalid persisted Telegram group policy without echoing the raw value", async () => { + const harness = createDoctorHarness(); + mockTelegramDoctorRegistry({ + agent: "openclaw", + inputs: [{ inputId: "groupPolicy", value: "definitely-not-a-policy" }], + }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const policyCheck = (report?.checks ?? []).find( + (check) => check.group === "Messaging" && check.label === "Telegram group policy", + ); + + expect(policyCheck).toBeDefined(); + expect(policyCheck?.status).toBe("warn"); + expect(policyCheck?.detail).toMatch( + /invalid persisted value \(expected: open \| allowlist \| disabled\)/, + ); + expect(policyCheck?.detail).not.toContain("definitely-not-a-policy"); + }); + + it("flags a present-but-empty Telegram mention-mode value as invalid rather than defaulting", async () => { + const harness = createDoctorHarness(); + mockTelegramDoctorRegistry({ + agent: "openclaw", + inputs: [{ inputId: "requireMention", value: "" }], + }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const mentionCheck = (report?.checks ?? []).find( + (check) => check.group === "Messaging" && check.label === "Telegram group mention mode", + ); + + expect(mentionCheck).toBeDefined(); + expect(mentionCheck?.status).toBe("warn"); + expect(mentionCheck?.detail).toMatch(/invalid persisted value/); + expect(mentionCheck?.detail).not.toMatch(/default/); + }); + + it("surfaces Telegram visible config from a plan compiled out of process env through doctor", async () => { + const harness = createDoctorHarness(); + const compiledPlan = await compileTelegramPlanForTests({ + envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist" }, + }); + const registry = requireDist("../../state/registry.js"); + vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); + vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); + vi.spyOn(registry, "getMessagingPlanFromEntry").mockReturnValue(compiledPlan); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); + + expect(messagingChecks.find((check) => check.label === "Telegram group policy")).toMatchObject({ + status: "ok", + detail: "allowlisted groups only (TELEGRAM_GROUP_POLICY=allowlist)", + }); + expect( + messagingChecks.find((check) => check.label === "Telegram group mention mode"), + ).toMatchObject({ + status: "ok", + detail: "mention-only (TELEGRAM_REQUIRE_MENTION=1)", + }); + }); + + it("reads Telegram visible config from a non-interactive compact registry entry through the real plan reader", async () => { + const harness = createDoctorHarness(); + await setupDoctorRealPlanReader(harness, { + envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, + isInteractive: false, + }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); + + expect(messagingChecks.find((check) => check.label === "Telegram group policy")).toMatchObject({ + status: "ok", + detail: "allowlisted groups only (TELEGRAM_GROUP_POLICY=allowlist)", + }); + expect( + messagingChecks.find((check) => check.label === "Telegram group mention mode"), + ).toMatchObject({ + status: "ok", + detail: "all group messages (TELEGRAM_REQUIRE_MENTION=0)", + }); + }); + + it("renders mention-only when a non-interactive compact registry entry omits TELEGRAM_REQUIRE_MENTION", async () => { + const harness = createDoctorHarness(); + await setupDoctorRealPlanReader(harness, { + envOverrides: { TELEGRAM_GROUP_POLICY: undefined, TELEGRAM_REQUIRE_MENTION: undefined }, + isInteractive: false, + }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); + + expect( + messagingChecks.find((check) => check.label === "Telegram group mention mode"), + ).toMatchObject({ + status: "ok", + detail: "mention-only (TELEGRAM_REQUIRE_MENTION=1)", + }); + }); + + it("bounds tampered out-of-allowlist Telegram visible config from a compact registry entry through the real plan reader", async () => { + const harness = createDoctorHarness(); + await setupDoctorRealPlanReader(harness, { + envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, + isInteractive: false, + tamperedInputs: { groupPolicy: "definitely-not-a-policy", requireMention: "" }, + }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); + + const policy = messagingChecks.find((check) => check.label === "Telegram group policy"); + expect(policy?.status).toBe("warn"); + expect(policy?.detail).toMatch( + /invalid persisted value \(expected: open \| allowlist \| disabled\)/, + ); + expect(policy?.detail).not.toContain("definitely-not-a-policy"); + + const mention = messagingChecks.find((check) => check.label === "Telegram group mention mode"); + expect(mention?.status).toBe("warn"); + expect(mention?.detail).toMatch(/invalid persisted value/); + }); + + it("redacts non-scalar Telegram visible config from a compact registry entry through the real plan reader", async () => { + const harness = createDoctorHarness(); + await setupDoctorRealPlanReader(harness, { + envOverrides: { TELEGRAM_GROUP_POLICY: "allowlist", TELEGRAM_REQUIRE_MENTION: "0" }, + isInteractive: false, + tamperedInputs: { + groupPolicy: { smuggled: "secret-id" } as unknown, + requireMention: ["1", "0"] as unknown, + }, + }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const messagingChecks = (report?.checks ?? []).filter((check) => check.group === "Messaging"); + + const policy = messagingChecks.find((check) => check.label === "Telegram group policy"); + expect(policy?.detail).toMatch(/invalid persisted value \(unsupported type\)/); + expect(policy?.detail).not.toContain("secret-id"); + expect(policy?.detail).not.toContain("smuggled"); + + const mention = messagingChecks.find((check) => check.label === "Telegram group mention mode"); + expect(mention?.detail).toMatch(/invalid persisted value \(unsupported type\)/); + }); +}); diff --git a/src/lib/actions/sandbox/doctor-messaging.ts b/src/lib/actions/sandbox/doctor-messaging.ts index d6f13d53ad0..21aa8bd7b0d 100644 --- a/src/lib/actions/sandbox/doctor-messaging.ts +++ b/src/lib/actions/sandbox/doctor-messaging.ts @@ -10,12 +10,16 @@ import { type MessagingChannelDiagnosticSpec, } from "../../messaging/diagnostics"; import { asMessagingAgent } from "../../messaging/manifest"; +import { + visibleConfigDoctorHint, + visibleConfigDoctorStatus, +} from "../../messaging/visible-config-output"; import { executeSandboxCommandForVerification } from "../../onboard/sandbox-verification-exec"; import { ROOT } from "../../runner"; import type { SandboxEntry } from "../../state/registry"; import * as registry from "../../state/registry"; import { buildStatusCommandDeps } from "../../status-command-deps"; -import type { DoctorCheck, DoctorStatus } from "./doctor-report"; +import type { DoctorCheck } from "./doctor-report"; const CHANNEL_STATUS_DIAGNOSTICS = collectBuiltInMessagingChannelDiagnostics(); @@ -269,30 +273,21 @@ function activeChannelsFromEntry(sb: SandboxEntry): string[] { return registered.filter((channel: string) => !disabled.has(channel)); } -function visibleConfigDoctorHint( - sandboxName: string, - channelName: string, - source: "persisted" | "default" | "invalid", -): string | undefined { - if (source === "default") { - return `run \`${CLI_NAME} ${sandboxName} channels status --channel ${channelName}\` to confirm the resolved value`; - } - if (source === "invalid") { - return `run \`${CLI_NAME} ${sandboxName} channels add ${channelName}\` to re-enter a valid value`; - } - return undefined; -} - function buildVisibleConfigDoctorCheck( sandboxName: string, channelName: string, record: ReturnType[number], ): DoctorCheck { - const hint = visibleConfigDoctorHint(sandboxName, channelName, record.display.source); + const hint = visibleConfigDoctorHint({ + cli: CLI_NAME, + sandboxName, + channelName, + source: record.display.source, + }); return { group: "Messaging", label: record.input.label, - status: doctorStatusForDisplay(record.display.source), + status: visibleConfigDoctorStatus(record.display.source), detail: record.display.detail, ...(hint === undefined ? {} : { hint }), }; @@ -302,13 +297,7 @@ function messagingChannelConfigDoctorChecks(sandboxName: string, sb: SandboxEntr const activeChannels = activeChannelsFromEntry(sb); if (activeChannels.length === 0) return []; const plan = registry.getMessagingPlanFromEntry(sb); - // Legacy sandbox entries from before the agent field was mandatory may - // still hydrate with `sb.agent === undefined`; default to "openclaw" so - // agent-applicability filtering matches the historical behaviour. Existing - // regression: doctor-flow.test.ts asserts the OpenClaw visible-config path - // when a legacy entry omits the field. Remove the fallback once every - // managed sandbox has been migrated by an explicit registry upgrade. - const agent = asMessagingAgent(sb.agent ?? "openclaw"); + const agent = asMessagingAgent(sb.agent); const checks: DoctorCheck[] = []; for (const channelName of activeChannels) { const diagnostic = getChannelStatusDiagnostic(channelName); @@ -321,17 +310,6 @@ function messagingChannelConfigDoctorChecks(sandboxName: string, sb: SandboxEntr return checks; } -function doctorStatusForDisplay(source: "persisted" | "default" | "invalid"): DoctorStatus { - switch (source) { - case "persisted": - return "ok"; - case "default": - return "info"; - case "invalid": - return "warn"; - } -} - export function collectMessagingDoctorChecks( sandboxName: string, sb: SandboxEntry, diff --git a/src/lib/actions/sandbox/gateway-state.ts b/src/lib/actions/sandbox/gateway-state.ts index b7ee96da27e..77de8830a70 100644 --- a/src/lib/actions/sandbox/gateway-state.ts +++ b/src/lib/actions/sandbox/gateway-state.ts @@ -35,11 +35,11 @@ import { OPENSHELL_OPERATION_TIMEOUT_MS, OPENSHELL_PROBE_TIMEOUT_MS, } from "../../adapters/openshell/timeouts"; -import { isDockerRuntimeDown, printDockerRuntimeDownGuidance } from "./gateway-failure-classifier"; import { - recoverDockerDriverSandbox, type DockerDriverRecoveryResult, + recoverDockerDriverSandbox, } from "../../onboard/docker-driver-sandbox-recovery"; +import { isDockerRuntimeDown, printDockerRuntimeDownGuidance } from "./gateway-failure-classifier"; export type SandboxGatewayState = { state: string; diff --git a/src/lib/actions/sandbox/host-aliases.ts b/src/lib/actions/sandbox/host-aliases.ts index b5e1d889a1c..b90fb872e6c 100644 --- a/src/lib/actions/sandbox/host-aliases.ts +++ b/src/lib/actions/sandbox/host-aliases.ts @@ -4,9 +4,9 @@ import { isIP } from "node:net"; import { + type DockerSpawnSyncResult, dockerExecFileSync, dockerSpawnSync, - type DockerSpawnSyncResult, } from "../../adapters/docker/exec"; import { CLI_NAME } from "../../cli/branding"; import type { SandboxEntry } from "../../state/registry"; diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index d85bb40c5f5..8ed9066acb7 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -8,7 +8,6 @@ import { type AgentDefinition, loadAgent } from "../../agent/defs"; import { CLI_DISPLAY_NAME, CLI_NAME } from "../../cli/branding"; import { prompt as askPrompt, getCredential } from "../../credentials/store"; import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; -import { getSandboxTargetGatewayName } from "./gateway-target"; import { type ChannelManifest, createBuiltInChannelManifestRegistry, @@ -30,6 +29,7 @@ import { } from "../../messaging"; import { hydrateMessagingChannelConfig } from "../../messaging-channel-config"; import { hashCredential } from "../../security/credential-hash"; +import { getSandboxTargetGatewayName } from "./gateway-target"; const { isNonInteractive } = require("../../onboard") as { isNonInteractive: () => boolean }; const onboardProviders = require("../../onboard/providers"); @@ -60,10 +60,10 @@ import { } from "../../sandbox/channels"; import * as registry from "../../state/registry"; import { isDockerRuntimeDown, printDockerRuntimeDownGuidance } from "./gateway-failure-classifier"; +import { ensureMessagingHostForwardAfterRebuild } from "./messaging-host-forward-lifecycle"; import { refreshSandboxPolicyContextFile } from "./policy-context-refresh"; import { executeSandboxCommand, executeSandboxExecCommand } from "./process-recovery"; import { rebuildSandbox } from "./rebuild"; -import { ensureMessagingHostForwardAfterRebuild } from "./messaging-host-forward-lifecycle"; type ChannelMutationOptions = { channel?: string; diff --git a/src/lib/actions/sandbox/policy-context-refresh.ts b/src/lib/actions/sandbox/policy-context-refresh.ts index d711939a7c1..f3798aae658 100644 --- a/src/lib/actions/sandbox/policy-context-refresh.ts +++ b/src/lib/actions/sandbox/policy-context-refresh.ts @@ -3,8 +3,8 @@ import { POLICY_CONTEXT_SANDBOX_PATH, - writePolicyContextToSandbox, type WritePolicyContextResult, + writePolicyContextToSandbox, } from "./policy-explain"; /** diff --git a/src/lib/actions/sandbox/policy-explain.test.ts b/src/lib/actions/sandbox/policy-explain.test.ts index a7cd9851279..635e69c639c 100644 --- a/src/lib/actions/sandbox/policy-explain.test.ts +++ b/src/lib/actions/sandbox/policy-explain.test.ts @@ -10,8 +10,8 @@ vi.mock("../../policy/context", () => ({ import type { PolicyContext } from "../../policy/context"; import { - POLICY_CONTEXT_SANDBOX_PATH, explainSandboxPolicy, + POLICY_CONTEXT_SANDBOX_PATH, writePolicyContextToSandbox, } from "./policy-explain"; diff --git a/src/lib/actions/sandbox/rebuild-flow-helpers.ts b/src/lib/actions/sandbox/rebuild-flow-helpers.ts index 3676c35f8f9..5d2caa24d1c 100644 --- a/src/lib/actions/sandbox/rebuild-flow-helpers.ts +++ b/src/lib/actions/sandbox/rebuild-flow-helpers.ts @@ -5,9 +5,12 @@ import { detectOpenShellStateRpcResultIssue, printOpenShellStateRpcIssue, } from "../../adapters/openshell/gateway-drift"; +import { loadAgent } from "../../agent/defs"; import { ensureAgentBaseImage } from "../../agent/onboard"; +import { CLI_NAME } from "../../cli/branding"; import { RD as _RD, G, R, YW } from "../../cli/terminal-style"; import { getNamedGatewayLifecycleState } from "../../gateway-runtime-action"; +import { resolveSandboxGatewayName } from "../../onboard/gateway-binding"; import { captureSandboxListWithGatewayRecovery, printSandboxListFailureWithRecoveryContext, @@ -17,9 +20,6 @@ import * as shields from "../../shields"; import * as registry from "../../state/registry"; import * as sandboxState from "../../state/sandbox"; import * as userManagedFilesProbe from "../../state/user-managed-files-probe"; -import { loadAgent } from "../../agent/defs"; -import { CLI_NAME } from "../../cli/branding"; -import { resolveSandboxGatewayName } from "../../onboard/gateway-binding"; import { getReconciledSandboxGatewayState, printGatewayLifecycleHint, diff --git a/src/lib/actions/sandbox/rebuild-shields.ts b/src/lib/actions/sandbox/rebuild-shields.ts index c370edc7394..93afd0cba72 100644 --- a/src/lib/actions/sandbox/rebuild-shields.ts +++ b/src/lib/actions/sandbox/rebuild-shields.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { G, R, RD as _RD, YW } from "../../cli/terminal-style"; +import { RD as _RD, G, R, YW } from "../../cli/terminal-style"; import * as shields from "../../shields"; export interface RebuildShieldsWindow { diff --git a/src/lib/actions/sandbox/sessions/delete.test.ts b/src/lib/actions/sandbox/sessions/delete.test.ts index b83e553e6fe..131facf4036 100644 --- a/src/lib/actions/sandbox/sessions/delete.test.ts +++ b/src/lib/actions/sandbox/sessions/delete.test.ts @@ -12,8 +12,8 @@ vi.mock("./gateway-rpc", () => ({ })); import { ensureLiveSandboxOrExit } from "../gateway-state"; -import { callOpenclawGateway } from "./gateway-rpc"; import { deleteSandboxSession } from "./delete"; +import { callOpenclawGateway } from "./gateway-rpc"; const ensureMock = ensureLiveSandboxOrExit as unknown as ReturnType; const gatewayMock = callOpenclawGateway as unknown as ReturnType; diff --git a/src/lib/actions/sandbox/sessions/export.ts b/src/lib/actions/sandbox/sessions/export.ts index 6b45c5bbaf2..1e04d748335 100644 --- a/src/lib/actions/sandbox/sessions/export.ts +++ b/src/lib/actions/sandbox/sessions/export.ts @@ -47,13 +47,13 @@ import * as registry from "../../../state/registry"; import { ensureLiveSandboxOrExit } from "../gateway-state"; import { resolveHostPathFromCwd } from "../host-path"; import { isWarmupSessionId } from "../warmup-session"; -import { type SessionIndexEntry, parseSessionIndex } from "./session-index"; import { DEFAULT_AGENT_ID, parseAgentIdFromSessionKey, validateAgentId, validateSessionKey, } from "./paths"; +import { parseSessionIndex, type SessionIndexEntry } from "./session-index"; export type SessionsExportFormat = "dir" | "tar" | "jsonl"; diff --git a/src/lib/actions/sandbox/status-text.ts b/src/lib/actions/sandbox/status-text.ts index 52df583125e..8c5feb1be1c 100644 --- a/src/lib/actions/sandbox/status-text.ts +++ b/src/lib/actions/sandbox/status-text.ts @@ -9,7 +9,7 @@ import type { ProviderHealthStatus } from "../../inference/health"; import * as nim from "../../inference/nim"; import * as sandboxVersion from "../../sandbox/version"; import * as shields from "../../shields"; -import type { SandboxGpuProofResult, SandboxEntry } from "../../state/registry"; +import type { SandboxEntry, SandboxGpuProofResult } from "../../state/registry"; import { createSystemDeps as createSessionDeps, getActiveSandboxSessions, diff --git a/src/lib/actions/sandbox/wipe-state.ts b/src/lib/actions/sandbox/wipe-state.ts index 3d735706efa..cef5fee60ba 100644 --- a/src/lib/actions/sandbox/wipe-state.ts +++ b/src/lib/actions/sandbox/wipe-state.ts @@ -3,7 +3,7 @@ import path from "node:path"; -import { YW, R } from "../../cli/terminal-style"; +import { R, YW } from "../../cli/terminal-style"; import { shellQuote } from "../../core/shell-quote"; import * as registry from "../../state/registry"; diff --git a/src/lib/messaging/__test-utils__/planner-harness.ts b/src/lib/messaging/__test-utils__/planner-harness.ts new file mode 100644 index 00000000000..7a7ef694290 --- /dev/null +++ b/src/lib/messaging/__test-utils__/planner-harness.ts @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + createBuiltInChannelManifestRegistry, + createBuiltInRenderTemplateResolver, +} from "../channels"; +import { MessagingWorkflowPlanner } from "../compiler/workflow-planner"; +import { createBuiltInMessagingHookRegistry } from "../hooks"; + +export const PLANNER_TEST_CREDENTIALS: Readonly> = { + TELEGRAM_BOT_TOKEN: "123456:test-telegram-token", + DISCORD_BOT_TOKEN: "test-discord-token", + WECHAT_BOT_TOKEN: "test-wechat-token", + SLACK_BOT_TOKEN: "xoxb-test-slack-token", + SLACK_APP_TOKEN: "xapp-test-slack-token", + MSTEAMS_APP_PASSWORD: "test-teams-client-secret", +}; + +const PLANNER_TEST_WECHAT_LOGIN = { + token: "test-wechat-token", + accountId: "test-wechat-account", + baseUrl: "https://ilinkai.wechat.com", + userId: "test-wechat-user", +} as const; + +export function createPlannerForTests(): MessagingWorkflowPlanner { + return new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + createBuiltInMessagingHookRegistry({ + common: { + env: {}, + getCredential: (key) => PLANNER_TEST_CREDENTIALS[key] ?? null, + saveCredential: () => {}, + prompt: async () => "unused", + log: () => {}, + }, + slack: { + validateCredentials: { + log: () => {}, + validateCredentials: () => ({ ok: true }), + }, + }, + telegram: { + fetch: async () => ({ + ok: true, + status: 200, + async json() { + return { ok: true }; + }, + async text() { + return ""; + }, + }), + }, + wechat: { + ilinkLogin: { + env: {}, + saveCredential: () => {}, + log: () => {}, + runLogin: async () => ({ + kind: "ok", + credentials: PLANNER_TEST_WECHAT_LOGIN, + }), + }, + seedOpenClawAccount: { + now: () => "2026-01-01T00:00:00.000Z", + }, + }, + }), + createBuiltInRenderTemplateResolver(), + ); +} + +export async function withPlannerEnv( + values: Readonly>, + run: () => Promise, +): Promise { + const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]])); + try { + applyPlannerEnv(values); + return await run(); + } finally { + applyPlannerEnv(previous); + } +} + +function applyPlannerEnv(values: Readonly>): void { + for (const [key, value] of Object.entries(values)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} diff --git a/src/lib/messaging/applier/host-state-applier.test.ts b/src/lib/messaging/applier/host-state-applier.test.ts index 1bd6232c080..64b7a76cae5 100644 --- a/src/lib/messaging/applier/host-state-applier.test.ts +++ b/src/lib/messaging/applier/host-state-applier.test.ts @@ -2,12 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { beforeEach, describe, expect, it, vi } from "vitest"; - +import * as registry from "../../state/registry"; import type { SandboxMessagingPlan } from "../manifest"; import { compactSandboxMessagingPlanForPersistence } from "../persistence"; import { MessagingHostStateApplier } from "./host-state-applier"; import { MessagingSetupApplier } from "./setup-applier"; -import * as registry from "../../state/registry"; vi.mock("../../state/registry", () => { const sandboxes = new Map>(); diff --git a/src/lib/messaging/applier/index.ts b/src/lib/messaging/applier/index.ts index 3cec51ee919..df7b1f5c098 100644 --- a/src/lib/messaging/applier/index.ts +++ b/src/lib/messaging/applier/index.ts @@ -1,11 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -export * from "./setup-applier"; -export * from "./host-state-applier"; export * from "./agent-config"; -export * from "./hook-phases"; export * from "./conflict-detection"; +export * from "./hook-phases"; +export * from "./host-state-applier"; export * from "./openshell-provider"; export * from "./policy"; +export * from "./setup-applier"; export type * from "./types"; diff --git a/src/lib/messaging/applier/openshell-provider.ts b/src/lib/messaging/applier/openshell-provider.ts index 32577df50e1..2d654b6e09e 100644 --- a/src/lib/messaging/applier/openshell-provider.ts +++ b/src/lib/messaging/applier/openshell-provider.ts @@ -3,12 +3,12 @@ import { redact } from "../../security/redact"; import type { SandboxMessagingCredentialBindingPlan, SandboxMessagingPlan } from "../manifest"; +import { filterEnabledPlanEntries } from "./plan-filter"; import type { MessagingCredentialApplyOptions, MessagingCredentialApplyResult, MessagingOpenShellRunner, } from "./types"; -import { filterEnabledPlanEntries } from "./plan-filter"; type MessagingCredentialApplyEntry = MessagingCredentialApplyResult["upserted"][number]; type MessagingCredentialReuseEntry = MessagingCredentialApplyResult["reused"][number]; diff --git a/src/lib/messaging/applier/types.ts b/src/lib/messaging/applier/types.ts index 70e6a77be43..35ff48737b1 100644 --- a/src/lib/messaging/applier/types.ts +++ b/src/lib/messaging/applier/types.ts @@ -1,21 +1,21 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { + MessagingHookInputMap, + MessagingHookOutputMap, + MessagingHookRunResult, +} from "../hooks"; import type { ChannelHookFailureMode, ChannelHookOutputSpec, ChannelHookPhase, MessagingAgentId, MessagingChannelId, - SandboxMessagingNetworkPolicyEntryPlan, SandboxMessagingHookReferencePlan, + SandboxMessagingNetworkPolicyEntryPlan, SandboxMessagingPlan, } from "../manifest"; -import type { - MessagingHookInputMap, - MessagingHookOutputMap, - MessagingHookRunResult, -} from "../hooks"; export const MESSAGING_SETUP_APPLIER_ENV_KEY = "NEMOCLAW_MESSAGING_PLAN_B64"; diff --git a/src/lib/messaging/channels/built-ins.ts b/src/lib/messaging/channels/built-ins.ts index aeb84391148..ca980e1ed37 100644 --- a/src/lib/messaging/channels/built-ins.ts +++ b/src/lib/messaging/channels/built-ins.ts @@ -5,15 +5,15 @@ import type { ChannelManifestRegistry } from "../manifest"; import { createChannelManifestRegistry } from "../manifest"; import { discordManifest } from "./discord/manifest"; import { slackManifest } from "./slack/manifest"; -import { telegramManifest } from "./telegram/manifest"; import { teamsManifest } from "./teams/manifest"; +import { telegramManifest } from "./telegram/manifest"; import { wechatManifest } from "./wechat/manifest"; import { whatsappManifest } from "./whatsapp/manifest"; export { discordManifest } from "./discord/manifest"; export { slackManifest } from "./slack/manifest"; -export { telegramManifest } from "./telegram/manifest"; export { teamsManifest } from "./teams/manifest"; +export { telegramManifest } from "./telegram/manifest"; export { wechatManifest } from "./wechat/manifest"; export { whatsappManifest } from "./whatsapp/manifest"; diff --git a/src/lib/messaging/channels/slack/hooks/credential-validation.ts b/src/lib/messaging/channels/slack/hooks/credential-validation.ts index a8b2f6f5fbf..f9ba82f1bc9 100644 --- a/src/lib/messaging/channels/slack/hooks/credential-validation.ts +++ b/src/lib/messaging/channels/slack/hooks/credential-validation.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { runCurlProbe, type CurlProbeResult } from "../../../../adapters/http/probe"; +import { type CurlProbeResult, runCurlProbe } from "../../../../adapters/http/probe"; export type SlackTokenKind = "bot" | "app"; export type SlackValidationFailureKind = "rejected" | "indeterminate"; diff --git a/src/lib/messaging/channels/slack/hooks/validate-credentials.ts b/src/lib/messaging/channels/slack/hooks/validate-credentials.ts index 922eb1e8621..b5417b674e2 100644 --- a/src/lib/messaging/channels/slack/hooks/validate-credentials.ts +++ b/src/lib/messaging/channels/slack/hooks/validate-credentials.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { formatSlackValidationFailure, validateSlackCredentials } from "./credential-validation"; import type { MessagingHookHandler, MessagingHookRegistration } from "../../../hooks/types"; +import { formatSlackValidationFailure, validateSlackCredentials } from "./credential-validation"; export const SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID = "slack.validateCredentials"; diff --git a/src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.ts b/src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.ts index ee2d113b6df..bd2b078f789 100644 --- a/src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.ts +++ b/src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.ts @@ -1,13 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { getActiveMessagingHostForward } from "../../../host-forward"; import { MessagingHookConflictError } from "../../../hooks/errors"; import type { MessagingHookContext, MessagingHookHandler, MessagingHookRegistration, } from "../../../hooks/types"; +import { getActiveMessagingHostForward } from "../../../host-forward"; import type { MessagingSerializableValue } from "../../../manifest"; import { parseSandboxMessagingPlan } from "../../../plan-validation"; diff --git a/src/lib/messaging/channels/template-resolver.ts b/src/lib/messaging/channels/template-resolver.ts index 11c1190b3b6..f83aa35176d 100644 --- a/src/lib/messaging/channels/template-resolver.ts +++ b/src/lib/messaging/channels/template-resolver.ts @@ -3,8 +3,8 @@ import { resolveDiscordTemplateReference } from "./discord/template-resolver"; import { resolveSlackTemplateReference } from "./slack/template-resolver"; -import { resolveTelegramTemplateReference } from "./telegram/template-resolver"; import { resolveTeamsTemplateReference } from "./teams/template-resolver"; +import { resolveTelegramTemplateReference } from "./telegram/template-resolver"; import type { BuiltInRenderTemplateResolver } from "./template-resolver-utils"; import { resolveWechatTemplateReference } from "./wechat/template-resolver"; import { resolveWhatsappTemplateReference } from "./whatsapp/template-resolver"; diff --git a/src/lib/messaging/channels/wechat/login.ts b/src/lib/messaging/channels/wechat/login.ts index 8b585d8bd9f..b7830a4c620 100644 --- a/src/lib/messaging/channels/wechat/login.ts +++ b/src/lib/messaging/channels/wechat/login.ts @@ -10,13 +10,13 @@ // tests can stay offline. import { + type FetchLike, fetchWechatQrSession, pollWechatQrStatus, - type FetchLike, + WECHAT_ILINK_BOOTSTRAP_BASE_URL, + WechatQrError, type WechatQrSession, type WechatQrStatusResponse, - WechatQrError, - WECHAT_ILINK_BOOTSTRAP_BASE_URL, } from "./qr"; /** Total deadline for a single login attempt. 8 minutes is long enough to diff --git a/src/lib/messaging/channels/wechat/qr.test.ts b/src/lib/messaging/channels/wechat/qr.test.ts index 716cd6b17e1..0aff5622249 100644 --- a/src/lib/messaging/channels/wechat/qr.test.ts +++ b/src/lib/messaging/channels/wechat/qr.test.ts @@ -5,12 +5,12 @@ import { describe, expect, it } from "vitest"; import { encodeIlinkClientVersion, + type FetchLike, fetchWechatQrSession, pollWechatQrStatus, - WechatQrError, WECHAT_ILINK_BOOTSTRAP_BASE_URL, WECHAT_ILINK_DEFAULT_BOT_TYPE, - type FetchLike, + WechatQrError, } from "./qr"; type Capture = { url: string; init?: { method?: string; headers?: Record } }; diff --git a/src/lib/messaging/channels/wechat/template-resolver.ts b/src/lib/messaging/channels/wechat/template-resolver.ts index 056273a7bd7..6ee7a8579c2 100644 --- a/src/lib/messaging/channels/wechat/template-resolver.ts +++ b/src/lib/messaging/channels/wechat/template-resolver.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import type { RenderTemplateContext } from "../../compiler/engines/template"; -import { normalizeWechatIlinkBaseUrl } from "./ilink-base-url"; import { allowedIds, type BuiltInRenderTemplateResolver, @@ -12,6 +11,7 @@ import { resolvedRenderTemplateReference, stateValue, } from "../template-resolver-utils"; +import { normalizeWechatIlinkBaseUrl } from "./ilink-base-url"; export const resolveWechatTemplateReference: BuiltInRenderTemplateResolver = ( reference, diff --git a/src/lib/messaging/compiler/engines/credential-binding-engine.ts b/src/lib/messaging/compiler/engines/credential-binding-engine.ts index 1a87227e597..fefb379dcb5 100644 --- a/src/lib/messaging/compiler/engines/credential-binding-engine.ts +++ b/src/lib/messaging/compiler/engines/credential-binding-engine.ts @@ -1,13 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { hashCredential } from "../../../security/credential-hash"; import type { ChannelManifest, SandboxMessagingCredentialBindingPlan, SandboxMessagingInputReference, } from "../../manifest"; import type { ManifestCompilerContext } from "../types"; -import { hashCredential } from "../../../security/credential-hash"; import { resolveSandboxNameTemplate } from "./template"; export function planCredentialBindings( diff --git a/src/lib/messaging/compiler/index.ts b/src/lib/messaging/compiler/index.ts index ae24e2779a2..5668d68b2d9 100644 --- a/src/lib/messaging/compiler/index.ts +++ b/src/lib/messaging/compiler/index.ts @@ -2,5 +2,5 @@ // SPDX-License-Identifier: Apache-2.0 export * from "./manifest-compiler"; -export * from "./workflow-planner"; export type * from "./types"; +export * from "./workflow-planner"; diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 8a9633b791b..48270bbf2b1 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -13,7 +13,7 @@ import { ChannelManifestRegistry, type SandboxMessagingPlan, } from "../manifest"; -import { ManifestCompiler } from "./manifest-compiler"; +import { ManifestCompiler, normalizeInputValue } from "./manifest-compiler"; const ALL_CHANNELS = ["telegram", "discord", "wechat", "slack", "whatsapp", "teams"] as const; const TEST_CREDENTIALS: Readonly> = { @@ -1422,3 +1422,47 @@ describe("ManifestCompiler", () => { ).rejects.toThrow("Missing messaging channel manifest(s): telegram"); }); }); + +describe("normalizeInputValue", () => { + const allowlistInput = { + id: "requireMention", + kind: "config" as const, + required: false, + validValues: ["0", "1"], + }; + const formatInput = { + id: "userId", + kind: "config" as const, + required: false, + formatPattern: "^\\d+$", + }; + + it("returns undefined for empty, whitespace-only, null, and undefined raw values", () => { + expect(normalizeInputValue(allowlistInput, "")).toBeUndefined(); + expect(normalizeInputValue(allowlistInput, " ")).toBeUndefined(); + expect(normalizeInputValue(allowlistInput, null)).toBeUndefined(); + expect(normalizeInputValue(allowlistInput, undefined)).toBeUndefined(); + }); + + it("returns undefined when the trimmed value is not in the manifest validValues allowlist", () => { + expect(normalizeInputValue(allowlistInput, "definitely-not-a-policy")).toBeUndefined(); + expect(normalizeInputValue(allowlistInput, "2")).toBeUndefined(); + }); + + it("returns undefined when the trimmed value fails the manifest formatPattern", () => { + expect(normalizeInputValue(formatInput, "abc")).toBeUndefined(); + expect(normalizeInputValue(formatInput, "123abc")).toBeUndefined(); + }); + + it("returns the trimmed value when it satisfies the manifest contract", () => { + expect(normalizeInputValue(allowlistInput, "1")).toBe("1"); + expect(normalizeInputValue(allowlistInput, " 0 ")).toBe("0"); + expect(normalizeInputValue(formatInput, " 123 ")).toBe("123"); + }); + + it("throws when the raw value contains line breaks rather than silently smuggling them through", () => { + expect(() => normalizeInputValue(allowlistInput, "1\n2")).toThrow( + "Messaging input values must not contain line breaks.", + ); + }); +}); diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 65a58ba5f10..53df94a98f9 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -379,7 +379,7 @@ function readInputDefaultValue( return normalizeInputValue(input, input.defaultValue); } -function normalizeInputValue( +export function normalizeInputValue( input: ChannelInputSpec, raw: string | null | undefined, ): string | undefined { diff --git a/src/lib/messaging/compiler/planner-empty-env-normalization.test.ts b/src/lib/messaging/compiler/planner-empty-env-normalization.test.ts new file mode 100644 index 00000000000..f6937b8f5de --- /dev/null +++ b/src/lib/messaging/compiler/planner-empty-env-normalization.test.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { createPlannerForTests, withPlannerEnv } from "../__test-utils__/planner-harness"; + +describe("planner empty-env normalization", () => { + it("does not persist an empty TELEGRAM_REQUIRE_MENTION env var into the plan at the planner source boundary", async () => { + const plan = await withPlannerEnv({ TELEGRAM_REQUIRE_MENTION: "" }, () => + createPlannerForTests().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram"], + credentialAvailability: { TELEGRAM_BOT_TOKEN: true }, + }), + ); + + const requireMention = plan.channels + .find((channel) => channel.channelId === "telegram") + ?.inputs.find((input) => input.inputId === "requireMention"); + expect(requireMention).toBeDefined(); + expect(requireMention?.value).not.toBe(""); + }); + + it("does not persist an empty DISCORD_REQUIRE_MENTION env var into the plan at the planner source boundary", async () => { + const plan = await withPlannerEnv({ DISCORD_REQUIRE_MENTION: "" }, () => + createPlannerForTests().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["discord"], + credentialAvailability: { DISCORD_BOT_TOKEN: true }, + }), + ); + + const requireMention = plan.channels + .find((channel) => channel.channelId === "discord") + ?.inputs.find((input) => input.inputId === "requireMention"); + expect(requireMention?.value).not.toBe(""); + }); + + it("does not persist an empty TEAMS_REQUIRE_MENTION env var into the plan at the planner source boundary", async () => { + const plan = await withPlannerEnv( + { + TEAMS_REQUIRE_MENTION: "", + MSTEAMS_APP_ID: "test-teams-app-id", + MSTEAMS_TENANT_ID: "test-teams-tenant-id", + TEAMS_ALLOWED_USERS: "00000000-0000-0000-0000-000000000001", + MSTEAMS_PORT: "3977", + }, + () => + createPlannerForTests().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["teams"], + credentialAvailability: { MSTEAMS_APP_PASSWORD: true }, + }), + ); + + const requireMention = plan.channels + .find((channel) => channel.channelId === "teams") + ?.inputs.find((input) => input.inputId === "requireMention"); + expect(requireMention?.value).not.toBe(""); + }); + + it("does not persist an empty TELEGRAM_GROUP_POLICY env var into the plan at the planner source boundary", async () => { + const plan = await withPlannerEnv({ TELEGRAM_GROUP_POLICY: "" }, () => + createPlannerForTests().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram"], + credentialAvailability: { TELEGRAM_BOT_TOKEN: true }, + }), + ); + + const groupPolicy = plan.channels + .find((channel) => channel.channelId === "telegram") + ?.inputs.find((input) => input.inputId === "groupPolicy"); + expect(groupPolicy?.value).not.toBe(""); + }); +}); diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 28c686b1e47..6d18f666c6b 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -826,29 +826,6 @@ describe("MessagingWorkflowPlanner", () => { }); }); - it("does not persist an empty TELEGRAM_REQUIRE_MENTION env var into the plan at the planner source boundary", async () => { - const plan = await withEnv( - { - TELEGRAM_REQUIRE_MENTION: "", - }, - () => - planner().buildPlan({ - sandboxName: "demo", - agent: "openclaw", - workflow: "onboard", - isInteractive: false, - configuredChannels: ["telegram"], - credentialAvailability: { TELEGRAM_BOT_TOKEN: true }, - }), - ); - - const requireMention = plan.channels - .find((channel) => channel.channelId === "telegram") - ?.inputs.find((input) => input.inputId === "requireMention"); - expect(requireMention).toBeDefined(); - expect(requireMention?.value).not.toBe(""); - }); - it("rebuilds from stored plan input values when config env is unavailable", async () => { const existingPlan = await withEnv( { diff --git a/src/lib/messaging/hooks/common/token-paste.test.ts b/src/lib/messaging/hooks/common/token-paste.test.ts index a4708ed70ce..d952071954e 100644 --- a/src/lib/messaging/hooks/common/token-paste.test.ts +++ b/src/lib/messaging/hooks/common/token-paste.test.ts @@ -9,9 +9,9 @@ import { runMessagingHook } from "../hook-runner"; import { MessagingHookRegistry } from "../registry"; import { COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + COMMON_HOOK_REGISTRATIONS, COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID, COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, - COMMON_HOOK_REGISTRATIONS, createTokenPasteHook, } from "./index"; diff --git a/src/lib/messaging/hooks/common/token-paste.ts b/src/lib/messaging/hooks/common/token-paste.ts index 88f4e62c659..6112cf349ed 100644 --- a/src/lib/messaging/hooks/common/token-paste.ts +++ b/src/lib/messaging/hooks/common/token-paste.ts @@ -1,17 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { - MessagingHookHandler, - MessagingHookOutputMap, - MessagingHookRegistration, -} from "../types"; import { createBuiltInChannelManifestRegistry } from "../../channels"; import type { ChannelHookOutputSpec, ChannelManifest, ChannelSecretInputSpec, } from "../../manifest"; +import type { + MessagingHookHandler, + MessagingHookOutputMap, + MessagingHookRegistration, +} from "../types"; export const COMMON_TOKEN_PASTE_HOOK_HANDLER_ID = "common.tokenPaste"; diff --git a/src/lib/messaging/hooks/index.ts b/src/lib/messaging/hooks/index.ts index 0db686d5b02..9fb03e07896 100644 --- a/src/lib/messaging/hooks/index.ts +++ b/src/lib/messaging/hooks/index.ts @@ -1,9 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -export * from "./hook-runner"; -export * from "./registry"; -export * from "./common"; export * from "./builtins"; +export * from "./common"; export * from "./errors"; +export * from "./hook-runner"; +export * from "./registry"; export type * from "./types"; diff --git a/src/lib/messaging/manifest/registry.test.ts b/src/lib/messaging/manifest/registry.test.ts index 77d9b398dbb..8ab16738659 100644 --- a/src/lib/messaging/manifest/registry.test.ts +++ b/src/lib/messaging/manifest/registry.test.ts @@ -144,4 +144,23 @@ describe("ChannelManifestRegistry", () => { "is not kind 'config' yet declares safeToPrintInDiagnostics=true", ); }); + + it("rejects registration when a config input declares safeToPrintInDiagnostics without a validValues allowlist", () => { + const malformed = { + ...TELEGRAM_MANIFEST, + id: "malformed-open-ended", + inputs: [ + { + id: "openEnded", + kind: "config", + required: false, + safeToPrintInDiagnostics: true, + }, + ], + } as unknown as ChannelManifest; + + expect(() => createChannelManifestRegistry([malformed])).toThrow( + "has safeToPrintInDiagnostics=true but no validValues allowlist", + ); + }); }); diff --git a/src/lib/messaging/manifest/registry.ts b/src/lib/messaging/manifest/registry.ts index 55b179e58ab..bdbf562d7a1 100644 --- a/src/lib/messaging/manifest/registry.ts +++ b/src/lib/messaging/manifest/registry.ts @@ -74,11 +74,23 @@ function assertDiagnosticContractValid(manifest: ChannelManifest): void { assertSafeToPrintOnlyOnConfig(manifest.id, input); continue; } + assertSafeToPrintRequiresValidValues(manifest.id, input); assertValueDisplayKeysAllowed(manifest.id, input); assertAgentApplicabilitySupported(manifest.id, input, supportedAgents); } } +function assertSafeToPrintRequiresValidValues( + channelId: MessagingChannelId, + input: ChannelConfigInputSpec, +): void { + if (input.safeToPrintInDiagnostics !== true) return; + if (input.validValues && input.validValues.length > 0) return; + throw new Error( + `Channel manifest '${channelId}' input '${input.id}' has safeToPrintInDiagnostics=true but no validValues allowlist; the diagnostic boundary cannot bound an open-ended value`, + ); +} + function assertSafeToPrintOnlyOnConfig( channelId: MessagingChannelId, input: ChannelManifest["inputs"][number], diff --git a/src/lib/messaging/plan-validation.test.ts b/src/lib/messaging/plan-validation.test.ts index a0993887ad6..ac28fac8ddf 100644 --- a/src/lib/messaging/plan-validation.test.ts +++ b/src/lib/messaging/plan-validation.test.ts @@ -531,4 +531,62 @@ describe("plan channel derivation", () => { DISCORD_USER_ID: "user-1", }); }); + + it("drops persisted Telegram config values that violate the manifest validValues allowlist", () => { + const plan = makePlan({ + channels: [ + { + ...makePlan().channels[0], + inputs: [ + { + channelId: "telegram", + inputId: "requireMention", + kind: "config", + required: false, + sourceEnv: "TELEGRAM_REQUIRE_MENTION", + statePath: "telegramConfig.requireMention", + value: "definitely-not-a-mention-mode", + }, + { + channelId: "telegram", + inputId: "groupPolicy", + kind: "config", + required: false, + sourceEnv: "TELEGRAM_GROUP_POLICY", + statePath: "telegramConfig.groupPolicy", + value: "definitely-not-a-policy", + }, + ], + }, + ], + }); + + const config = getMessagingChannelConfigFromPlan(plan) ?? {}; + expect(config).not.toHaveProperty("TELEGRAM_REQUIRE_MENTION"); + expect(config).not.toHaveProperty("TELEGRAM_GROUP_POLICY"); + }); + + it("drops persisted Telegram config values whose type is unsupported by the manifest", () => { + const plan = makePlan({ + channels: [ + { + ...makePlan().channels[0], + inputs: [ + { + channelId: "telegram", + inputId: "groupPolicy", + kind: "config", + required: false, + sourceEnv: "TELEGRAM_GROUP_POLICY", + statePath: "telegramConfig.groupPolicy", + value: ["open"] as unknown as string, + }, + ], + }, + ], + }); + + const config = getMessagingChannelConfigFromPlan(plan) ?? {}; + expect(config).not.toHaveProperty("TELEGRAM_GROUP_POLICY"); + }); }); diff --git a/src/lib/messaging/plan-validation.ts b/src/lib/messaging/plan-validation.ts index 11a33653f12..94b7dd719dc 100644 --- a/src/lib/messaging/plan-validation.ts +++ b/src/lib/messaging/plan-validation.ts @@ -2,7 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import type { MessagingChannelConfig } from "../messaging-channel-config"; +import { createBuiltInChannelManifestRegistry } from "./channels"; import type { + ChannelInputSpec, + ChannelManifest, MessagingAgentId, MessagingChannelId, MessagingSerializableValue, @@ -13,6 +16,62 @@ import { normalizePersistedSandboxMessagingPlanShape, } from "./persistence"; +let cachedBuiltInManifestsById: Map | null = null; + +function builtInManifestsById(): Map { + if (!cachedBuiltInManifestsById) { + cachedBuiltInManifestsById = new Map( + createBuiltInChannelManifestRegistry() + .list() + .map((manifest) => [manifest.id, manifest]), + ); + } + return cachedBuiltInManifestsById; +} + +function manifestInputById( + manifest: ChannelManifest, + inputId: string, +): ChannelInputSpec | undefined { + return manifest.inputs.find((input) => input.id === inputId); +} + +function persistedValueAllowedByManifest( + input: ChannelInputSpec, + value: MessagingSerializableValue, +): boolean { + if (input.kind !== "config") return true; + if (!input.validValues || input.validValues.length === 0) return true; + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { + return false; + } + const text = typeof value === "string" ? value : String(value); + return input.validValues.includes(text); +} + +export function sanitizePersistedManifestValues(plan: SandboxMessagingPlan): SandboxMessagingPlan { + const manifests = builtInManifestsById(); + let mutated = false; + const channels = plan.channels.map((channel) => { + const manifest = manifests.get(channel.channelId); + if (!manifest) return channel; + let channelMutated = false; + const inputs = channel.inputs.map((entry) => { + if (entry.kind !== "config" || entry.value === undefined || entry.value === null) { + return entry; + } + const spec = manifestInputById(manifest, entry.inputId); + if (!spec || persistedValueAllowedByManifest(spec, entry.value)) return entry; + channelMutated = true; + mutated = true; + const { value: _dropped, ...rest } = entry; + return rest; + }); + return channelMutated ? { ...channel, inputs } : channel; + }); + return mutated ? { ...plan, channels } : plan; +} + export interface SandboxMessagingPlanParseOptions { sandboxName?: string | null; agent?: MessagingAgentId | string | null; @@ -114,16 +173,17 @@ export function getMessagingChannelConfigFromPlan( plan: SandboxMessagingPlan | null | undefined, ): MessagingChannelConfig | null { if (!plan) return null; + const sanitized = sanitizePersistedManifestValues(plan); const config: MessagingChannelConfig = {}; - const stateValues = getMessagingPlanStateValues(plan); + const stateValues = getMessagingPlanStateValues(sanitized); - for (const update of plan.stateUpdates) { + for (const update of sanitized.stateUpdates) { if (update.kind !== "rebuild-hydration") continue; const value = stringifyPlanStateValue(stateValues[update.statePath]); if (value) config[update.env] = value; } - for (const channel of plan.channels) { + for (const channel of sanitized.channels) { for (const input of channel.inputs) { if (input.kind !== "config" || !input.sourceEnv || input.value == null) continue; if (config[input.sourceEnv]) continue; diff --git a/src/lib/messaging/visible-config-output.ts b/src/lib/messaging/visible-config-output.ts new file mode 100644 index 00000000000..bf294455580 --- /dev/null +++ b/src/lib/messaging/visible-config-output.ts @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { VisibleConfigDisplay } from "./diagnostics"; + +export type VisibleConfigSeverity = "ok" | "info" | "warn"; +export type VisibleConfigDoctorStatus = "ok" | "info" | "warn"; + +export function visibleConfigSeverity( + source: VisibleConfigDisplay["source"], +): VisibleConfigSeverity { + switch (source) { + case "persisted": + return "ok"; + case "default": + return "info"; + case "invalid": + return "warn"; + } +} + +export function visibleConfigDoctorStatus( + source: VisibleConfigDisplay["source"], +): VisibleConfigDoctorStatus { + switch (source) { + case "persisted": + return "ok"; + case "default": + return "info"; + case "invalid": + return "warn"; + } +} + +export interface VisibleConfigDoctorHintInput { + readonly cli: string; + readonly sandboxName: string; + readonly channelName: string; + readonly source: VisibleConfigDisplay["source"]; +} + +export function visibleConfigDoctorHint(input: VisibleConfigDoctorHintInput): string | undefined { + if (input.source === "default") { + return `run \`${input.cli} ${input.sandboxName} channels status --channel ${input.channelName}\` to confirm the resolved value`; + } + if (input.source === "invalid") { + return `run \`${input.cli} ${input.sandboxName} channels add ${input.channelName}\` to re-enter a valid value`; + } + return undefined; +} diff --git a/src/lib/state/config-io.ts b/src/lib/state/config-io.ts index e0a4cae76c2..9da8c5571e3 100644 --- a/src/lib/state/config-io.ts +++ b/src/lib/state/config-io.ts @@ -6,9 +6,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; - -import { shellQuote } from "../core/shell-quote"; import { isErrnoException, isPermissionError } from "../core/errno"; +import { shellQuote } from "../core/shell-quote"; // Strict JSON types for file serialization — unlike json-types.ts, // these exclude undefined since actual JSON cannot contain it. diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index bdd953b2f63..ddc683ab296 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -4,8 +4,8 @@ import fs from "node:fs"; import path from "node:path"; import { isErrnoException } from "../core/errno"; -import { inferenceSelectionRegistryFields } from "../inference/selection"; import type { InferenceSelection } from "../inference/selection"; +import { inferenceSelectionRegistryFields } from "../inference/selection"; import { ensureConfigDir, readConfigFile, writeConfigFile } from "./config-io"; import type { SandboxMessagingState } from "./registry-messaging"; diff --git a/src/lib/state/sandbox-session.test.ts b/src/lib/state/sandbox-session.test.ts index 6f6730e66ff..e0399d54d2a 100644 --- a/src/lib/state/sandbox-session.test.ts +++ b/src/lib/state/sandbox-session.test.ts @@ -1,15 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { - parseForwardList, - parseSshProcesses, - hasActiveForwards, - getForwardsForSandbox, classifySessionState, - getActiveSandboxSessions, type ForwardEntry, + getActiveSandboxSessions, + getForwardsForSandbox, + hasActiveForwards, + parseForwardList, + parseSshProcesses, type SessionDetectionDeps, } from "./sandbox-session"; From 1dbc44b441c4747aa68f063fc1d662ae42f94687 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 29 Jun 2026 05:43:18 +0000 Subject: [PATCH 29/30] test(messaging): assert Telegram visible config in live messaging-providers Signed-off-by: Tinson Lai --- .../live/messaging-providers.test.ts | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/test/e2e-scenario/live/messaging-providers.test.ts b/test/e2e-scenario/live/messaging-providers.test.ts index 153ee1ae235..807321c9a49 100644 --- a/test/e2e-scenario/live/messaging-providers.test.ts +++ b/test/e2e-scenario/live/messaging-providers.test.ts @@ -60,6 +60,43 @@ import { const runLiveTest = shouldRunLiveE2EScenarios() ? test : test.skip; +interface TelegramVisibleStatusReport { + readonly signals?: ReadonlyArray<{ + readonly label?: string; + readonly severity?: string; + readonly detail?: string; + }>; +} + +interface TelegramDoctorReport { + readonly checks?: ReadonlyArray<{ + readonly group?: string; + readonly label?: string; + readonly status?: string; + readonly detail?: string; + }>; +} + +function parseTelegramVisibilityJson(text: string): TelegramVisibleStatusReport | null { + const trimmed = text.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed) as TelegramVisibleStatusReport; + } catch { + return null; + } +} + +function parseTelegramDoctorJson(text: string): TelegramDoctorReport | null { + const trimmed = text.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed) as TelegramDoctorReport; + } catch { + return null; + } +} + runLiveTest( "messaging providers preserve placeholder, policy, runtime, and send contracts", testTimeoutOptions(LIVE_TIMEOUT_MS), @@ -574,6 +611,67 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w ); } + const telegramVisibleStatus = await runHost( + host, + "node", + [CLI_ENTRYPOINT, SANDBOX_NAME, "channels", "status", "--channel", "telegram", "--json"], + { + artifactName: "channels-status-telegram-visible-messaging-providers", + env: state.env, + redactionValues, + timeoutMs: 60_000, + }, + ); + expectExitZero(telegramVisibleStatus, "M-V0: channels status --channel telegram exits 0"); + const telegramVisibleReport = parseTelegramVisibilityJson(outputText(telegramVisibleStatus)); + const telegramVisibleSignals = telegramVisibleReport?.signals ?? []; + const telegramGroupPolicySignal = telegramVisibleSignals.find( + (signal) => signal.label === "Telegram group policy", + ); + check( + telegramGroupPolicySignal?.detail === "open groups (TELEGRAM_GROUP_POLICY=open)", + `M-V1: channels status renders Telegram group policy (got '${telegramGroupPolicySignal?.detail ?? "missing"}')`, + ); + const telegramMentionSignal = telegramVisibleSignals.find( + (signal) => signal.label === "Telegram group mention mode", + ); + check( + telegramMentionSignal?.detail === "mention-only (TELEGRAM_REQUIRE_MENTION=1)" || + telegramMentionSignal?.detail === "mention-only (default)", + `M-V2: channels status renders Telegram mention mode (got '${telegramMentionSignal?.detail ?? "missing"}')`, + ); + + const telegramDoctorOutput = await runHost( + host, + "node", + [CLI_ENTRYPOINT, SANDBOX_NAME, "doctor", "--json"], + { + artifactName: "doctor-telegram-visible-messaging-providers", + env: state.env, + redactionValues, + timeoutMs: 60_000, + }, + ); + const telegramDoctorReport = parseTelegramDoctorJson(outputText(telegramDoctorOutput)); + const telegramDoctorChecks = (telegramDoctorReport?.checks ?? []).filter( + (entry) => entry.group === "Messaging", + ); + const doctorGroupPolicy = telegramDoctorChecks.find( + (entry) => entry.label === "Telegram group policy", + ); + check( + doctorGroupPolicy?.detail === "open groups (TELEGRAM_GROUP_POLICY=open)", + `M-V3: doctor renders Telegram group policy (got '${doctorGroupPolicy?.detail ?? "missing"}')`, + ); + const doctorMention = telegramDoctorChecks.find( + (entry) => entry.label === "Telegram group mention mode", + ); + check( + doctorMention?.detail === "mention-only (TELEGRAM_REQUIRE_MENTION=1)" || + doctorMention?.detail === "mention-only (default)", + `M-V4: doctor renders Telegram mention mode (got '${doctorMention?.detail ?? "missing"}')`, + ); + const telegramReach = await sandboxOutput( sandbox, `node -e ' From d67a997896ba78011a1bf5e4fba97a64084f0912 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 29 Jun 2026 06:09:07 +0000 Subject: [PATCH 30/30] test(messaging): drop early-return ifs to keep test bodies linear Signed-off-by: Tinson Lai --- test/e2e-scenario/live/messaging-providers.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/e2e-scenario/live/messaging-providers.test.ts b/test/e2e-scenario/live/messaging-providers.test.ts index 807321c9a49..684f7b9c945 100644 --- a/test/e2e-scenario/live/messaging-providers.test.ts +++ b/test/e2e-scenario/live/messaging-providers.test.ts @@ -78,20 +78,16 @@ interface TelegramDoctorReport { } function parseTelegramVisibilityJson(text: string): TelegramVisibleStatusReport | null { - const trimmed = text.trim(); - if (!trimmed) return null; try { - return JSON.parse(trimmed) as TelegramVisibleStatusReport; + return JSON.parse(text.trim()) as TelegramVisibleStatusReport; } catch { return null; } } function parseTelegramDoctorJson(text: string): TelegramDoctorReport | null { - const trimmed = text.trim(); - if (!trimmed) return null; try { - return JSON.parse(trimmed) as TelegramDoctorReport; + return JSON.parse(text.trim()) as TelegramDoctorReport; } catch { return null; }