Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
71 changes: 10 additions & 61 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const {
const { cleanupTempDir }: typeof import("./onboard/temp-files") = require("./onboard/temp-files");
const { abortNonInteractive }: typeof import("./onboard/non-interactive-abort") = require("./onboard/non-interactive-abort");
const { stopStaleDashboardListenersForSandbox } = require("./onboard/stale-gateway-cleanup");
const {
TELEGRAM_NETWORK_CURL_CODES,
checkTelegramReachability,
}: typeof import("./onboard/telegram-reachability") = require("./onboard/telegram-reachability");
const {
ensureOllamaLoopbackSystemdOverride,
}: typeof import("./onboard/ollama-systemd") = require("./onboard/ollama-systemd");
Expand Down Expand Up @@ -5581,64 +5585,7 @@ function getRecordedMessagingChannelsForResume(
});
}

// Curl exit codes that indicate a network-level failure (not a token problem).
// 35 (TLS handshake failure) covers corporate proxies that MITM HTTPS.
const TELEGRAM_NETWORK_CURL_CODES = new Set([6, 7, 28, 35, 52, 56]);

async function checkTelegramReachability(token: string) {
if (process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY === "1") {
note(" [non-interactive] Skipping Telegram reachability probe by request.");
return;
}

const result = runCurlProbe([
"-sS",
"--connect-timeout",
"5",
"--max-time",
"10",
`https://api.telegram.org/bot${token}/getMe`,
]);

// HTTP 200 with "ok":true — Telegram is reachable and token is valid.
if (result.ok) return;

// HTTP 401 or 404 — token was rejected by Telegram (not a network issue).
if (result.httpStatus === 401 || result.httpStatus === 404) {
console.log(" ⚠ Bot token was rejected by Telegram — verify the token is correct.");
return;
}

// Network-level failure — Telegram is unreachable from this host.
if (result.curlStatus && TELEGRAM_NETWORK_CURL_CODES.has(result.curlStatus)) {
console.log("");
console.log(" ⚠ api.telegram.org is not reachable from this host.");
console.log(" Telegram integration requires outbound HTTPS access to api.telegram.org.");
console.log(" This is commonly blocked by corporate network proxies.");

if (isNonInteractive()) {
console.error(
" Aborting onboarding in non-interactive mode due to Telegram network reachability failure.",
);
process.exit(1);
} else {
if (!(await promptYesNoOrDefault(" Continue anyway?", null, false))) {
console.log(" Aborting onboarding.");
process.exit(1);
}
}
return;
}

// Unexpected probe failure — warn but don't block.
if (!result.ok && result.httpStatus > 0) {
console.log(
` ⚠ Telegram API returned HTTP ${result.httpStatus} — the bot may not work correctly.`,
);
} else if (!result.ok) {
console.log(` ⚠ Telegram reachability probe failed: ${result.message}`);
}
}
const telegramReachabilityDeps = { isNonInteractive, note, promptYesNoOrDefault };

