Skip to content
Closed
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
9 changes: 3 additions & 6 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,7 +704,6 @@ const { hydrateCredentialEnv }: typeof import("./onboard/credential-env") =
const {
summarizeCurlFailure,
summarizeProbeFailure,
runCurlProbe,
} = httpProbe;

const selectOnboardAgent = createSelectOnboardAgent({
Expand Down Expand Up @@ -5601,14 +5600,13 @@ async function setupMessagingChannels(

// Non-interactive: skip prompt, tokens come from env/credentials
if (isNonInteractive() || process.env.NEMOCLAW_NON_INTERACTIVE === "1") {
let found = Array.from(new Set(seedFromState(false)));
const 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) {
const reachability = await checkTelegramReachability(telegramToken, telegramReachabilityDeps);
if (reachability.skipped) found = found.filter((c) => c !== "telegram");
await checkTelegramReachability(telegramToken, telegramReachabilityDeps);
}
}
} else {
Expand Down Expand Up @@ -5737,8 +5735,7 @@ async function setupMessagingChannels(
if (!isNonInteractive() && enabled.has("telegram")) {
const telegramToken = getValidatedMessagingTokenByEnvKey(MESSAGING_CHANNELS, "TELEGRAM_BOT_TOKEN");
if (telegramToken) {
const reachability = await checkTelegramReachability(telegramToken, telegramReachabilityDeps);
if (reachability.skipped) enabled.delete("telegram");
await checkTelegramReachability(telegramToken, telegramReachabilityDeps);
}
}

Expand Down
137 changes: 82 additions & 55 deletions src/lib/onboard/telegram-reachability.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
// 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";

Expand All @@ -17,98 +10,132 @@ vi.mock("../adapters/http/probe", () => ({
}));

import { runCurlProbe } from "../adapters/http/probe";
import { checkTelegramReachability, type TelegramReachabilityDeps } from "./telegram-reachability";
import {
checkTelegramReachability,
TELEGRAM_NETWORK_CURL_CODES,
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: "" };
return { ok: false, httpStatus: 0, curlStatus, body: "", stderr: "", message: "curl failed" };
}

function makeDeps(overrides: Partial<TelegramReachabilityDeps> = {}): TelegramReachabilityDeps {
return {
isNonInteractive: vi.fn(() => true),
note: vi.fn(),
promptYesNoOrDefault: vi.fn(async () => true),
exit: vi.fn((code?: number): never => {
throw new Error(`process.exit(${code ?? 0})`);
}),
...overrides,
};
}

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

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

await expect(checkTelegramReachability("123:abc", makeDeps())).resolves.toBeUndefined();

expect(runCurlProbe).toHaveBeenCalledWith([
"-sS",
"--connect-timeout",
"5",
"--max-time",
"10",
"https://api.telegram.org/bot123:abc/getMe",
]);
});

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("warns but keeps Telegram enabled when Telegram rejects the token", async () => {
vi.mocked(runCurlProbe).mockReturnValue(probeHttpError(401));
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const deps = makeDeps();

await expect(checkTelegramReachability("123:abc", deps)).resolves.toBeUndefined();

expect(logSpy).toHaveBeenCalledWith(
" ⚠ Bot token was rejected by Telegram — verify the token is correct.",
);
expect(deps.exit).not.toHaveBeenCalled();
});

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]) {
it("aborts non-interactive onboarding for each Telegram network curl failure", async () => {
for (const code of TELEGRAM_NETWORK_CURL_CODES) {
vi.mocked(runCurlProbe).mockReturnValue(probeCurlError(code));
expect(
await checkTelegramReachability("123:abc", makeDeps()),
`curlStatus=${code}`,
).toEqual({ skipped: true });
const deps = makeDeps();
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});

await expect(checkTelegramReachability("123:abc", deps)).rejects.toThrow("process.exit(1)");
expect(deps.exit).toHaveBeenCalledWith(1);
}
});

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("continues after an interactive user accepts the network-failure warning", async () => {
vi.mocked(runCurlProbe).mockReturnValue(probeCurlError(7));
vi.spyOn(console, "log").mockImplementation(() => {});
const deps = makeDeps({
isNonInteractive: vi.fn(() => false),
promptYesNoOrDefault: vi.fn(async () => 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 });
});
await expect(checkTelegramReachability("123:abc", deps)).resolves.toBeUndefined();

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();
expect(deps.promptYesNoOrDefault).toHaveBeenCalledWith(" Continue anyway?", null, false);
expect(deps.exit).not.toHaveBeenCalled();
});

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

await expect(checkTelegramReachability("123:abc", deps)).rejects.toThrow("process.exit(1)");

expect(deps.exit).toHaveBeenCalledWith(1);
});

it("skips the probe when NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1", async () => {
process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY = "1";
const deps = makeDeps();

await expect(checkTelegramReachability("123:abc", deps)).resolves.toBeUndefined();

expect(runCurlProbe).not.toHaveBeenCalled();
expect(deps.note).toHaveBeenCalledWith(
" [non-interactive] Skipping Telegram reachability probe by request.",
);
});

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();
}
it("warns but does not block on unexpected HTTP errors", async () => {
vi.mocked(runCurlProbe).mockReturnValue(probeHttpError(500));
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const deps = makeDeps();

await expect(checkTelegramReachability("123:abc", deps)).resolves.toBeUndefined();

expect(logSpy).toHaveBeenCalledWith(
" ⚠ Telegram API returned HTTP 500 — the bot may not work correctly.",
);
expect(deps.exit).not.toHaveBeenCalled();
});
});
69 changes: 17 additions & 52 deletions src/lib/onboard/telegram-reachability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,11 @@
// 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;
Expand All @@ -19,30 +15,18 @@ export interface TelegramReachabilityDeps {
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}.`,
);
exit?(code?: number): never;
}

export async function checkTelegramReachability(
token: string,
deps: TelegramReachabilityDeps,
): Promise<TelegramReachabilityResult> {
): Promise<void> {
const exit = deps.exit ?? ((code?: number): never => process.exit(code));

if (process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY === "1") {
deps.note(" [non-interactive] Skipping Telegram reachability probe by request.");
return { skipped: false };
return;
}

const result = runCurlProbe([
Expand All @@ -55,49 +39,31 @@ export async function checkTelegramReachability(
]);

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

// 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.
// HTTP 401 or 404 — token was rejected by Telegram (not a network issue).
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 };
return;
}

// 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.
// 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 (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 };
console.error(
" Aborting onboarding in non-interactive mode due to Telegram network reachability failure.",
);
exit(1);
} else if (!(await deps.promptYesNoOrDefault(" Continue anyway?", null, false))) {
console.log(" Aborting onboarding.");
exit(1);
}
exitOnboardFromPrompt();
return;
}

// Unexpected probe failure — warn but don't block.
Expand All @@ -108,5 +74,4 @@ export async function checkTelegramReachability(
} else if (!result.ok) {
console.log(` ⚠ Telegram reachability probe failed: ${result.message}`);
}
return { skipped: false };
}