Compare commits

...

1 Commits

Author SHA1 Message Date
Dominic Cooney ff4f50b473 dev: add HTTP-controlled debug harness for the VSCode extension
Ports the debug harness from the SDK-migration branch to main as a standalone change. The harness (src/dev/debug-harness/server.ts) launches VSCode with the extension under CDP and exposes an HTTP API on :19229 for breakpoints, evaluate/stepping in the extension host and webview, Playwright UI automation, and OAuth-flow testing.

Includes the runtime affordances the harness depends on, all gated so they never activate in normal use: CLINE_CAPTURE_BROWSER browser-capture in openExternal() (utils/env.ts), a CLINE_CAPTURE_BROWSER-gated globalThis.__clineHandleUri hook for delivering simulated vscode:// OAuth callbacks (extension.ts), and window.__clineVsCodeApi exposure for harness postMessage access (platform.config.ts). Also includes the local MCP OAuth test server (src/dev/mcp-oauth-test-server) referenced by the harness README for end-to-end OAuth testing, plus .clinerules/debug-harness.md agent docs.

Verified: server boots on Windows and /health and /status respond on :19229; biome is clean on all touched files.
2026-06-11 20:52:31 +09:00
9 changed files with 2982 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
# Debug Harness
HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/server.ts`.
## Quick start
```bash
# Build extension first if needed (protos + esbuild):
npm run protos && IS_DEV=true node esbuild.mjs
# Launch (skip-build if already built):
npx tsx src/dev/debug-harness/server.ts --skip-build --auto-launch
# In another terminal:
curl localhost:19229/api -d '{"method":"status"}'
```
## Data Isolation
The debugee runs with `CLINE_DIR=~/.cline2` by default, separate from your real `~/.cline`.
This prevents the debugee's logout from logging out the debugger, and vice versa.
Override with `--cline-dir /tmp/test-dir`. Check with `status()``clineDir`.
## Browser Capture & OAuth
The debugee runs with `CLINE_CAPTURE_BROWSER=1`, which intercepts `openExternal()` in
`src/utils/env.ts`. URLs are captured instead of opening a real browser:
- Logged to `$CLINE_DIR/data/debug-captured-urls.jsonl`
- POSTed in real-time to `/captured-url` on the harness server
- Queryable via `oauth.captured_urls`
### OAuth API
- **`oauth.captured_urls`** `{clear?}` — URLs the debugee tried to open
- **`oauth.read_stored_token`** — Check auth token presence in secrets.json
- **`oauth.simulate_callback`** `{path, code?, state?, provider?, token?}` — Build vscode:// callback URI
- **`oauth.read_captured_urls_file`** — Read on-disk JSONL of captured URLs
### OAuth testing flow
For **Cline OAuth** (SDK local callback): The SDK starts a local HTTP server, the auth URL
is captured. To complete: open the captured URL in a real browser (it redirects back to the
SDK's callback server), OR extract the callback port and `curl http://127.0.0.1:PORT/callback?code=...`.
For **MCP/Provider OAuth** (vscode:// URI): The redirect goes to a vscode:// URI.
`oauth.simulate_callback` only *builds* the URI — it does not deliver it, and the ESM
extension host can't `require()` the handler. To actually deliver the callback, call the
debug-only hook via `ext.evaluate` (with `awaitPromise: true`):
`globalThis.__clineHandleUri("vscode://saoudrizwan.claude-dev/...?code=...&state=...")`.
It runs the same `SharedUriHandler.handleUri` as VSCode's real URI handler and exists only
when `CLINE_CAPTURE_BROWSER` is set (the harness always sets it; never ships in prod).
For end-to-end MCP OAuth, get a real `code` from the local MCP OAuth test server
(`npm run dev:mcp-oauth-test-server`).
## Navigating Views — Use Commands, Not Clicks
Don't try to find/click small sidebar icons. Use VSCode commands via command palette.
Registered in `src/registry.ts`:
| Command | View |
|---------|------|
| `cline.accountButtonClicked` | Account / sign-in |
| `cline.historyButtonClicked` | Task history |
| `cline.settingsButtonClicked` | Settings |
| `cline.mcpButtonClicked` | MCP servers |
| `cline.plusButtonClicked` | New task (chat) |
| `cline.worktreesButtonClicked` | Worktrees |
```bash
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
```
## Key commands
All via `POST localhost:19229/api` with `{"method":"...", "params":{...}}`:
- **`launch`** / **`shutdown`** — lifecycle
- **`ui.screenshot`** — screenshot to `/tmp/cline-debug/`; returns `{path}`**use `read_file` on the path to examine, do NOT `open` the file** (Preview.app covers the VSCode window)
- **`ui.open_sidebar`** — open the Cline sidebar
- **`ext.set_breakpoint`** `{file, line, condition?}` — breakpoint by source file (sourcemap-resolved)
- **`ext.evaluate`** `{expression, callFrameId?}` — eval in extension host
- **`ext.resume`** / **`ext.step_over`** / **`ext.step_into`** — stepping
- **`ext.call_stack`** — inspect when paused
- **`web.evaluate`** `{expression}` — eval in webview
- **`web.post_message`** `{message}` — send postMessage to extension host via exposed vsCodeApi
- **`wait_for_pause`** `{timeout?}` — block until breakpoint hit
- **`ui.locator`** `{role?, testId?, text?, frame?}` — Playwright locator (auto-retries on stale sidebar frame)
- **`ui.react_input`** `{text, selector?, clear?, submit?}` — set React textarea value via `execCommand('insertText')`; works reliably across multiple tasks
- **`ui.send_message`** `{text, images?, files?, responseType?}` — send chat message bypassing the textarea entirely (via gRPC postMessage)
- **`ui.command_palette`** `{command}` — run VSCode command
## Typical Session
```bash
# 1. Launch
curl localhost:19229/api -d '{"method":"launch","params":{"skipBuild":true}}'
# 2. Open sidebar + dismiss overlays (ALWAYS do this first)
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
curl localhost:19229/api -d '{"method":"web.evaluate","params":{"expression":"document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
# 3. Navigate to view
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
# 4. Check captured OAuth URLs if testing auth
curl localhost:19229/api -d '{"method":"oauth.captured_urls"}'
# 5. Verify
curl localhost:19229/api -d '{"method":"ui.screenshot"}'
```
## Caveats
- **⚠️ Dismiss promotional overlays FIRST**: On fresh launches, full-screen promo overlays block the sidebar. **Dismiss immediately after `ui.open_sidebar`**, before any other interaction or screenshot. May need to run twice:
```bash
curl localhost:19229/api -d '{"method": "ui.open_sidebar"}'
curl localhost:19229/api -d '{"method": "web.evaluate", "params": {"expression": "document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
```
- **Screenshots — don't open the file**: `ui.screenshot` and `ui.sidebar_screenshot` save PNGs to `/tmp/cline-debug/` and return the `{path}`. Use `read_file` on that path to examine screenshots. Running `open <path>` launches Preview.app on macOS which covers the VSCode window.
- **Scripts count = 0 after launch**: CDP connects after extension host starts, so scripts parsed during startup aren't tracked. Breakpoints still work via sourcemap resolution.
- **Port 9230**: Extension host inspector. If another VSCode instance uses this port, the harness will fail to connect. Kill other debug instances first.
- **macOS only** for now (Playwright Electron launch behavior).
- **Webview CDP**: `connect_webview` may fail depending on Electron version. `web.evaluate` still works via Playwright's `frame.evaluate()` fallback.
- **Sourcemap paths**: esbuild outputs relative paths like `../src/extension.ts` in the sourcemap. The resolver handles this, but if a file isn't found, use `ext.source_files` to see exact paths.
- **OAuth with fake codes**: Browser capture intercepts the URL but doesn't provide a valid auth code. For real OAuth testing, open the captured URL in a browser. For unit testing, mock the token exchange.
See `src/dev/debug-harness/README.md` for full API reference.
+1
View File
@@ -410,6 +410,7 @@
"test:coverage": "vscode-test --coverage",
"test:sca-server": "npx tsx watch scripts/test-standalone-core-api-server.ts",
"test:tp-orchestrator": "npx tsx scripts/testing-platform-orchestrator.ts",
"dev:mcp-oauth-test-server": "npx tsx src/dev/mcp-oauth-test-server/server.ts",
"e2e": "playwright test -c playwright.config.ts",
"test:e2e:build": "vsce package --allow-package-secrets sendgrid --out dist/e2e.vsix",
"test:e2e": "playwright install && npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
+471
View File
@@ -0,0 +1,471 @@
# Debug Harness
An HTTP-controlled debug server for the Cline VSCode extension. Provides
programmatic access to:
- **Extension host debugging** (Node.js): breakpoints, evaluate, step, pause/resume via CDP
- **Webview debugging** (Chrome): breakpoints, evaluate via CDP
- **UI automation**: click, type, screenshot, open sidebar via Playwright
- **Sourcemap resolution**: set breakpoints by original source file + line
- **Data isolation**: separate `~/.cline2` profile so debugee doesn't interfere with debugger
- **OAuth testing**: browser URL capture, token inspection, callback simulation
Designed to be driven from an agentic loop via `curl` commands.
## Quick Start
```bash
# Terminal 1: Start the debug harness server
npx tsx src/dev/debug-harness/server.ts --auto-launch --skip-build
# Terminal 2: Interact via curl
curl localhost:19229/api -d '{"method":"status"}'
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
curl localhost:19229/api -d '{"method":"ui.screenshot"}'
```
## Server Options
```
npx tsx src/dev/debug-harness/server.ts [options]
Options:
--skip-build Skip building extension/webview (use existing dist/)
--auto-launch Automatically launch VSCode on startup
--workspace PATH Workspace directory to open (default: /tmp/cline-debug-workspace)
--port PORT Server port (default: 19229)
--cline-dir PATH Override the debugee's CLINE_DIR (default: ~/.cline2)
```
## Full Build + Launch (first time)
```bash
# This builds protos, extension (unminified+sourcemaps), webview (unminified+sourcemaps),
# downloads VSCode, launches it, and connects CDP to the extension host.
npx tsx src/dev/debug-harness/server.ts --auto-launch
```
## Data Isolation
The debugee runs with `CLINE_DIR=~/.cline2` by default, keeping its data
separate from your real `~/.cline`. This prevents:
- Logging out of the debugger when the debugee logs out
- Task history, API keys, and settings leaking between instances
- State corruption from shared secrets.json
The isolated CLINE_DIR is reported in `status()` and `launch()` responses:
```bash
curl localhost:19229/api -d '{"method":"status"}'
# → { "clineDir": "/Users/you/.cline2", ... }
```
To use a different directory: `--cline-dir /tmp/test-cline-dir`
## Browser Capture & OAuth Testing
When the debug harness launches VSCode, it sets `CLINE_CAPTURE_BROWSER=1`
which intercepts all `openExternal()` calls in the debugee. Instead of
opening a real browser, URLs are:
1. **Logged to disk** at `$CLINE_DIR/data/debug-captured-urls.jsonl`
2. **POSTed in real-time** to the debug harness server at `/captured-url`
3. **Queryable** via the `oauth.captured_urls` API method
### OAuth API
| Method | Params | Description |
|--------|--------|-------------|
| `oauth.captured_urls` | `{clear?}` | Get URLs the debugee tried to open (captured by browser interception) |
| `oauth.read_stored_token` | | Read auth token presence from debugee's secrets.json |
| `oauth.simulate_callback` | `{path, code?, state?, provider?, token?}` | Build a vscode:// callback URI (for MCP/provider OAuth) |
| `oauth.read_captured_urls_file` | | Read the on-disk JSONL file of captured URLs |
### Testing Cline OAuth (login flow)
The Cline OAuth flow uses the SDK's local callback server. When the user
clicks "Login", the SDK:
1. Starts a local HTTP server on a random port
2. Calls `openExternal(authorizationUrl)` — which we capture
3. The user authenticates in the browser — which we need to simulate
4. The provider redirects to the local callback server with `?code=...`
5. The SDK captures the code and exchanges it for tokens
**To test this flow:**
```bash
# 1. Click "Login" in the debugee's sidebar
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
# Dismiss overlays first (see "Dismissing Promotional Overlays" below)
curl localhost:19229/api -d '{"method":"ui.locator","params":{"text":"Login to Cline","frame":"sidebar","action":"click"}}'
# 2. Check captured URLs to find the authorization URL
curl localhost:19229/api -d '{"method":"oauth.captured_urls"}'
# → { "urls": [{ "url": "https://api.cline.bot/auth/authorize?callback_url=http://127.0.0.1:PORT/..." }] }
# 3. The authorization URL has a callback_url pointing to the SDK's local server.
# To complete the flow, you need to either:
# a. Open the authorization URL in a real browser (it will redirect back
# to the SDK's local callback server automatically)
# b. Simulate the redirect by extracting the callback_url and making
# a curl request to it with a code parameter:
curl "http://127.0.0.1:PORT/auth/callback?code=TEST_CODE" 2>/dev/null
# 4. Verify the token was stored
curl localhost:19229/api -d '{"method":"oauth.read_stored_token"}'
# → { "found": true, "hasAccountId": true, "keys": ["cline:clineAccountId"] }
# 5. Take a screenshot to verify the UI shows authenticated state
curl localhost:19229/api -d '{"method":"ui.screenshot"}'
```
### Testing MCP OAuth
MCP servers that require OAuth use a different flow: the browser redirects
to a `vscode://` URI handled by the extension's URI handler. The auth provider
(e.g. Linear) decides the `code`; for end-to-end testing, pair this with the
local MCP OAuth test server (`npm run dev:mcp-oauth-test-server`, see
`src/dev/mcp-oauth-test-server/README.md`), which mints real codes/tokens.
```bash
# 1. Trigger MCP OAuth (e.g., click "Authenticate" button for a server)
# 2. Check captured URLs for the authorization URL
curl localhost:19229/api -d '{"method":"oauth.captured_urls"}'
# The authorize URL contains redirect_uri=vscode://saoudrizwan.claude-dev/mcp-auth/callback/HASH
# 3. Get a real authorization code from the auth server, e.g. by following the
# captured authorize URL (the test server auto-approves and 302s to the
# vscode:// callback carrying ?code=...&state=...):
curl -s -D - -o /dev/null "<captured-authorize-url>" | grep -i '^location:'
# 4. DELIVER the vscode:// callback to the extension. VSCode only routes real
# vscode:// URIs to the registered handler, which the harness can't
# synthesize — and the extension host is ESM, so you can't require() the
# handler module. Instead, call the __clineHandleUri hook (see below):
curl localhost:19229/api -d '{
"method": "ext.evaluate",
"params": {
"awaitPromise": true,
"expression": "globalThis.__clineHandleUri(\"vscode://saoudrizwan.claude-dev/mcp-auth/callback/HASH?code=REAL_CODE&state=SAVED_STATE\")"
}
}'
# 5. Verify tokens were stored
curl localhost:19229/api -d '{"method":"oauth.read_stored_token"}'
```
> **`globalThis.__clineHandleUri(url)` — debug-only URI delivery hook.**
> Registered in `src/extension.ts` during activation, **only** when
> `CLINE_CAPTURE_BROWSER` is set (which the harness always sets), so it never
> ships in production. It calls the same `SharedUriHandler.handleUri(url)` that
> VSCode's real `registerUriHandler` invokes, returning a `Promise<boolean>`
> (pass `awaitPromise: true`). Use it for any `vscode://` callback — MCP,
> OpenRouter, `/auth`, etc. `oauth.simulate_callback` only *builds* the URI; this
> hook actually *delivers* it.
### Testing Provider OAuth (OpenRouter, etc.)
```bash
# 1. Trigger provider login (e.g., "Get OpenRouter API Key" button)
# 2. Check captured URLs
curl localhost:19229/api -d '{"method":"oauth.captured_urls"}'
# 3. Simulate the redirect callback
curl localhost:19229/api -d '{
"method": "oauth.simulate_callback",
"params": {"path": "/openrouter", "code": "TEST_CODE"}
}'
```
## Practical Tips
### Dismissing Promotional Overlays
On fresh launches, one or more full-screen promo overlays may appear and
block all sidebar interactions. **Always dismiss them immediately after
opening the sidebar**, before any other interaction.
```bash
# Open sidebar first
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
# Dismiss ALL overlays (may need to run twice for multiple overlays)
curl localhost:19229/api -d '{"method":"web.evaluate","params":{"expression":"document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
```
### Navigating Between Views Using Commands
Instead of trying to find and click small icons in the sidebar header,
use VSCode commands via the command palette. These are registered in
`src/registry.ts`:
| Command | What it opens |
|---------|--------------|
| `cline.accountButtonClicked` | Account / sign-in view |
| `cline.historyButtonClicked` | Task history view |
| `cline.settingsButtonClicked` | Settings view |
| `cline.mcpButtonClicked` | MCP servers view |
| `cline.plusButtonClicked` | New task (chat view) |
| `cline.worktreesButtonClicked` | Worktrees view |
```bash
# Navigate to account view
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
# Navigate to history view
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.historyButtonClicked"}}'
# Navigate to settings view
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.settingsButtonClicked"}}'
# Navigate to MCP view
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.mcpButtonClicked"}}'
# Start a new task (return to chat view)
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.plusButtonClicked"}}'
```
### Typical Session Workflow
```bash
# 1. Launch (if not using --auto-launch)
curl localhost:19229/api -d '{"method":"launch","params":{"skipBuild":true}}'
# 2. Open sidebar and dismiss overlays
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
curl localhost:19229/api -d '{"method":"web.evaluate","params":{"expression":"document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
# 3. Check status (verify CLINE_DIR, browser capture, etc.)
curl localhost:19229/api -d '{"method":"status"}'
# 4. Navigate to the view you need
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
# 5. Interact and verify
curl localhost:19229/api -d '{"method":"ui.screenshot"}'
# 6. For OAuth flows, check captured URLs
curl localhost:19229/api -d '{"method":"oauth.captured_urls"}'
# 7. When done, shut down
curl localhost:19229/api -d '{"method":"shutdown"}'
```
## API
All commands are sent as `POST /api` with JSON body `{"method": "...", "params": {...}}`.
Responses: `{"result": {...}}` on success, `{"error": "..."}` on failure.
Convenience endpoints:
- `GET /health``{"status": "ok"}`
- `GET /status` — Full harness status
- `POST /captured-url` — Internal: receives captured browser URLs from debugee
### Lifecycle
| Method | Params | Description |
|--------|--------|-------------|
| `launch` | `{workspace?, skipBuild?}` | Build + launch VSCode |
| `shutdown` | | Close VSCode and CDP connections |
| `status` | | Current state of all components |
| `connect_webview` | | Connect CDP to the webview (call after sidebar is open) |
### Extension Host Debugging (Node.js)
| Method | Params | Description |
|--------|--------|-------------|
| `ext.set_breakpoint` | `{file, line, column?, condition?}` | Set breakpoint by source file (sourcemap-resolved) |
| `ext.set_breakpoint_raw` | `{url?, urlRegex?, scriptId?, lineNumber, columnNumber?, condition?}` | Set breakpoint with raw CDP params |
| `ext.remove_breakpoint` | `{breakpointId}` | Remove a breakpoint |
| `ext.evaluate` | `{expression, callFrameId?}` | Evaluate expression (at breakpoint or global) |
| `ext.pause` | | Pause execution |
| `ext.resume` | | Resume execution |
| `ext.step_over` | | Step over |
| `ext.step_into` | | Step into |
| `ext.step_out` | | Step out |
| `ext.call_stack` | | Get call stack (when paused) |
| `ext.scripts` | `{filter?}` | List loaded scripts |
| `ext.source_files` | | List source files from sourcemap |
| `ext.get_properties` | `{objectId}` | Get object properties |
| `ext.get_script_source` | `{scriptId}` | Get script source text |
### Webview Debugging (Chrome)
Call `connect_webview` first after the sidebar is open (only needed for breakpoints/stepping).
| Method | Params | Description |
|--------|--------|-------------|
| `web.set_breakpoint` | `{url, line, column?, condition?}` | Set breakpoint by URL pattern |
| `web.remove_breakpoint` | `{breakpointId}` | Remove a breakpoint |
| `web.evaluate` | `{expression, callFrameId?}` | Evaluate in sidebar (Playwright) or at breakpoint (CDP) |
| `web.post_message` | `{message}` | Send a postMessage to the extension host via exposed vsCodeApi |
| `web.pause` | | Pause |
| `web.resume` | | Resume |
| `web.step_over/into/out` | | Stepping |
### UI Automation (Playwright)
| Method | Params | Description |
|--------|--------|-------------|
| `ui.screenshot` | `{fullPage?}` | Take screenshot → returns `{path}` (use `read_file` on the path, don't `open` the file) |
| `ui.sidebar_screenshot` | | Screenshot focused on sidebar → returns `{path}` |
| `ui.click` | `{selector, frame?, delay?}` | Click element (`frame: "sidebar"` for webview) |
| `ui.fill` | `{selector, text, frame?}` | Fill input |
| `ui.press` | `{key}` | Press key (e.g., "Enter", "Meta+Shift+p") |
| `ui.type` | `{text, delay?}` | Type text |
| `ui.open_sidebar` | | Open the Cline sidebar |
| `ui.frames` | | List all frames |
| `ui.wait_for_selector` | `{selector, frame?, timeout?}` | Wait for element |
| `ui.command_palette` | `{command}` | Open command palette and run command |
| `ui.get_text` | `{selector, frame?}` | Get element text |
| `ui.locator` | `{role?, name?, testId?, text?, frame?, action?, value?}` | Rich Playwright locator (auto-retries with frame refresh for sidebar) |
| `ui.react_input` | `{text, selector?, clear?, submit?}` | Set React-controlled textarea value via `execCommand('insertText')` |
| `ui.send_message` | `{text, images?, files?, responseType?}` | Send a chat message bypassing the textarea (via gRPC postMessage) |
### OAuth & Browser Capture
| Method | Params | Description |
|--------|--------|-------------|
| `oauth.captured_urls` | `{clear?}` | Get URLs the debugee tried to open in a browser |
| `oauth.read_stored_token` | | Check auth token presence in debugee's secrets.json |
| `oauth.simulate_callback` | `{path, code?, state?, provider?, token?}` | Build a vscode:// callback URI for MCP/provider OAuth (does NOT deliver it) |
| `oauth.read_captured_urls_file` | | Read on-disk JSONL log of captured URLs |
To actually **deliver** a `vscode://` callback to the extension, call the
debug-only hook via `ext.evaluate` (with `awaitPromise: true`):
`globalThis.__clineHandleUri("vscode://saoudrizwan.claude-dev/...?code=...&state=...")`.
It invokes the same `SharedUriHandler.handleUri` as VSCode's real URI handler
and is registered only when `CLINE_CAPTURE_BROWSER` is set (never in prod). See
"Testing MCP OAuth" above.
### Combined
| Method | Params | Description |
|--------|--------|-------------|
| `wait_for_pause` | `{timeout?}` | Block until any debuggee hits a breakpoint |
## Example Workflows
### 1. Set a breakpoint and observe execution
```bash
curl localhost:19229/api -d '{
"method": "ext.set_breakpoint",
"params": {"file": "src/extension.ts", "line": 25}
}'
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
curl localhost:19229/api -d '{"method":"wait_for_pause","params":{"timeout":10000}}'
curl localhost:19229/api -d '{"method":"ext.call_stack"}'
curl localhost:19229/api -d '{"method":"ext.resume"}'
```
### 2. Test OAuth login flow
```bash
# Dismiss overlays, then click Login
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
curl localhost:19229/api -d '{"method":"web.evaluate","params":{"expression":"document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
curl localhost:19229/api -d '{"method":"ui.locator","params":{"text":"Login to Cline","frame":"sidebar","action":"click"}}'
# Check what URL was captured
curl localhost:19229/api -d '{"method":"oauth.captured_urls"}'
# The URL contains callback_url=http://127.0.0.1:PORT/...
# Open it in a real browser to complete auth, or simulate:
# (extract the port from the captured URL first)
curl "http://127.0.0.1:PORT/callback?code=real_or_test_code"
# Verify token stored
curl localhost:19229/api -d '{"method":"oauth.read_stored_token"}'
```
### 3. Navigate to Account view and check auth state
```bash
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
curl localhost:19229/api -d '{"method":"ui.screenshot"}'
```
## How It Works
1. **Build**: esbuild bundles `src/extension.ts``dist/extension.js` (unminified, with
sourcemaps). Vite builds `webview-ui/``webview-ui/build/` (unminified, inline sourcemaps).
2. **Launch**: Uses `@vscode/test-electron` to download VSCode, then Playwright's
`_electron.launch()` to start it with `--inspect-extensions=9230` for Node.js inspector
access and `--extensionDevelopmentPath` to load our extension.
3. **Data Isolation**: Sets `CLINE_DIR=~/.cline2` in the debugee's environment, ensuring
the debugee uses a completely separate data directory from the user's real `~/.cline`.
The `createStorageContext()` function in `src/shared/storage/storage-context.ts` reads
this environment variable to determine where to store globalState.json, secrets.json,
task history, and workspace state.
4. **Browser Capture**: Sets `CLINE_CAPTURE_BROWSER=1` and `CLINE_DEBUG_HARNESS_PORT=19229`
in the debugee's environment. When `openExternal()` is called in `src/utils/env.ts`, it
checks for `CLINE_CAPTURE_BROWSER` and, if set, logs the URL to a JSONL file and POSTs
it to the debug harness server instead of opening a real browser. This is essential for
testing OAuth flows without a visible browser.
5. **Extension CDP**: Connects to the extension host's V8 inspector via WebSocket on port 9230.
Enables `Debugger` and `Runtime` domains. Tracks `scriptParsed` events and `paused`/`resumed`
state.
6. **Sourcemap Resolution**: When setting breakpoints by source file, reads `dist/extension.js.map`
and resolves the original file + line to the generated (bundled) file + line using VLQ-decoded
sourcemap mappings.
7. **Webview CDP**: After the sidebar loads, creates a Playwright CDP session for the webview
frame, enabling debugger commands. Falls back to `frame.evaluate()` for expression evaluation.
8. **UI Automation**: Playwright's Page/Frame APIs provide click, fill, type, screenshot, locator
queries, and more. The sidebar webview is accessed as a Frame within the VSCode window.
## Caveats
**⚠️ Data Isolation**: The debugee uses `~/.cline2` by default. If you need to test with
existing data from your real `~/.cline`, copy it: `cp -r ~/.cline ~/.cline2`. Be aware that
secrets (API keys, auth tokens) will be shared if you do this.
**⚠️ "Introducing Cline Kanban" overlay**: On fresh launches, a full-screen promo overlay may
appear in the sidebar. It blocks all interactions and makes screenshots useless. **Dismiss it
immediately after opening the sidebar**, before doing anything else:
```bash
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
curl localhost:19229/api -d '{"method":"web.evaluate","params":{"expression":"document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
```
**Screenshots**: `ui.screenshot` and `ui.sidebar_screenshot` save PNG files to `/tmp/cline-debug/`
and return `{path}` in the response. **Do NOT `open` the file** — on macOS this launches Preview.app
which covers the VSCode window. Use `read_file` on the returned path to examine the image.
**OAuth with real providers**: The browser capture only intercepts the URL that the debugee tries
to open. For Cline OAuth, the SDK's local callback server is still running and will accept
redirects. For provider OAuth (OpenRouter, MCP), you need to simulate the `vscode://` callback
URI — see the OAuth testing section above.
**Cline OAuth with invalid codes**: If you simulate the OAuth callback with a fake code, the
SDK's token exchange will fail (the provider won't recognize the code). You need either a real
authorization code (obtained by completing the flow in a browser) or a way to mock the token
exchange endpoint.
## Troubleshooting
**"Inspector not available on port 9230"**: The extension host hasn't started yet. Wait longer
or check that the extension built correctly.
**"Sidebar frame not found"**: The Cline sidebar isn't open. Use `ui.open_sidebar` first.
**"Webview CDP not connected"**: Call `connect_webview` after the sidebar is open. If it fails,
webview breakpoints aren't available, but `web.evaluate` still works via Playwright.
**Sourcemap resolution fails**: Use `ext.source_files` to see what paths the sourcemap contains,
then use `ext.set_breakpoint_raw` with a `urlRegex` pattern.
**Screenshots directory**: Saved to `/tmp/cline-debug/` (configurable via SCREENSHOT_DIR).
**Debugee still uses ~/.cline**: Check that `CLINE_DIR` appears in the `status()` response.
If it's missing, the debugee may have been launched before the harness set the env var.
Shutdown and relaunch.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
# MCP OAuth Test Server
A self-contained, **zero-dependency** (Node `http` only) server for exercising
and debugging Cline's MCP OAuth flow locally.
It plays both roles that a real remote MCP server + its OAuth provider play:
1. **OAuth 2.0 Authorization Server** (RFC 8414 / RFC 7591 DCR / RFC 7636 PKCE):
- `GET /.well-known/oauth-protected-resource`
- `GET /.well-known/oauth-authorization-server`
- `POST /register` — Dynamic Client Registration
- `GET /authorize` — interactive **Approve / Deny** consent page
- `POST /token``authorization_code` + `refresh_token` grants
2. **MCP StreamableHTTP resource server**:
- `POST /mcp` — returns `401 + WWW-Authenticate: Bearer resource_metadata="..."`
until authenticated (this is what triggers Cline's OAuth flow), then a
minimal `initialize` response.
The endpoint shapes match what `@modelcontextprotocol/sdk` v1.25.x discovers.
## Why
Exercises MCP OAuth failure modes without a real remote server:
- **State expiry** — Cline's `McpOAuthManager` enforces a state lifetime
(`MCP_OAUTH_STATE_EXPIRY_MS`). If the callback returns after the window, it's
rejected. Use `--slow-authorize` to push past it.
- **Denial** — the consent page's Deny button (or `--auto-deny`) redirects back
with `error=access_denied`, so you can observe how Cline handles a denial.
## Run interactively
```bash
cd apps/vscode
npm run dev:mcp-oauth-test-server -- --verbose
# or directly:
npx tsx src/dev/mcp-oauth-test-server/server.ts --verbose
```
Then in Cline, add an MCP server (StreamableHTTP) pointing at:
```
http://127.0.0.1:7777/mcp
```
Click **Authenticate**. A browser opens the `/authorize` consent page where you
can click **Approve** or **Deny**.
## Options
| Flag | Description |
|------|-------------|
| `--port <n>` | Port to listen on (default `7777`, env `MCP_OAUTH_TEST_PORT`) |
| `--auto-approve` | Skip consent; always approve |
| `--auto-deny` | Skip consent; always deny (simulate "Deny" click) |
| `--code-ttl <ms>` | Authorization-code lifetime (default `600000`). Set small to force expiry. |
| `--slow-authorize <ms>` | Delay `/authorize` response (simulate a slow user) |
| `--verbose`, `-v` | Log every request |
| `--help`, `-h` | Show help |
## Reproducing specific bugs
**"OAuth state expired" race** — make the user take longer than Cline's
10-minute state window:
```bash
npx tsx src/dev/mcp-oauth-test-server/server.ts --slow-authorize 605000 --verbose
```
**Denied redirect** — always deny so every redirect carries `access_denied`:
```bash
npx tsx src/dev/mcp-oauth-test-server/server.ts --auto-deny --verbose
```
## Debug-harness integration
The server can be driven from the debug harness without a real browser:
- `TestServer`, `TestServerOptions`, and `parseArgs` are exported, so the
harness can `import` and start an instance in-process (the module only
auto-starts when run as the main script).
- Under `CLINE_CAPTURE_BROWSER=1` (see `src/utils/env.ts`), the authorization URL
Cline tries to open is captured instead of launched. The harness `curl`s the
captured `/authorize` URL (append `decision=approve` or `decision=deny` to
skip the consent page) to get the `vscode://` callback, then delivers it to the
extension via `globalThis.__clineHandleUri(...)` (see the debug harness README,
"Testing MCP OAuth").
## Manual flow (no browser, for scripting)
```bash
PORT=7777
# 1. Discover
curl -s localhost:$PORT/.well-known/oauth-authorization-server
# 2. Register a client
CID=$(curl -s -X POST localhost:$PORT/register -H 'Content-Type: application/json' \
-d '{"redirect_uris":["http://127.0.0.1:48801/cb"]}' \
| node -e "process.stdin.on('data',d=>console.log(JSON.parse(d).client_id))")
# 3. Approve and capture the code from the redirect Location header
# (append &decision=approve to skip the HTML page)
```
@@ -0,0 +1,637 @@
#!/usr/bin/env node
/**
* MCP OAuth Test Server
* =====================
*
* A self-contained, zero-dependency (Node `http` only) test server for
* exercising and debugging Cline's MCP OAuth flow locally, including failure
* modes such as:
*
* - State expiry — the OAuth `state` times out before the callback returns
* (e.g. a slow user, or a callback that arrives with a stale state).
* - Denial — the user clicks "Deny" on the consent screen, so the redirect
* comes back with `error=access_denied`.
*
* It plays BOTH roles that a real remote MCP server + its OAuth provider play:
*
* 1. OAuth 2.0 Authorization Server (RFC 8414 / RFC 7591 / RFC 7636 PKCE):
* GET /.well-known/oauth-protected-resource[/<path>]
* GET /.well-known/oauth-authorization-server[/<path>]
* POST /register (Dynamic Client Registration)
* GET /authorize (consent screen — Approve / Deny)
* POST /token (authorization_code + refresh_token grants)
*
* 2. MCP StreamableHTTP resource server:
* POST /mcp (returns 401 + WWW-Authenticate until authed,
* then a minimal initialize response)
*
* The endpoint shapes match what `@modelcontextprotocol/sdk` v1.25.x discovers
* (see node_modules/@modelcontextprotocol/sdk/dist/esm/client/auth.js).
*
* Fault-injection knobs (CLI flags / env) let us reproduce specific bugs:
*
* --port <n> Port to listen on (default 7777, env MCP_OAUTH_TEST_PORT)
* --auto-approve Skip the consent screen; always approve (default: off,
* shows an interactive Approve/Deny page)
* --auto-deny Skip the consent screen; always deny (redirect comes
* back with error=access_denied)
* --code-ttl <ms> How long an issued authorization code stays valid
* before /token rejects it (default 600000 = 10 min).
* Set small (e.g. 1000) to exercise expiry races.
* --slow-authorize <ms> Delay before /authorize responds, to simulate a user
* who takes a long time on the consent screen (useful
* for exercising Cline's OAuth state-expiry window).
* --verbose Log every request.
*
* Run interactively:
* cd apps/vscode
* npx tsx src/dev/mcp-oauth-test-server/server.ts --verbose
*
* Then add an MCP server to Cline pointing at:
* http://127.0.0.1:7777/mcp (type: streamableHttp)
*
* Click "Authenticate" in Cline; a browser opens the /authorize consent page.
*/
import crypto from "node:crypto"
import http from "node:http"
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
interface TestServerOptions {
port: number
host: string
autoApprove: boolean
autoDeny: boolean
codeTtlMs: number
slowAuthorizeMs: number
verbose: boolean
}
function parseArgs(argv: string[]): TestServerOptions {
const opts: TestServerOptions = {
port: Number(process.env.MCP_OAUTH_TEST_PORT) || 7777,
host: "127.0.0.1",
autoApprove: false,
autoDeny: false,
codeTtlMs: 10 * 60 * 1000,
slowAuthorizeMs: 0,
verbose: false,
}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
switch (arg) {
case "--port":
opts.port = Number(argv[++i])
break
case "--auto-approve":
opts.autoApprove = true
break
case "--auto-deny":
opts.autoDeny = true
break
case "--code-ttl":
opts.codeTtlMs = Number(argv[++i])
break
case "--slow-authorize":
opts.slowAuthorizeMs = Number(argv[++i])
break
case "--verbose":
case "-v":
opts.verbose = true
break
case "--help":
case "-h":
printUsageAndExit()
break
default:
console.error(`Unknown argument: ${arg}`)
printUsageAndExit(1)
}
}
if (opts.autoApprove && opts.autoDeny) {
console.error("Cannot set both --auto-approve and --auto-deny")
process.exit(1)
}
return opts
}
function printUsageAndExit(code = 0): never {
console.log(`MCP OAuth Test Server
Usage: npx tsx src/dev/mcp-oauth-test-server/server.ts [options]
Options:
--port <n> Port to listen on (default 7777)
--auto-approve Always approve authorization (no consent screen)
--auto-deny Always deny authorization (simulate "Deny" click)
--code-ttl <ms> Authorization code lifetime (default 600000)
--slow-authorize <ms> Delay /authorize response by <ms>
--verbose, -v Log every request
--help, -h Show this help
`)
process.exit(code)
}
// ---------------------------------------------------------------------------
// In-memory OAuth state
// ---------------------------------------------------------------------------
interface RegisteredClient {
client_id: string
client_secret?: string
redirect_uris: string[]
client_name?: string
token_endpoint_auth_method?: string
}
interface PendingAuthCode {
code: string
clientId: string
redirectUri: string
codeChallenge?: string
codeChallengeMethod?: string
/** OAuth `state` the client sent on /authorize — echoed back on redirect. */
state?: string
issuedAt: number
resource?: string
}
interface IssuedToken {
accessToken: string
refreshToken: string
clientId: string
issuedAt: number
}
class TestServer {
private readonly opts: TestServerOptions
private readonly clients = new Map<string, RegisteredClient>()
private readonly authCodes = new Map<string, PendingAuthCode>()
private readonly refreshTokens = new Map<string, IssuedToken>()
private server: http.Server | null = null
constructor(opts: TestServerOptions) {
this.opts = opts
}
private get baseUrl(): string {
return `http://${this.opts.host}:${this.opts.port}`
}
private log(...args: unknown[]): void {
if (this.opts.verbose) {
console.log("[mcp-oauth-test]", ...args)
}
}
start(): void {
this.server = http.createServer((req, res) => {
this.handleRequest(req, res).catch((err) => {
console.error("[mcp-oauth-test] Unhandled error:", err)
if (!res.headersSent) {
this.json(res, 500, { error: "server_error", error_description: String(err) })
}
})
})
this.server.listen(this.opts.port, this.opts.host, () => {
console.log(`MCP OAuth Test Server listening on ${this.baseUrl}`)
console.log(` MCP endpoint: ${this.baseUrl}/mcp (type: streamableHttp)`)
console.log(` Authorize page: ${this.baseUrl}/authorize`)
const mode = this.opts.autoApprove ? "auto-approve" : this.opts.autoDeny ? "auto-deny" : "interactive consent"
console.log(` Mode: ${mode}, code TTL: ${this.opts.codeTtlMs}ms`)
})
}
stop(): void {
this.server?.close()
this.server = null
}
// ------------------------------------------------------------------ routing
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const url = new URL(req.url || "/", this.baseUrl)
this.log(req.method, url.pathname + url.search)
// Discovery: protected-resource metadata (RFC 9728). The SDK probes both
// `/.well-known/oauth-protected-resource` and a path-suffixed variant.
if (url.pathname.startsWith("/.well-known/oauth-protected-resource")) {
return this.handleProtectedResourceMetadata(res)
}
// Discovery: authorization-server metadata (RFC 8414).
if (
url.pathname.startsWith("/.well-known/oauth-authorization-server") ||
url.pathname.startsWith("/.well-known/openid-configuration")
) {
return this.handleAuthServerMetadata(res)
}
switch (url.pathname) {
case "/register":
return this.handleRegister(req, res)
case "/authorize":
return this.handleAuthorize(url, res)
case "/token":
return this.handleToken(req, res)
case "/mcp":
return this.handleMcp(req, res)
case "/":
return this.text(res, 200, "MCP OAuth Test Server. See /mcp and /authorize.")
default:
return this.json(res, 404, { error: "not_found", path: url.pathname })
}
}
// ------------------------------------------------------------- discovery
private handleProtectedResourceMetadata(res: http.ServerResponse): void {
this.json(res, 200, {
resource: `${this.baseUrl}/mcp`,
authorization_servers: [this.baseUrl],
scopes_supported: ["mcp"],
bearer_methods_supported: ["header"],
})
}
private handleAuthServerMetadata(res: http.ServerResponse): void {
this.json(res, 200, {
issuer: this.baseUrl,
authorization_endpoint: `${this.baseUrl}/authorize`,
token_endpoint: `${this.baseUrl}/token`,
registration_endpoint: `${this.baseUrl}/register`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none", "client_secret_post"],
scopes_supported: ["mcp"],
})
}
// --------------------------------------------------- dynamic registration
private async handleRegister(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
if (req.method !== "POST") {
return this.json(res, 405, { error: "method_not_allowed" })
}
const body = await this.readJsonBody(req)
const rawRedirectUris = body?.redirect_uris
const redirectUris: string[] = Array.isArray(rawRedirectUris)
? rawRedirectUris.filter((u): u is string => typeof u === "string")
: []
if (redirectUris.length === 0) {
return this.json(res, 400, { error: "invalid_redirect_uri", error_description: "redirect_uris required" })
}
const clientId = `client_${crypto.randomBytes(12).toString("hex")}`
const client: RegisteredClient = {
client_id: clientId,
redirect_uris: redirectUris,
client_name: asString(body?.client_name),
token_endpoint_auth_method: asString(body?.token_endpoint_auth_method) ?? "none",
}
this.clients.set(clientId, client)
this.log("Registered client", clientId, "redirect_uris:", redirectUris)
this.json(res, 201, {
client_id: clientId,
redirect_uris: redirectUris,
client_name: client.client_name,
token_endpoint_auth_method: client.token_endpoint_auth_method,
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
})
}
// ----------------------------------------------------------- authorize
private async handleAuthorize(url: URL, res: http.ServerResponse): Promise<void> {
const clientId = url.searchParams.get("client_id") || ""
const redirectUri = url.searchParams.get("redirect_uri") || ""
const state = url.searchParams.get("state") || undefined
const codeChallenge = url.searchParams.get("code_challenge") || undefined
const codeChallengeMethod = url.searchParams.get("code_challenge_method") || undefined
const resource = url.searchParams.get("resource") || undefined
const decision = url.searchParams.get("decision") // set when posting back from consent page
const client = this.clients.get(clientId)
if (!client) {
return this.text(res, 400, `Unknown client_id: ${clientId}`)
}
if (!client.redirect_uris.includes(redirectUri)) {
// This is the real-world failure when the registered redirect_uri no
// longer matches (e.g. loopback port changed). Surface it clearly.
return this.text(
res,
400,
`redirect_uri "${redirectUri}" is not registered for this client.\nRegistered: ${client.redirect_uris.join(", ")}`,
)
}
if (this.opts.slowAuthorizeMs > 0) {
this.log(`Delaying /authorize by ${this.opts.slowAuthorizeMs}ms`)
await delay(this.opts.slowAuthorizeMs)
}
// Decide approve/deny.
let approved: boolean
if (this.opts.autoApprove) {
approved = true
} else if (this.opts.autoDeny) {
approved = false
} else if (decision === "approve") {
approved = true
} else if (decision === "deny") {
approved = false
} else {
// Show the interactive consent screen.
return this.html(res, 200, this.renderConsentPage(url))
}
if (!approved) {
// RFC 6749 §4.1.2.1 — redirect back with error=access_denied.
const redirect = new URL(redirectUri)
redirect.searchParams.set("error", "access_denied")
redirect.searchParams.set("error_description", "The user denied the authorization request.")
if (state) {
redirect.searchParams.set("state", state)
}
this.log("User DENIED authorization, redirecting to", redirect.toString())
return this.redirect(res, redirect.toString())
}
// Approved: mint an authorization code bound to PKCE + redirect_uri.
const code = crypto.randomBytes(24).toString("hex")
this.authCodes.set(code, {
code,
clientId,
redirectUri,
codeChallenge,
codeChallengeMethod,
state,
issuedAt: Date.now(),
resource,
})
const redirect = new URL(redirectUri)
redirect.searchParams.set("code", code)
if (state) {
redirect.searchParams.set("state", state)
}
this.log("User APPROVED, redirecting to", redirect.toString())
this.redirect(res, redirect.toString())
}
// --------------------------------------------------------------- token
private async handleToken(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
if (req.method !== "POST") {
return this.json(res, 405, { error: "method_not_allowed" })
}
const form = await this.readFormBody(req)
const grantType = form.get("grant_type")
if (grantType === "authorization_code") {
return this.handleAuthorizationCodeGrant(form, res)
}
if (grantType === "refresh_token") {
return this.handleRefreshTokenGrant(form, res)
}
return this.json(res, 400, { error: "unsupported_grant_type", error_description: String(grantType) })
}
private handleAuthorizationCodeGrant(form: URLSearchParams, res: http.ServerResponse): void {
const code = form.get("code") || ""
const redirectUri = form.get("redirect_uri") || ""
const codeVerifier = form.get("code_verifier") || ""
const pending = this.authCodes.get(code)
if (!pending) {
this.json(res, 400, { error: "invalid_grant", error_description: "Unknown or already-used code" })
return
}
// Codes are single-use.
this.authCodes.delete(code)
if (Date.now() - pending.issuedAt > this.opts.codeTtlMs) {
this.log("Authorization code expired")
this.json(res, 400, { error: "invalid_grant", error_description: "Authorization code expired" })
return
}
if (pending.redirectUri !== redirectUri) {
this.json(res, 400, { error: "invalid_grant", error_description: "redirect_uri mismatch" })
return
}
// Verify PKCE (S256).
if (pending.codeChallenge) {
const expected = base64UrlSha256(codeVerifier)
if (expected !== pending.codeChallenge) {
this.json(res, 400, { error: "invalid_grant", error_description: "PKCE verification failed" })
return
}
}
const token = this.issueToken(pending.clientId)
this.log("Issued tokens for client", pending.clientId)
this.json(res, 200, {
access_token: token.accessToken,
token_type: "Bearer",
expires_in: 3600,
refresh_token: token.refreshToken,
scope: "mcp",
})
}
private handleRefreshTokenGrant(form: URLSearchParams, res: http.ServerResponse): void {
const refreshToken = form.get("refresh_token") || ""
const existing = this.refreshTokens.get(refreshToken)
if (!existing) {
this.json(res, 400, { error: "invalid_grant", error_description: "Unknown refresh_token" })
return
}
this.refreshTokens.delete(refreshToken)
const token = this.issueToken(existing.clientId)
this.log("Refreshed tokens for client", existing.clientId)
this.json(res, 200, {
access_token: token.accessToken,
token_type: "Bearer",
expires_in: 3600,
refresh_token: token.refreshToken,
scope: "mcp",
})
}
private issueToken(clientId: string): IssuedToken {
const token: IssuedToken = {
accessToken: `at_${crypto.randomBytes(24).toString("hex")}`,
refreshToken: `rt_${crypto.randomBytes(24).toString("hex")}`,
clientId,
issuedAt: Date.now(),
}
this.refreshTokens.set(token.refreshToken, token)
return token
}
// ----------------------------------------------------------------- MCP
private async handleMcp(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const auth = req.headers.authorization
const hasBearer = typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")
if (!hasBearer) {
// This is what triggers Cline's OAuth flow: 401 + WWW-Authenticate
// with a resource_metadata pointer (RFC 9728).
const metadataUrl = `${this.baseUrl}/.well-known/oauth-protected-resource`
res.setHeader("WWW-Authenticate", `Bearer resource_metadata="${metadataUrl}"`)
return this.json(res, 401, { error: "unauthorized", error_description: "Authentication required" })
}
// Authenticated: respond to a minimal MCP `initialize` so the connection
// succeeds and Cline shows the server as connected.
const body = await this.readJsonBody(req)
const id = body?.id ?? null
if (body?.method === "initialize") {
return this.json(res, 200, {
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "mcp-oauth-test-server", version: "0.1.0" },
},
})
}
// Any other method: empty-ish OK so the SDK doesn't error out.
return this.json(res, 200, { jsonrpc: "2.0", id, result: {} })
}
// ----------------------------------------------------- consent HTML page
private renderConsentPage(url: URL): string {
const approveUrl = new URL(url.toString())
approveUrl.searchParams.set("decision", "approve")
const denyUrl = new URL(url.toString())
denyUrl.searchParams.set("decision", "deny")
const clientId = url.searchParams.get("client_id") || "(unknown)"
const redirectUri = url.searchParams.get("redirect_uri") || "(unknown)"
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MCP OAuth Test — Authorize</title>
<style>
body { font-family: system-ui, sans-serif; background: #1e1e1e; color: #ddd; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { background: #252526; border: 1px solid #3c3c3c; border-radius: 8px; padding: 32px; max-width: 480px; }
h1 { font-size: 1.3rem; margin-top: 0; }
code { background: #333; padding: 2px 6px; border-radius: 4px; font-size: 0.85em; word-break: break-all; }
.row { margin: 12px 0; }
.buttons { margin-top: 24px; display: flex; gap: 12px; }
a.btn { text-decoration: none; padding: 10px 20px; border-radius: 6px; font-weight: 600; }
a.approve { background: #2ea043; color: #fff; }
a.deny { background: #6e2222; color: #fff; }
</style>
</head>
<body>
<div class="card">
<h1>Authorize Cline?</h1>
<p>The MCP OAuth Test Server is asking you to authorize this client.</p>
<div class="row">Client: <code>${escapeHtml(clientId)}</code></div>
<div class="row">Redirect: <code>${escapeHtml(redirectUri)}</code></div>
<div class="buttons">
<a class="btn approve" href="${escapeHtml(approveUrl.toString())}">Approve</a>
<a class="btn deny" href="${escapeHtml(denyUrl.toString())}">Deny</a>
</div>
</div>
</body>
</html>`
}
// --------------------------------------------------------------- helpers
private async readJsonBody(req: http.IncomingMessage): Promise<Record<string, unknown> | undefined> {
const raw = await readBody(req)
if (!raw) {
return undefined
}
try {
return JSON.parse(raw) as Record<string, unknown>
} catch {
return undefined
}
}
private async readFormBody(req: http.IncomingMessage): Promise<URLSearchParams> {
const raw = await readBody(req)
return new URLSearchParams(raw)
}
private json(res: http.ServerResponse, status: number, body: unknown): void {
const payload = JSON.stringify(body)
res.writeHead(status, { "Content-Type": "application/json" })
res.end(payload)
}
private text(res: http.ServerResponse, status: number, body: string): void {
res.writeHead(status, { "Content-Type": "text/plain" })
res.end(body)
}
private html(res: http.ServerResponse, status: number, body: string): void {
res.writeHead(status, { "Content-Type": "text/html" })
res.end(body)
}
private redirect(res: http.ServerResponse, location: string): void {
res.writeHead(302, { Location: location })
res.end()
}
}
// ---------------------------------------------------------------------------
// Module-level helpers
// ---------------------------------------------------------------------------
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
req.on("data", (chunk) => chunks.push(chunk as Buffer))
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")))
req.on("error", reject)
})
}
function base64UrlSha256(input: string): string {
return crypto.createHash("sha256").update(input).digest("base64url")
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;")
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
export { parseArgs, TestServer, type TestServerOptions }
// Only auto-start when run directly (so this module can be imported by the
// debug harness later without spawning a server).
const isMain = process.argv[1] && /mcp-oauth-test-server[/\\]server\.(ts|js)$/.test(process.argv[1])
if (isMain) {
const opts = parseArgs(process.argv.slice(2))
const server = new TestServer(opts)
server.start()
process.on("SIGINT", () => {
console.log("\nShutting down...")
server.stop()
process.exit(0)
})
}
+9
View File
@@ -181,6 +181,15 @@ export async function activate(context: vscode.ExtensionContext) {
}
context.subscriptions.push(vscode.window.registerUriHandler({ handleUri }))
// Debug-harness affordance: VSCode only delivers real vscode:// URIs to the
// registered handler above, which the harness can't synthesize. When running
// under browser-capture (debug harness) mode, expose the same handler on
// globalThis so the harness can deliver simulated OAuth callbacks via
// `ext.evaluate`. Gated on CLINE_CAPTURE_BROWSER so it never ships in prod.
if (process.env.CLINE_CAPTURE_BROWSER === "1" || process.env.CLINE_CAPTURE_BROWSER === "true") {
;(globalThis as Record<string, unknown>).__clineHandleUri = (url: string) => SharedUriHandler.handleUri(url)
}
// Register size testing commands in development mode
if (IS_DEV) {
vscode.commands.executeCommand("setContext", "cline.isDevMode", IS_DEV)
+55
View File
@@ -37,10 +37,21 @@ export async function readTextFromClipboard(): Promise<string> {
* Opens an external URL in the default browser.
* Uses the host bridge RPC first (VS Code's openExternal which handles remote environments).
* Falls back to the `open` npm package if the host doesn't implement the RPC (e.g., JetBrains).
*
* When CLINE_CAPTURE_BROWSER is set (debug harness mode), the URL is captured
* to a file and/or posted to the debug harness instead of opening a real browser.
* This enables automated OAuth flow testing.
*
* @param url The URL to open
* @returns Promise that resolves when the operation is complete
*/
export async function openExternal(url: string): Promise<void> {
// Debug harness mode: capture URL instead of opening browser
if (process.env.CLINE_CAPTURE_BROWSER === "1" || process.env.CLINE_CAPTURE_BROWSER === "true") {
await captureBrowserUrl(url)
return
}
Logger.log("Opening browser:", url)
try {
await HostProvider.env.openExternal(StringRequest.create({ value: url }))
@@ -59,3 +70,47 @@ export async function openExternal(url: string): Promise<void> {
}
}
}
/**
* Captures a browser URL for the debug harness instead of opening it.
* Writes the URL to a JSONL file and optionally POSTs it to the debug harness server.
*/
async function captureBrowserUrl(url: string): Promise<void> {
const entry = { timestamp: Date.now(), url }
Logger.log(`[CaptureBrowser] Captured URL: ${url}`)
// Write to JSONL file in CLINE_DIR/data/
try {
const fs = await import("node:fs")
const path = await import("node:path")
const os = await import("node:os")
const clineDir = process.env.CLINE_DIR || path.join(os.homedir(), ".cline")
const dataDir = path.join(clineDir, "data")
fs.mkdirSync(dataDir, { recursive: true })
const captureFile = path.join(dataDir, "debug-captured-urls.jsonl")
fs.appendFileSync(captureFile, JSON.stringify(entry) + "\n")
} catch (e) {
Logger.error(`[CaptureBrowser] Failed to write captured URL to file:`, e)
}
// POST to debug harness server if configured
const harnessPort = process.env.CLINE_DEBUG_HARNESS_PORT
if (harnessPort) {
try {
const http = await import("node:http")
const body = JSON.stringify(entry)
const req = http.request({
hostname: "127.0.0.1",
port: Number(harnessPort),
path: "/captured-url",
method: "POST",
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
})
req.on("error", () => {}) // Fire-and-forget, don't block
req.write(body)
req.end()
} catch (e) {
Logger.error(`[CaptureBrowser] Failed to POST captured URL to harness:`, e)
}
}
}
@@ -54,6 +54,11 @@ declare global {
// Initialize the vscode API if available
const vsCodeApi = typeof acquireVsCodeApi === "function" ? acquireVsCodeApi() : null
// Expose the VSCode API for debug harness access
if (vsCodeApi && typeof window !== "undefined") {
;(window as any).__clineVsCodeApi = vsCodeApi
}
// Implementations for post message handling
const postMessageStrategies: Record<string, PostMessageFunction> = {
vscode: (message: any) => {