mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff4f50b473 |
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'")
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user