diff --git a/extensions/voice-call/src/webhook/tailscale.test.ts b/extensions/voice-call/src/webhook/tailscale.test.ts index aedaf29f24d..9b06bc1d61c 100644 --- a/extensions/voice-call/src/webhook/tailscale.test.ts +++ b/extensions/voice-call/src/webhook/tailscale.test.ts @@ -18,12 +18,14 @@ vi.mock("node:child_process", async () => { }); import { + appendTailscaleCommandStdout, cleanupTailscaleExposure, cleanupTailscaleExposureRoute, getTailscaleDnsName, getTailscaleSelfInfo, setupTailscaleExposure, setupTailscaleExposureRoute, + TAILSCALE_COMMAND_STDOUT_MAX_BYTES, } from "./tailscale.js"; function createProc(params?: { code?: number; stdout?: string }) { @@ -104,6 +106,21 @@ describe("voice-call tailscale helpers", () => { await expect(getTailscaleSelfInfo()).resolves.toBeNull(); }); + it("tracks tailscale stdout without retaining over-limit output", () => { + let stdout = appendTailscaleCommandStdout({ bytes: 0, exceeded: false, text: "" }, "ok", 4); + stdout = appendTailscaleCommandStdout(stdout, "boom", 4); + + expect(stdout).toEqual({ bytes: 6, exceeded: true, text: "" }); + }); + + it("kills tailscale status when stdout exceeds the capture limit", async () => { + const proc = createProc({ stdout: "x".repeat(TAILSCALE_COMMAND_STDOUT_MAX_BYTES + 1) }); + spawnMock.mockReturnValueOnce(proc); + + await expect(getTailscaleSelfInfo()).resolves.toBeNull(); + expect(proc.kill).toHaveBeenCalledWith("SIGKILL"); + }); + it("sets up and cleans up exposure routes with the selected mode", async () => { spawnMock .mockReturnValueOnce( @@ -127,7 +144,7 @@ describe("voice-call tailscale helpers", () => { expect(spawnMock).toHaveBeenNthCalledWith( 1, "tailscale", - ["status", "--json"], + ["status", "--json", "--peers=false"], tailscaleSpawnOptions, ); expect(spawnMock).toHaveBeenNthCalledWith( diff --git a/extensions/voice-call/src/webhook/tailscale.ts b/extensions/voice-call/src/webhook/tailscale.ts index 60476979ca8..9aa74baf315 100644 --- a/extensions/voice-call/src/webhook/tailscale.ts +++ b/extensions/voice-call/src/webhook/tailscale.ts @@ -6,6 +6,30 @@ type TailscaleSelfInfo = { nodeId: string | null; }; +export const TAILSCALE_COMMAND_STDOUT_MAX_BYTES = 4 * 1024 * 1024; + +type TailscaleCommandStdout = { + bytes: number; + exceeded: boolean; + text: string; +}; + +export function appendTailscaleCommandStdout( + current: TailscaleCommandStdout, + data: Buffer | string, + maxBytes = TAILSCALE_COMMAND_STDOUT_MAX_BYTES, +): TailscaleCommandStdout { + if (current.exceeded) { + return current; + } + const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data); + const bytes = current.bytes + buffer.byteLength; + if (bytes > maxBytes) { + return { bytes, exceeded: true, text: "" }; + } + return { bytes, exceeded: false, text: `${current.text}${buffer.toString("utf8")}` }; +} + function runTailscaleCommand( args: string[], timeoutMs = 2500, @@ -15,7 +39,7 @@ function runTailscaleCommand( stdio: ["ignore", "pipe", "pipe"], }); - let stdout = ""; + let stdout: TailscaleCommandStdout = { bytes: 0, exceeded: false, text: "" }; let settled = false; let timer: ReturnType; const finish = (result: { code: number; stdout: string }) => { @@ -28,7 +52,11 @@ function runTailscaleCommand( }; proc.stdout.on("data", (data) => { - stdout += data; + stdout = appendTailscaleCommandStdout(stdout, data); + if (stdout.exceeded) { + proc.kill("SIGKILL"); + finish({ code: -1, stdout: "" }); + } }); timer = setTimeout(() => { @@ -41,13 +69,13 @@ function runTailscaleCommand( }); proc.on("close", (code) => { - finish({ code: code ?? -1, stdout }); + finish({ code: code ?? -1, stdout: stdout.text }); }); }); } export async function getTailscaleSelfInfo(): Promise { - const { code, stdout } = await runTailscaleCommand(["status", "--json"]); + const { code, stdout } = await runTailscaleCommand(["status", "--json", "--peers=false"]); if (code !== 0) { return null; }