async function setupMessagingChannels(
agent: AgentDefinition | null = null,
Expand All @@ -5657,13 +5604,14 @@ async function setupMessagingChannels(

// Non-interactive: skip prompt, tokens come from env/credentials
if (isNonInteractive() || process.env.NEMOCLAW_NON_INTERACTIVE === "1") {
const found = Array.from(new Set(seedFromState(false)));
let found = Array.from(new Set(seedFromState(false)));
if (found.length > 0) {
note(` [non-interactive] Messaging tokens detected: ${found.join(", ")}`);
if (found.includes("telegram")) {
const telegramToken = getValidatedMessagingTokenByEnvKey(MESSAGING_CHANNELS, "TELEGRAM_BOT_TOKEN");
if (telegramToken) {
await checkTelegramReachability(telegramToken);
const reachability = await checkTelegramReachability(telegramToken, telegramReachabilityDeps);
if (reachability.skipped) found = found.filter((c) => c !== "telegram");
}
}
} else {
Expand Down Expand Up @@ -5792,7 +5740,8 @@ async function setupMessagingChannels(
if (!isNonInteractive() && enabled.has("telegram")) {
const telegramToken = getValidatedMessagingTokenByEnvKey(MESSAGING_CHANNELS, "TELEGRAM_BOT_TOKEN");
if (telegramToken) {
await checkTelegramReachability(telegramToken);
const reachability = await checkTelegramReachability(telegramToken, telegramReachabilityDeps);
if (reachability.skipped) enabled.delete("telegram");
}
}

Expand Down
114 changes: 114 additions & 0 deletions src/lib/onboard/telegram-reachability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Unit tests for the Telegram reachability + token-validation probe.
//
// Covers the warn-and-skip behavior introduced for #4238: when api.telegram.org
// is unreachable or the bot token is rejected, onboarding should drop the
// optional Telegram integration and continue — not abort. Mirrors the Brave
// optional-component path at src/lib/onboard/web-search-flow.ts.

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

import type { ProbeResult } from "./types";

vi.mock("../adapters/http/probe", () => ({
runCurlProbe: vi.fn(),
}));

import { runCurlProbe } from "../adapters/http/probe";
import { checkTelegramReachability, type TelegramReachabilityDeps } from "./telegram-reachability";

function probeOk(): ProbeResult {
return { ok: true, httpStatus: 200, curlStatus: 0, body: '{"ok":true}', stderr: "", message: "" };
}
function probeHttpError(httpStatus: number): ProbeResult {
return { ok: false, httpStatus, curlStatus: 0, body: "", stderr: "", message: "" };
}
function probeCurlError(curlStatus: number): ProbeResult {
return { ok: false, httpStatus: 0, curlStatus, body: "", stderr: "", message: "" };
}

function makeDeps(overrides: Partial<TelegramReachabilityDeps> = {}): TelegramReachabilityDeps {
return {
isNonInteractive: vi.fn(() => true),
note: vi.fn(),
promptYesNoOrDefault: vi.fn(async () => true),
...overrides,
};
}

beforeEach(() => {
vi.mocked(runCurlProbe).mockReset();
delete process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY;
});

describe("checkTelegramReachability", () => {
it("returns { skipped: false } on HTTP 200 (token valid, network reachable)", async () => {
vi.mocked(runCurlProbe).mockReturnValue(probeOk());
expect(await checkTelegramReachability("123:abc", makeDeps())).toEqual({ skipped: false });
});

it("returns { skipped: true } when curl exits 35 (TLS handshake failure)", async () => {
vi.mocked(runCurlProbe).mockReturnValue(probeCurlError(35));
expect(await checkTelegramReachability("123:abc", makeDeps())).toEqual({ skipped: true });
});

it("returns { skipped: true } for every curl exit in TELEGRAM_NETWORK_CURL_CODES (non-interactive)", async () => {
for (const code of [6, 7, 28, 35, 52, 56]) {
vi.mocked(runCurlProbe).mockReturnValue(probeCurlError(code));
expect(
await checkTelegramReachability("123:abc", makeDeps()),
`curlStatus=${code}`,
).toEqual({ skipped: true });
}
});

it("returns { skipped: true } on HTTP 401 (token rejected by Telegram)", async () => {
vi.mocked(runCurlProbe).mockReturnValue(probeHttpError(401));
expect(await checkTelegramReachability("123:abc", makeDeps())).toEqual({ skipped: true });
});

it("returns { skipped: true } on HTTP 404 (token rejected by Telegram)", async () => {
vi.mocked(runCurlProbe).mockReturnValue(probeHttpError(404));
expect(await checkTelegramReachability("123:abc", makeDeps())).toEqual({ skipped: true });
});

it("returns { skipped: false } and skips the probe when NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1", async () => {
process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY = "1";
expect(await checkTelegramReachability("123:abc", makeDeps())).toEqual({ skipped: false });
expect(vi.mocked(runCurlProbe)).not.toHaveBeenCalled();
});

it("prompts 'Disable Telegram?' on interactive network failure and returns { skipped: true } when accepted", async () => {
vi.mocked(runCurlProbe).mockReturnValue(probeCurlError(7));
const deps = makeDeps({
isNonInteractive: vi.fn(() => false),
promptYesNoOrDefault: vi.fn(async () => true),
});
const result = await checkTelegramReachability("123:abc", deps);
expect(result).toEqual({ skipped: true });
expect(deps.promptYesNoOrDefault).toHaveBeenCalledWith(
expect.stringContaining("Disable Telegram"),
null,
true,
);
});

it("calls process.exit(1) when interactive user declines the prompt", async () => {
vi.mocked(runCurlProbe).mockReturnValue(probeCurlError(7));
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`__test_exit_${code ?? 0}__`);
}) as never);
try {
const deps = makeDeps({
isNonInteractive: vi.fn(() => false),
promptYesNoOrDefault: vi.fn(async () => false),
});
await expect(checkTelegramReachability("123:abc", deps)).rejects.toThrow("__test_exit_1__");
expect(exitSpy).toHaveBeenCalledWith(1);
} finally {
exitSpy.mockRestore();
}
});
Comment thread
rluo8 marked this conversation as resolved.
});
112 changes: 112 additions & 0 deletions src/lib/onboard/telegram-reachability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { runCurlProbe } from "../adapters/http/probe";
import { cliName } from "./branding";
import { exitOnboardFromPrompt } from "./prompt-helpers";

// Curl exit codes that indicate a network-level failure (not a token problem).
// 35 (TLS handshake failure) covers corporate proxies that MITM HTTPS.
export const TELEGRAM_NETWORK_CURL_CODES = new Set([6, 7, 28, 35, 52, 56]);

export type TelegramReachabilityResult = { skipped: boolean };

export interface TelegramReachabilityDeps {
isNonInteractive(): boolean;
note(message: string): void;
promptYesNoOrDefault(
question: string,
envVar: string | null,
defaultIsYes: boolean,
): Promise<boolean>;
}

function announceTelegramSkip(reason: "unreachable" | "invalid-token"): void {
const because =
reason === "unreachable"
? "api.telegram.org is unreachable"
: "the bot token was rejected by Telegram";
const recovery =
reason === "unreachable"
? "once network access is restored"
: "after setting a valid TELEGRAM_BOT_TOKEN";
console.warn(` Telegram integration will be disabled for this onboard run because ${because}.`);
console.warn(
` Re-run onboarding (or \`${cliName()} <name> channels add telegram\`) ${recovery}.`,
);
}

export async function checkTelegramReachability(
token: string,
deps: TelegramReachabilityDeps,
): Promise<TelegramReachabilityResult> {
if (process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY === "1") {
deps.note(" [non-interactive] Skipping Telegram reachability probe by request.");
return { skipped: false };
}

const result = runCurlProbe([
"-sS",
"--connect-timeout",
"5",
"--max-time",
"10",
`https://api.telegram.org/bot${token}/getMe`,
]);

// HTTP 200 with "ok":true — Telegram is reachable and token is valid.
if (result.ok) return { skipped: false };

// HTTP 401 or 404 — Telegram rejected the bot token. The integration cannot
// function with an invalid token, so this is "validation fails" per #4238 and
// takes the same warn-and-skip path as a network failure: drop telegram from
// the active messaging channel set instead of letting onboarding write an
// unusable token into the sandbox/provider config.
if (result.httpStatus === 401 || result.httpStatus === 404) {
console.log("");
console.log(" ⚠ Bot token was rejected by Telegram — verify the token is correct.");
announceTelegramSkip("invalid-token");
return { skipped: true };
}

// Network-level failure — Telegram is unreachable from this host. Treat as
// an optional-integration soft-fail (#4238): warn, drop telegram from the
// active messaging channel set, and let onboarding continue. Matches the
// warn-and-skip pattern Brave uses at src/lib/onboard/web-search-flow.ts.
if (result.curlStatus && TELEGRAM_NETWORK_CURL_CODES.has(result.curlStatus)) {
console.log("");
console.log(" ⚠ api.telegram.org is not reachable from this host.");
console.log(" Telegram integration requires outbound HTTPS access to api.telegram.org.");
console.log(" This is commonly blocked by corporate network proxies.");

if (deps.isNonInteractive()) {
announceTelegramSkip("unreachable");
return { skipped: true };
}
// Interactive: prompt explicitly asks whether to skip telegram and continue.
// Default Y favors the soft-fail path so an enter-press lines up with the
// optional-integration contract. An explicit N still aborts onboarding —
// the user opted out of both telegram and the workaround.
if (
await deps.promptYesNoOrDefault(
" Disable Telegram for this run and continue?",
null,
true,
)
) {
announceTelegramSkip("unreachable");
return { skipped: true };
}
exitOnboardFromPrompt();
}

// Unexpected probe failure — warn but don't block.
if (!result.ok && result.httpStatus > 0) {
console.log(
` ⚠ Telegram API returned HTTP ${result.httpStatus} — the bot may not work correctly.`,
);
} else if (!result.ok) {
console.log(` ⚠ Telegram reachability probe failed: ${result.message}`);
}
return { skipped: false };
}
Loading