name: browser-auth-on-headless-vps description: Use when a browser-based login must be completed on a headless Ubuntu VPS but the user can only interact from a separate personal machine. Sets up a real browser on the VPS, exposes it via remote debugging, and keeps the steps clearly split between VPS-side and local-machine-side actions. version: 1.0.0 author: Antigravity license: MIT metadata: hermes: tags: [browser, auth, vps, ubuntu, remote-debugging, chrome, headless] related_skills: [hermes-agent, handoff-task]
Use this workflow when you need to authenticate a browser-backed service on an Ubuntu VPS that has no usable local display, while the user is operating from a different computer. The goal is to avoid brittle cookie-only setups by using a real Chrome/Chromium session on the VPS, then exposing that session through remote debugging so the user can complete login from their own machine.
Keep the execution split explicit:
- VPS-side work: install browser, start the browser, start remote debugging, verify the port is alive.
- Local-machine work: open SSH tunnel, attach Chrome DevTools to localhost:9222, complete login.
When wiring Codex to a 9router endpoint, keep the config in ~/.codex/ on the target machine. Use a config.toml that sets model_provider = "9router" and a base_url pointing at the remote router URL, plus an auth.json file for the credential mode expected by the CLI.
Install a real browser on the VPS. Prefer Google Chrome Stable or Chromium from the distro repos. Verify the browser binary exists before continuing.
Start Chrome with a dedicated profile. Use a separate user-data directory for the service so future auth is isolated from other logins.
Expose Chrome DevTools on loopback only.
Use --remote-debugging-port=9222, --remote-debugging-address=127.0.0.1, and --remote-allow-origins=* (to prevent HTTP 403 Forbidden errors when connecting via WebSocket).
If the VPS has no display, wrap the launch in xvfb-run or use --headless=new with --no-sandbox if running as root.
Verify the debugging endpoint from the VPS.
Check http://127.0.0.1:9222/json/version before asking the user to do anything. Completion criterion: the endpoint returns a JSON payload with the browser version and websocket URL.
Have the user open an SSH tunnel from their local machine.
The user runs ssh -N -L 9222:127.0.0.1:9222 root@<vps-ip> from their own computer.
Have the user inspect the browser session locally.
The user opens chrome://inspect, adds localhost:9222, and attaches to the target tab.
Let the user finish login. The user signs in on their machine while the real browser session is actually running on the VPS.
Re-check auth from the VPS. After login, run the service CLI's auth check or profile list to confirm a persistent profile was saved.
Refer to references/auth-architecture.md for details on the cookie hierarchy and internal auth mechanics.
check_auth() AloneThe CLI's check_auth() and nlm login --check can report false negatives — the user's Google session in Chrome may be perfectly valid while the CLI's cached auth files (cookies.json, auth.json) are stale. Before attempting any login flow:
bash
curl -s http://127.0.0.1:9223/json/versionnlm login --force:
bash
python3 /opt/ai-os/products/ceo/scripts/notebooklm_refresh.py --port 9223Why this order matters: nlm login --force launches its own Chrome instance, which conflicts with any existing Chrome using the same profile (SingletonLock). The user's existing Chrome session with a live Google login is the most reliable source of auth tokens. Never launch Chrome unless the old one is confirmed dead.
When the CLI reports auth expired but Chrome headless is running with a saved Google session:
# STRAIGHT TO COOKIE EXTRACTION — skip login commands entirely
cd /opt/ai-os/products/ceo/integrations/notebooklm-mcp-cli
.venv/bin/python /opt/ai-os/products/ceo/scripts/notebooklm_refresh.py --port 9223
This connects to Chrome's CDP WebSocket, calls Network.getAllCookies, extracts CSRF/session_id/build_label from the page DOM, and saves to the CLI's profile storage. Verification is automatic.
Do NOT run nlm login --force or nlm login when Chrome is already running. These commands try to start a second Chrome instance, hit SingletonLock, and timeout.
When Chrome headless itself is dead (port unreachable, no process), run:
/opt/ai-os/products/ceo/scripts/revive_notebooklm_auth.sh
This one-shot script:
1. Kills stale Chrome processes on port 9223.
2. Removes SingletonLock if profile is locked.
3. Relaunches Chrome headless with the existing profile.
4. Runs notebooklm_refresh.py to extract fresh cookies via CDP into the CLI profile.
5. Runs check_auth(live=True) to confirm validity.
Lưu ý (Trường hợp port 9223): Đôi khi Chrome headless chạy trên port 9223 thay vì 9222 (do config mặc định của company script notebooklm_refresh.py hoặc crontab setup). Hãy luôn check nhanh cả port 9223 bằng notebooklm_refresh.py --port 9223 trước khi kết luận auth hỏng hẳn.
After the user completes interactive login via SSH tunnel + chrome://inspect, the Chrome profile gets fresh cookies but the CLI auth files (profiles/default/cookies.json, auth.json) are still stale. Always run the refresh script:
cd /opt/ai-os/products/ceo/integrations/notebooklm-mcp-cli
.venv/bin/python /opt/ai-os/products/ceo/scripts/notebooklm_refresh.py
Without this sync step, the CLI will keep using expired cookies even though Chrome itself is authenticated. Note the script must be run via the virtual environment's python.
If the browser process terminates (e.g., after a VPS reboot or timeout), the user's session profile still exists on disk, but the debugging port will refuse connections. Do NOT ask the user to re-authenticate or copy cookies manually. Instead:
~/.notebooklm-mcp-cli/chrome-profiles/default):
bash
google-chrome-stable \
--headless=new \
--no-sandbox \
--disable-gpu \
--disable-dev-shm-usage \
--remote-debugging-port=9222 \
--remote-debugging-address=127.0.0.1 \
--remote-allow-origins=* \
--user-data-dir=$HOME/.notebooklm-mcp-cli/chrome-profiles/defaultbash
python3 /opt/ai-os/products/ceo/scripts/notebooklm_refresh.py --port 9222nlm login --check).Scripted equivalent:
/opt/ai-os/products/ceo/scripts/revive_notebooklm_auth.shhandles steps 1–3 automatically using port 9223.
Mixing up VPS actions and local actions. Always label what runs on the VPS versus what the user must do locally. If the user cannot see the VPS, do not instruct them to click there directly.
Trusting check_auth() false-negatives over the live Chrome session.
This is the #1 trap. The CLI's check_auth() and nlm login --check can report "expired" while Chrome itself has a perfectly valid Google session. The CLI maintains a separate cookie JSON file that does not auto-sync with Chrome's SQLite cookie store. First-line action should always be: connect to Chrome via CDP, extract cookies with Network.getAllCookies, and save to the CLI profile — not nlm login --force which launches a conflicting Chrome instance.
Detection protocol: Before believing any auth failure, verify Chrome CDP is alive and serving pages:
bash
curl -s http://127.0.0.1:9223/json/version | grep -q Browser && echo "Chrome alive with session"
If Chrome is alive, extract cookies via CDP directly. If the page URL shows notebooklm.google.com/ (not accounts.google.com), the session is valid and only the CLI cache is stale.
Using cookies as the primary plan. Cookies are a fallback, not the preferred path, when the service supports browser/profile auth. Use cookies only when browser auth cannot be completed.
Forgetting --no-sandbox when running Chrome as root.
On many VPS setups Chrome will refuse to start without it.
Running Python scripts outside the venv.
If a script imports tools from a local repository (like notebooklm_tools), it will fail with ModuleNotFoundError if run with the system python3. Always run them via the project's virtual environment, e.g., .venv/bin/python script.py.
WebSocket 403 Forbidden Handshake Errors.
Chrome blocks incoming WebSocket connections from differing origins by default. Always include --remote-allow-origins=* or specify the loopback address origin.
Running nlm login --force when Chrome is already running.
This will try to launch a second Chrome instance on the same profile, hit the SingletonLock, and timeout after 120s. If Chrome is alive, use notebooklm_refresh.py instead. If it's dead, use revive_notebooklm_auth.sh.
Skipping the loopback verification.
Before involving the user, confirm the remote debugging endpoint is alive on 127.0.0.1:9222 (or the chosen port).
Leaving the tunnel instructions ambiguous. The user needs the real VPS IP from their provider or existing SSH config; do not invent one.
Not syncing CLI profile after interactive login.
When the user logs in via SSH tunnel + chrome://inspect, the Chrome profile gets fresh cookies but the CLI auth files stay stale. Always run the refresh script after interactive login.
Stale chrome-port-map.json entries from dead processes.
The file ~/.notebooklm-mcp-cli/chrome-port-map.json persists port→profile mappings even if the Chrome process on that port died. A stale entry (e.g., port 9222 pointing to a defunct PID) can mislead the CLI into trying to reuse a dead port. After killing Chrome or on VPS restart, clean up stale port-map entries by hand or run the revive script which handles it.
Assuming a single port; not scanning for alternatives.
Chrome headless may be running on a non-default port (e.g., 9223 instead of 9222) if the original port was occupied or configured differently. Always probe a range of ports (9222–9232) for /json/version before concluding Chrome is dead.
Iterative trial-and-error in the main thread on auth issues.
When auth troubleshooting requires deep code analysis (reading auth.py, cdp.py, config.py), do not iterate in the main conversation — this burns tokens and frustrates the user. Instead, delegate code research to a subagent via delegate_task() using role=leaf with a clear context, goal, and path references. Let the subagent produce a documented recommendation, then implement it in one shot.
127.0.0.1:9222.chrome://inspect.