mirror of
https://github.com/cline/cline.git
synced 2026-09-09 06:45:53 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff4f50b473 | ||
|
|
e1bdeeff68 | ||
|
|
49897830bb | ||
|
|
1f316a2734 | ||
|
|
9c1f9133c7 | ||
|
|
2faef2b40d | ||
|
|
64829bca8c | ||
|
|
4c9ba6b091 |
@@ -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,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [3.89.2]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Complete the fix for the Anthropic provider on VS Code 1.123 and later by upgrading the bundled Anthropic SDK to a release compatible with the Node 24 runtime.
|
||||
- Update the Vertex AI provider to a compatible Anthropic Vertex SDK release so it works with the upgraded Anthropic SDK.
|
||||
|
||||
## [3.89.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restore the Anthropic provider on VS Code 1.123 and later, where the updated Node 24 runtime broke the bundled Anthropic SDK.
|
||||
- Handle the DeepSeek V4 reasoning format.
|
||||
|
||||
## [3.89.0]
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+16
-34
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.89.0",
|
||||
"version": "3.89.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.89.0",
|
||||
"version": "3.89.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.6.4",
|
||||
"@anthropic-ai/sdk": "^0.50.4",
|
||||
"@anthropic-ai/vertex-sdk": "^0.11.5",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
|
||||
"@aws-sdk/credential-providers": "^3.922.0",
|
||||
"@azure/identity": "^4.13.0",
|
||||
@@ -156,42 +156,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.37.0.tgz",
|
||||
"integrity": "sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==",
|
||||
"version": "0.50.4",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.50.4.tgz",
|
||||
"integrity": "sha512-zZOWyIuznx2uqiRcCNuidkAsLC8IBHgS9lTwSVEB29sUCwEcqL95MWpWP1nccHSKfiZga+hbZaI0btR2zjuMmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/node-fetch": "^2.6.4",
|
||||
"abort-controller": "^3.0.0",
|
||||
"agentkeepalive": "^4.2.1",
|
||||
"form-data-encoder": "1.7.2",
|
||||
"formdata-node": "^4.3.2",
|
||||
"node-fetch": "^2.6.7"
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
|
||||
"version": "18.19.130",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
||||
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk/node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@anthropic-ai/vertex-sdk": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.6.4.tgz",
|
||||
"integrity": "sha512-rMBlO2jF53TfMRmsQMm1bPO2JRUh4jYddjq/OJLj8DSAkfbCrNWhc0yhDed6oLYJg5s+VpDbvlPzMggqHhTfMw==",
|
||||
"version": "0.11.5",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.11.5.tgz",
|
||||
"integrity": "sha512-V7sB5nY80unEQu8lSQaEzh1WhYwpIdpC3iXNRHUskghkuQDhS6dQu2ASZBgA5MNuJ1Yv5PhY61NM15dLaQhPQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.35 <1",
|
||||
"@anthropic-ai/sdk": ">=0.50.3 <1",
|
||||
"google-auth-library": "^9.4.2"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.89.0",
|
||||
"version": "3.89.2",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -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",
|
||||
@@ -486,8 +487,8 @@
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.6.4",
|
||||
"@anthropic-ai/sdk": "^0.50.4",
|
||||
"@anthropic-ai/vertex-sdk": "^0.11.5",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
|
||||
"@aws-sdk/credential-providers": "^3.922.0",
|
||||
"@azure/identity": "^4.13.0",
|
||||
|
||||
@@ -11,7 +11,7 @@ import axios from "axios"
|
||||
import JSON5 from "json5"
|
||||
import OpenAI from "openai"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { ClineStorageMessage, getBase64ImageSource } from "@/shared/messages/content"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
@@ -302,10 +302,11 @@ namespace Gemini {
|
||||
if (block.type === "text") {
|
||||
parts.push({ text: block.text })
|
||||
} else if (block.type === "image") {
|
||||
const { mediaType, data } = getBase64ImageSource(block.source)
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: block.source.media_type,
|
||||
data: block.source.data,
|
||||
mimeType: mediaType,
|
||||
data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ClineStorageMessage, convertClineStorageToAnthropicMessage } from "@/sh
|
||||
* @returns Array of Anthropic-compatible messages with cache control applied
|
||||
*/
|
||||
export function sanitizeAnthropicMessages(
|
||||
clineMessages: Array<ClineStorageMessage | Anthropic.MessageParam>,
|
||||
clineMessages: ClineStorageMessage[],
|
||||
supportCache: boolean,
|
||||
): Array<Anthropic.MessageParam> {
|
||||
// The latest message will be the new user message, one before will be the assistant message from a previous request,
|
||||
|
||||
@@ -60,7 +60,7 @@ export function convertAnthropicContentToGemini(content: string | ClineStorageMe
|
||||
.filter((part): part is Part => part !== undefined) // Filter out unsupported blocks
|
||||
}
|
||||
|
||||
export function convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam): Content {
|
||||
export function convertAnthropicMessageToGemini(message: ClineStorageMessage): Content {
|
||||
return {
|
||||
role: message.role === "assistant" ? "model" : "user",
|
||||
parts: convertAnthropicContentToGemini(message.content),
|
||||
@@ -113,6 +113,7 @@ export function convertGeminiResponseToAnthropic(response: GenerateContentRespon
|
||||
output_tokens: response.usageMetadata?.candidatesTokenCount ?? 0,
|
||||
cache_creation_input_tokens: null,
|
||||
cache_read_input_tokens: null,
|
||||
server_tool_use: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AssistantMessage } from "@mistralai/mistralai/models/components/assista
|
||||
import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage"
|
||||
import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage"
|
||||
import { UserMessage } from "@mistralai/mistralai/models/components/usermessage"
|
||||
import { getImageDataUrl } from "@/shared/messages/content"
|
||||
|
||||
export type MistralMessage =
|
||||
| (SystemMessage & { role: "system" })
|
||||
@@ -33,7 +34,7 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
|
||||
return {
|
||||
type: "image_url",
|
||||
imageUrl: {
|
||||
url: `data:${part.source.media_type};base64,${part.source.data}`,
|
||||
url: getImageDataUrl(part.source),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +400,7 @@ export function convertO1ResponseToAnthropicMessage(
|
||||
output_tokens: completion.usage?.completion_tokens || 0,
|
||||
cache_creation_input_tokens: null,
|
||||
cache_read_input_tokens: null,
|
||||
server_tool_use: null,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ClineStorageMessage,
|
||||
ClineTextContentBlock,
|
||||
ClineUserToolResultContentBlock,
|
||||
getImageDataUrl,
|
||||
} from "@/shared/messages/content"
|
||||
|
||||
export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[]): Message[] {
|
||||
@@ -46,7 +47,7 @@ export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMess
|
||||
toolMessage.content
|
||||
?.map((part) => {
|
||||
if (part.type === "image") {
|
||||
toolResultImages.push(`data:${part.source.media_type};base64,${part.source.data}`)
|
||||
toolResultImages.push(getImageDataUrl(part.source))
|
||||
return "(see following user message for image)"
|
||||
}
|
||||
return part.text
|
||||
@@ -67,7 +68,7 @@ export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMess
|
||||
content: nonToolMessages
|
||||
.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return `data:${part.source.media_type};base64,${part.source.data}`
|
||||
return getImageDataUrl(part.source)
|
||||
}
|
||||
return part.text
|
||||
})
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
ClineAssistantThinkingBlock,
|
||||
ClineAssistantToolUseBlock,
|
||||
ClineImageContentBlock,
|
||||
ClineStorageMessage,
|
||||
ClineTextContentBlock,
|
||||
ClineUserToolResultContentBlock,
|
||||
getImageDataUrl,
|
||||
} from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
@@ -65,7 +65,7 @@ function transformToolCallIdForNativeApi(toolId: string, provider?: ApiProvider)
|
||||
* @returns Array of OpenAI.Chat.ChatCompletionMessageParam objects
|
||||
*/
|
||||
export function convertToOpenAiMessages(
|
||||
anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[],
|
||||
anthropicMessages: Anthropic.Messages.MessageParam[],
|
||||
provider?: ApiProvider,
|
||||
): OpenAI.Chat.ChatCompletionMessageParam[] {
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
|
||||
@@ -144,7 +144,7 @@ export function convertToOpenAiMessages(
|
||||
role: "user",
|
||||
content: toolResultImages.map((part) => ({
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
|
||||
image_url: { url: getImageDataUrl(part.source) },
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -158,7 +158,7 @@ export function convertToOpenAiMessages(
|
||||
return {
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:${part.source.media_type};base64,${part.source.data}`,
|
||||
url: getImageDataUrl(part.source),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -421,6 +421,7 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
|
||||
output_tokens: completion.usage?.completion_tokens || 0,
|
||||
cache_creation_input_tokens: null,
|
||||
cache_read_input_tokens: null,
|
||||
server_tool_use: null,
|
||||
},
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ResponseInput, ResponseInputMessageContentList, ResponseReasoningItem } from "openai/resources/responses/responses"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { ClineStorageMessage, getBase64ImageSource, getImageDataUrl } from "@/shared/messages/content"
|
||||
|
||||
/**
|
||||
* Converts an array of ClineStorageMessage objects (extension of Anthropic format) to a ResponseInput array to use with OpenAI's Responses API.
|
||||
@@ -177,7 +177,7 @@ export function convertToOpenAIResponsesInput(
|
||||
const imageItem: any = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: `[image:${part.source.media_type}]` }],
|
||||
content: [{ type: "output_text", text: `[image:${getBase64ImageSource(part.source).mediaType}]` }],
|
||||
}
|
||||
// Set message-level id if available (though images typically don't have call_id)
|
||||
if (part.call_id) {
|
||||
@@ -218,7 +218,7 @@ export function convertToOpenAIResponsesInput(
|
||||
messageContent.push({
|
||||
type: "input_image",
|
||||
detail: "auto",
|
||||
image_url: `data:${part.source.media_type};base64,${part.source.data}`,
|
||||
image_url: getImageDataUrl(part.source),
|
||||
})
|
||||
break
|
||||
case "tool_result": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ClineAssistantThinkingBlock, ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { ClineAssistantThinkingBlock, ClineStorageMessage, getImageDataUrl } from "@/shared/messages/content"
|
||||
|
||||
/**
|
||||
* DeepSeek Reasoner message format with reasoning_content support.
|
||||
@@ -87,7 +87,7 @@ export function convertToR1Format(messages: Anthropic.Messages.MessageParam[]):
|
||||
hasImages = true
|
||||
imageParts.push({
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
|
||||
image_url: { url: getImageDataUrl(part.source) },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ export function convertToVsCodeLmMessages(
|
||||
: (toolMessage.content?.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return new vscode.LanguageModelTextPart(
|
||||
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
|
||||
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && part.source.media_type) || "unknown media-type"} not supported by VSCode LM API]`,
|
||||
)
|
||||
}
|
||||
return new vscode.LanguageModelTextPart(part.text)
|
||||
@@ -87,7 +87,7 @@ export function convertToVsCodeLmMessages(
|
||||
...nonToolMessages.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return new vscode.LanguageModelTextPart(
|
||||
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
|
||||
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && part.source.media_type) || "unknown media-type"} not supported by VSCode LM API]`,
|
||||
)
|
||||
}
|
||||
return new vscode.LanguageModelTextPart(part.text)
|
||||
@@ -199,6 +199,7 @@ export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelC
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: null,
|
||||
cache_read_input_tokens: null,
|
||||
server_tool_use: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,11 +192,13 @@ export async function executePreCompactHookWithCleanup(params: PreCompactHookPar
|
||||
let contextRawPath: string | undefined
|
||||
|
||||
try {
|
||||
// Get current active context (respects previous compactions)
|
||||
// Get current active context (respects previous compactions).
|
||||
// getTruncatedMessages types its output as Anthropic.MessageParam[], but it slices the Cline-stored
|
||||
// conversation history (ClineStorageMessage[]) passed in, so narrow it back here.
|
||||
const currentContext = params.contextManager.getTruncatedMessages(
|
||||
params.apiConversationHistory,
|
||||
params.conversationHistoryDeletedRange,
|
||||
)
|
||||
) as ClineStorageMessage[]
|
||||
|
||||
// Write context files for hook access
|
||||
const contextFiles = await writePreCompactContextFiles(params.taskId, currentContext)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { McpMarketplaceCatalog } from "@/shared/mcp"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { syncWorker } from "@/shared/services/worker/sync"
|
||||
import { reconstructTaskHistory } from "../commands/reconstructTaskHistory"
|
||||
@@ -233,7 +234,7 @@ export async function getMcpSettingsFilePath(settingsDirectoryPath: string): Pro
|
||||
return mcpSettingsFilePath
|
||||
}
|
||||
|
||||
export async function getSavedApiConversationHistory(taskId: string): Promise<Anthropic.MessageParam[]> {
|
||||
export async function getSavedApiConversationHistory(taskId: string): Promise<ClineStorageMessage[]> {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.apiConversationHistory)
|
||||
const fileExists = await fileExistsAtPath(filePath)
|
||||
if (fileExists) {
|
||||
|
||||
@@ -2018,7 +2018,10 @@ export class Task {
|
||||
}
|
||||
|
||||
// Response API requires native tool calls to be enabled
|
||||
const stream = this.api.createMessage(systemPrompt, contextManagementMetadata.truncatedConversationHistory, tools)
|
||||
// ContextManager types its truncated output as Anthropic.MessageParam[], but the history it slices is the
|
||||
// Cline-stored conversation history (ClineStorageMessage[]), so narrow it back for the provider boundary.
|
||||
const truncatedConversationHistory = contextManagementMetadata.truncatedConversationHistory as ClineStorageMessage[]
|
||||
const stream = this.api.createMessage(systemPrompt, truncatedConversationHistory, tools)
|
||||
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -16,7 +16,7 @@ export function filterMessagesForClaudeCode(messages: Anthropic.Messages.Message
|
||||
if (block.type === "image") {
|
||||
// Replace image blocks with text placeholders
|
||||
const sourceType = block.source?.type || "unknown"
|
||||
const mediaType = block.source?.media_type || "unknown"
|
||||
const mediaType = (block.source?.type === "base64" && block.source.media_type) || "unknown"
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: `[Image (${sourceType}): ${mediaType} not supported by Claude Code]`,
|
||||
|
||||
@@ -131,6 +131,27 @@ export function convertClineStorageToAnthropicMessage(
|
||||
return { role, content: cleanedContent }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cline stores images as base64, so an image block's source is always a base64 source.
|
||||
* The Anthropic SDK types the source as a Base64ImageSource | URLImageSource union, so this
|
||||
* narrows to the base64 variant for the transform layer. URL sources are not produced by Cline,
|
||||
* so they degrade to empty values rather than throwing.
|
||||
*/
|
||||
export function getBase64ImageSource(source: Anthropic.ImageBlockParam["source"]): { mediaType: string; data: string } {
|
||||
if (source.type === "base64") {
|
||||
return { mediaType: source.media_type, data: source.data }
|
||||
}
|
||||
return { mediaType: "", data: "" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a base64 data URL from an image block's source. See getBase64ImageSource.
|
||||
*/
|
||||
export function getImageDataUrl(source: Anthropic.ImageBlockParam["source"]): string {
|
||||
const { mediaType, data } = getBase64ImageSource(source)
|
||||
return `data:${mediaType};base64,${data}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean a content block by removing Cline-specific fields and returning only Anthropic-compatible fields
|
||||
*/
|
||||
|
||||
@@ -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