diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 914737590f6..0e57584ca9b 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -639,6 +639,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. +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. Use `--json` for machine-readable output. @@ -998,6 +1000,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 ``` diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index ebc9a914932..d8699cc9212 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -828,6 +828,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. +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. Use `--json` for machine-readable output. @@ -1273,6 +1275,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/scripts/checks/no-test-dist-imports.ts b/scripts/checks/no-test-dist-imports.ts index 89eaf1cc2a1..544019b141f 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 = @@ -149,18 +151,53 @@ 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") || + violation.detail.startsWith("constructs a path into compiled CLI internals via dynamic require") + ); +} + +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); - 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); } + 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__/index.ts b/src/lib/actions/sandbox/__test-utils__/index.ts new file mode 100644 index 00000000000..2eb4681c17b --- /dev/null +++ b/src/lib/actions/sandbox/__test-utils__/index.ts @@ -0,0 +1,335 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +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"; +import { + getMessagingPlanFromEntry, + serializeSandboxMessagingStateForDisk, +} from "../../../state/registry-messaging"; + +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 } }; +} + +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; +} + +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 4d9d9005a3c..b12f31f360e 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -38,7 +38,9 @@ vi.mock("./process-recovery", () => ({ })); import type { AgentDefinition } from "../../agent/defs"; +import type { SandboxMessagingPlan } from "../../messaging/manifest"; import type { SandboxEntry } from "../../state/registry"; +import { type ChannelInputOverridesByChannel, fakePlanFromInputs } from "./__test-utils__"; import { showSandboxChannelStatus } from "./channel-status"; type ExecResult = { status: number; stdout: string; stderr: string }; @@ -156,18 +158,25 @@ function makeDeps(opts: { gatewayPresets?: string[] | null; agentName?: "openclaw" | "hermes"; sandbox?: SandboxEntry | undefined; + channelInputs?: ChannelInputOverridesByChannel; + messagingPlan?: SandboxMessagingPlan | null; 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: () => + opts.messagingPlan !== undefined + ? opts.messagingPlan + : fakePlanFromInputs(sandbox, opts.channelInputs), execSandbox: vi.fn(opts.exec), now: () => PROBED_AT, out, diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index edf28314b71..aea48f47467 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -14,14 +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 { - collectBuiltInMessagingChannelDiagnostics, - type MessagingChannelDiagnosticSpec, -} from "../../messaging/diagnostics"; import { createBuiltInChannelManifestRegistry, getMessagingManifestAvailabilityContext, } from "../../messaging"; +import { + collectBuiltInMessagingChannelDiagnostics, + collectVisibleConfigRecords, + type MessagingChannelDiagnosticSpec, +} from "../../messaging/diagnostics"; +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, @@ -68,6 +72,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; @@ -136,6 +141,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)), @@ -505,13 +511,22 @@ function buildBasicChannelReport( ? undefined : `run \`${CLI_NAME} ${sandboxName} policy-add ${policyPresets[0]}\``, }); + if (enabled && !disabled) { + for (const signal of buildConfigVisibilitySignals( + channelName, + diagnostic, + deps, + entry, + agent, + )) { + 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 (!channelSupportedByAgent(channelName, agent)) { signals.unshift({ label: "Agent support", @@ -528,6 +543,24 @@ function buildBasicChannelReport( }; } +function buildConfigVisibilitySignals( + channelName: string, + diagnostic: MessagingChannelDiagnosticSpec, + deps: Required, + entry: ReturnType, + agent: AgentDefinition, +): DiagnosticSignal[] { + if (diagnostic.visibleConfigInputs.length === 0) return []; + const plan = deps.getMessagingPlan(entry); + const messagingAgent = entry?.agent ? asMessagingAgent(agent.name) : null; + const records = collectVisibleConfigRecords(diagnostic, plan, channelName, messagingAgent); + return records.map(({ input, display }) => ({ + label: input.label, + severity: visibleConfigSeverity(display.source), + detail: display.detail, + })); +} + 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 86f20420a23..2c5499b4432 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -5,198 +5,16 @@ import { createRequire } from "node:module"; import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; import { testTimeoutOptions } from "../../../../test/helpers/timeouts"; - -type RunSandboxDoctor = typeof import("./doctor")["runSandboxDoctor"]; +import { + 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 createDoctorHarness(): DoctorHarness { + return createDoctorHarnessShared(requireDist); } describe("runSandboxDoctor flow", () => { 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 e40eb841e83..21aa8bd7b0d 100644 --- a/src/lib/actions/sandbox/doctor-messaging.ts +++ b/src/lib/actions/sandbox/doctor-messaging.ts @@ -6,8 +6,14 @@ import { compareChannelSets, probeChannelRuntimeStatus } from "../../channel-run import { CLI_NAME } from "../../cli/branding"; import { collectBuiltInMessagingChannelDiagnostics, + collectVisibleConfigRecords, 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"; @@ -261,12 +267,58 @@ 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 buildVisibleConfigDoctorCheck( + sandboxName: string, + channelName: string, + record: ReturnType[number], +): DoctorCheck { + const hint = visibleConfigDoctorHint({ + cli: CLI_NAME, + sandboxName, + channelName, + source: record.display.source, + }); + return { + group: "Messaging", + label: record.input.label, + status: visibleConfigDoctorStatus(record.display.source), + detail: record.display.detail, + ...(hint === undefined ? {} : { hint }), + }; +} + +function messagingChannelConfigDoctorChecks(sandboxName: string, sb: SandboxEntry): DoctorCheck[] { + const activeChannels = activeChannelsFromEntry(sb); + if (activeChannels.length === 0) return []; + const plan = registry.getMessagingPlanFromEntry(sb); + const agent = asMessagingAgent(sb.agent); + const checks: DoctorCheck[] = []; + for (const channelName of activeChannels) { + const diagnostic = getChannelStatusDiagnostic(channelName); + if (!diagnostic || diagnostic.visibleConfigInputs.length === 0) continue; + const records = collectVisibleConfigRecords(diagnostic, plan, channelName, agent); + for (const record of records) { + checks.push(buildVisibleConfigDoctorCheck(sandboxName, channelName, record)); + } + } + return checks; +} + export function collectMessagingDoctorChecks( sandboxName: string, sb: SandboxEntry, sandboxReachable: boolean, ): DoctorCheck[] { const checks = [configuredChannelsCheck(sandboxName, sb)]; + for (const check of messagingChannelConfigDoctorChecks(sandboxName, sb)) { + checks.push(check); + } const registered = registry.getConfiguredMessagingChannelsFromEntry(sb); const disabled = new Set(registry.getDisabledMessagingChannelsFromEntry(sb)); const enabled = registered.filter((channel: string) => !disabled.has(channel)); 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/__test-utils__/telegram-plan.ts b/src/lib/messaging/__test-utils__/telegram-plan.ts new file mode 100644 index 00000000000..f40a3576997 --- /dev/null +++ b/src/lib/messaging/__test-utils__/telegram-plan.ts @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { vi } from "vitest"; + +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; + readonly isInteractive?: boolean; +} + +export async function compileTelegramPlanForTests( + options: CompileTelegramPlanOptions, +): Promise { + const { envOverrides, sandboxName = "alpha", agent = "openclaw", isInteractive = true } = 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, + 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); + } +} + +// 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)) { + vi.stubEnv(key, value as string); + } +} 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/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index 0bed20f86e1..827633fc85c 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,13 @@ export const telegramManifest = { statePath: "telegramConfig.groupPolicy", 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", help: "Controls OpenClaw Telegram group access. Hermes does not expose an equivalent disable-groups policy.", 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/diagnostics.test.ts b/src/lib/messaging/diagnostics.test.ts index c075044aa74..1c364ae4a54 100644 --- a/src/lib/messaging/diagnostics.test.ts +++ b/src/lib/messaging/diagnostics.test.ts @@ -3,7 +3,15 @@ import { describe, expect, it } from "vitest"; -import { collectBuiltInMessagingChannelDiagnostics } from "./diagnostics"; +import { + getMessagingPlanFromEntry, + serializeSandboxMessagingStateForDisk, +} from "../state/registry-messaging"; +import { compileTelegramPlanForTests } from "./__test-utils__/telegram-plan"; +import { + collectBuiltInMessagingChannelDiagnostics, + collectVisibleConfigRecords, +} from "./diagnostics"; describe("messaging channel diagnostics", () => { it("derives common channel diagnostic metadata directly from manifests", () => { @@ -38,3 +46,157 @@ 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 plan = await compileTelegramPlanForTests({ + envOverrides: { + TELEGRAM_GROUP_POLICY: "allowlist", + TELEGRAM_REQUIRE_MENTION: undefined, + }, + }); + + 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: "allowlisted groups only (TELEGRAM_GROUP_POLICY=allowlist)", + }); + expect(byLabel("Telegram group mention mode")?.display).toMatchObject({ + source: "persisted", + detail: "mention-only (TELEGRAM_REQUIRE_MENTION=1)", + }); + }); + + it("redacts non-scalar persisted Telegram visible config values without echoing raw JSON", async () => { + const basePlan = await compileTelegramPlanForTests({ + envOverrides: { + TELEGRAM_GROUP_POLICY: undefined, + TELEGRAM_REQUIRE_MENTION: undefined, + }, + }); + + const tamperedPlan = { + ...basePlan, + 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( + (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(/"/); + }); + + 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/, + ); + }); + + 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: "allowlisted groups only (TELEGRAM_GROUP_POLICY=allowlist)", + }); + expect(byLabel("Telegram group mention mode")?.display).toMatchObject({ + source: "persisted", + detail: "all group messages (TELEGRAM_REQUIRE_MENTION=0)", + }); + }); +}); diff --git a/src/lib/messaging/diagnostics.ts b/src/lib/messaging/diagnostics.ts index 0b91a03e5d8..2973686f2c1 100644 --- a/src/lib/messaging/diagnostics.ts +++ b/src/lib/messaging/diagnostics.ts @@ -2,7 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 import { createBuiltInChannelManifestRegistry } from "./channels"; -import type { ChannelManifest, ChannelPolicyPresetReference, MessagingAgentId } from "./manifest"; +import type { + ChannelInputSpec, + ChannelManifest, + ChannelPolicyPresetReference, + MessagingAgentId, + MessagingSerializableValue, + SandboxMessagingChannelPlan, + SandboxMessagingInputReference, + SandboxMessagingPlan, +} from "./manifest"; export interface MessagingChannelDiagnosticSpec { readonly channelId: string; @@ -13,6 +22,17 @@ export interface MessagingChannelDiagnosticSpec { readonly detail: string; readonly hint: string; }; + readonly visibleConfigInputs: readonly VisibleChannelConfigInput[]; +} + +export interface VisibleChannelConfigInput { + readonly inputId: string; + readonly label: string; + readonly envKey?: string; + readonly defaultValue?: string; + readonly validValues: readonly string[]; + readonly valueDisplay?: Readonly>; + readonly agentApplicability?: readonly MessagingAgentId[]; } export function collectBuiltInMessagingChannelDiagnostics( @@ -35,10 +55,161 @@ 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 []; + if (input.safeToPrintInDiagnostics !== true) return []; + if (!input.validValues || input.validValues.length === 0) 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 } : {}), + validValues: [...input.validValues], + ...(input.valueDisplay ? { valueDisplay: { ...input.valueDisplay } } : {}), + ...(input.agentApplicability ? { agentApplicability: [...input.agentApplicability] } : {}), + } 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. + * + * 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" | "invalid"; +}; + +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 = + planInputPresent && rawValue !== undefined && rawValue !== null && rawValue !== ""; + if (persistedScalar) { + if (!isPrintableScalar(rawValue)) { + return invalidPersistedDisplay(input, "unsupported type"); + } + const valueText = stringifyScalar(rawValue); + if (!input.validValues.includes(valueText)) { + return invalidPersistedDisplay(input, `expected: ${input.validValues.join(" | ")}`); + } + 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 (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) { + return { detail: `${mapped} (default)`, source: "default" }; + } + return { detail: `${input.defaultValue} (default)`, source: "default" }; + } + 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 + * 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); + 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"; + return String(value); +} + function qrDeepProbeDoctorHint(): MessagingChannelDiagnosticSpec["doctorWhenNoHealthSignals"] { return { detail: 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 5cbcaf4c6d6..8ab16738659 100644 --- a/src/lib/messaging/manifest/registry.test.ts +++ b/src/lib/messaging/manifest/registry.test.ts @@ -84,4 +84,83 @@ 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", + ); + }); + + 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 145251fd29f..bdbf562d7a1 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,72 @@ 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; + } + 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], +): 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`, + ); + } + } +} diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index aa455658eeb..8a3b308936b 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -100,6 +100,28 @@ 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>; + /** + * 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. */ 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"; diff --git a/test/e2e-scenario/live/messaging-providers.test.ts b/test/e2e-scenario/live/messaging-providers.test.ts index 153ee1ae235..684f7b9c945 100644 --- a/test/e2e-scenario/live/messaging-providers.test.ts +++ b/test/e2e-scenario/live/messaging-providers.test.ts @@ -60,6 +60,39 @@ 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 { + try { + return JSON.parse(text.trim()) as TelegramVisibleStatusReport; + } catch { + return null; + } +} + +function parseTelegramDoctorJson(text: string): TelegramDoctorReport | null { + try { + return JSON.parse(text.trim()) as TelegramDoctorReport; + } catch { + return null; + } +} + runLiveTest( "messaging providers preserve placeholder, policy, runtime, and send contracts", testTimeoutOptions(LIVE_TIMEOUT_MS), @@ -574,6 +607,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 ' diff --git a/test/test-boundary-guards.test.ts b/test/test-boundary-guards.test.ts index 596d09160d1..c54d957f1f9 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,31 @@ 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 distPath = ["..", "dist", "lib", "value.js"].join("/"); + + const importOnly = findCompiledInternalViolations( + "test/example.test.ts", + `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, ${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(${JSON.stringify(distPath)});\n`, + ); + expect(requireOnly).toHaveLength(1); + expect(requireOnly.every(isPathConstructionViolation)).toBe(true); + }); }); describe("Vitest project membership boundary", () => {