diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 914737590f6..066a8e2c41f 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -996,16 +996,21 @@ nemohermes my-assistant channels start telegram ### `nemohermes channels status` -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. +Run messaging channel status checks. Without `--channel`, the command prints a compact summary for every configured channel, including registration, policy coverage, and non-secret rendered config comparisons. With `--channel`, it prints the detailed status for that channel. + +For WhatsApp, `--channel whatsapp` also 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 registered channels, the status output also compares non-secret config inputs from the sandbox registry against the values rendered into the agent config, such as Telegram group policy in `openclaw.json` or mention mode in Hermes config. Secret inputs, including tokens, are not printed. ```bash +nemohermes my-assistant channels status nemohermes my-assistant channels status --channel whatsapp ``` | Flag | Description | |------|-------------| -| `--channel ` | Channel to inspect; defaults to `whatsapp` when registered | -| `--json` | Emit the diagnostic report as JSON (exit non-zero when the verdict is not `healthy` or `unknown`) | +| `--channel ` | Channel to inspect in detail | +| `--json` | Emit the status report as JSON (for the detailed WhatsApp probe, exit non-zero when the verdict is not `healthy` or `unknown`) | The probe is bounded by an in-sandbox `openshell sandbox exec` with a hard timeout, captures only short matched bridge log signals (e.g. `connection.open`, `401 unauthorized`, `qr expired`), and never forwards message bodies to the host diagnostic output. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index ebc9a914932..276c80f703c 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1271,16 +1271,21 @@ $$nemoclaw my-assistant channels start telegram ### `$$nemoclaw channels status` -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. +Run messaging channel status checks. Without `--channel`, the command prints a compact summary for every configured channel, including registration, policy coverage, and non-secret rendered config comparisons. With `--channel`, it prints the detailed status for that channel. + +For WhatsApp, `--channel whatsapp` also 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 registered channels, the status output also compares non-secret config inputs from the sandbox registry against the values rendered into the agent config, such as Telegram group policy in `openclaw.json` or mention mode in Hermes config. Secret inputs, including tokens, are not printed. ```bash +$$nemoclaw my-assistant channels status $$nemoclaw my-assistant channels status --channel whatsapp ``` | Flag | Description | |------|-------------| -| `--channel ` | Channel to inspect; defaults to `whatsapp` when registered | -| `--json` | Emit the diagnostic report as JSON (exit non-zero when the verdict is not `healthy` or `unknown`) | +| `--channel ` | Channel to inspect in detail | +| `--json` | Emit the status report as JSON (for the detailed WhatsApp probe, exit non-zero when the verdict is not `healthy` or `unknown`) | The probe is bounded by an in-sandbox `openshell sandbox exec` with a hard timeout, captures only short matched bridge log signals (e.g. `connection.open`, `401 unauthorized`, `qr expired`), and never forwards message bodies to the host diagnostic output. diff --git a/src/commands/sandbox/channels/status.ts b/src/commands/sandbox/channels/status.ts index 731120dbb26..b6ee3ce48ac 100644 --- a/src/commands/sandbox/channels/status.ts +++ b/src/commands/sandbox/channels/status.ts @@ -11,9 +11,9 @@ export default class SandboxChannelsStatusCommand extends NemoClawCommand { static id = "sandbox:channels:status"; static strict = true; static enableJsonFlag = true; - static summary = "Inspect a messaging channel's runtime diagnostics"; + static summary = "Inspect messaging channel status"; static description = - "Report channel-specific runtime diagnostics — for WhatsApp, separately reports QR/session state, Noise WebSocket state, inbound event delivery, and policy coverage so a paired-but-idle channel does not appear healthy."; + "Report configured messaging channels, policy coverage, and non-secret rendered config comparisons. Pass --channel whatsapp for the deeper WhatsApp QR/session and inbound-delivery probe."; static usage = [" [--channel ] [--json]"]; static examples = [ "<%= config.bin %> sandbox channels status alpha --channel whatsapp", @@ -24,7 +24,7 @@ export default class SandboxChannelsStatusCommand extends NemoClawCommand { }; static flags = { channel: Flags.string({ - description: "Messaging channel to inspect (defaults to whatsapp when registered)", + description: "Messaging channel to inspect in detail", required: false, }), }; diff --git a/src/lib/actions/sandbox/channel-status-config.ts b/src/lib/actions/sandbox/channel-status-config.ts new file mode 100644 index 00000000000..3ed50c01461 --- /dev/null +++ b/src/lib/actions/sandbox/channel-status-config.ts @@ -0,0 +1,433 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import YAML from "yaml"; +import type { AgentDefinition } from "../../agent/defs"; +import { CLI_NAME } from "../../cli/branding"; +import type { + RenderedChannelConfigParser, + RenderedConfigSource, + RenderedConfigVisibilityKey, +} from "../../messaging"; +import { + createBuiltInChannelManifestRegistry, + getBuiltInRenderedConfigParser, + tryGetMessagingAgentId, +} from "../../messaging"; +import type { + ChannelConfigInputSpec, + MessagingAgentId, + MessagingSerializableValue, + SandboxMessagingInputReference, +} from "../../messaging/manifest"; +import type { DiagnosticSignal } from "../../sandbox/whatsapp-diagnostics"; +import * as registry from "../../state/registry"; + +const CONFIG_STATUS_TIMEOUT_MS = 5_000; +const channelManifestRegistry = createBuiltInChannelManifestRegistry(); + +type ExecRunner = ( + sandboxName: string, + command: string, + timeoutMs?: number, +) => { + status: number; + stdout: string; + stderr: string; +} | null; + +export type ChannelStatusConfigDeps = { + execSandbox: ExecRunner; +}; + +// Inline single-quote shell quoting — config status probes only quote trusted +// path strings derived from agent/channel manifests. +export function quotePath(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +export function buildConfigStatusSignals( + sandboxName: string, + channelName: string, + entry: ReturnType, + agent: AgentDefinition, + deps: ChannelStatusConfigDeps, +): DiagnosticSignal[] { + const plan = registry.getMessagingPlanFromEntry(entry); + const channelPlan = plan?.channels.find((channel) => channel.channelId === channelName); + if (!channelPlan?.configured) return []; + + const manifest = channelManifestRegistry.get(channelName); + const agentId = tryGetMessagingAgentId( + { name: plan?.agent ?? agent.name }, + channelManifestRegistry.list(), + ); + const parser = manifest ? getBuiltInRenderedConfigParser(manifest.id) : null; + const renderSources = + parser && manifest && agentId + ? resolveRenderedConfigSources( + parser.listConfigVisibilityKeys({ manifest, agentId, inputs: channelPlan.inputs }), + agentId, + agent, + ) + : []; + const sourceReads = parser + ? readConfigSourceValues(sandboxName, renderSources, parser, deps) + : emptyConfigSourceReads(); + const configInputs = new Map( + channelPlan.inputs + .filter((input) => input.kind === "config") + .map((input) => [input.inputId, input] as const), + ); + const signals: DiagnosticSignal[] = configSourceReadSignals(sandboxName, sourceReads.targetReads); + + for (const input of manifest?.inputs ?? []) { + if (input.kind !== "config") continue; + const signal = configInputSignal(input, configInputs.get(input.id), renderSources, sourceReads); + if (signal) signals.push(signal); + } + + return signals; +} + +function configInputSignal( + input: ChannelConfigInputSpec, + planInput: SandboxMessagingInputReference | undefined, + renderSources: readonly ConfigRenderSource[], + sourceReads: ConfigSourceReads, +): DiagnosticSignal | null { + const label = configInputLabel(input, planInput); + const expected = expectedConfigValue(input, planInput); + const sources = renderSources.filter((source) => source.inputId === input.id); + if (sources.length === 0) { + return null; + } + + const comparisons = sources.map((source) => + compareConfigSource(expected, source, sourceReads.sourceValues), + ); + const checkedComparisons = comparisons.filter((comparison) => comparison.checked); + const hasMismatch = checkedComparisons.some((comparison) => !comparison.matches); + if (!expected.hasValue && !hasMismatch) return null; + const allSourcesChecked = + checkedComparisons.length === comparisons.length && checkedComparisons.length > 0; + return { + label, + severity: hasMismatch ? "warn" : allSourcesChecked ? "ok" : "info", + detail: Array.from(new Set(comparisons.map((comparison) => comparison.detail))).join("; "), + }; +} + +type SandboxMessagingInputWithValue = SandboxMessagingInputReference & { + readonly value: Exclude; +}; + +function planInputHasValue( + input: SandboxMessagingInputReference | undefined, +): input is SandboxMessagingInputWithValue { + return input?.value !== undefined && input.value !== null; +} + +function configInputLabel( + input: ChannelConfigInputSpec, + planInput: SandboxMessagingInputReference | undefined, +): string { + const label = input.prompt?.label ?? input.envKey ?? input.id; + const envKey = input.envKey ?? planInput?.sourceEnv; + if (!envKey || label === envKey) return label; + return `${label} (${envKey})`; +} + +function configInputDetail(value: MessagingSerializableValue | undefined): string { + if (value === undefined || value === null) return "not set"; + return formatConfigValue(value); +} + +type ExpectedConfigValue = { + readonly value: MessagingSerializableValue | undefined; + readonly detail: string; + readonly hasValue: boolean; +}; + +function expectedConfigValue( + input: ChannelConfigInputSpec, + planInput: SandboxMessagingInputReference | undefined, +): ExpectedConfigValue { + if (planInputHasValue(planInput)) { + return { + value: planInput.value, + detail: configInputDetail(planInput.value), + hasValue: true, + }; + } + + const defaultValue = input.defaultValue?.trim(); + if (defaultValue) { + return { + value: defaultValue, + detail: `${configInputDetail(defaultValue)} (default)`, + hasValue: true, + }; + } + + return { + value: undefined, + detail: configInputDetail(undefined), + hasValue: false, + }; +} + +function formatConfigValue(value: MessagingSerializableValue): string { + if (typeof value === "string") return value.length === 0 ? '""' : value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) { + if (value.length === 0) return "[]"; + return value.map(formatConfigValue).join(", "); + } + return JSON.stringify(value); +} + +interface ConfigRenderSource extends RenderedConfigVisibilityKey { + readonly resolvedTarget: string; +} + +type ConfigSourceRead = + | { + readonly ok: true; + readonly value: MessagingSerializableValue | undefined; + } + | { + readonly ok: false; + readonly error: string; + }; + +type ConfigTargetRead = + | { + readonly ok: true; + readonly contents: string; + } + | { + readonly ok: false; + readonly error: string; + }; + +type ConfigSourceReads = { + readonly sourceValues: ReadonlyMap; + readonly targetReads: ReadonlyMap; +}; + +type ParsedConfigSourceRead = + | { + readonly ok: true; + readonly source: RenderedConfigSource; + } + | { + readonly ok: false; + readonly error: string; + }; + +function configSourceReadSignals( + sandboxName: string, + targetReads: ReadonlyMap, +): DiagnosticSignal[] { + const signals: DiagnosticSignal[] = []; + for (const [target, read] of targetReads.entries()) { + if (read.ok) continue; + signals.push({ + label: "Rendered config source", + severity: "warn", + detail: `${read.error}; config comparisons not checked`, + hint: `inspect \`${target}\` with \`${CLI_NAME} ${sandboxName} exec -- cat ${target}\`, then re-run \`${CLI_NAME} ${sandboxName} rebuild\` if the channel block needs to be regenerated`, + }); + } + return signals; +} + +function emptyConfigSourceReads(): ConfigSourceReads { + return { sourceValues: new Map(), targetReads: new Map() }; +} + +function resolveRenderedConfigSources( + sources: readonly RenderedConfigVisibilityKey[], + agentId: MessagingAgentId, + agent: AgentDefinition, +): ConfigRenderSource[] { + return sources.flatMap((source) => { + const resolvedTarget = resolveConfigTarget(source.target, agentId, agent); + return resolvedTarget ? [{ ...source, resolvedTarget }] : []; + }); +} + +function resolveConfigTarget( + target: string, + agentId: MessagingAgentId, + agent: AgentDefinition, +): string | null { + if (agentId === "openclaw" && target === "openclaw.json") { + return `${agent.configPaths.dir}/${agent.configPaths.configFile}`; + } + const configDir = agent.configPaths.dir.replace(/\/+$/, ""); + if (agentId === "openclaw" && target.startsWith("~/.openclaw/")) { + return `${configDir}/${target.slice("~/.openclaw/".length)}`; + } + if (agentId === "hermes" && target.startsWith("~/.hermes/")) { + return `${configDir}/${target.slice("~/.hermes/".length)}`; + } + if (target.startsWith("/sandbox/")) return target; + return null; +} + +function readConfigSourceValues( + sandboxName: string, + sources: readonly ConfigRenderSource[], + parser: RenderedChannelConfigParser, + deps: ChannelStatusConfigDeps, +): ConfigSourceReads { + const targetReads = new Map(); + for (const target of new Set(sources.map((source) => source.resolvedTarget))) { + const result = deps.execSandbox( + sandboxName, + `cat ${quotePath(target)}`, + CONFIG_STATUS_TIMEOUT_MS, + ); + targetReads.set( + target, + result && result.status === 0 + ? { ok: true, contents: result.stdout } + : { ok: false, error: `could not read ${target}` }, + ); + } + + const reads = new Map(); + for (const source of sources) { + const targetRead = targetReads.get(source.resolvedTarget); + const key = configSourceKey(source); + if (!targetRead?.ok) { + reads.set(key, { + ok: false, + error: `${source.resolvedTarget} unavailable`, + }); + continue; + } + const parsed = parseRenderedConfigSource( + targetRead.contents, + source.resolvedTarget, + source.kind, + ); + reads.set( + key, + parsed.ok ? { ok: true, value: parser.getValue(source, parsed.source) } : parsed, + ); + } + return { sourceValues: reads, targetReads }; +} + +function parseEnvLines(raw: string): Map { + const entries = new Map(); + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + entries.set(key, unquoteEnvValue(value)); + } + return entries; +} + +function unquoteEnvValue(value: string): string { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + return value; +} + +function parseRenderedConfigSource( + raw: string, + target: string, + kind: ConfigRenderSource["kind"], +): ParsedConfigSourceRead { + if (kind === "env") return { ok: true, source: { kind: "env", entries: parseEnvLines(raw) } }; + try { + const value = + target.endsWith(".yaml") || target.endsWith(".yml") ? YAML.parse(raw) : JSON.parse(raw); + return { ok: true, source: { kind: "structured", value } }; + } catch { + return { ok: false, error: `could not parse ${target}` }; + } +} + +function compareConfigSource( + expected: ExpectedConfigValue, + source: ConfigRenderSource, + sourceValues: ReadonlyMap, +): { readonly checked: boolean; readonly matches: boolean; readonly detail: string } { + const actual = sourceValues.get(configSourceKey(source)); + if (!actual) { + return { + checked: false, + matches: false, + detail: `${expected.detail} (not checked)`, + }; + } + if (!actual.ok) { + return { + checked: false, + matches: false, + detail: `${expected.detail} (not checked)`, + }; + } + const matches = configValuesEqual(expected.value, actual.value); + return { + checked: true, + matches, + detail: matches + ? expected.detail + : `expected ${expected.detail}; rendered ${configInputDetail(actual.value)}`, + }; +} + +function configSourceKey(source: ConfigRenderSource): string { + return `${source.resolvedTarget}:${source.kind}:${source.key}`; +} + +function configValuesEqual( + expected: MessagingSerializableValue | undefined, + actual: MessagingSerializableValue | undefined, +): boolean { + if (expected === undefined || expected === null) return actual === undefined || actual === null; + if (actual === undefined || actual === null) return false; + if (Array.isArray(expected) || Array.isArray(actual)) { + const expectedList = listConfigValues(expected); + const actualList = listConfigValues(actual); + return ( + expectedList.length === actualList.length && + expectedList.every((value, index) => value === actualList[index]) + ); + } + const expectedBoolean = booleanConfigValue(expected); + const actualBoolean = booleanConfigValue(actual); + if (expectedBoolean !== null && actualBoolean !== null) return expectedBoolean === actualBoolean; + return formatConfigValue(expected) === formatConfigValue(actual); +} + +function listConfigValues(value: MessagingSerializableValue): string[] { + const values = Array.isArray(value) ? value : String(value).split(","); + return values + .map((entry) => String(entry).trim()) + .filter((entry) => entry.length > 0) + .sort(); +} + +function booleanConfigValue(value: MessagingSerializableValue): boolean | null { + if (typeof value === "boolean") return value; + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + if (normalized === "1" || normalized === "true") return true; + if (normalized === "0" || normalized === "false") return false; + return null; +} diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index 4d9d9005a3c..13a1f76a1d2 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -15,6 +15,7 @@ vi.mock("../../policy", () => ({ vi.mock("../../state/registry", () => ({ getSandbox: vi.fn(), + getMessagingPlanFromEntry: vi.fn((entry) => entry?.messaging?.plan ?? null), getConfiguredMessagingChannelsFromEntry: vi.fn((entry) => { const channels = entry?.messaging?.plan?.channels; return Array.isArray(channels) @@ -38,6 +39,7 @@ vi.mock("./process-recovery", () => ({ })); import type { AgentDefinition } from "../../agent/defs"; +import type { SandboxMessagingInputReference } from "../../messaging/manifest"; import type { SandboxEntry } from "../../state/registry"; import { showSandboxChannelStatus } from "./channel-status"; @@ -65,7 +67,12 @@ function fakeAgent(name: "openclaw" | "hermes" = "openclaw"): AgentDefinition { return { kind: "ui" as const, label: "UI", path: "/" }; }, get configPaths() { - return { dir: configDir, configFile: "config.json", envFile: null, format: "json" }; + return { + dir: configDir, + configFile: name === "openclaw" ? "openclaw.json" : "config.yaml", + envFile: name === "hermes" ? ".env" : null, + format: name === "openclaw" ? "json" : "yaml", + }; }, get inferenceProviderOptions() { return []; @@ -115,17 +122,19 @@ function fakeAgent(name: "openclaw" | "hermes" = "openclaw"): AgentDefinition { function entry( messagingChannels: string[] = ["whatsapp"], disabledChannels: string[] = [], + channelInputs: Record = {}, + agentName: "openclaw" | "hermes" = "openclaw", ): SandboxEntry { const disabled = new Set(disabledChannels); return { name: "alpha", - agent: "openclaw", + agent: agentName, messaging: { schemaVersion: 1, plan: { schemaVersion: 1, sandboxName: "alpha", - agent: "openclaw", + agent: agentName, workflow: "onboard", channels: messagingChannels.map((channelId) => ({ channelId, @@ -135,7 +144,7 @@ function entry( selected: true, configured: true, disabled: disabled.has(channelId), - inputs: [], + inputs: channelInputs[channelId] ?? [], hooks: [], })), disabledChannels, @@ -203,7 +212,12 @@ describe("showSandboxChannelStatus (whatsapp)", () => { exec: () => ({ status: 0, stdout, stderr: "" }), }); try { - await showSandboxChannelStatus("alpha", { deps, quietJson: true, asJson: true }); + await showSandboxChannelStatus("alpha", { + deps, + channel: "whatsapp", + quietJson: true, + asJson: true, + }); } finally { exitSpy.mockRestore(); } @@ -238,7 +252,7 @@ describe("showSandboxChannelStatus (whatsapp)", () => { }); let threw: Error | null = null; try { - await showSandboxChannelStatus("alpha", { deps }); + await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); } catch (err) { threw = err as Error; } finally { @@ -271,7 +285,7 @@ describe("showSandboxChannelStatus (whatsapp)", () => { const { deps, out_lines } = makeDeps({ exec: () => ({ status: 0, stdout, stderr: "" }), }); - const result = await showSandboxChannelStatus("alpha", { deps }); + const result = await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); expect(result && "report" in result && result.report.verdict).toBe("healthy"); const dump = out_lines.join("\n"); expect(dump).toMatch(/Verdict:.*healthy/); @@ -286,7 +300,7 @@ describe("showSandboxChannelStatus (whatsapp)", () => { }); let threw: Error | null = null; try { - await showSandboxChannelStatus("alpha", { deps }); + await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); } catch (err) { threw = err as Error; } finally { @@ -304,7 +318,7 @@ describe("showSandboxChannelStatus (whatsapp)", () => { }); let threw: Error | null = null; try { - await showSandboxChannelStatus("alpha", { deps, asJson: true }); + await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp", asJson: true }); } catch (err) { threw = err as Error; } finally { @@ -333,7 +347,7 @@ describe("showSandboxChannelStatus (whatsapp)", () => { }); let threw: Error | null = null; try { - await showSandboxChannelStatus("alpha", { deps }); + await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); } catch (err) { threw = err as Error; } finally { @@ -357,7 +371,7 @@ describe("showSandboxChannelStatus (whatsapp)", () => { agentName: "hermes", }); try { - await showSandboxChannelStatus("alpha", { deps }); + await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); } catch { /* expected exit(1) for unpaired */ } finally { @@ -397,7 +411,7 @@ describe("showSandboxChannelStatus (whatsapp)", () => { exec: () => ({ status: 0, stdout: stdoutNoMatch, stderr: "" }), }); try { - await showSandboxChannelStatus("alpha", { deps: depsNoMatch }); + await showSandboxChannelStatus("alpha", { deps: depsNoMatch, channel: "whatsapp" }); } catch { /* expected exit(1) for stale-heartbeat + no bridge */ } @@ -423,7 +437,7 @@ describe("showSandboxChannelStatus (whatsapp)", () => { const { deps: depsTimeout, out_lines: linesTimeout } = makeDeps({ exec: () => ({ status: 0, stdout: stdoutTimeout, stderr: "" }), }); - await showSandboxChannelStatus("alpha", { deps: depsTimeout }); + await showSandboxChannelStatus("alpha", { deps: depsTimeout, channel: "whatsapp" }); const dumpTimeout = linesTimeout.join("\n"); expect(dumpTimeout).toMatch(/Bridge process: could not enumerate sandbox processes/); expect(dumpTimeout).toMatch(/Verdict:.*healthy/); @@ -451,7 +465,7 @@ describe("showSandboxChannelStatus (whatsapp)", () => { }) as never); const { deps } = makeDeps({ exec }); try { - await showSandboxChannelStatus("alpha", { deps }); + await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); } catch { /* unpaired path exits 1 */ } finally { @@ -479,13 +493,80 @@ describe("showSandboxChannelStatus (whatsapp)", () => { sandbox: entry(["whatsapp"], ["whatsapp"]), }); deps.execSandbox = execSpy as unknown as typeof deps.execSandbox; - const result = await showSandboxChannelStatus("alpha", { deps }); + const result = await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); expect(execSpy).not.toHaveBeenCalled(); expect(result && "verdict" in result && result.verdict).toBe("info"); const dump = out_lines.join("\n"); expect(dump).toMatch(/registered but currently paused/); }); + it("emits a compact all-channel report when no channel is selected", async () => { + const commands: string[] = []; + const { deps, out_lines } = makeDeps({ + exec: (_sandbox, command) => { + commands.push(command); + return command.includes("/sandbox/.openclaw/openclaw.json") + ? { + status: 0, + stdout: JSON.stringify({ + channels: { + telegram: { + accounts: { + default: { + groupPolicy: "open", + }, + }, + }, + }, + }), + stderr: "", + } + : { status: 1, stdout: "", stderr: "" }; + }, + sandbox: entry(["telegram", "whatsapp"], [], { + telegram: [ + { + channelId: "telegram", + inputId: "groupPolicy", + kind: "config", + required: false, + sourceEnv: "TELEGRAM_GROUP_POLICY", + statePath: "telegramConfig.groupPolicy", + value: "open", + }, + ], + }), + appliedPresets: ["telegram", "whatsapp"], + }); + const result = await showSandboxChannelStatus("alpha", { deps }); + + expect( + result && "channels" in result && result.channels.map((channel) => channel.channel), + ).toEqual(["telegram", "whatsapp"]); + expect(commands.join("\n")).not.toMatch(/NEMOCLAW_WA_DIAG_OK/); + const dump = out_lines.join("\n"); + expect(dump).toMatch(/NemoClaw channels status:.*alpha/); + expect(dump).toMatch(/\btelegram\b/); + expect(dump).toMatch(/\bwhatsapp\b/); + expect(dump).toMatch(/Telegram group policy \(TELEGRAM_GROUP_POLICY\):\s+open/); + expect(dump).not.toMatch(/Deep diagnostics/); + expect(dump).not.toMatch(/Probed at/); + }); + + it("prints an empty-state hint when no channels are configured", async () => { + const { deps, out_lines } = makeDeps({ + exec: () => ({ status: 0, stdout: "", stderr: "" }), + sandbox: entry([]), + appliedPresets: [], + }); + const result = await showSandboxChannelStatus("alpha", { deps }); + + expect(result && "channels" in result && result.channels).toEqual([]); + const dump = out_lines.join("\n"); + expect(dump).toMatch(/Configured channels: none/); + expect(dump).toMatch(/channels add /); + }); + it("emits a basic per-channel report for non-whatsapp channels", async () => { const { deps, out_lines } = makeDeps({ exec: () => ({ status: 0, stdout: "", stderr: "" }), @@ -501,4 +582,808 @@ describe("showSandboxChannelStatus (whatsapp)", () => { expect(dump).toMatch(/telegram registered/); expect(dump).toMatch(/preset applied/); }); + + it("marks rendered config ok when the sandbox config matches the sandbox entry", async () => { + const { deps, out_lines } = makeDeps({ + exec: (_sandbox, command) => + command.includes("/sandbox/.openclaw/openclaw.json") + ? { + status: 0, + stdout: JSON.stringify({ + channels: { + telegram: { + accounts: { + default: { + groupPolicy: "allowlist", + }, + }, + }, + }, + }), + stderr: "", + } + : { status: 1, stdout: "", stderr: "" }, + sandbox: entry(["telegram"], [], { + telegram: [ + { + channelId: "telegram", + inputId: "botToken", + kind: "secret", + required: true, + sourceEnv: "TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + }, + { + channelId: "telegram", + inputId: "requireMention", + kind: "config", + required: false, + sourceEnv: "TELEGRAM_REQUIRE_MENTION", + statePath: "telegramConfig.requireMention", + value: "1", + }, + { + channelId: "telegram", + inputId: "groupPolicy", + kind: "config", + required: false, + sourceEnv: "TELEGRAM_GROUP_POLICY", + statePath: "telegramConfig.groupPolicy", + value: "allowlist", + }, + ], + }), + appliedPresets: ["telegram"], + }); + const result = await showSandboxChannelStatus("alpha", { + deps, + channel: "telegram", + }); + + expect(result && "verdict" in result && result.verdict).toBe("info"); + const signals = result && "signals" in result ? result.signals : []; + expect( + signals.find((signal) => signal.label === "Telegram group policy (TELEGRAM_GROUP_POLICY)"), + ).toMatchObject({ + severity: "ok", + detail: "allowlist", + }); + expect( + signals.find( + (signal) => signal.label === "Telegram group mention mode (TELEGRAM_REQUIRE_MENTION)", + ), + ).toBeUndefined(); + const dump = out_lines.join("\n"); + expect(dump).toMatch(/Telegram group policy \(TELEGRAM_GROUP_POLICY\):\s+allowlist/); + expect(dump).not.toMatch(/Telegram Bot Token/); + expect(dump).not.toMatch(/TELEGRAM_BOT_TOKEN/); + }); + + it("compares Hermes Telegram group policy from rendered env config", async () => { + const { deps } = makeDeps({ + exec: (_sandbox, command) => + command.includes("/sandbox/.hermes/.env") + ? { + status: 0, + stdout: ["TELEGRAM_ALLOWED_USERS=7895072570", "TELEGRAM_GROUP_POLICY=allowlist"].join( + "\n", + ), + stderr: "", + } + : { status: 1, stdout: "", stderr: "" }, + agentName: "hermes", + sandbox: entry( + ["telegram"], + [], + { + telegram: [ + { + channelId: "telegram", + inputId: "groupPolicy", + kind: "config", + required: false, + sourceEnv: "TELEGRAM_GROUP_POLICY", + statePath: "telegramConfig.groupPolicy", + value: "allowlist", + }, + ], + }, + "hermes", + ), + appliedPresets: ["telegram"], + }); + const result = await showSandboxChannelStatus("alpha", { + deps, + channel: "telegram", + }); + + const signals = result && "signals" in result ? result.signals : []; + expect( + signals.find((signal) => signal.label === "Telegram group policy (TELEGRAM_GROUP_POLICY)"), + ).toMatchObject({ + severity: "ok", + detail: "allowlist", + }); + }); + + it("warns when rendered config differs from the sandbox entry", async () => { + const { deps } = makeDeps({ + exec: () => ({ + status: 0, + stdout: JSON.stringify({ + channels: { + telegram: { + accounts: { + default: { + groupPolicy: "open", + }, + }, + }, + }, + }), + stderr: "", + }), + sandbox: entry(["telegram"], [], { + telegram: [ + { + channelId: "telegram", + inputId: "groupPolicy", + kind: "config", + required: false, + sourceEnv: "TELEGRAM_GROUP_POLICY", + statePath: "telegramConfig.groupPolicy", + value: "allowlist", + }, + ], + }), + appliedPresets: ["telegram"], + }); + const result = await showSandboxChannelStatus("alpha", { + deps, + channel: "telegram", + }); + + const signals = result && "signals" in result ? result.signals : []; + expect( + signals.find((signal) => signal.label === "Telegram group policy (TELEGRAM_GROUP_POLICY)"), + ).toMatchObject({ + severity: "warn", + detail: "expected allowlist; rendered open", + }); + }); + + it("warns once when a shared rendered config source is unreadable", async () => { + const { deps, out_lines } = makeDeps({ + exec: () => ({ + status: 1, + stdout: "", + stderr: "cat: /sandbox/.openclaw/openclaw.json: No such file or directory", + }), + sandbox: entry(["teams"], [], { + teams: [ + { + channelId: "teams", + inputId: "appId", + kind: "config", + required: true, + sourceEnv: "MSTEAMS_APP_ID", + statePath: "teamsConfig.appId", + value: "2542103c-7a1e-408a-b2f3-667e09e86783", + }, + { + channelId: "teams", + inputId: "tenantId", + kind: "config", + required: true, + sourceEnv: "MSTEAMS_TENANT_ID", + statePath: "teamsConfig.tenantId", + value: "43083d15-7273-40c1-b7db-39efd9ccc17a", + }, + { + channelId: "teams", + inputId: "allowedUsers", + kind: "config", + required: false, + sourceEnv: "TEAMS_ALLOWED_USERS", + statePath: "allowedIds.teams", + value: "205f29da-231e-4a0e-a0b2-b398e6302087", + }, + { + channelId: "teams", + inputId: "webhookPort", + kind: "config", + required: false, + sourceEnv: "MSTEAMS_PORT", + statePath: "teamsConfig.webhookPort", + value: "3978", + }, + { + channelId: "teams", + inputId: "requireMention", + kind: "config", + required: false, + sourceEnv: "TEAMS_REQUIRE_MENTION", + statePath: "teamsConfig.requireMention", + value: "1", + }, + ], + }), + appliedPresets: ["teams"], + }); + const result = await showSandboxChannelStatus("alpha", { + deps, + channel: "teams", + }); + + const signals = result && "signals" in result ? result.signals : []; + const sourceWarnings = signals.filter((signal) => signal.label === "Rendered config source"); + expect(sourceWarnings).toHaveLength(1); + expect(sourceWarnings[0]).toMatchObject({ + severity: "warn", + detail: "could not read /sandbox/.openclaw/openclaw.json; config comparisons not checked", + }); + expect( + signals.find((signal) => signal.label === "Microsoft Teams Client ID (MSTEAMS_APP_ID)"), + ).toMatchObject({ + severity: "info", + detail: "2542103c-7a1e-408a-b2f3-667e09e86783 (not checked)", + }); + expect( + signals.find( + (signal) => + signal.label === + "Microsoft Teams AAD Object IDs (comma-separated allowlist) (TEAMS_ALLOWED_USERS)", + ), + ).toMatchObject({ + severity: "info", + detail: "205f29da-231e-4a0e-a0b2-b398e6302087 (not checked)", + }); + expect( + signals.find( + (signal) => signal.label === "Microsoft Teams mention mode (TEAMS_REQUIRE_MENTION)", + ), + ).toMatchObject({ + severity: "info", + detail: "1 (not checked)", + }); + const sourceReadFailures = out_lines + .join("\n") + .match(/could not read \/sandbox\/\.openclaw\/openclaw\.json/g); + expect(sourceReadFailures).toHaveLength(1); + }); + + it("treats 0/1 registry config as matching boolean rendered config", async () => { + const { deps } = makeDeps({ + exec: () => ({ + status: 0, + stdout: JSON.stringify({ + channels: { + msteams: { + requireMention: true, + }, + }, + }), + stderr: "", + }), + sandbox: entry(["teams"], [], { + teams: [ + { + channelId: "teams", + inputId: "requireMention", + kind: "config", + required: false, + sourceEnv: "TEAMS_REQUIRE_MENTION", + statePath: "teamsConfig.requireMention", + value: "1", + }, + ], + }), + appliedPresets: ["teams"], + }); + const result = await showSandboxChannelStatus("alpha", { + deps, + channel: "teams", + }); + + const signals = result && "signals" in result ? result.signals : []; + expect( + signals.find( + (signal) => signal.label === "Microsoft Teams mention mode (TEAMS_REQUIRE_MENTION)", + ), + ).toMatchObject({ + severity: "ok", + detail: "1", + }); + }); + + it("compares manifest-derived allowlist render values", async () => { + const { deps } = makeDeps({ + exec: () => ({ + status: 0, + stdout: JSON.stringify({ + channels: { + msteams: { + allowFrom: ["205f29da-231e-4a0e-a0b2-b398e6302087"], + }, + }, + }), + stderr: "", + }), + sandbox: entry(["teams"], [], { + teams: [ + { + channelId: "teams", + inputId: "allowedUsers", + kind: "config", + required: false, + sourceEnv: "TEAMS_ALLOWED_USERS", + statePath: "allowedIds.teams", + value: "205f29da-231e-4a0e-a0b2-b398e6302087", + }, + ], + }), + appliedPresets: ["teams"], + }); + const result = await showSandboxChannelStatus("alpha", { + deps, + channel: "teams", + }); + + const signals = result && "signals" in result ? result.signals : []; + expect( + signals.find( + (signal) => + signal.label === + "Microsoft Teams AAD Object IDs (comma-separated allowlist) (TEAMS_ALLOWED_USERS)", + ), + ).toMatchObject({ + severity: "ok", + detail: "205f29da-231e-4a0e-a0b2-b398e6302087", + }); + }); + + it("compares Discord guild-derived render values", async () => { + const { deps, out_lines } = makeDeps({ + exec: () => ({ + status: 0, + stdout: JSON.stringify({ + channels: { + discord: { + guilds: { + "1504155275899437177": { + requireMention: true, + }, + }, + }, + }, + }), + stderr: "", + }), + sandbox: entry(["discord"], [], { + discord: [ + { + channelId: "discord", + inputId: "serverId", + kind: "config", + required: false, + sourceEnv: "DISCORD_SERVER_ID", + statePath: "discordGuilds.serverId", + value: "1504155275899437177", + }, + { + channelId: "discord", + inputId: "requireMention", + kind: "config", + required: false, + sourceEnv: "DISCORD_REQUIRE_MENTION", + statePath: "discordGuilds.requireMention", + value: "1", + }, + ], + }), + appliedPresets: ["discord"], + }); + const result = await showSandboxChannelStatus("alpha", { + deps, + channel: "discord", + }); + + const signals = result && "signals" in result ? result.signals : []; + expect( + signals.find( + (signal) => + signal.label === "Discord Server ID (for guild workspace access) (DISCORD_SERVER_ID)", + ), + ).toMatchObject({ + severity: "ok", + detail: "1504155275899437177", + }); + expect( + signals.find((signal) => signal.label === "Discord mention mode (DISCORD_REQUIRE_MENTION)"), + ).toMatchObject({ + severity: "ok", + detail: "1", + }); + expect( + signals.find( + (signal) => signal.label === "Discord User ID (optional guild allowlist) (DISCORD_USER_ID)", + ), + ).toBeUndefined(); + const dump = out_lines.join("\n"); + expect(dump).toMatch( + /Discord Server ID \(for guild workspace access\) \(DISCORD_SERVER_ID\):\s+1504155275899437177/, + ); + expect(dump).toMatch(/Discord mention mode \(DISCORD_REQUIRE_MENTION\):\s+1/); + }); + + it("compares Slack OpenClaw allowlist render values", async () => { + const { deps } = makeDeps({ + exec: () => ({ + status: 0, + stdout: JSON.stringify({ + channels: { + slack: { + accounts: { + default: { + allowFrom: ["U01ABC2DEF3"], + channels: { + C012AB3CD: { + enabled: true, + requireMention: true, + users: ["U01ABC2DEF3"], + }, + }, + }, + }, + }, + }, + }), + stderr: "", + }), + sandbox: entry(["slack"], [], { + slack: [ + { + channelId: "slack", + inputId: "allowedUsers", + kind: "config", + required: false, + sourceEnv: "SLACK_ALLOWED_USERS", + statePath: "allowedIds.slack", + value: "U01ABC2DEF3", + }, + { + channelId: "slack", + inputId: "allowedChannels", + kind: "config", + required: false, + sourceEnv: "SLACK_ALLOWED_CHANNELS", + statePath: "slackConfig.allowedChannels", + value: "C012AB3CD", + }, + ], + }), + appliedPresets: ["slack"], + }); + const result = await showSandboxChannelStatus("alpha", { + deps, + channel: "slack", + }); + + const signals = result && "signals" in result ? result.signals : []; + expect( + signals.find( + (signal) => + signal.label === "Slack Member IDs (comma-separated allowlist) (SLACK_ALLOWED_USERS)", + ), + ).toMatchObject({ + severity: "ok", + detail: "U01ABC2DEF3", + }); + expect( + signals.find( + (signal) => + signal.label === "Slack Channel IDs (comma-separated allowlist) (SLACK_ALLOWED_CHANNELS)", + ), + ).toMatchObject({ + severity: "ok", + detail: "C012AB3CD", + }); + }); + + it("does not treat Slack wildcard channel policy as configured channel IDs", async () => { + const { deps } = makeDeps({ + exec: () => ({ + status: 0, + stdout: JSON.stringify({ + channels: { + slack: { + accounts: { + default: { + allowFrom: ["U0B5BQABTL4"], + channels: { + "*": { + enabled: true, + requireMention: true, + users: ["U0B5BQABTL4"], + }, + }, + }, + }, + }, + }, + }), + stderr: "", + }), + sandbox: entry(["slack"], [], { + slack: [ + { + channelId: "slack", + inputId: "allowedUsers", + kind: "config", + required: false, + sourceEnv: "SLACK_ALLOWED_USERS", + statePath: "allowedIds.slack", + value: "U0B5BQABTL4", + }, + ], + }), + appliedPresets: ["slack"], + }); + const result = await showSandboxChannelStatus("alpha", { + deps, + channel: "slack", + }); + + const signals = result && "signals" in result ? result.signals : []; + expect( + signals.find( + (signal) => + signal.label === "Slack Member IDs (comma-separated allowlist) (SLACK_ALLOWED_USERS)", + ), + ).toMatchObject({ + severity: "ok", + detail: "U0B5BQABTL4", + }); + expect( + signals.find( + (signal) => + signal.label === "Slack Channel IDs (comma-separated allowlist) (SLACK_ALLOWED_CHANNELS)", + ), + ).toBeUndefined(); + }); + + it("compares OpenClaw WeChat account render values", async () => { + const renderedResponses: Array<[string, ExecResult]> = [ + [ + "/sandbox/.openclaw/openclaw.json", + { + status: 0, + stdout: JSON.stringify({ + channels: { + "openclaw-weixin": { + accounts: { + "wechat-account": { + enabled: true, + }, + }, + }, + }, + }), + stderr: "", + }, + ], + [ + "/sandbox/.openclaw/openclaw-weixin/accounts/wechat-account.json", + { + status: 0, + stdout: JSON.stringify({ + baseUrl: "https://ilinkai.wechat.com", + userId: "wechat-user", + }), + stderr: "", + }, + ], + ]; + const { deps } = makeDeps({ + exec: (_sandbox, command) => + renderedResponses.find(([needle]) => command.includes(needle))?.[1] ?? { + status: 1, + stdout: "", + stderr: "", + }, + sandbox: entry(["wechat"], [], { + wechat: [ + { + channelId: "wechat", + inputId: "accountId", + kind: "config", + required: true, + sourceEnv: "WECHAT_ACCOUNT_ID", + statePath: "wechatConfig.accountId", + value: "wechat-account", + }, + { + channelId: "wechat", + inputId: "baseUrl", + kind: "config", + required: false, + sourceEnv: "WECHAT_BASE_URL", + statePath: "wechatConfig.baseUrl", + value: "https://ilinkai.wechat.com", + }, + { + channelId: "wechat", + inputId: "userId", + kind: "config", + required: false, + sourceEnv: "WECHAT_USER_ID", + statePath: "wechatConfig.userId", + value: "wechat-user", + }, + { + channelId: "wechat", + inputId: "allowedIds", + kind: "config", + required: false, + sourceEnv: "WECHAT_ALLOWED_IDS", + statePath: "allowedIds.wechat", + value: "wechat-user", + }, + ], + }), + appliedPresets: ["wechat"], + }); + const result = await showSandboxChannelStatus("alpha", { + deps, + channel: "wechat", + }); + + const signals = result && "signals" in result ? result.signals : []; + expect(signals.find((signal) => signal.label === "WECHAT_ACCOUNT_ID")).toMatchObject({ + severity: "ok", + detail: "wechat-account", + }); + expect(signals.find((signal) => signal.label === "WECHAT_BASE_URL")).toMatchObject({ + severity: "ok", + detail: "https://ilinkai.wechat.com", + }); + expect(signals.find((signal) => signal.label === "WECHAT_USER_ID")).toMatchObject({ + severity: "ok", + detail: "wechat-user", + }); + expect( + signals.find( + (signal) => signal.label === "WeChat User ID(s) (DM allowlist) (WECHAT_ALLOWED_IDS)", + ), + ).toBeUndefined(); + }); + + it("compares Hermes WeChat values through rendered WEIXIN env keys", async () => { + const { deps } = makeDeps({ + exec: (_sandbox, command) => + command.includes("/sandbox/.hermes/.env") + ? { + status: 0, + stdout: [ + "WEIXIN_ACCOUNT_ID=wxid_abc", + "WEIXIN_BASE_URL=https://wechat.example.test", + "WEIXIN_ALLOWED_USERS=wxid_abc,wxid_def", + ].join("\n"), + stderr: "", + } + : { status: 1, stdout: "", stderr: "" }, + agentName: "hermes", + sandbox: entry( + ["wechat"], + [], + { + wechat: [ + { + channelId: "wechat", + inputId: "accountId", + kind: "config", + required: true, + sourceEnv: "WECHAT_ACCOUNT_ID", + statePath: "wechatConfig.accountId", + value: "wxid_abc", + }, + { + channelId: "wechat", + inputId: "baseUrl", + kind: "config", + required: false, + sourceEnv: "WECHAT_BASE_URL", + statePath: "wechatConfig.baseUrl", + value: "https://wechat.example.test", + }, + { + channelId: "wechat", + inputId: "userId", + kind: "config", + required: false, + sourceEnv: "WECHAT_USER_ID", + statePath: "wechatConfig.userId", + value: "wxid_abc", + }, + { + channelId: "wechat", + inputId: "allowedIds", + kind: "config", + required: false, + sourceEnv: "WECHAT_ALLOWED_IDS", + statePath: "allowedIds.wechat", + value: "wxid_abc,wxid_def", + }, + ], + }, + "hermes", + ), + appliedPresets: ["wechat"], + }); + const result = await showSandboxChannelStatus("alpha", { + deps, + channel: "wechat", + }); + + const signals = result && "signals" in result ? result.signals : []; + expect(signals.find((signal) => signal.label === "WECHAT_ACCOUNT_ID")).toMatchObject({ + severity: "ok", + detail: "wxid_abc", + }); + expect(signals.find((signal) => signal.label === "WECHAT_BASE_URL")).toMatchObject({ + severity: "ok", + detail: "https://wechat.example.test", + }); + expect( + signals.find( + (signal) => signal.label === "WeChat User ID(s) (DM allowlist) (WECHAT_ALLOWED_IDS)", + ), + ).toMatchObject({ + severity: "ok", + detail: "wxid_abc,wxid_def", + }); + expect(signals.find((signal) => signal.label === "WECHAT_USER_ID")).toBeUndefined(); + }); + + it("uses manifest defaults when no stored config value exists", async () => { + const { deps, out_lines } = makeDeps({ + exec: () => ({ + status: 0, + stdout: JSON.stringify({ + channels: { + telegram: { + accounts: { + default: { + groupPolicy: "open", + }, + }, + }, + }, + }), + stderr: "", + }), + sandbox: entry(["telegram"]), + appliedPresets: ["telegram"], + }); + const result = await showSandboxChannelStatus("alpha", { + deps, + channel: "telegram", + }); + + const signals = result && "signals" in result ? result.signals : []; + expect( + signals.find((signal) => signal.label === "Telegram group policy (TELEGRAM_GROUP_POLICY)"), + ).toMatchObject({ + severity: "ok", + detail: "open (default)", + }); + expect( + signals.find( + (signal) => signal.label === "Telegram group mention mode (TELEGRAM_REQUIRE_MENTION)", + ), + ).toBeUndefined(); + const dump = out_lines.join("\n"); + expect(dump).not.toMatch(/Telegram User ID \(for DM access\)/); + expect(dump).toMatch(/Telegram group policy \(TELEGRAM_GROUP_POLICY\):\s+open \(default\)/); + }); }); diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index edf28314b71..e4475cb33d4 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -14,14 +14,14 @@ 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 { - collectBuiltInMessagingChannelDiagnostics, - type MessagingChannelDiagnosticSpec, -} from "../../messaging/diagnostics"; import { createBuiltInChannelManifestRegistry, getMessagingManifestAvailabilityContext, } from "../../messaging"; +import { + collectBuiltInMessagingChannelDiagnostics, + type MessagingChannelDiagnosticSpec, +} from "../../messaging/diagnostics"; import * as policies from "../../policy"; import { type DiagnosticSeverity, @@ -34,6 +34,7 @@ import { type WhatsappProbeInput, } from "../../sandbox/whatsapp-diagnostics"; import * as registry from "../../state/registry"; +import { buildConfigStatusSignals, quotePath } from "./channel-status-config"; // runner.ts (which process-recovery transitively depends on) uses a few CJS // `require()` calls that vitest's CLI-test project cannot resolve at import @@ -44,15 +45,6 @@ function loadProcessRecovery(): typeof import("./process-recovery") { return require("./process-recovery") as typeof import("./process-recovery"); } -// Inline single-quote shell quoting — the probe script only ever quotes -// trusted path strings derived from the agent manifest (`configDir/...`), -// so we don't need the full quoting matrix from `runner.shellQuote`. Keep -// the implementation tiny and avoid the runner import so the orchestrator -// stays loadable from unit tests. -function quotePath(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'`; -} - type ExecRunner = ( sandboxName: string, command: string, @@ -83,7 +75,7 @@ export type ChannelStatusOptions = { deps?: StatusDeps; }; -export type ChannelStatusReport = +type ChannelStatusSingleReport = | { schemaVersion: 1; sandbox: string; channel: string; report: WhatsappDiagnosticReport } | { schemaVersion: 1; @@ -93,6 +85,14 @@ export type ChannelStatusReport = signals: DiagnosticSignal[]; }; +export type ChannelStatusReport = + | ChannelStatusSingleReport + | { + schemaVersion: 1; + sandbox: string; + channels: ChannelStatusSingleReport[]; + }; + // Bound how long we are willing to block inside an `openshell sandbox exec` // for the inline diagnostic snippet. WhatsApp's bridge sometimes goes // unresponsive when the Noise WebSocket is stuck; a fast hard cap keeps @@ -152,19 +152,6 @@ function diagnosticChannelNames(): string[] { return CHANNEL_STATUS_DIAGNOSTICS.map((diagnostic) => diagnostic.channelId); } -function selectDefaultChannel(configuredChannels: readonly string[]): string { - const preferredConfigured = configuredChannels.find( - (channel) => getChannelStatusDiagnostic(channel)?.preferredDefault === true, - ); - if (preferredConfigured) return preferredConfigured; - if (configuredChannels.length > 0) return configuredChannels[0]; - return ( - CHANNEL_STATUS_DIAGNOSTICS.find((diagnostic) => diagnostic.preferredDefault)?.channelId ?? - CHANNEL_STATUS_DIAGNOSTICS[0]?.channelId ?? - "" - ); -} - function resolveStateDirs(agent: AgentDefinition): string[] { const configDir = agent.configPaths?.dir; if (!configDir) return []; @@ -424,8 +411,38 @@ function renderReport( deps.out(JSON.stringify(report, null, 2)); return; } + if ("channels" in report) { + renderAllChannelReport(report, deps); + return; + } deps.out(""); deps.out(` ${B}${CLI_DISPLAY_NAME} channels status:${R} ${report.sandbox} / ${report.channel}`); + renderSingleChannelSignals(report, deps, { includeDeepDiagnostics: true }); +} + +function renderAllChannelReport( + report: Extract, + deps: Required, +): void { + deps.out(""); + deps.out(` ${B}${CLI_DISPLAY_NAME} channels status:${R} ${report.sandbox}`); + if (report.channels.length === 0) { + deps.out(` ${severityLabel("info")} Configured channels: none`); + deps.out(` ${D}hint: run \`${CLI_NAME} ${report.sandbox} channels add \`${R}`); + deps.out(""); + return; + } + for (const channelReport of report.channels) { + deps.out(` ${B}${channelReport.channel}${R}`); + renderSingleChannelSignals(channelReport, deps, { includeDeepDiagnostics: false }); + } +} + +function renderSingleChannelSignals( + report: ChannelStatusSingleReport, + deps: Required, + options: { readonly includeDeepDiagnostics: boolean }, +): void { if ("report" in report) { deps.out(` Probed at ${report.report.probedAt} (agent: ${report.report.agent})`); deps.out(""); @@ -448,6 +465,7 @@ function renderReport( return; } for (const signal of report.signals) { + if (!options.includeDeepDiagnostics && signal.label === "Deep diagnostics") continue; deps.out(` ${severityLabel(signal.severity)} ${signal.label}: ${signal.detail}`); if (signal.hint) deps.out(` ${D}hint: ${signal.hint}${R}`); } @@ -455,6 +473,7 @@ function renderReport( } function exitCodeFor(report: ChannelStatusReport): number { + if ("channels" in report) return 0; if ("report" in report) { switch (report.report.verdict) { case "healthy": @@ -473,7 +492,8 @@ function buildBasicChannelReport( agent: AgentDefinition, deps: Required, diagnostic: MessagingChannelDiagnosticSpec, -): ChannelStatusReport { + options: { readonly includeDeepDiagnostics?: boolean } = {}, +): ChannelStatusSingleReport { const entry = deps.getSandbox(sandboxName); const enabled = registry.getConfiguredMessagingChannelsFromEntry(entry).includes(channelName); const disabled = registry.getDisabledMessagingChannelsFromEntry(entry).includes(channelName); @@ -505,11 +525,16 @@ function buildBasicChannelReport( ? undefined : `run \`${CLI_NAME} ${sandboxName} policy-add ${policyPresets[0]}\``, }); - signals.push({ - label: "Deep diagnostics", - severity: "info", - detail: `not implemented for ${channelName}; see \`${CLI_NAME} ${sandboxName} doctor\` and \`${CLI_NAME} ${sandboxName} logs --follow\``, - }); + if (enabled) { + signals.push(...buildConfigStatusSignals(sandboxName, channelName, entry, agent, deps)); + } + if (options.includeDeepDiagnostics ?? true) { + 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 (!channelSupportedByAgent(channelName, agent)) { @@ -528,6 +553,25 @@ function buildBasicChannelReport( }; } +function buildUnknownConfiguredChannelReport( + sandboxName: string, + channelName: string, +): ChannelStatusSingleReport { + return { + schemaVersion: 1, + sandbox: sandboxName, + channel: channelName, + verdict: "info", + signals: [ + { + label: "Channel registration", + severity: "warn", + detail: `${channelName} registered but not recognized by this CLI build`, + }, + ], + }; +} + function channelSupportedByAgent(channelName: string, agent: AgentDefinition): boolean { return channelManifestRegistry .listAvailable(getMessagingManifestAvailabilityContext(agent, channelManifestRegistry.list())) @@ -565,14 +609,31 @@ export async function showSandboxChannelStatus( process.exit(1); } - let channelName = channelArg; - if (!channelName) { + const agent = deps.loadAgent(entry.agent || "openclaw"); + + if (!channelArg) { const configuredChannels = registry.getConfiguredMessagingChannelsFromEntry(entry); - channelName = selectDefaultChannel(configuredChannels); + const report: ChannelStatusReport = { + schemaVersion: 1, + sandbox: sandboxName, + channels: configuredChannels.map((channelName) => { + const diagnostic = getChannelStatusDiagnostic(channelName); + return diagnostic + ? buildBasicChannelReport(sandboxName, channelName, agent, deps, diagnostic, { + includeDeepDiagnostics: false, + }) + : buildUnknownConfiguredChannelReport(sandboxName, channelName); + }), + }; + if (!(asJson && quietJson)) { + renderReport(report, asJson, deps); + } + return report; } - const diagnostic = channelName ? getChannelStatusDiagnostic(channelName) : null; - if (!channelName || !diagnostic) { + const channelName = channelArg; + const diagnostic = getChannelStatusDiagnostic(channelName); + if (!diagnostic) { const known = diagnosticChannelNames().join(", "); if (asJson) { deps.out( @@ -588,8 +649,6 @@ export async function showSandboxChannelStatus( process.exit(1); } - const agent = deps.loadAgent(entry.agent || "openclaw"); - const disabledChannels = new Set(registry.getDisabledMessagingChannelsFromEntry(entry)); const channelIsPaused = disabledChannels.has(channelName); diff --git a/src/lib/actions/sandbox/process-recovery.test.ts b/src/lib/actions/sandbox/process-recovery.test.ts index cbfe901000a..bc46f1cb450 100644 --- a/src/lib/actions/sandbox/process-recovery.test.ts +++ b/src/lib/actions/sandbox/process-recovery.test.ts @@ -5,10 +5,35 @@ import { afterEach, describe, expect, it } from "vitest"; // Import from compiled dist for parity with the other CLI tests in this project. import { + buildSandboxExecMarkedCommand, probeSandboxInferenceGatewayHealth, waitForRecoveredSandboxGateway, } from "./process-recovery"; +describe("sandbox exec command wrapping", () => { + it("keeps single-line payloads readable while avoiding marker newlines", () => { + const wrapped = buildSandboxExecMarkedCommand("echo SECRET_BOUNDARY_OK"); + + expect(wrapped).not.toMatch(/[\r\n]/); + expect(wrapped).toContain("echo SECRET_BOUNDARY_OK"); + expect(wrapped).not.toContain("base64 -d | sh"); + }); + + it("keeps the OpenShell command argument newline-free while preserving multi-line payloads", () => { + const payload = "printf 'hello\\n'\ncat '/sandbox/.openclaw/openclaw.json'"; + const wrapped = buildSandboxExecMarkedCommand(payload); + + expect(wrapped).not.toMatch(/[\r\n]/); + expect(wrapped).toContain("__NEMOCLAW_SANDBOX_EXEC_STARTED__"); + expect(wrapped).toContain("base64 -d | sh"); + expect(wrapped).not.toContain(payload); + + const encoded = wrapped.match(/printf '%s' '([^']+)' \| base64 -d \| sh/)?.[1]; + expect(encoded).toBeTruthy(); + expect(Buffer.from(encoded as string, "base64").toString("utf8")).toBe(payload); + }); +}); + describe("probeSandboxInferenceGatewayHealth gateway-chain subprobe (#3265)", () => { const makeExec = (stdout: string, status = 0) => diff --git a/src/lib/actions/sandbox/process-recovery.ts b/src/lib/actions/sandbox/process-recovery.ts index 47bcfb34a08..6558f670890 100644 --- a/src/lib/actions/sandbox/process-recovery.ts +++ b/src/lib/actions/sandbox/process-recovery.ts @@ -67,9 +67,9 @@ export type SandboxForwardHealth = boolean | "occupied" | null; const SANDBOX_EXEC_STARTED_MARKER = "__NEMOCLAW_SANDBOX_EXEC_STARTED__"; -function buildSandboxExecMarkedCommand(command: string): string { - if (!command.includes("validate-hermes-env-secret-boundary.py")) { - return `printf '%s\n' '${SANDBOX_EXEC_STARTED_MARKER}'; ${command}`; +export function buildSandboxExecMarkedCommand(command: string): string { + if (!/[\r\n]/.test(command)) { + return `printf '%s\\n' '${SANDBOX_EXEC_STARTED_MARKER}'; ${command}`; } const encodedCommand = Buffer.from(command, "utf8").toString("base64"); return [ diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index 131945ccddb..85ec47c4ace 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -185,7 +185,7 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { group: "Messaging Channels", order: 25, usage: "nemoclaw channels status", - description: "Channel-specific runtime diagnostics", + description: "Messaging channel status", flags: "[--channel ] [--json]", }, ], diff --git a/src/lib/messaging/channels/discord/rendered-config-parser.test.ts b/src/lib/messaging/channels/discord/rendered-config-parser.test.ts new file mode 100644 index 00000000000..18517f8624b --- /dev/null +++ b/src/lib/messaging/channels/discord/rendered-config-parser.test.ts @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import type { ChannelManifest } from "../../manifest"; +import { discordRenderedConfigParser } from "./rendered-config-parser"; + +describe("discord rendered config parser", () => { + it("treats missing guild mention policy values as unset", () => { + const requireMentionKey = discordRenderedConfigParser + .listConfigVisibilityKeys({ + agentId: "openclaw", + manifest: { id: "discord" } as ChannelManifest, + inputs: [], + }) + .find((key) => key.key === "guildRequireMention"); + + expect(requireMentionKey).toBeDefined(); + expect( + discordRenderedConfigParser.getValue(requireMentionKey!, { + kind: "structured", + value: { + channels: { + discord: { + guilds: { + "1504155275899437177": { + enabled: true, + }, + }, + }, + }, + }, + }), + ).toBeUndefined(); + }); +}); diff --git a/src/lib/messaging/channels/discord/rendered-config-parser.ts b/src/lib/messaging/channels/discord/rendered-config-parser.ts new file mode 100644 index 00000000000..13ec0e4c5f1 --- /dev/null +++ b/src/lib/messaging/channels/discord/rendered-config-parser.ts @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingSerializableValue } from "../../manifest"; +import { + envConfigKey, + getEnvConfigValue, + getStructuredConfigValue, + getStructuredPath, + type RenderedChannelConfigParser, + type RenderedConfigSource, + type RenderedConfigVisibilityKey, + structuredConfigKey, +} from "../rendered-config-parser-utils"; + +const OPENCLAW_GUILDS_PATH = ["channels", "discord", "guilds"] as const; + +export const discordRenderedConfigParser: RenderedChannelConfigParser = { + listConfigVisibilityKeys(context) { + if (context.agentId === "openclaw") { + return [ + structuredConfigKey("serverId", "openclaw.json", OPENCLAW_GUILDS_PATH, "guildIds"), + structuredConfigKey( + "requireMention", + "openclaw.json", + OPENCLAW_GUILDS_PATH, + "guildRequireMention", + ), + structuredConfigKey("userId", "openclaw.json", OPENCLAW_GUILDS_PATH, "guildUsers"), + ]; + } + if (context.agentId === "hermes") { + return [ + envConfigKey("serverId", "~/.hermes/.env", "NEMOCLAW_DISCORD_GUILD_IDS"), + envConfigKey("userId", "~/.hermes/.env", "DISCORD_ALLOWED_USERS"), + structuredConfigKey("requireMention", "~/.hermes/config.yaml", [ + "discord", + "require_mention", + ]), + ]; + } + return []; + }, + + getValue(key, source) { + switch (key.key) { + case "guildIds": + return Object.keys(discordGuilds(source, key)); + case "guildRequireMention": + return discordRequireMention(discordGuilds(source, key)); + case "guildUsers": + return discordGuildUsers(discordGuilds(source, key)); + default: + return key.kind === "env" + ? getEnvConfigValue(source, key.envKey) + : getStructuredConfigValue(source, key.path); + } + }, +}; + +function discordGuilds( + source: RenderedConfigSource, + key: RenderedConfigVisibilityKey, +): Record { + const value = getStructuredConfigValue(source, key.path); + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function discordRequireMention( + guilds: Readonly>, +): MessagingSerializableValue | undefined { + const values = Object.values(guilds) + .map((guild) => + guild && typeof guild === "object" && !Array.isArray(guild) + ? getStructuredPath(guild, ["requireMention"]) + : undefined, + ) + .filter((entry): entry is MessagingSerializableValue => entry !== undefined); + if (values.length === 0) return undefined; + const uniqueValues = Array.from(new Set(values.map(formatProjectionValue))); + return uniqueValues.length === 1 ? values[0] : uniqueValues; +} + +function discordGuildUsers( + guilds: Readonly>, +): MessagingSerializableValue | undefined { + const users = Object.values(guilds).flatMap((guild) => { + if (!guild || typeof guild !== "object" || Array.isArray(guild)) return []; + const rawUsers = getStructuredPath(guild, ["users"]); + return Array.isArray(rawUsers) ? rawUsers.map(String) : []; + }); + return users.length > 0 ? users : undefined; +} + +function formatProjectionValue(value: MessagingSerializableValue): string { + return typeof value === "string" ? value : JSON.stringify(value); +} diff --git a/src/lib/messaging/channels/index.ts b/src/lib/messaging/channels/index.ts index 187cb7e14d5..04823f39f2f 100644 --- a/src/lib/messaging/channels/index.ts +++ b/src/lib/messaging/channels/index.ts @@ -3,4 +3,5 @@ export * from "./built-ins"; export * from "./metadata"; +export * from "./rendered-config-parser"; export { createBuiltInRenderTemplateResolver } from "./template-resolver"; diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 8d08c248b18..07f8d799e3f 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -20,6 +20,7 @@ import { BUILT_IN_CHANNEL_MANIFESTS, createBuiltInChannelManifestRegistry, discordManifest, + getBuiltInRenderedConfigParser, slackManifest, teamsManifest, telegramManifest, @@ -221,6 +222,15 @@ describe("built-in channel manifests", () => { ); }); + it("keeps rendered config parsers aligned with built-in manifests", () => { + expect( + BUILT_IN_CHANNEL_MANIFESTS.map((manifest) => [ + manifest.id, + Boolean(getBuiltInRenderedConfigParser(manifest.id)), + ]), + ).toEqual(BUILT_IN_CHANNEL_MANIFESTS.map((manifest) => [manifest.id, true])); + }); + it("keeps phase-1 manifest and hook files free of production side-effect imports", () => { const manifestPaths = [ "src/lib/messaging/channels/telegram/manifest.ts", diff --git a/src/lib/messaging/channels/rendered-config-parser-utils.ts b/src/lib/messaging/channels/rendered-config-parser-utils.ts new file mode 100644 index 00000000000..a572e1f917c --- /dev/null +++ b/src/lib/messaging/channels/rendered-config-parser-utils.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelManifest, + MessagingAgentId, + MessagingSerializableValue, + SandboxMessagingInputReference, +} from "../manifest"; + +export type RenderedConfigSourceKind = "structured" | "env"; + +export interface RenderedConfigVisibilityKey { + readonly key: string; + readonly inputId: string; + readonly target: string; + readonly kind: RenderedConfigSourceKind; + readonly path?: readonly string[]; + readonly envKey?: string; +} + +export type RenderedConfigSource = + | { + readonly kind: "structured"; + readonly value: unknown; + } + | { + readonly kind: "env"; + readonly entries: ReadonlyMap; + }; + +export interface RenderedChannelConfigParserContext { + readonly manifest: ChannelManifest; + readonly agentId: MessagingAgentId; + readonly inputs: readonly SandboxMessagingInputReference[]; +} + +export interface RenderedChannelConfigParser { + listConfigVisibilityKeys( + context: RenderedChannelConfigParserContext, + ): readonly RenderedConfigVisibilityKey[]; + getValue( + key: RenderedConfigVisibilityKey, + source: RenderedConfigSource, + ): MessagingSerializableValue | undefined; +} + +export function structuredConfigKey( + inputId: string, + target: string, + path: readonly string[], + key = inputId, +): RenderedConfigVisibilityKey { + return { key, inputId, target, kind: "structured", path }; +} + +export function envConfigKey( + inputId: string, + target: string, + envKey: string, + key = inputId, +): RenderedConfigVisibilityKey { + return { key, inputId, target, kind: "env", envKey }; +} + +export function getStructuredConfigValue( + source: RenderedConfigSource, + path: readonly string[] | undefined, +): MessagingSerializableValue | undefined { + if (source.kind !== "structured" || !path) return undefined; + return getStructuredPath(source.value, path); +} + +export function getEnvConfigValue( + source: RenderedConfigSource, + envKey: string | undefined, +): MessagingSerializableValue | undefined { + if (source.kind !== "env" || !envKey) return undefined; + return source.entries.get(envKey); +} + +export function getStructuredPath( + value: unknown, + path: readonly string[], +): MessagingSerializableValue | undefined { + let current = value; + for (const segment of path) { + if (Array.isArray(current)) { + const index = Number(segment); + current = Number.isInteger(index) ? current[index] : undefined; + continue; + } + if (!current || typeof current !== "object") return undefined; + current = (current as Record)[segment]; + } + return isMessagingSerializableValue(current) ? current : undefined; +} + +export function isMessagingSerializableValue(value: unknown): value is MessagingSerializableValue { + if (value === null) return true; + const type = typeof value; + if (type === "string" || type === "number" || type === "boolean") return true; + if (Array.isArray(value)) return value.every(isMessagingSerializableValue); + if (type !== "object") return false; + return Object.values(value as Record).every(isMessagingSerializableValue); +} diff --git a/src/lib/messaging/channels/rendered-config-parser.ts b/src/lib/messaging/channels/rendered-config-parser.ts new file mode 100644 index 00000000000..14bf1b81e44 --- /dev/null +++ b/src/lib/messaging/channels/rendered-config-parser.ts @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChannelManifest } from "../manifest"; +import { BUILT_IN_CHANNEL_MANIFESTS } from "./built-ins"; +import { discordRenderedConfigParser } from "./discord/rendered-config-parser"; +import type { RenderedChannelConfigParser } from "./rendered-config-parser-utils"; +import { slackRenderedConfigParser } from "./slack/rendered-config-parser"; +import { teamsRenderedConfigParser } from "./teams/rendered-config-parser"; +import { telegramRenderedConfigParser } from "./telegram/rendered-config-parser"; +import { wechatRenderedConfigParser } from "./wechat/rendered-config-parser"; +import { whatsappRenderedConfigParser } from "./whatsapp/rendered-config-parser"; + +export * from "./rendered-config-parser-utils"; + +const BUILT_IN_RENDERED_CONFIG_PARSERS: ReadonlyMap = new Map( + BUILT_IN_CHANNEL_MANIFESTS.map((manifest) => [ + manifest.id, + renderedConfigParserForBuiltInManifest(manifest), + ]), +); + +export function getBuiltInRenderedConfigParser( + channelId: string, +): RenderedChannelConfigParser | null { + return BUILT_IN_RENDERED_CONFIG_PARSERS.get(channelId) ?? null; +} + +function renderedConfigParserForBuiltInManifest( + manifest: ChannelManifest, +): RenderedChannelConfigParser { + switch (manifest.id) { + case "discord": + return discordRenderedConfigParser; + case "slack": + return slackRenderedConfigParser; + case "teams": + return teamsRenderedConfigParser; + case "telegram": + return telegramRenderedConfigParser; + case "wechat": + return wechatRenderedConfigParser; + case "whatsapp": + return whatsappRenderedConfigParser; + default: + throw new Error(`missing rendered config parser for built-in channel '${manifest.id}'`); + } +} diff --git a/src/lib/messaging/channels/slack/rendered-config-parser.ts b/src/lib/messaging/channels/slack/rendered-config-parser.ts new file mode 100644 index 00000000000..c3cedc5c263 --- /dev/null +++ b/src/lib/messaging/channels/slack/rendered-config-parser.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + envConfigKey, + getEnvConfigValue, + getStructuredConfigValue, + type RenderedChannelConfigParser, + type RenderedConfigSource, + type RenderedConfigVisibilityKey, + structuredConfigKey, +} from "../rendered-config-parser-utils"; + +const OPENCLAW_CHANNELS_KEY = "openclawAllowedChannels"; + +export const slackRenderedConfigParser: RenderedChannelConfigParser = { + listConfigVisibilityKeys(context) { + if (context.agentId === "openclaw") { + return [ + structuredConfigKey("allowedUsers", "openclaw.json", [ + "channels", + "slack", + "accounts", + "default", + "allowFrom", + ]), + structuredConfigKey( + "allowedChannels", + "openclaw.json", + ["channels", "slack", "accounts", "default", "channels"], + OPENCLAW_CHANNELS_KEY, + ), + ]; + } + if (context.agentId === "hermes") { + return [ + envConfigKey("allowedUsers", "~/.hermes/.env", "SLACK_ALLOWED_USERS"), + envConfigKey("allowedChannels", "~/.hermes/.env", "SLACK_ALLOWED_CHANNELS"), + ]; + } + return []; + }, + + getValue(key, source) { + if (key.key === OPENCLAW_CHANNELS_KEY) return slackAllowedChannelIds(source, key); + return key.kind === "env" + ? getEnvConfigValue(source, key.envKey) + : getStructuredConfigValue(source, key.path); + }, +}; + +function slackAllowedChannelIds( + source: RenderedConfigSource, + key: RenderedConfigVisibilityKey, +): string[] | undefined { + const value = getStructuredConfigValue(source, key.path); + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const channelIds = Object.keys(value).filter((channelId) => channelId !== "*"); + return channelIds.length > 0 ? channelIds : undefined; +} diff --git a/src/lib/messaging/channels/teams/rendered-config-parser.ts b/src/lib/messaging/channels/teams/rendered-config-parser.ts new file mode 100644 index 00000000000..cd102cac36d --- /dev/null +++ b/src/lib/messaging/channels/teams/rendered-config-parser.ts @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + envConfigKey, + getEnvConfigValue, + getStructuredConfigValue, + type RenderedChannelConfigParser, + structuredConfigKey, +} from "../rendered-config-parser-utils"; + +export const teamsRenderedConfigParser: RenderedChannelConfigParser = { + listConfigVisibilityKeys(context) { + if (context.agentId === "openclaw") { + return [ + structuredConfigKey("appId", "openclaw.json", ["channels", "msteams", "appId"]), + structuredConfigKey("tenantId", "openclaw.json", ["channels", "msteams", "tenantId"]), + structuredConfigKey("allowedUsers", "openclaw.json", ["channels", "msteams", "allowFrom"]), + structuredConfigKey("webhookPort", "openclaw.json", [ + "channels", + "msteams", + "webhook", + "port", + ]), + structuredConfigKey("requireMention", "openclaw.json", [ + "channels", + "msteams", + "requireMention", + ]), + ]; + } + if (context.agentId === "hermes") { + return [ + envConfigKey("appId", "~/.hermes/.env", "TEAMS_CLIENT_ID"), + envConfigKey("tenantId", "~/.hermes/.env", "TEAMS_TENANT_ID"), + envConfigKey("allowedUsers", "~/.hermes/.env", "TEAMS_ALLOWED_USERS"), + envConfigKey("webhookPort", "~/.hermes/.env", "TEAMS_PORT"), + ]; + } + return []; + }, + + getValue(key, source) { + return key.kind === "env" + ? getEnvConfigValue(source, key.envKey) + : getStructuredConfigValue(source, key.path); + }, +}; diff --git a/src/lib/messaging/channels/telegram/rendered-config-parser.ts b/src/lib/messaging/channels/telegram/rendered-config-parser.ts new file mode 100644 index 00000000000..b949d6f90cc --- /dev/null +++ b/src/lib/messaging/channels/telegram/rendered-config-parser.ts @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + envConfigKey, + getEnvConfigValue, + getStructuredConfigValue, + type RenderedChannelConfigParser, + structuredConfigKey, +} from "../rendered-config-parser-utils"; + +export const telegramRenderedConfigParser: RenderedChannelConfigParser = { + listConfigVisibilityKeys(context) { + if (context.agentId === "openclaw") { + return [ + structuredConfigKey("allowedIds", "openclaw.json", [ + "channels", + "telegram", + "accounts", + "default", + "allowFrom", + ]), + structuredConfigKey("groupPolicy", "openclaw.json", [ + "channels", + "telegram", + "accounts", + "default", + "groupPolicy", + ]), + ]; + } + if (context.agentId === "hermes") { + return [ + envConfigKey("allowedIds", "~/.hermes/.env", "TELEGRAM_ALLOWED_USERS"), + envConfigKey("groupPolicy", "~/.hermes/.env", "TELEGRAM_GROUP_POLICY"), + structuredConfigKey("requireMention", "~/.hermes/config.yaml", [ + "telegram", + "require_mention", + ]), + ]; + } + return []; + }, + + getValue(key, source) { + return key.kind === "env" + ? getEnvConfigValue(source, key.envKey) + : getStructuredConfigValue(source, key.path); + }, +}; diff --git a/src/lib/messaging/channels/wechat/rendered-config-parser.ts b/src/lib/messaging/channels/wechat/rendered-config-parser.ts new file mode 100644 index 00000000000..8b410af17cb --- /dev/null +++ b/src/lib/messaging/channels/wechat/rendered-config-parser.ts @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + envConfigKey, + getEnvConfigValue, + getStructuredConfigValue, + type RenderedChannelConfigParser, + type RenderedChannelConfigParserContext, + type RenderedConfigSource, + type RenderedConfigVisibilityKey, + structuredConfigKey, +} from "../rendered-config-parser-utils"; + +const OPENCLAW_ACCOUNT_IDS_KEY = "openclawWeixinAccountIds"; + +export const wechatRenderedConfigParser: RenderedChannelConfigParser = { + listConfigVisibilityKeys(context) { + if (context.agentId === "openclaw") return openClawConfigVisibilityKeys(context); + if (context.agentId === "hermes") { + return [ + envConfigKey("accountId", "~/.hermes/.env", "WEIXIN_ACCOUNT_ID"), + envConfigKey("baseUrl", "~/.hermes/.env", "WEIXIN_BASE_URL"), + envConfigKey("allowedIds", "~/.hermes/.env", "WEIXIN_ALLOWED_USERS"), + ]; + } + return []; + }, + + getValue(key, source) { + if (key.key === OPENCLAW_ACCOUNT_IDS_KEY) return openClawWeixinAccountIds(source, key); + return key.kind === "env" + ? getEnvConfigValue(source, key.envKey) + : getStructuredConfigValue(source, key.path); + }, +}; + +function openClawConfigVisibilityKeys( + context: RenderedChannelConfigParserContext, +): readonly RenderedConfigVisibilityKey[] { + const keys: RenderedConfigVisibilityKey[] = [ + structuredConfigKey( + "accountId", + "openclaw.json", + ["channels", "openclaw-weixin", "accounts"], + OPENCLAW_ACCOUNT_IDS_KEY, + ), + ]; + const accountId = safeAccountId(inputValue(context, "accountId")); + if (!accountId) return keys; + const accountTarget = `~/.openclaw/openclaw-weixin/accounts/${accountId}.json`; + keys.push( + structuredConfigKey("baseUrl", accountTarget, ["baseUrl"]), + structuredConfigKey("userId", accountTarget, ["userId"]), + ); + return keys; +} + +function openClawWeixinAccountIds( + source: RenderedConfigSource, + key: RenderedConfigVisibilityKey, +): string[] | undefined { + const value = getStructuredConfigValue(source, key.path); + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const accountIds = Object.keys(value); + return accountIds.length > 0 ? accountIds : undefined; +} + +function inputValue( + context: RenderedChannelConfigParserContext, + inputId: string, +): string | undefined { + const value = context.inputs.find((input) => input.inputId === inputId)?.value; + return typeof value === "string" ? value.trim() || undefined : undefined; +} + +function safeAccountId(value: string | undefined): string | undefined { + if ( + !value || + value === "." || + value === ".." || + value.includes("..") || + /[\\/\0-\x1F\x7F]/.test(value) + ) { + return undefined; + } + return value; +} diff --git a/src/lib/messaging/channels/whatsapp/rendered-config-parser.ts b/src/lib/messaging/channels/whatsapp/rendered-config-parser.ts new file mode 100644 index 00000000000..ed122b28cd9 --- /dev/null +++ b/src/lib/messaging/channels/whatsapp/rendered-config-parser.ts @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + envConfigKey, + getEnvConfigValue, + type RenderedChannelConfigParser, +} from "../rendered-config-parser-utils"; + +export const whatsappRenderedConfigParser: RenderedChannelConfigParser = { + listConfigVisibilityKeys(context) { + if (context.agentId !== "hermes") return []; + return [envConfigKey("allowedIds", "~/.hermes/.env", "WHATSAPP_ALLOWED_USERS")]; + }, + + getValue(key, source) { + return getEnvConfigValue(source, key.envKey); + }, +}; diff --git a/test/cli/connect-recovery-settle.test.ts b/test/cli/connect-recovery-settle.test.ts index bd0f4787464..1307934fc07 100644 --- a/test/cli/connect-recovery-settle.test.ts +++ b/test/cli/connect-recovery-settle.test.ts @@ -7,13 +7,19 @@ // declaring a recovery that is already dying. Split from // connect-recovery.test.ts, which is at the default size budget. -import { describe, it, expect } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { describe, expect, it } from "vitest"; import { runWithEnv, writeSandboxRegistry } from "./helpers"; +const DECODE_SANDBOX_EXEC_COMMAND_LINES = [ + "decode_sandbox_exec_cmd() {", + ` ${JSON.stringify(process.execPath)} -e "const s=process.argv[1]||'';const m=s.match(/printf '%s' '([A-Za-z0-9+/=]+)' \\| base64 -d \\| sh/);process.stdout.write(m?Buffer.from(m[1],'base64').toString('utf8'):s);" "$1"`, + "}", +]; + describe("CLI dispatch", () => { it("fails probe-only when a wedged gateway serves once and then drops its listener (#4710)", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-connect-probe-wedge-")); @@ -28,6 +34,7 @@ describe("CLI dispatch", () => { path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", + ...DECODE_SANDBOX_EXEC_COMMAND_LINES, `marker_file=${JSON.stringify(markerFile)}`, `state_file=${JSON.stringify(stateFile)}`, `ready_count_file=${JSON.stringify(readyCountFile)}`, @@ -43,6 +50,7 @@ describe("CLI dispatch", () => { "fi", 'if [ "$1" = "sandbox" ] && [ "$2" = "exec" ] && [ "$3" = "--name" ] && [ "$4" = "alpha" ]; then', ' cmd="$8"', + ' cmd="$(decode_sandbox_exec_cmd "$cmd")"', ' case "$cmd" in', ' *"OPENCLAW="*)', ' echo recovered > "$state_file"', diff --git a/test/cli/connect-recovery.test.ts b/test/cli/connect-recovery.test.ts index cd0c8f5b1e0..2066c444cff 100644 --- a/test/cli/connect-recovery.test.ts +++ b/test/cli/connect-recovery.test.ts @@ -16,6 +16,12 @@ import { writeSandboxRegistry, } from "./helpers"; +const DECODE_SANDBOX_EXEC_COMMAND_LINES = [ + "decode_sandbox_exec_cmd() {", + ` ${JSON.stringify(process.execPath)} -e "const s=process.argv[1]||'';const m=s.match(/printf '%s' '([A-Za-z0-9+/=]+)' \\| base64 -d \\| sh/);process.stdout.write(m?Buffer.from(m[1],'base64').toString('utf8'):s);" "$1"`, + "}", +]; + describe("CLI dispatch", () => { it("connect does not pre-start a duplicate port forward", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-connect-forward-")); @@ -159,6 +165,7 @@ describe("CLI dispatch", () => { path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", + ...DECODE_SANDBOX_EXEC_COMMAND_LINES, `marker_file=${JSON.stringify(markerFile)}`, `state_file=${JSON.stringify(stateFile)}`, 'printf \'%s\\n\' "$*" >> "$marker_file"', @@ -173,6 +180,7 @@ describe("CLI dispatch", () => { "fi", 'if [ "$1" = "sandbox" ] && [ "$2" = "exec" ] && [ "$3" = "--name" ] && [ "$4" = "alpha" ]; then', ' cmd="$8"', + ' cmd="$(decode_sandbox_exec_cmd "$cmd")"', ' case "$cmd" in', ' *"OPENCLAW="*)', ' echo recovered > "$state_file"', @@ -221,6 +229,7 @@ describe("CLI dispatch", () => { path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", + ...DECODE_SANDBOX_EXEC_COMMAND_LINES, `marker_file=${JSON.stringify(markerFile)}`, `state_file=${JSON.stringify(stateFile)}`, `ready_count_file=${JSON.stringify(readyCountFile)}`, @@ -236,6 +245,7 @@ describe("CLI dispatch", () => { "fi", 'if [ "$1" = "sandbox" ] && [ "$2" = "exec" ] && [ "$3" = "--name" ] && [ "$4" = "alpha" ]; then', ' cmd="$8"', + ' cmd="$(decode_sandbox_exec_cmd "$cmd")"', ' case "$cmd" in', ' *"OPENCLAW="*)', ' echo recovered > "$state_file"', @@ -285,6 +295,7 @@ describe("CLI dispatch", () => { path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", + ...DECODE_SANDBOX_EXEC_COMMAND_LINES, `marker_file=${JSON.stringify(markerFile)}`, 'printf \'%s\\n\' "$*" >> "$marker_file"', 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', @@ -298,6 +309,7 @@ describe("CLI dispatch", () => { "fi", 'if [ "$1" = "sandbox" ] && [ "$2" = "exec" ] && [ "$3" = "--name" ] && [ "$4" = "alpha" ]; then', ' cmd="$8"', + ' cmd="$(decode_sandbox_exec_cmd "$cmd")"', ' if [[ "$cmd" == *"curl -so"* ]]; then echo "__NEMOCLAW_SANDBOX_EXEC_STARTED__"; echo RUNNING; exit 0; fi', ' if [[ "$cmd" == *"OPENCLAW="* ]]; then echo "__NEMOCLAW_SANDBOX_EXEC_STARTED__"; echo UNEXPECTED_RECOVERY; exit 1; fi', "fi", @@ -333,6 +345,7 @@ describe("CLI dispatch", () => { path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", + ...DECODE_SANDBOX_EXEC_COMMAND_LINES, `marker_file=${JSON.stringify(markerFile)}`, 'printf \'%s\\n\' "$*" >> "$marker_file"', 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', @@ -346,6 +359,7 @@ describe("CLI dispatch", () => { "fi", 'if [ "$1" = "sandbox" ] && [ "$2" = "exec" ] && [ "$3" = "--name" ] && [ "$4" = "alpha" ]; then', ' cmd="$8"', + ' cmd="$(decode_sandbox_exec_cmd "$cmd")"', ' if [[ "$cmd" == *"OPENCLAW="* ]]; then echo "__NEMOCLAW_SANDBOX_EXEC_STARTED__"; echo RECOVERY_FAILED >&2; exit 42; fi', ' if [[ "$cmd" == *"curl -so"* ]]; then echo "__NEMOCLAW_SANDBOX_EXEC_STARTED__"; echo STOPPED; exit 0; fi', "fi", @@ -550,6 +564,7 @@ describe("CLI dispatch", () => { path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", + ...DECODE_SANDBOX_EXEC_COMMAND_LINES, `calls=${JSON.stringify(openshellCalls)}`, `state_file=${JSON.stringify(stateFile)}`, 'printf \'%s\\n\' "$*" >> "$calls"', @@ -564,6 +579,7 @@ describe("CLI dispatch", () => { "fi", 'if [ "$1" = "sandbox" ] && [ "$2" = "exec" ] && [ "$3" = "--name" ] && [ "$4" = "alpha" ]; then', ' cmd="$8"', + ' cmd="$(decode_sandbox_exec_cmd "$cmd")"', ' if [[ "$cmd" == *"curl -so"* ]]; then', " echo '__NEMOCLAW_SANDBOX_EXEC_STARTED__'", ' if [ "$(cat "$state_file")" = recovered ]; then echo RUNNING; else echo STOPPED; fi',