Files
Deheng Huang f0a0930afa fix: address Copilot review comments
- config.py: add _sanitize_agent_id() to strip path traversal chars and
  limit length; use it in get_agent_config_dir()
- account_manager.py: set_agent_active_account() validates account exists
  before saving, raises ValueError if not found
- account_manager.py: switch_account() also persists to agent-specific
  file when running under an agent ID
- auth_manager.py: status() shows 'Active Account (agent-specific):',
  'Active Account (global):', or 'Active Account:' based on context
- SKILL.md: add 'accounts remove <id>' to commands table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 14:34:22 +08:00

1121 lines
44 KiB
Python
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Authentication Manager for nblm
Handles Google authentication using agent-browser
"""
import argparse
import json
import os
import re
import sys
import time
from pathlib import Path
from datetime import datetime, timedelta, timezone
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
from typing import Optional
sys.path.insert(0, str(Path(__file__).parent))
from config import (
DATA_DIR,
AUTH_DIR,
GOOGLE_AUTH_FILE,
ZLIBRARY_AUTH_FILE,
AGENT_BROWSER_SESSION_FILE,
DEFAULT_SESSION_ID,
AGENT_BROWSER_ACTIVITY_FILE,
AGENT_BROWSER_WATCHDOG_PID_FILE,
AGENT_BROWSER_IDLE_TIMEOUT_SECONDS,
get_agent_id,
)
from agent_browser_client import AgentBrowserClient, AgentBrowserError
from account_manager import AccountManager, AccountInfo
def _pid_is_alive(pid: int) -> bool:
"""Check whether a PID is alive."""
if pid <= 0:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def get_watchdog_status() -> dict:
"""Return watchdog and daemon status details."""
last_activity = None
owner_pid = None
if AGENT_BROWSER_ACTIVITY_FILE.exists():
try:
payload = json.loads(AGENT_BROWSER_ACTIVITY_FILE.read_text())
last_activity = payload.get("timestamp")
owner_pid = payload.get("owner_pid")
except Exception:
last_activity = None
owner_pid = None
idle_seconds = None
if last_activity is not None:
try:
idle_seconds = max(0, time.time() - float(last_activity))
except Exception:
idle_seconds = None
watchdog_pid = None
if AGENT_BROWSER_WATCHDOG_PID_FILE.exists():
try:
watchdog_pid = int(AGENT_BROWSER_WATCHDOG_PID_FILE.read_text().strip())
except Exception:
watchdog_pid = None
watchdog_alive = bool(watchdog_pid and _pid_is_alive(watchdog_pid))
owner_alive = bool(owner_pid and _pid_is_alive(int(owner_pid)))
daemon_running = AgentBrowserClient(session_id=DEFAULT_SESSION_ID)._daemon_is_running()
return {
"watchdog_pid": watchdog_pid,
"watchdog_alive": watchdog_alive,
"last_activity": last_activity,
"idle_seconds": idle_seconds,
"idle_timeout_seconds": AGENT_BROWSER_IDLE_TIMEOUT_SECONDS,
"owner_pid": owner_pid,
"owner_alive": owner_alive,
"daemon_running": daemon_running
}
NOTEBOOKLM_AUTH_TTL_DAYS = 10
class AuthManager:
"""Unified auth manager for multiple services"""
SERVICES = {
"google": {
"file": GOOGLE_AUTH_FILE,
"login_url": "https://notebooklm.google.com",
"success_indicators": ["notebooklm", "notebook"]
},
"zlibrary": {
"file": ZLIBRARY_AUTH_FILE,
"login_url": "https://zh.zlib.li/",
"success_indicators": ["logout", "退出"]
}
}
def __init__(self):
DATA_DIR.mkdir(parents=True, exist_ok=True)
AUTH_DIR.mkdir(parents=True, exist_ok=True)
self.account_manager = AccountManager()
# Ensure symlink is up-to-date after migration (silent)
self._ensure_storage_state_symlink(quiet=True)
def _get_service_config(self, service: str) -> dict:
service = service or "google"
if service not in self.SERVICES:
raise ValueError(f"Unknown service: {service}")
return self.SERVICES[service]
def _auth_file(self, service: str) -> Path:
service = service or "google"
if service == "google":
# Use active account from AccountManager
auth_file = self.account_manager.get_active_auth_file()
if auth_file:
return auth_file
# Fallback to legacy path if no accounts configured
return GOOGLE_AUTH_FILE
return self._get_service_config(service)["file"]
def _auth_timestamp(self, auth_file: Path) -> str:
try:
return datetime.fromtimestamp(auth_file.stat().st_mtime).isoformat()
except Exception:
return "Unknown"
def is_authenticated(self, service: str = "google") -> bool:
"""Check if service has valid saved auth"""
info = self.get_auth_info(service)
return bool(info.get("authenticated"))
def get_auth_info(self, service: str = "google") -> dict:
"""Get authentication info for a service"""
auth_file = self._auth_file(service)
if not auth_file.exists():
return {"authenticated": False}
try:
payload = json.loads(auth_file.read_text())
except Exception:
return {"authenticated": False}
authenticated = bool(payload.get("cookies") or payload.get("origins"))
timestamp = self._auth_timestamp(auth_file)
return {
"authenticated": authenticated,
"timestamp": timestamp
}
def save_auth(self, service: str = "google", client: AgentBrowserClient = None) -> bool:
"""Save current browser state for service"""
owns_client = False
if client is None:
client = AgentBrowserClient(session_id=self._load_session_id() or DEFAULT_SESSION_ID)
client.connect()
owns_client = True
try:
payload = client.get_storage_state()
if not payload:
return False
auth_file = self._auth_file(service)
auth_file.parent.mkdir(parents=True, exist_ok=True)
auth_file.write_text(json.dumps(payload))
self._save_session_id(client.session_id)
return True
except Exception:
return False
finally:
if owns_client:
client.disconnect()
def restore_auth(self, service: str = "google", client: AgentBrowserClient = None) -> bool:
"""Restore saved auth state to browser"""
auth_file = self._auth_file(service)
if not auth_file.exists():
return False
try:
payload = json.loads(auth_file.read_text())
except Exception:
return False
owns_client = False
if client is None:
client = AgentBrowserClient(session_id=self._load_session_id() or DEFAULT_SESSION_ID)
client.connect()
owns_client = True
try:
return client.set_storage_state(payload)
finally:
if owns_client:
client.disconnect()
def _save_session_id(self, session_id: str):
"""Save session ID for future use"""
AGENT_BROWSER_SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(AGENT_BROWSER_SESSION_FILE, 'w') as f:
f.write(session_id)
def _ensure_storage_state_symlink(self, quiet: bool = False):
"""Create symlink from storage_state.json -> active account file for notebooklm-py compatibility.
The notebooklm-py library's download methods use Playwright internally and look for
storage at NOTEBOOKLM_HOME/storage_state.json. This symlink ensures the library
uses the active account's auth data.
Args:
quiet: If True, don't print status messages
"""
storage_state_path = AUTH_DIR / "storage_state.json"
# Get active account auth file
active_auth_file = self.account_manager.get_active_auth_file()
if not active_auth_file or not active_auth_file.exists():
return
# Remove existing symlink or file if it exists
if storage_state_path.exists() or storage_state_path.is_symlink():
storage_state_path.unlink()
# Create symlink to active account file (need relative path)
try:
# Calculate relative path from AUTH_DIR to the account file
rel_path = active_auth_file.relative_to(AUTH_DIR)
storage_state_path.symlink_to(rel_path)
if not quiet:
print(" ✓ Updated storage_state.json symlink for notebooklm-py")
except (OSError, ValueError) as e:
# On Windows or if relative path fails, fall back to copying
import shutil
shutil.copy2(active_auth_file, storage_state_path)
if not quiet:
print(" ✓ Updated storage_state.json copy for notebooklm-py")
def _load_session_id(self) -> str:
"""Load saved session ID"""
if AGENT_BROWSER_SESSION_FILE.exists():
with open(AGENT_BROWSER_SESSION_FILE) as f:
return f.read().strip()
return None
def _snapshot_indicates_auth(self, service: str, snapshot: str, client: AgentBrowserClient) -> bool:
if not snapshot:
return False
service_name = service or "google"
snapshot_lower = snapshot.lower()
if service_name == "google":
if client.check_auth(snapshot):
return False
if service_name == "zlibrary":
login_indicators = (
"login",
"log in",
"sign in",
"sign up",
"register",
"登录",
"注册",
)
if any(indicator in snapshot_lower for indicator in login_indicators):
return False
indicators = self._get_service_config(service_name)["success_indicators"]
if any(indicator in snapshot_lower for indicator in indicators):
return True
return True
indicators = self._get_service_config(service_name)["success_indicators"]
return any(indicator in snapshot_lower for indicator in indicators)
def setup(self, service: str = "google", use_fresh_profile: bool = False):
"""Interactive authentication setup for specified service
Args:
service: Service to authenticate ("google" or "zlibrary")
use_fresh_profile: If True, use a fresh browser profile to allow adding new accounts
"""
# Default to google if not specified
service = service or "google"
# Use Patchright for Google auth (bypasses "browser not secure" check)
if service == "google":
return self._setup_google_with_patchright(use_fresh_profile=use_fresh_profile)
# For other services, use agent-browser
return self._setup_with_agent_browser(service)
def _setup_google_with_patchright(self, use_fresh_profile: bool = False):
"""Setup Google auth using Patchright (anti-detection browser)
IMPORTANT: Google authentication MUST use Patchright to bypass the
"This browser or app may not be secure" blocking. Agent-browser uses
Chrome for Testing which is detected as an automation browser.
"""
try:
from patchright_auth import authenticate_with_patchright
except ImportError as e:
print(f"❌ Patchright not available: {e}")
print()
print(" Google authentication requires Patchright (anti-detection browser).")
print(" Chrome for Testing is blocked by Google's security checks.")
print()
print(" To install Patchright:")
print(" pip install patchright && patchright install chromium")
return False
except Exception as e:
print(f"❌ Patchright import error: {e}")
print()
print(" Please reinstall Patchright:")
print(" pip install --upgrade patchright && patchright install chromium")
return False
try:
success, email, storage_state = authenticate_with_patchright(
use_fresh_profile=use_fresh_profile
)
if success and storage_state:
if email:
# Check if account already exists
if self.account_manager.account_exists(email):
# Update existing account
existing = self.account_manager.get_account_by_email(email)
self.account_manager.update_account_credentials(existing.index, storage_state)
# Switch to this account
self.account_manager.switch_account(existing.index)
print(f" ✓ Updated existing account: {email}")
else:
# Add new account
account = self.account_manager.add_account(email, storage_state)
print(f" ✓ Added new account [{account.index}]: {email}")
else:
# No email extracted - prompt user
email = input(" Enter your Google email: ").strip()
if self.account_manager.account_exists(email):
existing = self.account_manager.get_account_by_email(email)
self.account_manager.update_account_credentials(existing.index, storage_state)
self.account_manager.switch_account(existing.index)
print(f" ✓ Updated existing account: {email}")
else:
account = self.account_manager.add_account(email, storage_state)
print(f" ✓ Added new account [{account.index}]: {email}")
self._ensure_storage_state_symlink()
return success
except Exception as e:
print(f"❌ Patchright authentication failed: {e}")
print()
print(" Please ensure:")
print(" 1. Google Chrome is installed on your system")
print(" 2. Patchright is properly installed: pip install patchright")
print(" 3. Patchright browser is installed: patchright install chromium")
return False
def _setup_with_agent_browser(self, service: str):
"""Original setup using agent-browser"""
service_config = self._get_service_config(service)
print(f"🔐 Setting up {service} authentication...")
print(" A browser window will open for you to log in.")
print()
client = AgentBrowserClient(session_id=DEFAULT_SESSION_ID, headed=True)
try:
client.connect()
client.navigate(service_config["login_url"])
time.sleep(2)
snapshot = client.snapshot()
if not self._snapshot_indicates_auth(service, snapshot, client):
print("📄 Current page state:")
print(snapshot[:1000])
print()
print("⏳ Please complete login in the browser window...")
print(" (This script will wait for you to finish)")
for _ in range(300): # 5 minute timeout
time.sleep(2)
snapshot = client.snapshot()
if self._snapshot_indicates_auth(service, snapshot, client):
print()
print("✅ Authentication successful!")
self._save_session_id(client.session_id)
self.save_auth(service, client=client)
# Extract NotebookLM tokens and create symlink for notebooklm-py
if service == "google":
self._extract_and_save_tokens(client)
self._ensure_storage_state_symlink()
return True
print()
print("❌ Authentication timeout")
return False
print("✅ Already authenticated!")
self._save_session_id(client.session_id)
self.save_auth(service, client=client)
# Extract NotebookLM tokens and create symlink for notebooklm-py
if service == "google":
self._extract_and_save_tokens(client)
self._ensure_storage_state_symlink()
return True
except AgentBrowserError as e:
print(f"❌ [{e.code}]: {e.message}")
print(f"🔧 Recovery: {e.recovery}")
return False
finally:
client.disconnect()
def get_notebooklm_credentials(
self,
client: AgentBrowserClient = None,
force_refresh: bool = False,
) -> dict:
"""Return NotebookLM auth token and cookie header, persisting if refreshed."""
auth_file = self._auth_file("google")
payload = {}
if auth_file.exists():
try:
payload = json.loads(auth_file.read_text())
except Exception:
payload = {}
env_token = os.environ.get("NOTEBOOKLM_AUTH_TOKEN")
env_cookies = os.environ.get("NOTEBOOKLM_COOKIES")
if env_token and env_cookies:
return self._persist_notebooklm_credentials(auth_file, payload, env_token, env_cookies)
cached_token = payload.get("notebooklm_auth_token")
cached_cookies = payload.get("notebooklm_cookies")
if cached_token and cached_cookies and not force_refresh:
if self._notebooklm_credentials_fresh(payload):
return {"auth_token": cached_token, "cookies": cached_cookies}
http_credentials = None
try:
http_credentials = self._fetch_notebooklm_token_http(payload)
except Exception:
http_credentials = None
if http_credentials:
token, cookies = http_credentials
return self._persist_notebooklm_credentials(auth_file, payload, token, cookies)
if cached_token and cached_cookies and not force_refresh:
return {"auth_token": cached_token, "cookies": cached_cookies}
extracted = None
owns_client = False
if client is None:
client = AgentBrowserClient(session_id=self._load_session_id() or DEFAULT_SESSION_ID)
client.connect()
owns_client = True
try:
self.restore_auth("google", client=client)
extracted = self._extract_notebooklm_credentials(client)
except Exception:
extracted = None
finally:
if owns_client:
client.disconnect()
if extracted:
token, cookies = extracted
return self._persist_notebooklm_credentials(auth_file, payload, token, cookies)
if cached_token and cached_cookies:
return {"auth_token": cached_token, "cookies": cached_cookies}
if self.setup(service="google"):
try:
payload = json.loads(auth_file.read_text())
except Exception:
payload = {}
http_credentials = None
try:
http_credentials = self._fetch_notebooklm_token_http(payload)
except Exception:
http_credentials = None
if http_credentials:
token, cookies = http_credentials
return self._persist_notebooklm_credentials(auth_file, payload, token, cookies)
extracted = None
client = AgentBrowserClient(session_id=self._load_session_id() or DEFAULT_SESSION_ID)
client.connect()
try:
self.restore_auth("google", client=client)
extracted = self._extract_notebooklm_credentials(client)
except Exception:
extracted = None
finally:
client.disconnect()
if extracted:
token, cookies = extracted
return self._persist_notebooklm_credentials(auth_file, payload, token, cookies)
token = payload.get("notebooklm_auth_token")
cookies = payload.get("notebooklm_cookies")
if token and cookies:
return {"auth_token": token, "cookies": cookies}
raise RuntimeError(
"NotebookLM auth token or cookies unavailable. "
"Run: python scripts/run.py auth_manager.py setup"
)
@staticmethod
def _extract_notebooklm_token_from_html(html: str) -> Optional[str]:
if not html:
return None
candidates = [html]
if '\\"' in html:
candidates.append(html.replace('\\"', '"'))
patterns = [
r'"SNlM0e"\s*:\s*"([^"]+)"',
r'SNlM0e"\s*,\s*"([^"]+)"',
]
for candidate in candidates:
for pattern in patterns:
match = re.search(pattern, candidate)
if match:
token = match.group(1)
return token.replace("\\u003d", "=")
return None
@staticmethod
def _notebooklm_credentials_fresh(payload: dict) -> bool:
updated_at = payload.get("notebooklm_updated_at")
if not updated_at:
return False
try:
timestamp = datetime.fromisoformat(updated_at)
except Exception:
return False
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - timestamp
return age <= timedelta(days=NOTEBOOKLM_AUTH_TTL_DAYS)
@staticmethod
def _filter_cookies_for_domains(cookies: list, substrings: list) -> list:
filtered = []
for cookie in cookies or []:
domain = (cookie.get("domain") or "").lower()
if any(sub in domain for sub in substrings):
filtered.append(cookie)
return filtered
def _fetch_notebooklm_token_http(self, payload: dict) -> Optional[tuple]:
cookies = payload.get("cookies") or []
filtered = self._filter_cookies_for_domains(cookies, ["google", "notebooklm"])
cookie_header = self._build_cookie_header(filtered)
if not cookie_header:
return None
request = Request(
"https://notebooklm.google.com/",
headers={
"Cookie": cookie_header,
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
),
},
)
try:
with urlopen(request, timeout=15) as response:
final_url = response.geturl() or ""
html = response.read().decode("utf-8", errors="replace")
except (URLError, HTTPError, ValueError, TimeoutError):
return None
if "notebooklm.google.com" not in final_url:
return None
if "accounts.google.com" in html or "Sign in" in html:
return None
token = self._extract_notebooklm_token_from_html(html)
if not token:
return None
return token, cookie_header
@staticmethod
def _build_cookie_header(cookies: list) -> str:
pairs = []
for cookie in cookies or []:
name = cookie.get("name")
value = cookie.get("value")
if name is None or value is None:
continue
pairs.append(f"{name}={value}")
return "; ".join(pairs)
@staticmethod
def _persist_notebooklm_credentials(auth_file: Path, payload: dict, token: str, cookies: str) -> dict:
payload["notebooklm_auth_token"] = token
payload["notebooklm_cookies"] = cookies
payload["notebooklm_updated_at"] = datetime.now(timezone.utc).isoformat()
auth_file.parent.mkdir(parents=True, exist_ok=True)
auth_file.write_text(json.dumps(payload))
return {"auth_token": token, "cookies": cookies}
def _extract_notebooklm_credentials(self, client: AgentBrowserClient):
login_url = self._get_service_config("google")["login_url"]
try:
client.navigate(login_url)
except Exception:
pass
token = client.evaluate("window.WIZ_global_data?.SNlM0e")
cookie_list = client.get_cookies(login_url)
cookie_header = self._build_cookie_header(cookie_list)
if not token or not cookie_header:
return None
return token, cookie_header
def _extract_notebooklm_tokens_from_page(self, client: AgentBrowserClient) -> dict:
"""Extract csrf_token and session_id from NotebookLM page via JavaScript."""
js_code = """
(() => {
const scripts = document.querySelectorAll('script');
let csrf = null, session = null;
for (const s of scripts) {
const text = s.textContent || '';
const csrfMatch = text.match(/SNlM0e['"\\s:]+['"]([^'"]+)['"]/);
const sessionMatch = text.match(/FdrFJe['"\\s:]+['"]([^'"]+)['"]/);
if (csrfMatch) csrf = csrfMatch[1];
if (sessionMatch) session = sessionMatch[1];
}
// Also try WIZ_global_data
if (!csrf && window.WIZ_global_data?.SNlM0e) {
csrf = window.WIZ_global_data.SNlM0e;
}
if (!session && window.WIZ_global_data?.FdrFJe) {
session = window.WIZ_global_data.FdrFJe;
}
return { csrf_token: csrf, session_id: session };
})()
"""
result = client.evaluate(js_code)
if not result:
return {"csrf_token": None, "session_id": None}
return result
def _extract_and_save_tokens(self, client: AgentBrowserClient) -> None:
"""Navigate to NotebookLM if needed and extract/save tokens."""
try:
# Check current URL - if not on NotebookLM, navigate there
current_url = client.get_current_url() if hasattr(client, 'get_current_url') else ""
if "notebooklm.google.com" not in current_url:
print(" ⏳ Navigating to NotebookLM to extract API tokens...")
client.navigate("https://notebooklm.google.com")
time.sleep(3) # Wait for page to load
tokens = self._extract_notebooklm_tokens_from_page(client)
if tokens.get("csrf_token") and tokens.get("session_id"):
auth_file = self._auth_file("google")
payload = json.loads(auth_file.read_text())
payload["csrf_token"] = tokens["csrf_token"]
payload["session_id"] = tokens["session_id"]
payload["extracted_at"] = datetime.now(timezone.utc).isoformat()
auth_file.write_text(json.dumps(payload))
print(" ✓ Extracted NotebookLM API tokens")
else:
print(" ⚠ Could not extract API tokens (missing csrf_token or session_id)")
except Exception as e:
print(f" ⚠ Could not extract API tokens: {e}")
def refresh_notebooklm_tokens(self) -> dict:
"""Silently refresh csrf_token and session_id using stored cookies."""
auth_file = self._auth_file("google")
if not auth_file.exists():
raise RuntimeError("No Google auth file found")
try:
payload = json.loads(auth_file.read_text())
except Exception:
raise RuntimeError("Invalid Google auth file")
client = AgentBrowserClient(session_id=self._load_session_id() or DEFAULT_SESSION_ID)
try:
client.connect()
# Restore cookies
if payload.get("cookies"):
client.set_storage_state({"cookies": payload["cookies"]})
# Navigate to NotebookLM
client.navigate("https://notebooklm.google.com")
time.sleep(3)
# Check if still authenticated
snapshot = client.snapshot()
if client.check_auth(snapshot):
raise RuntimeError("Session expired, re-authentication required")
# Extract fresh tokens
tokens = self._extract_notebooklm_tokens_from_page(client)
if not tokens.get("csrf_token") or not tokens.get("session_id"):
raise RuntimeError("Failed to extract NotebookLM tokens from page")
# Update auth file with new tokens
payload["csrf_token"] = tokens["csrf_token"]
payload["session_id"] = tokens["session_id"]
payload["extracted_at"] = datetime.now(timezone.utc).isoformat()
auth_file.write_text(json.dumps(payload))
return tokens
finally:
client.disconnect()
def validate(self, service: str = "google") -> bool:
"""Validate current authentication is still valid"""
print("🔍 Validating authentication...")
session_id = self._load_session_id()
if not session_id:
print("❌ No saved session")
return False
client = AgentBrowserClient(session_id=session_id)
try:
client.connect()
self.restore_auth(service, client=client)
client.navigate(self._get_service_config(service)["login_url"])
time.sleep(2)
snapshot = client.snapshot()
if self._snapshot_indicates_auth(service, snapshot, client):
print("✅ Authentication valid")
self.save_auth(service, client=client)
return True
print("❌ Authentication expired")
return False
except AgentBrowserError as e:
print(f"⚠️ Validation error: {e.message}")
return False
finally:
client.disconnect()
def clear(self, service: str = None):
"""Clear authentication data"""
print("🧹 Clearing authentication data...")
if service:
auth_file = self._auth_file(service)
if auth_file.exists():
auth_file.unlink()
print(f" ✓ Removed {auth_file.name}")
else:
for config in self.SERVICES.values():
auth_file = config["file"]
if auth_file.exists():
auth_file.unlink()
print(f" ✓ Removed {auth_file.name}")
if AGENT_BROWSER_SESSION_FILE.exists():
AGENT_BROWSER_SESSION_FILE.unlink()
print(" ✓ Removed session_id")
print("✅ Authentication data cleared")
print(" Note: Browser profile preserved for future sessions.")
def status(self, service: str = None):
"""Show current authentication status"""
print("🔐 Authentication Status")
print("=" * 40)
# Show agent ID if set
agent_id = get_agent_id()
if agent_id:
print(f"Agent ID: {agent_id}")
print()
# Show active account for Google
if service is None or service == "google":
active = self.account_manager.get_active_account()
if active:
if agent_id:
agent_specific = self.account_manager._load_agent_active_account()
label = "Active Account (agent-specific):" if agent_specific is not None else "Active Account (global):"
else:
label = "Active Account:"
print(f"{label} [{active.index}] {active.email}")
else:
accounts = self.account_manager.list_accounts()
if accounts:
print("Active Account: None selected")
else:
print("Active Account: No accounts configured")
print()
services = [service] if service else list(self.SERVICES.keys())
for service_name in services:
info = self.get_auth_info(service_name)
print(f"Service: {service_name}")
if info.get("authenticated"):
print(" Status: ✅ Authenticated")
print(f" Since: {info.get('timestamp', 'Unknown')}")
else:
print(" Status: ❌ Not authenticated")
print(f" Run: python scripts/run.py auth_manager.py setup --service {service_name}")
session_id = self._load_session_id()
if session_id:
print(f" Session: {session_id}")
def watchdog_status(self):
"""Show watchdog and daemon status"""
status = get_watchdog_status()
print("🧭 Watchdog Status")
print("=" * 40)
print(f" Daemon running: {'' if status['daemon_running'] else ''}")
print(f" Watchdog PID: {status['watchdog_pid'] or 'None'}")
print(f" Watchdog alive: {'' if status['watchdog_alive'] else ''}")
print(f" Owner PID: {status['owner_pid'] or 'None'}")
print(f" Owner alive: {'' if status['owner_alive'] else ''}")
if status["idle_seconds"] is not None:
idle = int(status["idle_seconds"])
timeout = int(status["idle_timeout_seconds"])
remaining = max(0, timeout - idle)
print(f" Idle seconds: {idle}")
print(f" Idle timeout: {timeout}")
print(f" Time remaining: {remaining}")
else:
print(" Idle seconds: Unknown")
def stop_daemon(self) -> bool:
"""Stop the agent-browser daemon for the saved session"""
session_id = self._load_session_id() or DEFAULT_SESSION_ID
client = AgentBrowserClient(session_id=session_id)
stopped = client.shutdown()
if stopped:
print("✅ Daemon stopped")
else:
print(" No daemon running")
return stopped
def handle_accounts_command(self, action: str, identifier: str = None):
"""Handle accounts subcommand actions."""
if action == "list":
self._accounts_list()
elif action == "add":
self._accounts_add()
elif action == "switch":
if not identifier:
print("❌ Error: Provide account index or email")
print(" Usage: auth_manager.py accounts switch <index|email>")
return False
self._accounts_switch(identifier)
elif action == "remove":
if not identifier:
print("❌ Error: Provide account index or email")
print(" Usage: auth_manager.py accounts remove <index|email>")
return False
self._accounts_remove(identifier)
elif action == "reauth":
if not identifier:
print("❌ Error: Provide account index or email")
print(" Usage: auth_manager.py accounts reauth <index|email>")
return False
self._accounts_reauth(identifier)
elif action == "use":
if not identifier:
print("❌ Error: Provide account index or email")
print(" Usage: auth_manager.py accounts use <index|email>")
return False
self._accounts_use(identifier)
elif action == "clear":
self._accounts_clear()
else:
print(f"❌ Unknown accounts action: {action}")
return False
return True
def _accounts_list(self):
"""List all accounts."""
accounts = self.account_manager.list_accounts()
active = self.account_manager.get_active_account()
if not accounts:
print("📧 No Google accounts configured")
print(" Run: python scripts/run.py auth_manager.py accounts add")
return
print("📧 Google Accounts:")
for acc in accounts:
is_active = " (active)" if active and acc.index == active.index else ""
print(f" [{acc.index}] {acc.email}{is_active}")
print(f" Added: {acc.added_at[:10] if acc.added_at else 'Unknown'}")
def _accounts_add(self):
"""Add a new account via authentication."""
print("🔐 Adding new Google account...")
# Use fresh profile to force account selection instead of auto-login
success = self.setup(service="google", use_fresh_profile=True)
if success:
active = self.account_manager.get_active_account()
if active:
print(f"✅ Account ready: [{active.index}] {active.email}")
def _accounts_switch(self, identifier: str):
"""Switch active account and update active notebook."""
try:
account = self.account_manager.switch_account(identifier)
print(f"✅ Switched to: [{account.index}] {account.email}")
# Update symlink to point to new active account
self._ensure_storage_state_symlink(quiet=True)
# Switch active notebook to one belonging to the new account
self._switch_active_notebook_for_account(account.index)
except ValueError as e:
print(f"{e}")
print(" Run: auth_manager.py accounts list")
def _switch_active_notebook_for_account(self, account_index: int):
"""Switch active notebook to one belonging to the specified account."""
from notebook_manager import NotebookLibrary
library = NotebookLibrary()
notebooks = library.list_notebooks_for_account(account_index)
if notebooks:
# Activate the first notebook for this account
first_notebook = notebooks[0]
notebook_id = first_notebook.get('id') or first_notebook.get('notebooklm_id')
if notebook_id and notebook_id in library.notebooks:
library.active_notebook_id = notebook_id
library._save_library()
print(f" 📓 Active notebook: {first_notebook.get('name', notebook_id)}")
else:
# No notebooks for this account - clear active notebook
if library.active_notebook_id:
library.active_notebook_id = None
library._save_library()
print(" 📓 No notebooks for this account")
def _accounts_remove(self, identifier: str):
"""Remove an account."""
# Get account info before removal for display
accounts = self.account_manager.list_accounts()
target = None
for acc in accounts:
if str(acc.index) == str(identifier) or acc.email.lower() == identifier.lower():
target = acc
break
if not target:
print(f"❌ Account not found: {identifier}")
return
print("⚠️ Dangerous operation detected!")
print("Operation: Remove account credentials")
print(f"Target account: {target.email}")
print("Risk: Credentials will be deleted")
print()
confirm = input("Are you sure you want to continue? [y/N]: ").strip().lower()
if confirm not in ("y", "yes"):
print("❌ Cancelled")
return
if self.account_manager.remove_account(identifier):
print(f"✅ Removed account: {target.email}")
else:
print(f"❌ Failed to remove account")
def _accounts_reauth(self, identifier: str):
"""Re-authenticate an existing account."""
# Find the account
accounts = self.account_manager.list_accounts()
target = None
for acc in accounts:
if str(acc.index) == str(identifier) or acc.email.lower() == identifier.lower():
target = acc
break
if not target:
print(f"❌ Account not found: {identifier}")
return
print(f"🔐 Re-authenticating: {target.email}")
print(" Please log in with this specific account in the browser.")
# Switch to this account first
self.account_manager.switch_account(target.index)
# Run auth
success = self.setup(service="google")
if success:
print(f"✅ Re-authenticated: [{target.index}] {target.email}")
def _accounts_use(self, identifier: str):
"""Set the agent-specific active account (OpenClaw account isolation)."""
agent_id = get_agent_id()
if not agent_id:
print("❌ No agent ID set (NBLM_AGENT_ID / OPENCLAW_AGENT / AGENT_NAME)")
return
accounts = self.account_manager.list_accounts()
target = None
for acc in accounts:
if str(acc.index) == str(identifier) or acc.email.lower() == identifier.lower():
target = acc
break
if not target:
print(f"❌ Account not found: {identifier}")
return
self.account_manager.set_agent_active_account(target.index)
print(f"✅ Agent '{agent_id}' will use: [{target.index}] {target.email}")
def _accounts_clear(self):
"""Clear the agent-specific active account override."""
agent_id = get_agent_id()
if not agent_id:
print("❌ No agent ID set (NBLM_AGENT_ID / OPENCLAW_AGENT / AGENT_NAME)")
return
self.account_manager.clear_agent_active_account()
print(f"✅ Agent '{agent_id}' account override cleared (global active account will be used)")
def main():
parser = argparse.ArgumentParser(description='Manage NotebookLM authentication')
subparsers = parser.add_subparsers(dest='command', help='Command to run')
# Service choices for reuse
service_choices = list(AuthManager.SERVICES.keys())
# Subcommands with --service option
setup_parser = subparsers.add_parser('setup', help='Setup authentication')
setup_parser.add_argument('--service', choices=service_choices,
help='Auth service (default: google)')
setup_parser.add_argument('--fresh', action='store_true',
help='Use fresh browser profile (forces account selection)')
status_parser = subparsers.add_parser('status', help='Show authentication status')
status_parser.add_argument('--service', choices=service_choices,
help='Auth service (default: all)')
validate_parser = subparsers.add_parser('validate', help='Validate authentication')
validate_parser.add_argument('--service', choices=service_choices,
help='Auth service (default: google)')
reauth_parser = subparsers.add_parser('reauth', help='Re-authenticate (uses fresh profile)')
reauth_parser.add_argument('--service', choices=service_choices,
help='Auth service (default: google)')
clear_parser = subparsers.add_parser('clear', help='Clear authentication data')
clear_parser.add_argument('--service', choices=service_choices,
help='Auth service (default: all)')
subparsers.add_parser('stop-daemon', help='Stop browser daemon')
subparsers.add_parser('watchdog-status', help='Show watchdog status')
# New accounts subcommand
accounts_parser = subparsers.add_parser('accounts', help='Manage multiple Google accounts')
accounts_parser.add_argument('action', choices=['list', 'add', 'switch', 'remove', 'reauth', 'use', 'clear'],
help='Account action')
accounts_parser.add_argument('identifier', nargs='?', help='Account index or email')
args = parser.parse_args()
auth = AuthManager()
if args.command == 'accounts':
success = auth.handle_accounts_command(args.action, args.identifier)
sys.exit(0 if success else 1)
elif args.command == 'setup':
success = auth.setup(service=args.service, use_fresh_profile=args.fresh)
sys.exit(0 if success else 1)
elif args.command == 'status':
auth.status(service=args.service)
elif args.command == 'validate':
success = auth.validate(service=args.service or "google")
sys.exit(0 if success else 1)
elif args.command == 'reauth':
service = args.service or "google"
auth.clear(service=None if args.service is None else service)
# Always use fresh profile for reauth to force account selection
success = auth.setup(service=service, use_fresh_profile=True)
sys.exit(0 if success else 1)
elif args.command == 'clear':
auth.clear(service=args.service)
elif args.command == 'stop-daemon':
success = auth.stop_daemon()
sys.exit(0 if success else 1)
elif args.command == 'watchdog-status':
auth.watchdog_status()
else:
parser.print_help()
if __name__ == "__main__":
main()