From c285a4c813409cb202367e6e7239f8501b5a824e Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 13 Aug 2026 17:16:05 +0700 Subject: [PATCH] fix(voice): copy cookies to temp before yt-dlp + fall back to Invidious on cookie/permission errors yt-dlp 2026.07.04 rewrites the --cookies file on close. Handing it the root-owned /etc/.../ytcookies.txt (not writable by the gmw service user) caused PermissionError -> exit 1 on every screen-share download attempt. - buildCookieArgs on-disk branch now copies the system cookie file into a per-run temp file (like the env branch) so write-back lands somewhere we own; unreadable -> anonymous. - resolveInputWithRetry Invidious fallback regex now also matches permission|EACCES|cookie, so a cookie failure triggers the link-alternative (no-auth Invidious mirror) path instead of failing all retries. - adds regression test asserting the original cookie path is never passed to yt-dlp --- .../modules/voice-recording/mediaSource.ts | 34 +++++++++++++-- .../voice-recording/screenShareController.ts | 4 +- .../tests/screenShareInput.test.ts | 43 +++++++++++++++++++ 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/services/discord-gateway/src/modules/voice-recording/mediaSource.ts b/services/discord-gateway/src/modules/voice-recording/mediaSource.ts index 54a3b4b..c63fc4e 100644 --- a/services/discord-gateway/src/modules/voice-recording/mediaSource.ts +++ b/services/discord-gateway/src/modules/voice-recording/mediaSource.ts @@ -4,6 +4,7 @@ import { existsSync, mkdtempSync, readdirSync, + readFileSync, rmSync, statSync, writeFileSync, @@ -244,7 +245,7 @@ function buildCookieArgs(): string[] { // which the Nix deploy writes from BWS once at start. if (_cachedCookiePath) return ["--cookies", _cachedCookiePath]; const envCookies = process.env.GMW_YT_DOWNLOADER_COOKIES?.trim(); - if (envCookies && envCookies.includes("LOGIN_INFO")) { + if (envCookies?.includes("LOGIN_INFO")) { const fdPath = join(tmpdir(), `gmw-ytcookies.${process.pid}.txt`); writeFileSync(fdPath, envCookies); try { @@ -263,12 +264,37 @@ function buildCookieArgs(): string[] { process.env.GMW_YT_COOKIES_PATH ?? "/etc/gmw-discord-gateway/ytcookies.txt"; try { if (cookiePath && existsSync(cookiePath)) { - _cachedCookiePath = cookiePath; + // Never hand the ORIGINAL system file to yt-dlp: recent yt-dlp rewrites + // the cookie file on close (`--cookies` implies write-back). The system + // file is owned by another user (root/deploy) and the service user + // cannot write it → PermissionError → yt-dlp exits 1 → screen share + // fails for every attempt. Copy to a per-run temp file (like the env + // branch above) so write-back lands somewhere we own; if the original + // is not readable we fall back to anonymous (YouTube may 403 → the + // screen-share controller retries via Invidious mirrors without auth). + let cookieContents: string; + try { + cookieContents = readFileSync(cookiePath, "utf8"); + } catch { + logger.warn( + { cookiePath }, + "Cookie file not readable; continuing without cookies (anon)", + ); + return []; + } + const fdPath = join(tmpdir(), `gmw-ytcookies.${process.pid}.txt`); + writeFileSync(fdPath, cookieContents); + try { + chmodSync(fdPath, 0o600); + } catch { + /* best-effort */ + } + _cachedCookiePath = fdPath; logger.info( - { cookiePath, source: "on-disk file" }, + { cookiePath: fdPath, source: "on-disk file (copied)" }, "Using YouTube cookies for yt-dlp", ); - return ["--cookies", cookiePath]; + return ["--cookies", fdPath]; } } catch { /* ignore — fallback to anon */ diff --git a/services/discord-gateway/src/modules/voice-recording/screenShareController.ts b/services/discord-gateway/src/modules/voice-recording/screenShareController.ts index dddb593..b187f23 100644 --- a/services/discord-gateway/src/modules/voice-recording/screenShareController.ts +++ b/services/discord-gateway/src/modules/voice-recording/screenShareController.ts @@ -87,7 +87,9 @@ export class ScreenShareController { if ( isYt && lastError && - /403|bot|Sign in|not a bot|access denied/i.test(lastError.message) && + /403|bot|Sign in|not a bot|access denied|permission|EACCES|cookie/i.test( + lastError.message, + ) && invidiousIdx < INVIDIOUS_INSTANCES.length ) { const inst = INVIDIOUS_INSTANCES[invidiousIdx]; diff --git a/services/discord-gateway/tests/screenShareInput.test.ts b/services/discord-gateway/tests/screenShareInput.test.ts index 1d9176d..04acd77 100644 --- a/services/discord-gateway/tests/screenShareInput.test.ts +++ b/services/discord-gateway/tests/screenShareInput.test.ts @@ -113,4 +113,47 @@ describe("downloadScreenInput", () => { process.env.PATH = realPath2; } }); + + it("copies the on-disk cookies to a temp file instead of handing yt-dlp the original path", async () => { + // Regression: recent yt-dlp rewrites `--cookies` file on close. If we hand + // it the original system file (root-owned, not writable by the service + // user), save-back throws PermissionError → exit 1 → screen share fails. + // The copy lives in tmpdir where the service user owns it. + const cookieDir = mkdtempSync(join(tmpdir(), "gmw-fake-cookies-")); + const cookiePath = join(cookieDir, "ytcookies.txt"); + writeFileSync( + cookiePath, + "# Netscape HTTP Cookie File\n.youtube.com\tTRUE\t/\tTRUE\t0\tLOGIN_INFO\tabc123\n", + ); + const argsDump = join( + tmpdir(), + `gmw-ytargs-${process.pid}-${Date.now()}.txt`, + ); + process.env.GMW_FAKE_YTDLP_DUMP_ARGS = argsDump; + process.env.GMW_YT_COOKIES_PATH = cookiePath; + const realPath2 = process.env.PATH; + const dir = fakeBinDir as unknown as string; + const existing = join(dir, "yt-dlp"); + writeFileSync(existing, ytShimDump); + chmodSync(existing, 0o755); + try { + await downloadScreenInput("https://youtu.be/abc"); + await new Promise((r) => setTimeout(r, 100)); + const args = readFileSync(argsDump, "utf8").trim(); + expect(args).toContain("--cookies"); + const cookieArg = args + .split(/\s+/) + .at(args.split(/\s+/).indexOf("--cookies") + 1); + expect(cookieArg).toBeDefined(); + expect(cookieArg).not.toBe(cookiePath); // never the original system file + expect(cookieArg).toMatch(/gmw-ytcookies\.\d+\.txt/); // per-process temp copy + expect(cookieArg).not.toMatch(/^\/etc\//); + } finally { + delete process.env.GMW_FAKE_YTDLP_DUMP_ARGS; + delete process.env.GMW_YT_COOKIES_PATH; + rmSync(argsDump, { force: true }); + rmSync(cookieDir, { recursive: true, force: true }); + process.env.PATH = realPath2; + } + }); });