Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
a20b198
fix(messaging): surface telegram config inputs in channels status
laitingsheng Jun 24, 2026
53a053c
fix(doctor): surface telegram visible config in messaging section
laitingsheng Jun 24, 2026
581ad20
chore: biome format
laitingsheng Jun 24, 2026
371c38d
fix(messaging): render telegram mention-mode behavior text in diagnos…
laitingsheng Jun 24, 2026
120a479
fix(messaging): filter agent-scoped diagnostics and bound invalid values
laitingsheng Jun 24, 2026
bf35a86
fix(messaging): reject present-empty plan values and exercise compile…
laitingsheng Jun 24, 2026
ed906b3
chore(test): rewrite env-override helper without branching
laitingsheng Jun 24, 2026
c9206f9
test(messaging): exercise compiled telegram plan through status + doc…
laitingsheng Jun 24, 2026
b0258ec
Merge remote-tracking branch 'origin/main' into fix/channels-status-t…
laitingsheng Jun 28, 2026
343037f
refactor(doctor): extract visible-config helpers to bound complexity
laitingsheng Jun 28, 2026
2d14eca
fix(messaging): bound diagnostic disclosure to allowlisted inputs
laitingsheng Jun 28, 2026
144e0dc
test(messaging): rewrite tamper plan helper without conditional
laitingsheng Jun 28, 2026
a664a51
fix(messaging): require validValues on visible config display contract
laitingsheng Jun 28, 2026
cd7c19b
style: biome format channel-status visibility tests
laitingsheng Jun 28, 2026
b059070
test(messaging): share Telegram plan compile helper across diagnostic…
laitingsheng Jun 28, 2026
9b5419c
Merge remote-tracking branch 'origin/main' into fix/channels-status-t…
laitingsheng Jun 28, 2026
f2b0282
chore(checks): exempt validation-exit spawn fixture from no-test-dist…
laitingsheng Jun 28, 2026
1693804
style: drop residual blank line after helper extraction
laitingsheng Jun 28, 2026
6f8dd3e
chore(checks): move spawn-only validation fixture to no-test-dist-imp…
laitingsheng Jun 28, 2026
f5c9db4
test(messaging): cover serialize/parse round-trip for telegram visibl…
laitingsheng Jun 28, 2026
cd4290f
docs: sync hermes variant for channels status visible-config note
laitingsheng Jun 28, 2026
536efa8
test(onboard): keep validation test on source boundary
cv Jun 28, 2026
bf985fd
test(messaging): cover non-interactive compact-registry path through …
laitingsheng Jun 28, 2026
1001763
Merge remote-tracking branch 'origin/main' into fix/channels-status-t…
laitingsheng Jun 28, 2026
97c9d5a
style: apply biome format to telegram test helpers
laitingsheng Jun 28, 2026
c20ee5d
Merge remote-tracking branch 'origin/main' into fix/channels-status-t…
laitingsheng Jun 28, 2026
6436d36
fix(messaging): tighten telegram diagnostics contract and extract tes…
laitingsheng Jun 28, 2026
6fc2425
refactor(messaging): centralize compact-registry test helpers and ali…
laitingsheng Jun 28, 2026
f6979df
Merge remote-tracking branch 'origin/main' into fix/channels-status-t…
laitingsheng Jun 28, 2026
373fd97
style: apply biome format to compact-registry test helpers
laitingsheng Jun 28, 2026
a928426
test(checks): distinguish dynamic-require path construction from bare…
laitingsheng Jun 28, 2026
fd42b72
test(checks): build dist patterns from templates to keep them past th…
laitingsheng Jun 28, 2026
88dc960
Merge remote-tracking branch 'origin/main' into fix/channels-status-t…
laitingsheng Jun 29, 2026
331f342
refactor(messaging): harden visible config boundary and split monolit…
laitingsheng Jun 29, 2026
5ef3e73
Merge remote-tracking branch 'origin/main' into fix/channels-status-t…
laitingsheng Jun 29, 2026
1dbc44b
test(messaging): assert Telegram visible config in live messaging-pro…
laitingsheng Jun 29, 2026
d67a997
test(messaging): drop early-return ifs to keep test bodies linear
laitingsheng Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/reference/commands-nemohermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
```
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
```
Expand Down
55 changes: 46 additions & 9 deletions scripts/checks/no-test-dist-imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 =
Expand Down Expand Up @@ -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")),
Expand Down
245 changes: 245 additions & 0 deletions src/lib/actions/sandbox/__test-utils__/doctor-harness.ts
Original file line number Diff line number Diff line change
@@ -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<ChannelInputOverride>;
},
): void {
applyTelegramDoctorRegistryMocks(requireDist("../../state/registry.js"), options);
}

export async function setupDoctorRealPlanReader(
requireDist: DistRequire,
harness: { getSandboxSpy: MockInstance },
options: CompactTelegramEntryOptions,
): Promise<void> {
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),
);
}
Loading
Loading