diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index b001649403..58721bde1a 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -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"); @@ -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, @@ -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 { @@ -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"); } } diff --git a/src/lib/onboard/telegram-reachability.test.ts b/src/lib/onboard/telegram-reachability.test.ts new file mode 100644 index 0000000000..d653970034 --- /dev/null +++ b/src/lib/onboard/telegram-reachability.test.ts @@ -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 { + 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(); + } + }); +}); diff --git a/src/lib/onboard/telegram-reachability.ts b/src/lib/onboard/telegram-reachability.ts new file mode 100644 index 0000000000..f8c910f4c6 --- /dev/null +++ b/src/lib/onboard/telegram-reachability.ts @@ -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; +} + +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()} channels add telegram\`) ${recovery}.`, + ); +} + +export async function checkTelegramReachability( + token: string, + deps: TelegramReachabilityDeps, +): Promise { + 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 }; +}