Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions src/lib/messaging-bridge-health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("./resolve-openshell.js", () => ({
resolveOpenshell: vi.fn(() => "/usr/local/bin/openshell"),
}));

vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return { ...actual, spawnSync: vi.fn() };
});

import { checkMessagingBridgeHealth } from "./messaging-bridge-health.js";
import { spawnSync } from "node:child_process";
import { resolveOpenshell } from "./resolve-openshell.js";

const spawnSyncMock = vi.mocked(spawnSync);
const resolveOpenshellMock = vi.mocked(resolveOpenshell);

type SpawnResult = ReturnType<typeof spawnSync>;
function mockSpawn(overrides: Partial<SpawnResult> = {}): void {
spawnSyncMock.mockReturnValue({
pid: 0,
output: [],
stdout: "",
stderr: "",
status: 0,
signal: null,
...overrides,
} as unknown as SpawnResult);
}

beforeEach(() => {
vi.clearAllMocks();
resolveOpenshellMock.mockReturnValue("/usr/local/bin/openshell");
});

describe("checkMessagingBridgeHealth", () => {
it("returns empty and does not spawn when channels does not include telegram", () => {
const result = checkMessagingBridgeHealth("alpha", ["discord", "slack"]);
expect(result).toEqual([]);
expect(spawnSyncMock).not.toHaveBeenCalled();
});

it("returns empty and does not spawn when channels is null/undefined", () => {
expect(checkMessagingBridgeHealth("alpha", null)).toEqual([]);
expect(checkMessagingBridgeHealth("alpha", undefined)).toEqual([]);
expect(spawnSyncMock).not.toHaveBeenCalled();
});

it("returns empty when openshell binary cannot be resolved", () => {
resolveOpenshellMock.mockReturnValue(null);
const result = checkMessagingBridgeHealth("alpha", ["telegram"]);
expect(result).toEqual([]);
expect(spawnSyncMock).not.toHaveBeenCalled();
});

it("returns a conflict entry when gateway log reports N conflicts", () => {
mockSpawn({ stdout: "32\n" });
expect(checkMessagingBridgeHealth("alpha", ["telegram"])).toEqual([
{ channel: "telegram", conflicts: 32 },
]);
});

it("returns empty when the conflict count is zero", () => {
mockSpawn({ stdout: "0\n" });
expect(checkMessagingBridgeHealth("alpha", ["telegram"])).toEqual([]);
});

it("returns empty when the count is non-numeric", () => {
mockSpawn({ stdout: "\n" });
expect(checkMessagingBridgeHealth("alpha", ["telegram"])).toEqual([]);
});

it("returns empty when spawnSync throws", () => {
spawnSyncMock.mockImplementation(() => {
throw new Error("spawn EPIPE");
});
expect(checkMessagingBridgeHealth("alpha", ["telegram"])).toEqual([]);
});

it("returns empty when spawnSync reports a non-zero exit status (exec failed)", () => {
mockSpawn({ stderr: "/bin/bash: alpha: command not found\n", status: 127 });
expect(checkMessagingBridgeHealth("alpha", ["telegram"])).toEqual([]);
});

it("returns empty when spawnSync returns an error object (e.g. timeout)", () => {
mockSpawn({
stdout: null as unknown as string,
stderr: null as unknown as string,
status: null,
signal: "SIGTERM",
error: new Error("spawnSync timed out"),
});
expect(checkMessagingBridgeHealth("alpha", ["telegram"])).toEqual([]);
});

// Regression for #2018: `openshell sandbox exec` requires the --name/-n
// flag. Passing the sandbox name as a positional causes the name to be
// interpreted as the first word of the command and fails with exit 127.
describe("argv shape (#2018 regression)", () => {
beforeEach(() => mockSpawn({ stdout: "5\n" }));

it("passes the sandbox name via --name/-n, not as a positional", () => {
checkMessagingBridgeHealth("tele-brev", ["telegram"]);

expect(spawnSyncMock).toHaveBeenCalledTimes(1);
const [binary, args] = spawnSyncMock.mock.calls[0];
expect(binary).toBe("/usr/local/bin/openshell");

const argsArray = args as string[];
const nameFlagIdx = argsArray.indexOf("-n");
expect(nameFlagIdx).toBeGreaterThan(-1);
expect(argsArray[nameFlagIdx + 1]).toBe("tele-brev");

// Pre-fix, args were ["sandbox", "exec", "<name>", "sh", "-c", <script>].
// Ensure the sandbox name is not directly adjacent to "exec" (positional).
const execIdx = argsArray.indexOf("exec");
expect(argsArray[execIdx + 1]).not.toBe("tele-brev");
});

it("separates the command from flags with `--`", () => {
checkMessagingBridgeHealth("tele-brev", ["telegram"]);
const [, args] = spawnSyncMock.mock.calls[0];
const argsArray = args as string[];
const sepIdx = argsArray.indexOf("--");
expect(sepIdx).toBeGreaterThan(-1);
expect(argsArray[sepIdx + 1]).toBe("sh");
expect(argsArray[sepIdx + 2]).toBe("-c");
});
});
});
42 changes: 42 additions & 0 deletions src/lib/messaging-bridge-health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { spawnSync } from "node:child_process";
import { resolveOpenshell } from "./resolve-openshell.js";

