diff --git a/scripts/lib/bird_x.py b/scripts/lib/bird_x.py index 66dc13c..548b81c 100644 --- a/scripts/lib/bird_x.py +++ b/scripts/lib/bird_x.py @@ -36,10 +36,19 @@ def set_credentials(auth_token: Optional[str], ct0: Optional[str]): _credentials['CT0'] = ct0 +def _has_injected_credentials() -> bool: + """Return True when both X session cookies were injected from config.""" + return bool(_credentials.get('AUTH_TOKEN') and _credentials.get('CT0')) + + def _subprocess_env() -> Dict[str, str]: """Build env dict for Node subprocesses, merging injected credentials.""" env = os.environ.copy() env.update(_credentials) + # When repo config already provides cookies, disable browser-cookie fallback + # so vendored Bird never hits Safari/Chrome keychain during automation. + if _has_injected_credentials(): + env.setdefault("BIRD_DISABLE_BROWSER_COOKIES", "1") return env @@ -126,6 +135,9 @@ def is_bird_authenticated() -> Optional[str]: if not is_bird_installed(): return None + if _has_injected_credentials(): + return "env AUTH_TOKEN" + try: result = subprocess.run( ["node", str(_BIRD_SEARCH_MJS), "--whoami"], @@ -473,4 +485,4 @@ def parse_bird_response(response: Dict[str, Any]) -> List[Dict[str, Any]]: items.append(item) - return items \ No newline at end of file + return items diff --git a/scripts/lib/vendor/bird-search/lib/cookies.js b/scripts/lib/vendor/bird-search/lib/cookies.js index 83ca799..49789a1 100644 --- a/scripts/lib/vendor/bird-search/lib/cookies.js +++ b/scripts/lib/vendor/bird-search/lib/cookies.js @@ -14,6 +14,13 @@ function normalizeValue(value) { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } +function envFlagEnabled(name) { + const value = normalizeValue(process.env[name]); + if (!value) { + return false; + } + return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()); +} function cookieHeader(authToken, ct0) { return `auth_token=${authToken}; ct0=${ct0}`; } @@ -123,6 +130,8 @@ export async function extractCookiesFromFirefox(profile) { export async function resolveCredentials(options) { const warnings = []; const cookies = buildEmpty(); + const disableBrowserCookies = envFlagEnabled('BIRD_DISABLE_BROWSER_COOKIES') || + envFlagEnabled('LAST30DAYS_DISABLE_BROWSER_COOKIES'); const cookieTimeoutMs = typeof options.cookieTimeoutMs === 'number' && Number.isFinite(options.cookieTimeoutMs) && options.cookieTimeoutMs > 0 @@ -146,6 +155,15 @@ export async function resolveCredentials(options) { cookies.cookieHeader = cookieHeader(cookies.authToken, cookies.ct0); return { cookies, warnings }; } + if (disableBrowserCookies) { + if (!cookies.authToken) { + warnings.push('Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or disable BIRD_DISABLE_BROWSER_COOKIES to allow browser cookie lookup'); + } + if (!cookies.ct0) { + warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or disable BIRD_DISABLE_BROWSER_COOKIES to allow browser cookie lookup'); + } + return { cookies, warnings }; + } const sourcesToTry = resolveSources(options.cookieSource); for (const source of sourcesToTry) { const res = await readTwitterCookiesFromBrowser({ @@ -170,4 +188,4 @@ export async function resolveCredentials(options) { } return { cookies, warnings }; } -//# sourceMappingURL=cookies.js.map \ No newline at end of file +//# sourceMappingURL=cookies.js.map diff --git a/scripts/lib/youtube_yt.py b/scripts/lib/youtube_yt.py index c560d49..cf22793 100644 --- a/scripts/lib/youtube_yt.py +++ b/scripts/lib/youtube_yt.py @@ -176,6 +176,8 @@ def search_youtube( # filtering returns 0 for evergreen topics like "thumbnail tips". cmd = [ "yt-dlp", + "--ignore-config", + "--no-cookies-from-browser", f"ytsearch{count}:{core_topic}", "--dump-json", "--no-warnings", @@ -295,6 +297,8 @@ def fetch_transcript(video_id: str, temp_dir: str) -> Optional[str]: """ cmd = [ "yt-dlp", + "--ignore-config", + "--no-cookies-from-browser", "--write-auto-subs", "--sub-lang", "en", "--sub-format", "vtt", diff --git a/tests/test_bird_x.py b/tests/test_bird_x.py index 3d78bd8..3944376 100644 --- a/tests/test_bird_x.py +++ b/tests/test_bird_x.py @@ -11,6 +11,9 @@ from lib import bird_x class TestExtractCoreSubject(unittest.TestCase): + def tearDown(self): + bird_x._credentials.clear() + def test_strips_trending_noise(self): result = bird_x._extract_core_subject("trendiest Claude Code skills") self.assertNotIn("trendiest", result) @@ -28,6 +31,9 @@ class TestExtractCoreSubject(unittest.TestCase): class TestBirdSearchRetries(unittest.TestCase): + def tearDown(self): + bird_x._credentials.clear() + def test_last_chance_retry_uses_strongest_token(self): """When shorter retry also returns 0, uses longest non-noise token.""" empty = {"items": []} @@ -53,5 +59,29 @@ class TestBirdSearchRetries(unittest.TestCase): self.assertEqual(run_mock.call_count, 1) +class TestBirdAuthEnvironment(unittest.TestCase): + def tearDown(self): + bird_x._credentials.clear() + + def test_subprocess_env_disables_browser_cookie_fallback_when_injected(self): + bird_x.set_credentials("auth-token", "ct0-token") + + env = bird_x._subprocess_env() + + self.assertEqual(env["AUTH_TOKEN"], "auth-token") + self.assertEqual(env["CT0"], "ct0-token") + self.assertEqual(env["BIRD_DISABLE_BROWSER_COOKIES"], "1") + + def test_is_bird_authenticated_short_circuits_when_credentials_injected(self): + bird_x.set_credentials("auth-token", "ct0-token") + + with mock.patch.object(bird_x, "is_bird_installed", return_value=True), \ + mock.patch.object(bird_x.subprocess, "run") as run_mock: + result = bird_x.is_bird_authenticated() + + self.assertEqual(result, "env AUTH_TOKEN") + run_mock.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_youtube_yt.py b/tests/test_youtube_yt.py new file mode 100644 index 0000000..bb6248b --- /dev/null +++ b/tests/test_youtube_yt.py @@ -0,0 +1,49 @@ +"""Tests for yt-dlp invocation safety flags.""" + +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +from lib import youtube_yt + + +class _DummyProc: + def __init__(self): + self.pid = 12345 + self.returncode = 0 + + def communicate(self, timeout=None): + return "", "" + + def wait(self, timeout=None): + return 0 + + +class TestYtDlpFlags(unittest.TestCase): + def test_search_ignores_global_config_and_browser_cookies(self): + proc = _DummyProc() + with mock.patch.object(youtube_yt, "is_ytdlp_installed", return_value=True), \ + mock.patch.object(youtube_yt.subprocess, "Popen", return_value=proc) as popen_mock: + youtube_yt.search_youtube("Claude Code", "2026-02-01", "2026-03-01") + + cmd = popen_mock.call_args.args[0] + self.assertIn("--ignore-config", cmd) + self.assertIn("--no-cookies-from-browser", cmd) + + def test_transcript_fetch_ignores_global_config_and_browser_cookies(self): + proc = _DummyProc() + with tempfile.TemporaryDirectory() as temp_dir, \ + mock.patch.object(youtube_yt.subprocess, "Popen", return_value=proc) as popen_mock: + youtube_yt.fetch_transcript("abc123", temp_dir) + + cmd = popen_mock.call_args.args[0] + self.assertIn("--ignore-config", cmd) + self.assertIn("--no-cookies-from-browser", cmd) + + +if __name__ == "__main__": + unittest.main()