From a7a6c1913f252fafd6ef466f56ac833e6d5e8b39 Mon Sep 17 00:00:00 2001 From: CloakHQ Date: Thu, 8 Oct 2026 02:37:19 +0200 Subject: [PATCH] fix(js): write download and GeoIP status lines to stderr, not stdout Stdout status lines corrupted MCP stdio servers and any caller parsing stdout on first run. Matches the banner, Python, and .NET. Tests assert nothing reaches stdout. --- js/src/download.ts | 36 ++++++++++++++++++------------------ js/src/geoip.ts | 4 ++-- js/tests/geoip.test.ts | 26 ++++++++++++++++++++++++++ js/tests/update.test.ts | 18 +++++++++++++++++- 4 files changed, 63 insertions(+), 21 deletions(-) diff --git a/js/src/download.ts b/js/src/download.ts index 578a811..8d6a89d 100644 --- a/js/src/download.ts +++ b/js/src/download.ts @@ -93,7 +93,7 @@ export async function ensureBinary( `CLOAKBROWSER_BINARY_PATH set to '${localOverride}' but file does not exist` ); } - console.log(`[cloakbrowser] Using local binary override: ${localOverride}`); + console.error(`[cloakbrowser] Using local binary override: ${localOverride}`); return localOverride; } @@ -159,7 +159,7 @@ export async function ensureBinary( return binaryPath; } - console.log( + console.error( `[cloakbrowser] Stealth Chromium ${requestedVersion} not found. Downloading for ${getPlatformTag()}...` ); await downloadAndExtract(requestedVersion); @@ -196,7 +196,7 @@ export async function ensureBinary( } // Download platform's hardcoded version - console.log( + console.error( `[cloakbrowser] Stealth Chromium ${platformVersion} not found. Downloading for ${getPlatformTag()}...` ); await downloadAndExtract(); @@ -219,7 +219,7 @@ export function clearCache(): void { const cacheDir = getCacheDir(); if (fs.existsSync(cacheDir)) { fs.rmSync(cacheDir, { recursive: true, force: true }); - console.log(`[cloakbrowser] Cache cleared: ${cacheDir}`); + console.error(`[cloakbrowser] Cache cleared: ${cacheDir}`); } } @@ -270,7 +270,7 @@ export async function checkForUpdate(): Promise { return latest; } - console.log(`[cloakbrowser] Downloading Chromium ${latest}...`); + console.error(`[cloakbrowser] Downloading Chromium ${latest}...`); await downloadAndExtract(latest); writeVersionMarker(latest); return latest; @@ -296,7 +296,7 @@ export async function checkForProUpdate( } if (!proBinaryReady(latest)) { - console.log(`[cloakbrowser] Downloading Pro Chromium ${latest}...`); + console.error(`[cloakbrowser] Downloading Pro Chromium ${latest}...`); await downloadProBinary(latest, licenseKey); if (!fs.existsSync(getBinaryPath(latest, true))) { throw new Error( @@ -611,7 +611,7 @@ export function verifySignature(manifestBytes: Uint8Array, sigB64Bytes: Uint8Arr } try { if (cryptoVerify(null, manifestBytes, keyObject, signature)) { - console.log("[cloakbrowser] SHA256SUMS signature verified: Ed25519 OK"); + console.error("[cloakbrowser] SHA256SUMS signature verified: Ed25519 OK"); return; } } catch { @@ -684,11 +684,11 @@ async function verifyChecksum(filePath: string, expectedHash: string): Promise): Promise { - console.log(`[cloakbrowser] Downloading from ${url}`); + console.error(`[cloakbrowser] Downloading from ${url}`); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS); @@ -731,7 +731,7 @@ async function downloadFile(url: string, dest: string, headers?: Record { - console.log(`[cloakbrowser] Extracting to ${destDir}`); + console.error(`[cloakbrowser] Extracting to ${destDir}`); const bp = binaryPath || getBinaryPath(); // Extract into a private sibling dir, then rename it into place. Concurrent @@ -1100,7 +1100,7 @@ async function extractArchive( } if (fs.existsSync(bp)) { - console.log(`[cloakbrowser] Binary ready: ${bp}`); + console.error(`[cloakbrowser] Binary ready: ${bp}`); } } @@ -1299,12 +1299,12 @@ async function checkAndDownloadUpdate(): Promise { return; } - console.log( + console.error( `[cloakbrowser] Newer Chromium available: ${latest} (current: ${platformVersion}). Downloading in background...` ); await downloadAndExtract(latest); writeVersionMarker(latest); - console.log( + console.error( `[cloakbrowser] Background update complete: Chromium ${latest} ready. Will use on next launch.` ); } catch (err) { diff --git a/js/src/geoip.ts b/js/src/geoip.ts index 78dbd3e..0a13d0f 100644 --- a/js/src/geoip.ts +++ b/js/src/geoip.ts @@ -369,7 +369,7 @@ async function ensureGeoipDb(): Promise { async function downloadGeoipDb(dest: string): Promise { const dir = path.dirname(dest); fs.mkdirSync(dir, { recursive: true }); - console.log("[cloakbrowser] Downloading GeoIP database (~70 MB)…"); + console.error("[cloakbrowser] Downloading GeoIP database (~70 MB)…"); const tmpPath = `${dest}.tmp.${Date.now()}`; try { @@ -395,7 +395,7 @@ async function downloadGeoipDb(dest: string): Promise { }); fs.renameSync(tmpPath, dest); - console.log(`[cloakbrowser] GeoIP database ready: ${dest}`); + console.error(`[cloakbrowser] GeoIP database ready: ${dest}`); } catch (err) { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); throw err; diff --git a/js/tests/geoip.test.ts b/js/tests/geoip.test.ts index 780a490..c5b6b45 100644 --- a/js/tests/geoip.test.ts +++ b/js/tests/geoip.test.ts @@ -127,6 +127,32 @@ describe("maybeResolveGeoip", () => { expect(fetchSpy.mock.calls[0][1]).toEqual({ redirect: "follow" }); }); + it("GeoIP database download writes nothing to stdout", async () => { + const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cloak-geoip-stdout-")); + tempDirs.push(cacheDir); + process.env.CLOAKBROWSER_CACHE_DIR = cacheDir; + process.env.CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS = "0.001"; + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.close(); + }, + }), + } as Response); + vi.spyOn(console, "error").mockImplementation(() => {}); + const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); + const stdoutWrite = vi.spyOn(process.stdout, "write"); + + await expect(resolveProxyGeo("http://203.0.113.10:8080")).rejects.toThrow(); + + expect(fs.existsSync(path.join(cacheDir, "geoip", "GeoLite2-City.mmdb"))).toBe(true); + expect(consoleLog).not.toHaveBeenCalled(); + expect(stdoutWrite).not.toHaveBeenCalled(); + }); + it("throws when the GeoIP database is unavailable", async () => { const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cloak-geoip-db-failure-")); tempDirs.push(cacheDir); diff --git a/js/tests/update.test.ts b/js/tests/update.test.ts index 8723031..4e8fd58 100644 --- a/js/tests/update.test.ts +++ b/js/tests/update.test.ts @@ -770,7 +770,7 @@ describe("concurrent first-run download", () => { "replaces a partial install left by an interrupted extraction", async () => { vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(archiveBytes)); - vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); fs.mkdirSync(path.join(getBinaryDir(), "lib"), { recursive: true }); fs.writeFileSync(path.join(getBinaryDir(), "lib", "part-0.bin"), "truncated"); @@ -779,6 +779,22 @@ describe("concurrent first-run download", () => { }, ); + // MCP stdio servers use stdout as their JSON-RPC channel; status lines there break them. + it.skipIf(process.platform === "win32")( + "first-run download writes nothing to stdout", + async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(archiveBytes)); + vi.spyOn(console, "error").mockImplementation(() => {}); + const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); + const stdoutWrite = vi.spyOn(process.stdout, "write"); + + expect(await ensureBinary()).toBe(getBinaryPath()); + expect(inspectInstall()).toBe("complete"); + expect(consoleLog).not.toHaveBeenCalled(); + expect(stdoutWrite).not.toHaveBeenCalled(); + }, + ); + it.skipIf(process.platform === "win32")( "concurrent processes replace a partial install left by an interrupted extraction", async () => {