Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions demo/three_notebooks/notebooks/cpu-a.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"# Demo CPU A\n",
"Local repository notebook managed by Cool Colab MCP. Demonstrates multi-notebook orchestration."
],
"id": "sXn-ZCP0ftBW"
"id": "kE_AdzaefQX0"
},
{
"cell_type": "code",
Expand All @@ -25,7 +25,7 @@
"}\n",
"print(json.dumps(info, indent=2))"
],
"id": "nLaj1EM3ftEy",
"id": "J3L28UMOfQb1",
"execution_count": null,
"outputs": [
{
Expand All @@ -36,7 +36,7 @@
" \"notebook\": \"cpu-a\",\n",
" \"python\": \"3.12.13\",\n",
" \"machine\": \"x86_64\",\n",
" \"timestamp\": \"2026-07-20T17:07:23.911916+00:00\"\n",
" \"timestamp\": \"2026-07-21T16:23:36.215064+00:00\"\n",
"}\n"
]
}
Expand All @@ -54,7 +54,7 @@
"except ImportError:\n",
" print(\"accelerator: CPU (no torch)\")"
],
"id": "o6-Me2H4ftLk",
"id": "q9kfzfgxfQie",
"execution_count": null,
"outputs": [
{
Expand All @@ -65,6 +65,31 @@
]
}
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Mount Google Drive (consent auto-approved with --auto-drive)\n",
"from google.colab import drive\n",
"\n",
"drive.mount(\"/content/drive\")\n",
"import os\n",
"\n",
"print(\"drive-mounted:\", os.path.isdir(\"/content/drive/MyDrive\"))\n"
],
"id": "_hGo-TE_fQsH",
"execution_count": null,
"outputs": [
{
"output_type": "stream",
"name": "stdout",
"text": [
"Drive already mounted at /content/drive; to attempt to forcibly remount, call drive.mount(\"/content/drive\", force_remount=True).\n",
"drive-mounted: True\n"
]
}
]
}
],
"metadata": {},
Expand Down
33 changes: 29 additions & 4 deletions demo/three_notebooks/notebooks/t4.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"# Demo T4 GPU\n",
"Local repository notebook managed by Cool Colab MCP. Demonstrates GPU session in a concurrent multi-notebook run."
],
"id": "R1QLUCcuci5v"
"id": "jlE-a7wMfWCb"
},
{
"cell_type": "code",
Expand All @@ -25,7 +25,7 @@
"}\n",
"print(json.dumps(info, indent=2))"
],
"id": "Sr0BweNsci7p",
"id": "0LTBK6L_fWH9",
"execution_count": null,
"outputs": [
{
Expand All @@ -36,7 +36,7 @@
" \"notebook\": \"t4\",\n",
" \"python\": \"3.12.13\",\n",
" \"machine\": \"x86_64\",\n",
" \"timestamp\": \"2026-07-20T21:32:53.572745+00:00\"\n",
" \"timestamp\": \"2026-07-21T16:23:44.817544+00:00\"\n",
"}\n"
]
}
Expand All @@ -58,7 +58,7 @@
"except ImportError:\n",
" print(\"torch not installed\")"
],
"id": "SXjQjvKHcjAo",
"id": "3cPgrIXFfWQn",
"execution_count": null,
"outputs": [
{
Expand All @@ -70,6 +70,31 @@
]
}
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Mount Google Drive (consent auto-approved with --auto-drive)\n",
"from google.colab import drive\n",
"\n",
"drive.mount(\"/content/drive\")\n",
"import os\n",
"\n",
"print(\"drive-mounted:\", os.path.isdir(\"/content/drive/MyDrive\"))\n"
],
"id": "XudqQqRVfWZk",
"execution_count": null,
"outputs": [
{
"output_type": "stream",
"name": "stdout",
"text": [
"Mounted at /content/drive\n",
"drive-mounted: True\n"
]
}
]
}
],
"metadata": {},
Expand Down
17 changes: 16 additions & 1 deletion demo/three_notebooks/run_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ async def live_phase(
headless: bool = False,
cdp_url: str | None = None,
session_file: Path | None = None,
auto_drive: bool = False,
) -> None:
os.environ[UPLOAD_DIRS_ENV] = str(UPLOAD_FILE.parent)
os.environ[NOTEBOOK_DIRS_ENV] = os.pathsep.join(
Expand All @@ -523,7 +524,10 @@ async def live_phase(
browser = None
if auto_approve:
browser = BrowserController(
headless=headless, cdp_url=cdp_url, session_file=session_file
headless=headless,
cdp_url=cdp_url,
session_file=session_file,
auto_drive=auto_drive,
)
await browser.start()
print(
Expand Down Expand Up @@ -583,6 +587,15 @@ def parse_args() -> argparse.Namespace:
"that window; the profile is reused afterwards."
),
)
parser.add_argument(
"--auto-drive",
action="store_true",
help=(
"auto-approve Google Drive mount consent (Colab dialog + OAuth popup) "
"for cells that call drive.mount. Grants notebook code access to your "
"Drive files, so it is opt-in and separate from --auto-approve."
),
)
parser.add_argument(
"--headless",
action="store_true",
Expand Down Expand Up @@ -678,6 +691,7 @@ async def _run() -> None:
session_file=session_path(args.session_file)
if args.session_file
else None,
auto_drive=args.auto_drive,
)

asyncio.run(_run())
Expand All @@ -692,6 +706,7 @@ async def _run() -> None:
session_file=session_path(args.session_file)
if args.session_file
else None,
auto_drive=args.auto_drive,
)
)

