apps/vscode/src/dev/debug-harness/README.md
An HTTP-controlled debug server for the Cline VSCode extension. Provides programmatic access to:
~/.cline2 profile so debugee doesn't interfere with debuggerDesigned to be driven from an agentic loop via curl commands.
# Terminal 1: Start the debug harness server
bun 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"}'
bun 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)
# This builds protos, extension (unminified+sourcemaps), webview (unminified+sourcemaps),
# downloads VSCode, launches it, and connects CDP to the extension host.
bun src/dev/debug-harness/server.ts --auto-launch
The debugee runs with CLINE_DIR=~/.cline2 by default, keeping its data
separate from your real ~/.cline. This prevents:
The isolated CLINE_DIR is reported in status() and launch() responses:
curl localhost:19229/api -d '{"method":"status"}'
# → { "clineDir": "/Users/you/.cline2", ... }
To use a different directory: --cline-dir /tmp/test-cline-dir
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:
$CLINE_DIR/data/debug-captured-urls.jsonl/captured-urloauth.captured_urls API method| 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 |
The Cline OAuth flow uses the SDK's local callback server. When the user clicks "Login", the SDK:
openExternal(authorizationUrl) — which we capture?code=...To test this flow:
# 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"}'
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 (bun run dev:mcp-oauth-test-server, see
src/dev/mcp-oauth-test-server/README.md), which mints real codes/tokens.
# 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 insrc/extension.tsduring activation, only whenCLINE_CAPTURE_BROWSERis set (which the harness always sets), so it never ships in production. It calls the sameSharedUriHandler.handleUri(url)that VSCode's realregisterUriHandlerinvokes, returning aPromise<boolean>(passawaitPromise: true). Use it for anyvscode://callback — MCP, OpenRouter,/auth, etc.oauth.simulate_callbackonly builds the URI; this hook actually delivers it.
# 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"}
}'
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.
# 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())"}}'
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 |
# 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"}}'
# 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"}'
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 statusPOST /captured-url — Internal: receives captured browser URLs from debugee| 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) |
| 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 |
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 |
| 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) |
| 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.
| Method | Params | Description |
|---|---|---|
wait_for_pause | {timeout?} | Block until any debuggee hits a breakpoint |
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"}'
# 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"}'
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
curl localhost:19229/api -d '{"method":"ui.screenshot"}'
Build: esbuild bundles src/extension.ts → dist/extension.js (unminified, with
sourcemaps). Vite builds webview-ui/ → webview-ui/build/ (unminified, inline sourcemaps).
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.
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.
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.
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.
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.
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.
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.
⚠️ 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:
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.
"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.