diff --git a/.agents/skills/telegram-e2e-userbot/scripts/telegram-test-api-proxy.mjs b/.agents/skills/telegram-e2e-userbot/scripts/telegram-test-api-proxy.mjs index c8d2157aa3b0..ffcc40f8f614 100755 --- a/.agents/skills/telegram-e2e-userbot/scripts/telegram-test-api-proxy.mjs +++ b/.agents/skills/telegram-e2e-userbot/scripts/telegram-test-api-proxy.mjs @@ -16,6 +16,8 @@ const HOP_BY_HOP_HEADERS = new Set([ "transfer-encoding", "upgrade", ]); +const TELEGRAM_PROXY_ERROR_CODE = + /^(?:ABORT_ERR|EAI_AGAIN|E(?:CONNREFUSED|CONNRESET|HOSTUNREACH|NETUNREACH|NOTFOUND|PIPE|TIMEDOUT)|UND_ERR_(?:ABORTED|BODY_TIMEOUT|CONNECT_TIMEOUT|HEADERS_TIMEOUT|SOCKET))$/u; export function telegramTestApiPath(pathname) { const match = pathname.match(/^((?:\/file)?\/bot[^/]+)(\/.*)$/u); @@ -47,6 +49,32 @@ function telegramApiMethod(pathname) { return pathname.match(/^\/bot[^/]+\/([^/?]+)/u)?.[1]; } +function reportProxyFailure(error, phase, method) { + let current = error; + let errorCode; + for (let depth = 0; depth < 4 && current && typeof current === "object"; depth += 1) { + if (typeof current.code === "string" && TELEGRAM_PROXY_ERROR_CODE.test(current.code)) { + errorCode = current.code; + break; + } + current = current.cause; + } + // Error messages, URLs, and arbitrary names/codes can contain bot tokens. Emit only + // fixed phases and allowlisted atoms so retained QA logs remain safe to publish. + const safeMethod = + typeof method === "string" && /^[A-Za-z][A-Za-z0-9_]{0,63}$/u.test(method) ? method : "unknown"; + console.error( + JSON.stringify({ + event: "telegram_test_api_proxy_failure", + phase, + method: safeMethod, + errorClass: + error instanceof TypeError ? "TypeError" : error instanceof Error ? "Error" : "unknown", + errorCode: errorCode ?? "unknown", + }), + ); +} + async function drainTelegramTestUpdates(apiRoot, token) { let offset = 0; for (;;) { @@ -137,6 +165,8 @@ export async function startTelegramTestApiProxy({ }); async function handleRequest(request, response) { let logged; + let method; + let failurePhase = "request"; const recordDoneAt = () => { if (logged) { logged.doneAt ??= Date.now(); @@ -148,12 +178,14 @@ export async function startTelegramTestApiProxy({ request.once("aborted", abortUpstream); response.once("close", abortUpstream); try { + failurePhase = "lease"; assertLeaseHealthy(); + failurePhase = "request"; const incoming = new URL(request.url || "/", `http://${host}`); const upstreamUrl = new URL(upstream); upstreamUrl.pathname = telegramTestApiPath(incoming.pathname); upstreamUrl.search = incoming.search; - const method = telegramApiMethod(incoming.pathname); + method = telegramApiMethod(incoming.pathname); const loggedMethod = method ?? (incoming.pathname.startsWith("/file/bot") ? "file" : undefined); const ordinal = (methodOrdinals.get(method) ?? 0) + 1; @@ -202,6 +234,7 @@ export async function startTelegramTestApiProxy({ if (matches && rejection.skip > 0) { rejection.skip -= 1; } else if (matches) { + failurePhase = "lease"; assertLeaseHealthy(); rejection.times -= 1; if (rejection.times <= 0) requestRejection = undefined; @@ -242,6 +275,7 @@ export async function startTelegramTestApiProxy({ return; } } + failurePhase = "upstream-fetch"; const result = await fetchImpl(upstreamUrl, { method: request.method, headers: requestHeaders(request.headers), @@ -249,7 +283,9 @@ export async function startTelegramTestApiProxy({ signal: upstreamController.signal, }); if (logged) logged.status = result.status; + failurePhase = "lease"; assertLeaseHealthy(); + failurePhase = "response-stream"; const hold = method ? claimResponseHold(method, ordinal) : undefined; if (method === "getUpdates") { try { @@ -278,13 +314,17 @@ export async function startTelegramTestApiProxy({ } finally { clearInterval(heartbeat); } + failurePhase = "lease"; assertLeaseHealthy(); + failurePhase = "response-stream"; heldResponse.event.releasedAt = Date.now(); heldResponse = undefined; response.end(body); return; } + failurePhase = "lease"; assertLeaseHealthy(); + failurePhase = "response-stream"; response.writeHead(result.status, responseHeaders(result.headers)); if (!result.body) { response.end(); @@ -310,8 +350,9 @@ export async function startTelegramTestApiProxy({ response.once("close", finished); readable.pipe(response); }); - } catch { + } catch (error) { recordDoneAt(); + reportProxyFailure(error, failurePhase, method); if (!response.headersSent) { response.writeHead(502, { "content-type": "application/json" }); } diff --git a/.agents/skills/telegram-e2e-userbot/scripts/telegram-test-api-proxy.test.mjs b/.agents/skills/telegram-e2e-userbot/scripts/telegram-test-api-proxy.test.mjs index 751a2280afe4..0ed92219c574 100644 --- a/.agents/skills/telegram-e2e-userbot/scripts/telegram-test-api-proxy.test.mjs +++ b/.agents/skills/telegram-e2e-userbot/scripts/telegram-test-api-proxy.test.mjs @@ -363,6 +363,7 @@ test("getFile timing ends when the streamed response finishes", async (t) => { }); test("records completion timing on upstream and response-stream errors", async (t) => { + t.mock.method(console, "error", () => {}); for (const [expectedStatus, fetchImpl] of [ [ 502, @@ -392,7 +393,42 @@ test("records completion timing on upstream and response-stream errors", async ( } }); -test("proxy close aborts the in-flight Test Server request", async () => { +test("reports upstream failures without exposing exception details or bot tokens", async (t) => { + const privateDetail = "synthetic-private-upstream-detail"; + const cause = Object.assign(new Error(privateDetail), { code: "ECONNRESET" }); + const diagnostic = t.mock.method(console, "error", () => {}); + const proxy = await startTelegramTestApiProxy({ + fetchImpl: async () => { + throw new TypeError(privateDetail, { cause }); + }, + }); + t.after(() => proxy.close()); + + const response = await fetch(`${proxy.apiRoot}/bot123:ABC/deleteWebhook`, { + method: "POST", + body: "{}", + }); + + assert.equal(response.status, 502); + assert.deepEqual(await response.json(), { + ok: false, + description: "Telegram Test Server proxy failed.", + }); + assert.equal(diagnostic.mock.calls.length, 1); + const line = diagnostic.mock.calls[0]?.arguments[0]; + assert.equal(typeof line, "string"); + assert.deepEqual(JSON.parse(line), { + event: "telegram_test_api_proxy_failure", + phase: "upstream-fetch", + method: "deleteWebhook", + errorClass: "TypeError", + errorCode: "ECONNRESET", + }); + assert.doesNotMatch(line, /123:ABC|synthetic-private/u); +}); + +test("proxy close aborts the in-flight Test Server request", async (t) => { + t.mock.method(console, "error", () => {}); let upstreamStarted; let upstreamAborted = false; const started = new Promise((resolve) => { @@ -424,6 +460,7 @@ test("proxy close aborts the in-flight Test Server request", async () => { }); test("lease revocation blocks every later Bot API request", async (t) => { + t.mock.method(console, "error", () => {}); const leaseError = new Error("lease revoked"); let healthy = true; let revoke;