export interface BridgeConflict {
channel: string;
conflicts: number;
}

const CONFLICT_SCRIPT =
'tail -n 200 /tmp/gateway.log 2>/dev/null | grep -cE "getUpdates conflict|409[[:space:]:]+Conflict" || true';

export function checkMessagingBridgeHealth(
sandboxName: string,
channels: readonly string[] | null | undefined,
): BridgeConflict[] {
if (!Array.isArray(channels) || !channels.includes("telegram")) return [];

const binary = resolveOpenshell();
if (!binary) return [];

// `openshell sandbox exec` requires --name/-n for the sandbox name; a
// positional there gets parsed as the first word of the command and
// fails with exit 127 (#2018).
const args = ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-c", CONFLICT_SCRIPT];

try {
const result = spawnSync(binary, args, {
encoding: "utf-8",
timeout: 3000,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error || result.status !== 0) return [];
const count = Number.parseInt((result.stdout || "").trim(), 10);
if (!Number.isFinite(count) || count === 0) return [];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return [{ channel: "telegram", conflicts: count }];
} catch {
return [];
}
}
25 changes: 3 additions & 22 deletions src/nemoclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1047,27 +1047,7 @@ async function credentialsCommand(args) {
process.exit(1);
}

function checkMessagingBridgeHealth(sandboxName, channels) {
// Only Telegram currently emits a recognizable conflict signature in the
// gateway log. Discord/Slack have similar single-consumer constraints but
// log differently; we can extend the regex when those patterns are known.
if (!Array.isArray(channels) || !channels.includes("telegram")) return [];
const { spawnSync } = require("child_process");
const script =
'tail -n 200 /tmp/gateway.log 2>/dev/null | grep -cE "getUpdates conflict|409[[:space:]:]+Conflict" || true';
try {
const result = spawnSync(
getOpenshellBinary(),
["sandbox", "exec", sandboxName, "sh", "-c", script],
{ encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "pipe"] },
);
const count = Number.parseInt((result.stdout || "").trim(), 10);
if (!Number.isFinite(count) || count === 0) return [];
return [{ channel: "telegram", conflicts: count }];
} catch {
return [];
}
}
const { checkMessagingBridgeHealth } = require("./lib/messaging-bridge-health");

function makeConflictProbe() {
// Upfront liveness check so we can distinguish "provider not attached" from
Expand Down Expand Up @@ -1111,9 +1091,10 @@ function readGatewayLog(sandboxName) {
try {
const result = spawnSync(
getOpenshellBinary(),
["sandbox", "exec", sandboxName, "sh", "-c", "tail -n 10 /tmp/gateway.log 2>/dev/null"],
["sandbox", "exec", "-n", sandboxName, "--", "sh", "-c", "tail -n 10 /tmp/gateway.log 2>/dev/null"],
{ encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "pipe"] },
);
if (result.error || result.status !== 0) return null;
const output = (result.stdout || "").trim();
return output || null;
} catch {
Expand Down