Expand Down
119 changes: 119 additions & 0 deletions src/cool_colab_mcp/browser/adapters/colab/drive_mount.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Copyright 2026 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Auto-approval of the Google Drive mount flow (Colab dialog + OAuth popup).

When a cell calls `drive.mount(...)`, Colab raises an in-page permission dialog
("This notebook is requesting access to your Google Drive files"), and accepting
it opens a real popup window on accounts.google.com with one or more consent
screens. Once consent is granted, later mounts in the session show neither
surface — so this module is a *watcher*: it approves each surface if and when it
appears, and stays idle otherwise. `drive.mount` itself times out after about
two minutes, which is why a human-speed manual flow often fails on servers and
the approval has to be automated.

Granting this consent lets notebook code read and modify all of the user's
Drive files, so the watcher only ever runs when the operator explicitly opted
in (the demo's --auto-drive flag / BrowserController(auto_drive=True)).

Verified UI contracts (2026-07-21, live probe):
- The Colab dialog is a *generic* `mwc-dialog.yes-no-dialog` — unlike the MCP
connect dialog it has no distinctive class, so it is identified by its
"Google Drive" text. Confirm button: `[dialogaction="ok"]`.
- Popup consent screens are locale-dependent in text ("Weiter"/"Continue") but
structurally stable: footer buttons carry `jsname="LgbsSe"` with Cancel first
and Confirm last in DOM order; `jsname="NakZHc"` is a scroll-down affordance
that must be dismissed before the footer becomes reachable.
- The popup closes itself when consent completes; that close is the success
signal, never the click.
"""

import asyncio
import logging

logger = logging.getLogger(__name__)

OAUTH_POPUP_HOST = "accounts.google.com"

# Poll cadence and bounds for one watch pass.
_POLL_S = 1.0
_MAX_POPUP_STEPS = 10
_POPUP_STEP_S = 2.0

# Approve the Colab Drive permission dialog if it is open. JS click: stacked
# dialogs intercept real pointer events (same lesson as the runtime-type flow).
_CLICK_DRIVE_DIALOG_JS = """() => {
for (const d of document.querySelectorAll('mwc-dialog[open]')) {
if ((d.textContent || '').includes('Google Drive')) {
const ok = d.querySelector(
'md-text-button[dialogaction="ok"], mwc-button[dialogaction="ok"]');
if (ok) { ok.click(); return true; }
}
}
return false;
}"""

# One consent-screen step: dismiss the scroll affordance, then click the last
# enabled footer button (Cancel precedes Confirm in DOM order, in every locale).
_POPUP_STEP_JS = """() => {
const scroll = document.querySelector('button[jsname="NakZHc"]');
if (scroll) scroll.click();
const btns = Array.from(document.querySelectorAll('button[jsname="LgbsSe"]'))
.filter(b => !b.disabled);
if (!btns.length) return false;
btns[btns.length - 1].click();
return true;
}"""


def _is_oauth_popup(page) -> bool:
try:
return OAUTH_POPUP_HOST in page.url
except Exception:
return False


async def _step_through_popup(popup) -> None:
"""Click consent screens until the popup closes itself (or we give up)."""
for step in range(_MAX_POPUP_STEPS):
if popup.is_closed():
logger.info("drive consent popup closed after %d steps", step)
return
try:
await popup.evaluate(_POPUP_STEP_JS)
except Exception:
# The popup navigates or closes between screens; both are normal.
pass
await asyncio.sleep(_POPUP_STEP_S)
logger.warning("drive consent popup did not close after %d steps", _MAX_POPUP_STEPS)


async def watch(page, context) -> None:
"""Approve Drive dialogs/popups for `page` until it closes or we are cancelled.

Runs as a background task per managed tab. Idles at one poll per second;
every surface is optional because an already-consented account shows none.
"""
while not page.is_closed():
try:
if await page.evaluate(_CLICK_DRIVE_DIALOG_JS):
logger.info("approved Colab Drive permission dialog")
except Exception:
# Page navigating; try again next tick.
pass
for candidate in context.pages:
if candidate is not page and _is_oauth_popup(candidate):
logger.info("stepping through Drive consent popup")
await _step_through_popup(candidate)
await asyncio.sleep(_POLL_S)
23 changes: 22 additions & 1 deletion src/cool_colab_mcp/browser/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
import os
from pathlib import Path

from cool_colab_mcp.browser.adapters.colab import approval, runtime_type
import asyncio

from cool_colab_mcp.browser.adapters.colab import approval, drive_mount, runtime_type
from cool_colab_mcp.constants import (
AUTOMATION_FLAG,
BROWSER_PROFILE_DIR_NAME,
Expand All @@ -47,6 +49,7 @@ def __init__(
use_chrome: bool = True,
cdp_url: str | None = None,
session_file: Path | None = None,
auto_drive: bool = False,
):
self.headless = headless
# Real Chrome by default: Google refuses sign-in in Playwright's bundled
Expand All @@ -60,11 +63,16 @@ def __init__(
# automation at sign-in, so a headless browser holding these cookies is
# already authenticated — this is what makes a terminal-only server work.
self.session_file = session_file
# Auto-approve Google Drive mount consent (Colab dialog + OAuth popup).
# Off by default: granting it lets notebook code modify the user's Drive
# files, so it needs the operator's explicit opt-in.
self.auto_drive = auto_drive
self._playwright = None
self._context = None
self._owns_browser = True
self._browser = None
self._pages: dict[str, object] = {}
self._drive_watchers: dict[str, asyncio.Task] = {}

async def start(self) -> None:
"""Launch Chrome with the persistent profile, or attach to a running one."""
Expand Down Expand Up @@ -159,6 +167,11 @@ async def open_and_approve(
logger.info("opened notebook tab (notebook_id=%s, port=%d)", notebook_id, port)
await approval.approve(page, token, port, DIALOG_TIMEOUT_MS)
logger.info("approved MCP dialog (notebook_id=%s)", notebook_id)
if self.auto_drive and notebook_id not in self._drive_watchers:
self._drive_watchers[notebook_id] = asyncio.create_task(
drive_mount.watch(page, self._context)
)
logger.info("drive auto-approval watcher started (%s)", notebook_id)

async def set_runtime_type(self, notebook_id: str, accelerator: str) -> None:
"""Bind this notebook's already-open tab to `accelerator` via the UI.
Expand Down Expand Up @@ -217,14 +230,22 @@ async def open_page(self, url: str):
await page.goto(url, wait_until="domcontentloaded")
return page

def _cancel_drive_watcher(self, notebook_id: str) -> None:
task = self._drive_watchers.pop(notebook_id, None)
if task is not None:
task.cancel()

async def close(self, notebook_id: str) -> None:
"""Close one notebook's tab."""
self._cancel_drive_watcher(notebook_id)
page = self._pages.pop(notebook_id, None)
if page is not None:
await page.close()

async def aclose(self) -> None:
"""Shut down what we started; never close a browser the operator owns."""
for notebook_id in list(self._drive_watchers):
self._cancel_drive_watcher(notebook_id)
self._pages.clear()
if self._context is not None and self._owns_browser:
await self._context.close()
Expand Down
Loading