Compare commits

..
Author SHA1 Message Date
leokun c6109ec2cf docs: sync README from source repo 2026-05-25 10:51:33 +08:00
leokunandGitHub be53be943a Update README.md 2026-05-21 19:29:34 +08:00
leokunandGitHub e490edede6 Update README.md 2026-05-21 19:29:04 +08:00
leokunandGitHub 553def7143 Update README.md 2026-05-21 19:27:30 +08:00
leokun d1dee196fc docs: sync README from source repo 2026-05-20 18:26:49 +08:00
leokunandGitHub 2395c364c5 Update README.md 2026-05-20 02:31:47 +08:00
leokun 3e543a6cab docs: sync README from source repo 2026-05-11 15:13:01 +08:00
leokunandGitHub 98db5046b6 Update README.md 2026-05-07 01:35:10 +08:00
leokunandGitHub 55ee70ab21 Update README.md 2026-05-07 01:34:48 +08:00
leokunandGitHub 5192a3417f Update README.md 2026-05-07 01:34:23 +08:00
leokunandGitHub a53d10b9b3 Update README.md 2026-05-04 00:51:58 +08:00
leokunandGitHub 690edfa985 Update README.md 2026-05-04 00:51:20 +08:00
leokun 69119b1a39 docs: sync README from source repo 2026-05-04 00:39:19 +08:00
leokunandGitHub c6113cd4a8 Update README.md 2026-05-03 21:25:00 +08:00
leokun 1242346404 docs: sync README from source repo 2026-04-22 22:36:15 +08:00
leokunandGitHub 89898a602e Update README.md 2026-04-22 16:49:43 +08:00
leokunandGitHub cf1332aaa0 Update README.md 2026-04-20 11:31:20 +08:00
leokunandGitHub f50c855083 Fix duplicate Rule issue and add images
Added images and fixed duplicate Rule issue.
2026-04-20 11:24:44 +08:00
leokun 2138bb0521 docs: sync README from source repo 2026-04-16 12:01:52 +08:00
leokun fef84da85e docs: sync README from source repo 2026-04-16 00:57:25 +08:00
leokun 9e6c29b13e docs: sync README from source repo 2026-04-15 16:03:55 +08:00
leokun ee10eaeba7 docs: sync README from source repo 2026-04-15 15:17:36 +08:00
leokun 4a13f565f6 docs: sync README from source repo 2026-04-12 18:59:00 +08:00
leokun b6dc8cf3c3 docs: sync README from source repo 2026-04-12 18:38:15 +08:00
242 changed files with 31 additions and 45477 deletions
View File
-26
View File
@@ -1,26 +0,0 @@
claude-server.tar
.DS_Store
dist
.task
bin
logs/
gen/
logs.zip
frontend/bindings
dist
node_modules
*.tsbuildinfo
cursor-server.tar
/cursor-server/target/
/cursor-server/*.db
/cursor-server/*.db-shm
/cursor-server/*.db-wal
server-node/cursor.tar
server-go/cursor.tar
server-go/log/
.cursor-local-assistant
.cursor-local-assistant-v2
.cursor-app-formatted/
proto/extensions-cursor-app/
cursor-tab-server/cursor-tab-server-linux-amd64.tar
/cursor-proto/proto/
-9
View File
@@ -1,9 +0,0 @@
# AGENTS.md
- Do not preserve backward compatibility. Remove obsolete paths instead of adding compatibility layers, fallbacks, or migrations.
- Choose the simplest implementation that fully meets the current requirements. Avoid speculative abstractions, configuration, and indirection.
- Grow the system in layers. Start from the smallest version that works end to end, and add each new capability on top of a product that already works. Never trade a working product for unfinished complexity.
- Keep components modular and concerns clearly separated.
- Prefer established, well-maintained libraries when they reduce overall complexity or improve reliability. Do not reimplement common functionality without a clear reason.
- Lean on the dependencies already in the project before writing your own implementation or adding packages. Do not assume a library lacks a capability without checking its documentation and types.
- Make architectural decisions for the long term. Do not accept a stopgap that only works for now and is meant to be replaced later.
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 leookun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+31 -70
View File
@@ -1,87 +1,48 @@
# cursor-byok
## 为什么做这个项目
`cursor-byok` 是一个基于真实 Cursor Agent 流量与 protobuf 实现的自托管服务端,用于把 Cursor 客户端接入用户指定的 LLM Provider
公司喜欢把 Agent 服务与模型绑定在一起,让用户只能在指定模型、指定订阅和指定计费方式下使用工具
当前 Rust 服务 `cursor-server` 已实现:
我希望打破这种绑定关系:模型应该可以自由选择。开发者应该能够把自己的模型 API 接入到任何 IDE、Chat、Agent 或开发工具中,也可以自托管整套服务,避免被单一平台锁定。
- Cursor `RunSSE + BidiAppend` 双向协议与 Connect envelope
- OpenAI Chat、OpenAI Responses、Anthropic 三种无状态流式端点。
- `LLM → 客户端工具 → 结果提交 → 下一轮 LLM` 的通用 Loop。
- append-only canonical messages、不可变 revision 分支和同对话 Run 抢占。
- Cursor typed tool UI、Exec/Interaction、多阶段编辑、MCP 与子代理。
- Blob CAS、KV GET/SET ACK、两阶段 checkpoint 和 pending ToolRound 恢复。
- 未匹配 Cursor backend 路由原样流式转发到上游。
- React + Vite + TypeScript + Tailwind 管理台,支持 Provider 配置、模型发现和调用明细。
- 每次 Provider 调用的时间、模型快照、状态和 authoritative usage;详细模式保存脱敏请求与原始流响应。
这个项目的目标,是让模型选择权重新回到用户手里
启动方式见 [cursor-server/README.md](./cursor-server/README.md)。协议证据见 [Cursor上下文与状态同步抓包分析.md](./Cursor上下文与状态同步抓包分析.md),当前实现约束见 [一次性重构计划计划.md](./docs/一次性重构计划计划.md)。
## 路线图
## 核心数据流
[正式版路线图](https://github.com/leookun/cursor-byok/discussions/32)
```text
HTTP / Connect
Cursor adapter
↓ ClientCommand / ClientEvent
RunEngine
canonical messages + selected revision
typed ModelRequest
Provider adapter → HTTP/SSE → ModelEvent
```
注:正式版发布后的不久,本代码库将会全面开源,并迎来更多有趣的工具,local-first是我们的终极目标
Loop 不依赖 Cursor protobuf、Blob、checkpoint、数字 wire id 或具体 Provider JSON。Cursor adapter 和 Provider adapter 只在各自边界做协议投射。
## 后续
## 状态与 checkpoint
后续会继续扩展更多工具和使用场景,包括但不限于:
- SQLite 中的 immutable messages 与 revision 父链是对话事实源;回滚只选择旧 revision 并建立新分支。
- ToolRound 保存完整 assistant、原始 call 顺序和真实 result 完成顺序;结果未齐时不会把悬空 tool call 投给下一轮模型。
- BlobID 是 `SHA-256(data)`;Blob 类型来自引用字段,不编码在 ID 中。
- checkpoint 引用的新 Blob 必须先收到对应 KV SET ACK。协议中不存在 checkpoint ACK,也不保存跨流 outbox。
- staged checkpoint 内联完整 pending assistantToolRound 全部结果提交后才折叠进 stable roots。抓包没有单 ToolResult checkpoint,因此实现也不制造该状态。
- settled checkpoint 必须先于下一轮 LLM 调用。最终文本轮严格发送 `turn_ended → staged → settled → settled 重发 → EndStream`
- Cursor.app 只把 `turn_ended` 前的 checkpoint 作为自动恢复候选;恢复 pending assistant 时先继续工具,不重复调用 LLM。
- 支持更多 IDE 接入
- 支持更多 Chat 类应用
- 支持更多 Agent 工具和工作流
- 提供更完善的自托管部署方式
- 持续优化不同模型 API 的兼容性
- 降低接入成本,让已有模型额度可以被更充分地利用
## 目录边界
最终希望做到:让你的模型 API 可以自由接入到你想使用的任何工具中。
```text
cursor-server/src/
├── control/ # 客户端无关的 Provider、模型、调用观测 HTTP API
├── client/ # 所有客户端共用的最小 command/event port
├── model/ # canonical message、revision、ModelSpec、typed history
├── run/ # 协议无关 Loop、ModelCycle、ToolRound 和 RunRegistry
├── provider/ # 各 LLM 端点请求与流事件适配
├── cursor/
│ ├── request/ # RunRequest → PreparedRun/CursorRunContext
│ ├── prompting/ # Cursor prompt、工具 catalog 和 mode manifest
│ ├── projection/ # Cursor AI-SDK stable/pending JSON 编解码
│ ├── interaction/ # UI 更新、InteractionQuery、typed ToolCall 渲染
│ ├── tools/ # Cursor 工具 transport、runtime、dispatch 和 result
│ └── checkpoint/ # root/Turn/derived/recovery 与串行 worker
└── store/ # SQLite messages、revision、ToolRound、Run 和 Blob CAS
console/ # React 管理台;只依赖 control API,不依赖 Cursor protobuf
```
## 截图
详细到文件的目标目录和验收项只在重构计划中维护,README 不复制第二份易漂移的完整文件清单。
## 工程原则
- 不保留旧路径、兼容层或失败后的隐式 fallback。
- 同一状态只有一个所有者;协议层不做 Loop 决策。
- PromptSpec、ModelSpec 和 selected revision 决定可重放的 ModelRequestrequest id、时间和 model call id 不进入模型输入。
- 前缀稳定限定在相同 PromptSpec/ModelSpec/Provider route;新 Run 切换模型或模式时只替换 Cursor system root,其他历史 message roots 继续复用。
- Provider replay state 只回传给产生它的端点;可展示 thinking 不是跨端点 reasoning 字段。
- Provider usage 只采用端点报告的单轮最终值,不自行估算。
- 同一个取消信号覆盖等待 HTTP 响应头和读取 SSE 两段。
<img width="820" alt="image" src="https://github.com/user-attachments/assets/2e1710b0-cdbd-4576-bd24-1614df016219" />
## 验证
<img width="820" alt="image" src="https://github.com/user-attachments/assets/00885453-6a91-4052-aadf-f686daeec881" />
```bash
cd cursor-server
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test --all-targets
```
<img width="820" alt="image" src="https://github.com/user-attachments/assets/a607be84-a738-4e33-9750-13352e74001c" />
## Star History
<a href="https://www.star-history.com/?repos=leookun%2Fcursor-byok&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&legend=top-left" />
</picture>
</a>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cursor BYOK</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-2533
View File
File diff suppressed because it is too large Load Diff
-26
View File
@@ -1,26 +0,0 @@
{
"name": "cursor-byok-console",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@tanstack/react-query": "^5.90.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"tailwindcss": "^4.1.0",
"typescript": "~5.9.0",
"vite": "^7.1.0"
}
}
-38
View File
@@ -1,38 +0,0 @@
import type { CallDetail, LlmCall, Provider, ProviderInput, ProviderModel } from './types'
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
headers: { 'content-type': 'application/json', ...init?.headers },
})
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }))
throw new Error(error.message ?? `HTTP ${response.status}`)
}
if (response.status === 204) return undefined as T
return response.json() as Promise<T>
}
export const api = {
providers: () => request<Provider[]>('/api/providers'),
createProvider: (input: ProviderInput) =>
request<Provider>('/api/providers', { method: 'POST', body: JSON.stringify(input) }),
updateProvider: (id: number, input: ProviderInput) =>
request<Provider>(`/api/providers/${id}`, { method: 'PUT', body: JSON.stringify(input) }),
deleteProvider: (id: number) => request<void>(`/api/providers/${id}`, { method: 'DELETE' }),
discoverModels: (id: number) =>
request<{ models: string[] }>(`/api/providers/${id}/models/discover`, { method: 'POST' }),
saveModels: (id: number, models: unknown[]) =>
request<ProviderModel[]>(`/api/providers/${id}/models`, {
method: 'POST',
body: JSON.stringify({ models }),
}),
models: () => request<ProviderModel[]>('/api/models'),
deleteModel: (hash: string) => request<void>(`/api/models/${hash}`, { method: 'DELETE' }),
calls: () => request<LlmCall[]>('/api/llm-calls'),
call: (id: string) => request<CallDetail>(`/api/llm-calls/${encodeURIComponent(id)}`),
observability: () => request<{ detailed: boolean }>('/api/settings/observability'),
setObservability: (detailed: boolean) => request<{ detailed: boolean }>('/api/settings/observability', {
method: 'PUT', body: JSON.stringify({ detailed }),
}),
}
-61
View File
@@ -1,61 +0,0 @@
export type ProviderType = 'openai-chat' | 'openai-responses' | 'anthropic'
export interface Provider {
provider_id: number
name: string
provider_type: ProviderType
base_url: string
has_api_key: boolean
custom_headers: Record<string, string | null>
created_at_ms: number
updated_at_ms: number
}
export interface ProviderInput {
name: string
provider_type: ProviderType
base_url: string
api_key?: string
custom_headers: Record<string, string | null>
}
export interface ProviderModel {
model_hash: string
provider_id: number
model_id: string
display_name: string
enabled: boolean
sort_order: number
context_window_tokens?: number
max_output_tokens?: number
reasoning_enabled: boolean
reasoning_effort?: string
extra_params: Record<string, unknown>
created_at_ms: number
updated_at_ms: number
}
export interface LlmCall {
call_id: string
run_id: string
conversation_id: string
model_hash?: string
model_id: string
display_name: string
provider_type: ProviderType
status: string
created_at_ms: number
duration_ms?: number
ttfb_ms?: number
ttft_ms?: number
input_tokens?: number
output_tokens?: number
total_tokens?: number
detailed: boolean
}
export interface CallDetail {
call: LlmCall
request?: { headers: Record<string, string>; body: unknown; byte_count: number }
response_chunks: { seq: number; received_offset_ms: number; data: string; byte_count: number }[]
}
-48
View File
@@ -1,48 +0,0 @@
import { NavLink, Route, Routes } from 'react-router-dom'
import { CallDetailPage } from '../features/calls/CallDetailPage'
import { CallsPage } from '../features/calls/CallsPage'
import { ModelsPage } from '../features/models/ModelsPage'
import { ProvidersPage } from '../features/providers/ProvidersPage'
import { ObservabilityPage } from '../features/settings/ObservabilityPage'
const links = [
['/', 'Providers'],
['/models', 'Models'],
['/calls', 'LLM Calls'],
['/settings', 'Settings'],
] as const
export function App() {
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100">
<header className="border-b border-zinc-800 bg-zinc-950/90">
<div className="mx-auto flex max-w-7xl items-center gap-8 px-6 py-4">
<div className="text-lg font-semibold">Cursor BYOK</div>
<nav className="flex gap-2">
{links.map(([to, label]) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`rounded-md px-3 py-2 text-sm ${isActive ? 'bg-zinc-800 text-white' : 'text-zinc-400 hover:text-white'}`
}
>
{label}
</NavLink>
))}
</nav>
</div>
</header>
<main className="mx-auto max-w-7xl px-6 py-8">
<Routes>
<Route path="/" element={<ProvidersPage />} />
<Route path="/models" element={<ModelsPage />} />
<Route path="/calls" element={<CallsPage />} />
<Route path="/calls/:callId" element={<CallDetailPage />} />
<Route path="/settings" element={<ObservabilityPage />} />
</Routes>
</main>
</div>
)
}
@@ -1,28 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'react-router-dom'
import { api } from '../../api/client'
export function CallDetailPage() {
const { callId = '' } = useParams()
const detail = useQuery({ queryKey: ['call', callId], queryFn: () => api.call(callId) })
if (!detail.data) return <p></p>
const { call, request, response_chunks: chunks } = detail.data
return <section className="grid gap-6">
<div><h1>{call.display_name}</h1><p><code>{call.call_id}</code> · {call.status}</p></div>
<div className="grid gap-3 md:grid-cols-4">
<Metric label="TTFB" value={call.ttfb_ms} suffix="ms" /><Metric label="TTFT" value={call.ttft_ms} suffix="ms" />
<Metric label="Duration" value={call.duration_ms} suffix="ms" /><Metric label="Total tokens" value={call.total_tokens} />
</div>
<Payload title="Request" value={request ?? '详细模式未记录'} />
<Payload title="Response stream" value={chunks.length ? chunks : '详细模式未记录'} />
</section>
}
function Metric({ label, value, suffix = '' }: { label: string; value?: number; suffix?: string }) {
return <div className="rounded-xl border border-zinc-800 bg-zinc-900 p-4"><p>{label}</p><div className="mt-2 text-xl">{value ?? '—'} {value == null ? '' : suffix}</div></div>
}
function Payload({ title, value }: { title: string; value: unknown }) {
return <div><h2>{title}</h2><pre className="mt-2 max-h-[32rem] overflow-auto rounded-xl border border-zinc-800 bg-black p-4 text-xs text-zinc-300">{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}</pre></div>
}
-17
View File
@@ -1,17 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { api } from '../../api/client'
import { Link } from 'react-router-dom'
export function CallsPage() {
const calls = useQuery({ queryKey: ['calls'], queryFn: api.calls, refetchInterval: 3000 })
return <section><div><h1>LLM Calls</h1><p> Provider </p></div>
<div className="mt-6 overflow-hidden rounded-xl border border-zinc-800">
<table><thead><tr><th></th><th></th><th></th><th>TTFT</th><th></th><th>Tokens</th></tr></thead>
<tbody>{calls.data?.map((call) => <tr key={call.call_id}>
<td><Link className="text-blue-400 hover:underline" to={`/calls/${encodeURIComponent(call.call_id)}`}>{new Date(call.created_at_ms).toLocaleString()}</Link></td><td>{call.display_name}<small>{call.model_id}</small></td><td>{call.status}</td>
<td>{call.ttft_ms == null ? '—' : `${call.ttft_ms} ms`}</td><td>{call.duration_ms == null ? '—' : `${call.duration_ms} ms`}</td><td>{call.total_tokens ?? '—'}</td>
</tr>)}</tbody></table>
</div>
</section>
}
@@ -1,46 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { api } from '../../api/client'
import type { ProviderModel } from '../../api/types'
export function ModelsPage() {
const client = useQueryClient()
const models = useQuery({ queryKey: ['models'], queryFn: api.models })
const save = useMutation({
mutationFn: ({ model, input }: { model: ProviderModel; input: ModelEdit }) =>
api.saveModels(model.provider_id, [{
model_id: model.model_id, display_name: input.displayName, enabled: input.enabled, sort_order: model.sort_order,
context_window_tokens: input.contextWindow || undefined, max_output_tokens: input.maxOutput || undefined,
reasoning_enabled: input.reasoning, reasoning_effort: input.effort || undefined,
extra_params: model.extra_params,
}]),
onSuccess: () => client.invalidateQueries({ queryKey: ['models'] }),
})
return <section><div><h1>Models</h1><p>Hash Cursor 使</p></div>
<div className="mt-6 overflow-hidden rounded-xl border border-zinc-800">
<table><thead><tr><th>Hash / Provider ID</th><th>Display name</th><th>Context</th><th>Max output</th><th>Reasoning</th><th></th><th></th></tr></thead>
<tbody>{models.data?.map((model) => <ModelRow key={model.model_hash} model={model} onSave={(input) => save.mutate({ model, input })} />)}</tbody></table>
</div>
</section>
}
interface ModelEdit { displayName: string; enabled: boolean; contextWindow: number; maxOutput: number; reasoning: boolean; effort: string }
function ModelRow({ model, onSave }: { model: ProviderModel; onSave: (input: ModelEdit) => void }) {
const [displayName, setDisplayName] = useState(model.display_name)
const [contextWindow, setContextWindow] = useState(model.context_window_tokens ?? 0)
const [maxOutput, setMaxOutput] = useState(model.max_output_tokens ?? 0)
const [reasoning, setReasoning] = useState(model.reasoning_enabled)
const [effort, setEffort] = useState(model.reasoning_effort ?? '')
const value = (enabled: boolean): ModelEdit => ({ displayName, enabled, contextWindow, maxOutput, reasoning, effort })
return <tr>
<td><code>{model.model_hash}</code><small>{model.model_id}</small></td>
<td><input value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></td>
<td><input type="number" value={contextWindow || ''} onChange={(event) => setContextWindow(Number(event.target.value))} /></td>
<td><input type="number" value={maxOutput || ''} onChange={(event) => setMaxOutput(Number(event.target.value))} /></td>
<td><div className="flex items-center gap-2"><input className="h-4 w-4" type="checkbox" checked={reasoning} onChange={(event) => setReasoning(event.target.checked)} /><input placeholder="effort" value={effort} onChange={(event) => setEffort(event.target.value)} /></div></td>
<td>{model.enabled ? 'Enabled' : 'Disabled'}</td>
<td><div className="flex gap-2"><button onClick={() => onSave(value(model.enabled))}></button><button onClick={() => onSave(value(!model.enabled))}>{model.enabled ? '停用' : '启用'}</button></div></td>
</tr>
}
@@ -1,38 +0,0 @@
import { useState } from 'react'
import type { Provider, ProviderInput, ProviderType } from '../../api/types'
export function ProviderForm({ provider, onSave, busy }: { provider?: Provider; onSave: (value: ProviderInput) => void; busy: boolean }) {
const [name, setName] = useState(provider?.name ?? '')
const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type ?? 'openai-chat')
const [baseUrl, setBaseUrl] = useState(provider?.base_url ?? 'https://api.openai.com/v1')
const [apiKey, setApiKey] = useState('')
const [headers, setHeaders] = useState(JSON.stringify(provider?.custom_headers ?? {}, null, 2))
return (
<form
className="grid gap-4 rounded-xl border border-zinc-800 bg-zinc-900 p-5 md:grid-cols-2"
onSubmit={(event) => {
event.preventDefault()
onSave({ name, provider_type: providerType, base_url: baseUrl, api_key: apiKey || undefined, custom_headers: JSON.parse(headers) })
}}
>
<Field label="名称"><input value={name} onChange={(e) => setName(e.target.value)} required /></Field>
<Field label="类型">
<select value={providerType} onChange={(e) => setProviderType(e.target.value as ProviderType)}>
<option value="openai-chat">OpenAI Chat</option>
<option value="openai-responses">OpenAI Responses</option>
<option value="anthropic">Anthropic</option>
</select>
</Field>
<Field label="Base URL"><input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} required /></Field>
<Field label="API Key"><input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} /></Field>
<Field label="Custom headers (JSON)"><textarea value={headers} onChange={(e) => setHeaders(e.target.value)} /></Field>
<div className="md:col-span-2"><button disabled={busy}>{busy ? '保存中…' : provider ? '保存 Provider' : '添加 Provider'}</button></div>
</form>
)
}
function Field({ label, children }: React.PropsWithChildren<{ label: string }>) {
return <label className="grid gap-2 text-sm text-zinc-400"><span>{label}</span>{children}</label>
}
@@ -1,65 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { api } from '../../api/client'
import { ProviderForm } from './ProviderForm'
import type { Provider } from '../../api/types'
export function ProvidersPage() {
const client = useQueryClient()
const providers = useQuery({ queryKey: ['providers'], queryFn: api.providers })
const [discoveries, setDiscoveries] = useState<Record<number, string[]>>({})
const [editing, setEditing] = useState<Provider>()
const create = useMutation({
mutationFn: api.createProvider,
onSuccess: () => client.invalidateQueries({ queryKey: ['providers'] }),
})
const update = useMutation({
mutationFn: ({ id, value }: { id: number; value: Parameters<typeof api.updateProvider>[1] }) => api.updateProvider(id, value),
onSuccess: () => { setEditing(undefined); client.invalidateQueries({ queryKey: ['providers'] }) },
})
const remove = useMutation({
mutationFn: api.deleteProvider,
onSuccess: () => client.invalidateQueries({ queryKey: ['providers'] }),
})
const discover = useMutation({
mutationFn: api.discoverModels,
onSuccess: (result, id) => setDiscoveries((current) => ({ ...current, [id]: result.models })),
})
const save = useMutation({
mutationFn: ({ id, model }: { id: number; model: string }) => api.saveModels(id, [{
model_id: model, display_name: model, enabled: true, sort_order: 0,
reasoning_enabled: false, extra_params: {},
}]),
onSuccess: () => client.invalidateQueries({ queryKey: ['models'] }),
})
return (
<section className="grid gap-8">
<div><h1>Provider</h1><p> Provider </p></div>
<ProviderForm key={editing?.provider_id ?? 'new'} provider={editing} onSave={(value) => editing
? update.mutate({ id: editing.provider_id, value })
: create.mutate(value)} busy={create.isPending || update.isPending} />
<div className="grid gap-4">
{providers.data?.map((provider) => (
<article key={provider.provider_id} className="rounded-xl border border-zinc-800 bg-zinc-900 p-5">
<div className="flex items-start justify-between gap-4">
<div><h2>{provider.name}</h2><p>{provider.provider_type} · {provider.base_url}</p></div>
<div className="flex gap-2"><button onClick={() => setEditing(provider)}></button><button onClick={() => discover.mutate(provider.provider_id)}></button><button className="danger" onClick={() => remove.mutate(provider.provider_id)}></button></div>
</div>
{discoveries[provider.provider_id] && (
<div className="mt-4 grid gap-2 border-t border-zinc-800 pt-4">
{discoveries[provider.provider_id].map((model) => (
<div key={model} className="flex items-center justify-between rounded-md bg-zinc-950 px-3 py-2 text-sm">
<code>{model}</code>
<button onClick={() => save.mutate({ id: provider.provider_id, model })}></button>
</div>
))}
</div>
)}
</article>
))}
</div>
</section>
)
}
@@ -1,15 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../../api/client'
export function ObservabilityPage() {
const client = useQueryClient()
const settings = useQuery({ queryKey: ['observability'], queryFn: api.observability })
const update = useMutation({ mutationFn: api.setObservability, onSuccess: () => client.invalidateQueries({ queryKey: ['observability'] }) })
return <section><h1>Observability</h1><p></p>
<label className="mt-6 flex max-w-xl items-center justify-between rounded-xl border border-zinc-800 bg-zinc-900 p-5">
<span><strong></strong><p></p></span>
<input className="h-5 w-5" type="checkbox" checked={settings.data?.detailed ?? false} onChange={(event) => update.mutate(event.target.checked)} />
</label>
</section>
}
-19
View File
@@ -1,19 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
import { App } from './app/App'
import './styles/index.css'
const queryClient = new QueryClient()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<HashRouter>
<App />
</HashRouter>
</QueryClientProvider>
</StrictMode>,
)
-17
View File
@@ -1,17 +0,0 @@
@import "tailwindcss";
@layer base {
body { @apply m-0 bg-zinc-950 font-sans text-zinc-100 antialiased; }
h1 { @apply text-2xl font-semibold tracking-tight; }
h2 { @apply text-base font-semibold; }
p { @apply mt-1 text-sm text-zinc-400; }
input, select, textarea { @apply w-full rounded-md border border-zinc-700 bg-zinc-950 px-3 py-2 text-zinc-100 outline-none focus:border-blue-500; }
textarea { @apply min-h-24 font-mono text-xs; }
button { @apply rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50; }
table { @apply w-full border-collapse bg-zinc-900 text-left text-sm; }
th { @apply bg-zinc-950 px-4 py-3 font-medium text-zinc-400; }
td { @apply border-t border-zinc-800 px-4 py-3; }
td small { @apply block text-zinc-500; }
code { @apply font-mono text-xs text-blue-300; }
button.danger { @apply bg-red-950 text-red-300 hover:bg-red-900; }
}
-20
View File
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
-7
View File
@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
-11
View File
@@ -1,11 +0,0 @@
{
"compilerOptions": {
"composite": true,
"noEmit": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true
},
"include": ["vite.config.ts"]
}
-12
View File
@@ -1,12 +0,0 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
base: '/console/',
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: { '/api': 'http://127.0.0.1:3000' },
},
})
-70
View File
@@ -1,70 +0,0 @@
# Cursor Protocol Debugger
[中文](README.md) | [English](README.en.md)
This standalone local Cursor API debugging service forwards every HTTP request outside the `__debuger__` debugging namespace to the fixed upstream `https://api2.cursor.sh`, preserving the method, path, query, headers, and body. It continues to capture `BidiAppend`, `RunSSE`, Fork Chat, and model-discovery traffic.
It is not a general-purpose HTTP proxy, does not handle `CONNECT`, requires no CA certificate, and does not modify the system proxy.
## Start
Generate the sibling `cursor-proto` module's Go code before the first build:
```bash
(cd ../cursor-proto && ./scripts/generate.sh)
go run .
```
The service listens on a single port:
- Cursor API service: `http://127.0.0.1:9090`
- Debugging UI: `http://127.0.0.1:9090/__debuger__/`
- Debugging API: `http://127.0.0.1:9090/__debuger__/api/*`
- Fixed upstream: `https://api2.cursor.sh`
The debugging UI opens automatically after startup.
## Configure Cursor
Quit Cursor completely, then launch it from a terminal with the local API address:
```bash
CURSOR_API_ENDPOINT=http://127.0.0.1:9090 \
CURSOR_API_BASE_URL=http://127.0.0.1:9090 \
/Applications/Cursor.app/Contents/MacOS/Cursor
```
`CURSOR_API_ENDPOINT` overrides the Agent API endpoint. `CURSOR_API_BASE_URL` also routes requests such as authentication that use the base API address through this service. Cursor proxy and Network settings do not need to be changed.
## Build
```bash
(cd ../cursor-proto && ./scripts/generate.sh)
go build -o ./bin/cursor-proxy-debugger .
```
## Dependency Layout
The debugger is an independent Go module. Cursor protobuf message packages are generated by the sibling `cursor-proto` module. The generated `gen/` directory is not committed, so run its `scripts/generate.sh` before the first build. This project does not depend on the outer `cursor-byok` Go module.
## Options
```text
-addr Cursor API service listen address; default: 127.0.0.1:9090
-max-exchanges Maximum exchanges retained in memory; default: 200
-db SQLite database path; defaults to the user configuration directory
-open Open the browser after startup; default: true
```
## Data Handling
- Every request received by the service is forwarded to `https://api2.cursor.sh`; clients cannot select another upstream.
- The `__debuger__` namespace is reserved for the local debugging page and API and is never forwarded upstream.
- `RunSSE` is decoded incrementally using the 5-byte Connect frame header and supports per-frame gzip decompression.
- `BidiAppendRequest.data` is further decoded as `agent.v1.AgentClientMessage`.
- Fork Chat's `ForkBackgroundComposer`, `NotifyConversationClone`, and `UploadConversationBlobs` traffic is decoded bidirectionally as protobuf JSON.
- `CppService/AvailableModels`, `AiService/AvailableModels`, `GetDefaultModel`, and `GetDefaultModelNudgeData` are decoded bidirectionally.
- Requests can be filtered by time and protocol `request_id`; the UI can query by `conversation_id` and group requests by conversation.
- Complete captures are stored in SQLite and remain queryable after restart; `max-exchanges` only limits hot in-memory data.
- Sensitive headers such as `Authorization`, `Cookie`, and `Set-Cookie` are hidden in the UI by default.
- Raw bodies are retained up to 2 MiB per side by default; capture limits never truncate forwarded traffic.
-70
View File
@@ -1,70 +0,0 @@
# Cursor 协议调试器
[中文](README.md) | [English](README.en.md)
这是一个独立运行的本地 Cursor API 调试服务。除 `__debuger__` 调试命名空间外,进入服务端口的 HTTP 请求都会保留方法、路径、查询参数、请求头和请求体,并转发到固定上游 `https://api2.cursor.sh`。服务同时记录 `BidiAppend``RunSSE`、Fork Chat 和模型发现等流量。
它不是通用 HTTP 代理,不处理 `CONNECT`,不需要 CA 证书,也不会修改系统代理。
## 启动
首次构建前先生成相邻 `cursor-proto` 项目的 Go 代码:
```bash
(cd ../cursor-proto && ./scripts/generate.sh)
go run .
```
服务只监听一个端口:
- Cursor API 服务:`http://127.0.0.1:9090`
- 调试界面:`http://127.0.0.1:9090/__debuger__/`
- 调试 API`http://127.0.0.1:9090/__debuger__/api/*`
- 固定上游:`https://api2.cursor.sh`
启动后会自动打开调试界面。
## 配置 Cursor
完全退出 Cursor 后,从终端指定本地 API 地址启动:
```bash
CURSOR_API_ENDPOINT=http://127.0.0.1:9090 \
CURSOR_API_BASE_URL=http://127.0.0.1:9090 \
/Applications/Cursor.app/Contents/MacOS/Cursor
```
`CURSOR_API_ENDPOINT` 覆盖 Agent API 地址;`CURSOR_API_BASE_URL` 让使用基础 API 地址的认证等请求也经过本服务。无需修改 Cursor 代理设置或 Network 设置。
## 构建
```bash
(cd ../cursor-proto && ./scripts/generate.sh)
go build -o ./bin/cursor-proxy-debugger .
```
## 依赖说明
调试器是独立 Go module。Cursor protobuf 消息包由相邻的 `cursor-proto` module 生成;生成的 `gen/` 目录不提交到 Git,因此首次构建前需要运行其 `scripts/generate.sh`。本项目不依赖外层 `cursor-byok` Go module。
## 参数
```text
-addr Cursor API 服务监听地址,默认 127.0.0.1:9090
-max-exchanges 内存中保留的最大请求数,默认 200
-db SQLite 数据库路径,默认位于用户配置目录
-open 启动后是否打开浏览器,默认 true
```
## 数据处理
- 所有服务端口收到的请求都固定转发到 `https://api2.cursor.sh`,不会接受客户端指定的其他上游。
- `__debuger__` 命名空间由本地调试页面和调试 API 保留,不会转发到上游。
- `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。
- `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`
- Fork Chat 的 `ForkBackgroundComposer``NotifyConversationClone``UploadConversationBlobs` 会双向解码为 protobuf JSON。
- `CppService/AvailableModels``AiService/AvailableModels``GetDefaultModel``GetDefaultModelNudgeData` 会双向解码模型相关数据。
- 请求列表支持按时间和协议 `request_id` 过滤;调试界面可按 `conversation_id` 查询并按会话分组。
- 完整抓包写入 SQLite,重启后仍可查询;`max-exchanges` 只限制内存热数据数量。
- `Authorization``Cookie``Set-Cookie` 等敏感请求头在界面中默认隐藏。
- 单侧原始正文默认最多保留 2 MiB;转发内容不会被抓取上限截断。
-95
View File
@@ -1,95 +0,0 @@
// capture.go 在不影响上游转发的前提下截取有限大小的 HTTP 流。
package main
import (
"bytes"
"encoding/hex"
"io"
"sync"
)
// captureReadCloser 包装响应体并并发安全地累计诊断副本。
type captureReadCloser struct {
source io.ReadCloser
mu sync.Mutex
buffer bytes.Buffer
limit int
size int64
truncated bool
done bool
onChunk func([]byte)
onDone func(captured []byte, size int64, truncated bool, readErr error)
}
// newCaptureReadCloser 创建带分块和完成回调的捕获读取器。
func newCaptureReadCloser(
source io.ReadCloser,
limit int,
onChunk func([]byte),
onDone func(captured []byte, size int64, truncated bool, readErr error),
) *captureReadCloser {
return &captureReadCloser{
source: source,
limit: limit,
onChunk: onChunk,
onDone: onDone,
}
}
// Read 转发读取结果并保存不超过限制的副本。
func (reader *captureReadCloser) Read(payload []byte) (int, error) {
read, err := reader.source.Read(payload)
if read > 0 {
chunk := payload[:read]
reader.mu.Lock()
reader.size += int64(read)
remaining := reader.limit - reader.buffer.Len()
if remaining > 0 {
captured := read
if captured > remaining {
captured = remaining
}
_, _ = reader.buffer.Write(chunk[:captured])
}
if reader.buffer.Len() >= reader.limit && reader.size > int64(reader.buffer.Len()) {
reader.truncated = true
}
reader.mu.Unlock()
if reader.onChunk != nil {
reader.onChunk(append([]byte(nil), chunk...))
}
}
if err != nil {
reader.finish(err)
}
return read, err
}
// Close 关闭原始响应体并保证完成回调只执行一次。
func (reader *captureReadCloser) Close() error {
err := reader.source.Close()
reader.finish(err)
return err
}
// finish 固化捕获快照并在锁外调用完成回调。
func (reader *captureReadCloser) finish(readErr error) {
reader.mu.Lock()
if reader.done {
reader.mu.Unlock()
return
}
reader.done = true
captured := append([]byte(nil), reader.buffer.Bytes()...)
size := reader.size
truncated := reader.truncated
reader.mu.Unlock()
if reader.onDone != nil {
reader.onDone(captured, size, truncated, readErr)
}
}
// rawHex 把捕获字节编码为便于 JSON 持久化的十六进制文本。
func rawHex(payload []byte) string {
return hex.EncodeToString(payload)
}
-345
View File
@@ -1,345 +0,0 @@
// capture_pipeline.go 负责服务请求响应体的捕获、解码和事件追加。
package main
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
)
// exchangeIDContextKey 隔离反向服务内部使用的捕获编号。
type exchangeIDContextKey struct{}
// captureRequest 捕获请求元数据并安装请求体读取器。
func (server *Server) captureRequest(request *http.Request) *http.Request {
if request == nil {
return request
}
server.captureMu.RLock()
id := strconv.FormatUint(server.counter.Add(1), 10)
path := request.URL.Path
upstreamURL := *request.URL
upstreamURL.Scheme = server.upstream.Scheme
upstreamURL.Host = server.upstream.Host
upstreamURL.User = nil
requestContentType := request.Header.Get("Content-Type")
requestCodec := requestContentCodec(path, request.Header)
exchange := &Exchange{
ExchangeSummary: ExchangeSummary{
ID: id,
StartedAt: time.Now(),
Method: request.Method,
URL: upstreamURL.String(),
Host: server.upstream.Host,
Path: path,
State: "pending",
},
Request: Payload{
Headers: sortedHeaders(request.Header),
ContentType: requestContentType,
ContentCodec: requestCodec,
Frames: make([]FrameView, 0),
},
Response: Payload{Headers: make([]Header, 0), Frames: make([]FrameView, 0)},
}
server.store.create(exchange)
server.captureMu.RUnlock()
request = request.WithContext(context.WithValue(request.Context(), exchangeIDContextKey{}, id))
request.Close = false
if request.Body == nil {
server.finishRequestBody(id, path, requestContentType, requestCodec, nil, 0, false, nil)
return request
}
var frameDecoder *connectFrameDecoder
if messageType := streamingRequestMessageType(path); messageType != "" {
frameDecoder = newConnectFrameDecoder(
messageType,
requestCodec,
server.config.MaxFrames,
func(frame FrameView) { server.appendRequestFrame(id, frame) },
)
}
request.Body = newCaptureReadCloser(
request.Body,
server.config.MaxCaptureBytes,
func(chunk []byte) {
if frameDecoder != nil {
frameDecoder.Write(chunk)
}
},
func(captured []byte, size int64, truncated bool, readErr error) {
if frameDecoder != nil {
frameDecoder.Close()
}
server.finishRequestBody(id, path, requestContentType, requestCodec, captured, size, truncated, readErr)
},
)
return request
}
// clearExchanges 清空内存和持久化捕获,并重置递增编号。
func (server *Server) clearExchanges() error {
server.captureMu.Lock()
defer server.captureMu.Unlock()
if err := server.store.clear(); err != nil {
return err
}
server.counter.Store(0)
return nil
}
// captureResponse 创建响应记录更新并包装响应体捕获器。
func (server *Server) captureResponse(response *http.Response) error {
if response == nil {
return nil
}
id := exchangeID(response.Request)
if id == "" {
return nil
}
path := ""
if response.Request != nil && response.Request.URL != nil {
path = response.Request.URL.Path
}
responseCodec := responseContentCodec(path, response.Header)
responseContentType := response.Header.Get("Content-Type")
server.store.update(id, func(exchange *Exchange) {
exchange.Status = response.StatusCode
exchange.State = "streaming"
exchange.DurationMS = elapsedMS(exchange.StartedAt)
exchange.Response.Headers = sortedHeaders(response.Header)
exchange.Response.ContentType = responseContentType
exchange.Response.ContentCodec = responseCodec
})
if response.Body == nil {
server.finishResponseBody(id, path, responseContentType, responseCodec, nil, 0, false, nil)
return nil
}
var frameDecoder *connectFrameDecoder
if messageType := streamingResponseMessageType(path); messageType != "" {
frameDecoder = newConnectFrameDecoder(
messageType,
responseCodec,
server.config.MaxFrames,
func(frame FrameView) { server.appendResponseFrame(id, frame) },
)
}
response.Body = newCaptureReadCloser(
response.Body,
server.config.MaxCaptureBytes,
func(chunk []byte) {
if frameDecoder != nil {
frameDecoder.Write(chunk)
}
},
func(captured []byte, size int64, truncated bool, readErr error) {
if frameDecoder != nil {
frameDecoder.Close()
}
server.finishResponseBody(id, path, responseContentType, responseCodec, captured, size, truncated, readErr)
},
)
return nil
}
// failExchange 保存反向转发失败状态。
func (server *Server) failExchange(request *http.Request, upstreamErr error) {
id := exchangeID(request)
if id == "" || upstreamErr == nil {
return
}
server.store.update(id, func(exchange *Exchange) {
exchange.State = "error"
exchange.Error = upstreamErr.Error()
exchange.DurationMS = elapsedMS(exchange.StartedAt)
})
}
// finishRequestBody 解压、解码并保存完整请求体的最终状态。
func (server *Server) finishRequestBody(id, path, contentType, codec string, captured []byte, size int64, truncated bool, readErr error) {
decodePayload := captured
var contentDecodeErr error
decodeProto := decodesUnaryRequest(path) && isUnaryProtoContentType(contentType)
if decodeProto && truncated {
contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码")
} else if decodeProto && codec != "" && !strings.EqualFold(codec, "identity") {
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
}
decodedJSON, decodedLang, kind, requestID, conversationID, decodeErr := "", "", "", "", "", contentDecodeErr
if decodeProto && decodeErr == nil {
decodedJSON, kind, requestID, conversationID, decodeErr = decodeUnaryRequest(path, decodePayload)
}
if decodeErr == nil && decodedJSON != "" {
decodedLang = "json"
} else if !decodeProto {
decodedJSON, decodedLang, decodeErr = decodeCapturedContent(captured, contentType, codec)
}
server.store.update(id, func(exchange *Exchange) {
exchange.RequestBytes = size
exchange.Request.Size = size
exchange.Request.RawHex = rawHex(captured)
exchange.Request.RawTruncated = truncated
if decodedJSON != "" {
exchange.Request.DecodedJSON = decodedJSON
exchange.Request.DecodedLang = decodedLang
}
if kind != "" {
exchange.RequestKind = kind
}
if requestID != "" {
exchange.RequestID = requestID
}
if conversationID != "" {
exchange.ConversationID = conversationID
}
if decodeErr != nil {
exchange.Request.DecodeError = decodeErr.Error()
}
if readErr != nil && !errors.Is(readErr, io.EOF) {
exchange.Error = readErr.Error()
}
})
}
// requestContentCodec 读取请求方向的 Connect 或 HTTP 压缩编码。
func requestContentCodec(path string, headers http.Header) string {
if streamingRequestMessageType(path) != "" {
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
}
return strings.TrimSpace(headers.Get("Content-Encoding"))
}
// responseContentCodec 读取响应方向的 Connect 或 HTTP 压缩编码。
func responseContentCodec(path string, headers http.Header) string {
if streamingResponseMessageType(path) != "" {
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
}
if !decodesUnaryResponse(path) {
if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" {
return codec
}
}
return strings.TrimSpace(headers.Get("Content-Encoding"))
}
// finishResponseBody 解压、解码并保存完整响应体的最终状态。
func (server *Server) finishResponseBody(id, path, contentType, codec string, captured []byte, size int64, truncated bool, readErr error) {
decodePayload := captured
var contentDecodeErr error
decodeProto := decodesUnaryResponse(path) && isUnaryProtoContentType(contentType)
if decodeProto && truncated {
contentDecodeErr = errors.New("响应正文超过抓取上限,无法完整解码")
} else if decodeProto && codec != "" && !strings.EqualFold(codec, "identity") {
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
}
decodedJSON, decodedLang, kind, decodeErr := "", "", "", contentDecodeErr
if decodeProto && decodeErr == nil {
decodedJSON, kind, decodeErr = decodeUnaryResponse(path, decodePayload)
}
if decodeErr == nil && decodedJSON != "" {
decodedLang = "json"
} else if !decodeProto {
decodedJSON, decodedLang, decodeErr = decodeCapturedContent(captured, contentType, codec)
}
server.store.update(id, func(exchange *Exchange) {
exchange.ResponseBytes = size
exchange.Response.Size = size
exchange.Response.RawHex = rawHex(captured)
exchange.Response.RawTruncated = truncated
if decodedJSON != "" {
exchange.Response.DecodedJSON = decodedJSON
exchange.Response.DecodedLang = decodedLang
}
if kind != "" {
exchange.ResponseKind = kind
}
if decodeErr != nil {
exchange.Response.DecodeError = decodeErr.Error()
}
exchange.DurationMS = elapsedMS(exchange.StartedAt)
exchange.State = "completed"
if readErr != nil && !errors.Is(readErr, io.EOF) {
exchange.State = "error"
exchange.Error = readErr.Error()
}
})
}
// appendRequestFrame 把请求方向的流式帧追加到临时快照。
func (server *Server) appendRequestFrame(id string, frame FrameView) {
server.store.updateTransient(id, func(exchange *Exchange) {
if len(exchange.Request.Frames) < server.config.MaxFrames {
exchange.Request.Frames = append(exchange.Request.Frames, frame)
}
if frame.Kind != "" {
exchange.RequestKind = frame.Kind
}
if frame.RequestID != "" {
exchange.RequestID = frame.RequestID
}
})
}
// appendResponseFrame 把响应方向的流式帧追加到临时快照。
func (server *Server) appendResponseFrame(id string, frame FrameView) {
server.store.updateTransient(id, func(exchange *Exchange) {
if len(exchange.Response.Frames) < server.config.MaxFrames {
exchange.Response.Frames = append(exchange.Response.Frames, frame)
}
exchange.FrameCount = len(exchange.Response.Frames)
if frame.Kind != "" && frame.Kind != "end_stream" {
exchange.ResponseKind = frame.Kind
}
if frame.Error != "" {
exchange.Response.DecodeError = frame.Error
}
})
}
// exchangeID 从请求上下文读取捕获记录编号。
func exchangeID(request *http.Request) string {
if request == nil {
return ""
}
value, ok := request.Context().Value(exchangeIDContextKey{}).(string)
if !ok {
return ""
}
return value
}
// browserAddress 把通配监听地址转换为浏览器可访问的回环地址。
func browserAddress(address string) string {
host, port, err := net.SplitHostPort(address)
if err != nil {
return address
}
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
return net.JoinHostPort(host, port)
}
// validateLoopbackAddress 拒绝把调试服务暴露到非回环网卡。
func validateLoopbackAddress(address string) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("调试服务监听地址无效:%w", err)
}
if strings.EqualFold(host, "localhost") {
return nil
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return errors.New("调试服务只能监听本机回环地址")
}
return nil
}
-380
View File
@@ -1,380 +0,0 @@
// decode.go 解析 Connect 帧、压缩载荷和 Cursor protobuf 消息视图。
package main
import (
"bytes"
"compress/gzip"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"strings"
agentv1 "github.com/leookun/cursor-byok/cursor-proto/gen/agent/v1"
aiserverv1 "github.com/leookun/cursor-byok/cursor-proto/gen/aiserver/v1"
"google.golang.org/protobuf/proto"
)
// maxConnectFrameBytes 防止异常帧长度导致调试器分配过大内存。
const maxConnectFrameBytes = 64 << 20
// 协议路径常量用于选择精确的 protobuf 请求、响应和流式消息类型。
const (
bidiAppendPath = "/aiserver.v1.BidiService/BidiAppend"
forkBackgroundComposerPath = "/aiserver.v1.BackgroundComposerService/ForkBackgroundComposer"
notifyConversationClonePath = "/agent.v1.AgentService/NotifyConversationClone"
uploadConversationBlobsPath = "/agent.v1.AgentService/UploadConversationBlobs"
cppAvailableModelsPath = "/aiserver.v1.CppService/AvailableModels"
aiAvailableModelsPath = "/aiserver.v1.AiService/AvailableModels"
aiGetDefaultModelPath = "/aiserver.v1.AiService/GetDefaultModel"
aiDefaultModelNudgeDataPath = "/aiserver.v1.AiService/GetDefaultModelNudgeData"
mcpGetKnownServersPath = "/aiserver.v1.MCPRegistryService/GetKnownServers"
serverGetConfigPath = "/aiserver.v1.ServerConfigService/GetServerConfig"
runSSEPath = "/agent.v1.AgentService/RunSSE"
)
// connectFrameDecoder 在任意读取边界下累计并解析 Connect 五字节帧。
type connectFrameDecoder struct {
buffer []byte
messageType string
codec string
maxFrames int
frameCount int
onFrame func(FrameView)
}
// newConnectFrameDecoder 创建指定 protobuf 类型的流式解码器。
func newConnectFrameDecoder(messageType string, codec string, maxFrames int, onFrame func(FrameView)) *connectFrameDecoder {
return &connectFrameDecoder{
messageType: messageType,
codec: strings.TrimSpace(codec),
maxFrames: maxFrames,
onFrame: onFrame,
}
}
// Write 追加任意长度的网络片段并尽可能产出完整帧。
func (decoder *connectFrameDecoder) Write(payload []byte) {
if len(payload) == 0 || decoder.frameCount >= decoder.maxFrames {
return
}
decoder.buffer = append(decoder.buffer, payload...)
for len(decoder.buffer) >= 5 && decoder.frameCount < decoder.maxFrames {
flags := decoder.buffer[0]
length := int(binary.BigEndian.Uint32(decoder.buffer[1:5]))
if length < 0 || length > maxConnectFrameBytes {
decoder.emit(FrameView{Flags: flags, Length: length, Error: "Connect 帧长度异常"})
decoder.buffer = nil
return
}
if len(decoder.buffer) < 5+length {
return
}
framePayload := append([]byte(nil), decoder.buffer[5:5+length]...)
decoder.buffer = decoder.buffer[5+length:]
decoder.emit(decoder.decode(flags, framePayload))
}
}
// Close 标记流结束并暴露尚未完整的尾部错误。
func (decoder *connectFrameDecoder) Close() {
if len(decoder.buffer) > 0 && decoder.frameCount < decoder.maxFrames {
decoder.emit(FrameView{
Length: len(decoder.buffer),
RawHex: clippedHex(decoder.buffer, 4096),
Error: "流结束时仍有不完整的 Connect 帧",
})
}
decoder.buffer = nil
}
// emit 在达到帧数上限前调用帧回调。
func (decoder *connectFrameDecoder) emit(frame FrameView) {
frame.Index = decoder.frameCount
decoder.frameCount++
if decoder.onFrame != nil {
decoder.onFrame(frame)
}
}
// decode 解压并解析单条 Connect 帧。
func (decoder *connectFrameDecoder) decode(flags uint8, payload []byte) FrameView {
frame := FrameView{
Flags: flags,
Length: len(payload),
Compressed: flags&0x01 != 0,
EndStream: flags&0x02 != 0,
RawHex: clippedHex(payload, 4096),
}
decoded := payload
if frame.Compressed {
var err error
decoded, err = decompressPayload(payload, decoder.codec)
if err != nil {
frame.Error = err.Error()
return frame
}
}
if frame.EndStream {
frame.Kind = "end_stream"
frame.MessageType = "connect.error.v1.EndStreamResponse"
frame.JSON = prettyJSON(decoded)
return frame
}
message := newMessage(decoder.messageType)
if message == nil {
frame.Error = "未知的 protobuf 消息类型"
return frame
}
if err := proto.Unmarshal(decoded, message); err != nil {
frame.Error = fmt.Sprintf("protobuf 解码失败:%v", err)
return frame
}
frame.MessageType = decoder.messageType
frame.Kind = activeOneofName(message)
if requestID, ok := message.(*aiserverv1.BidiRequestId); ok {
frame.RequestID = strings.TrimSpace(requestID.GetRequestId())
}
frame.JSON = marshalProtoJSON(message)
return frame
}
// decompressPayload 使用协议声明的编码解压载荷。
func decompressPayload(payload []byte, codec string) ([]byte, error) {
if codec != "" && !strings.EqualFold(codec, "gzip") {
return nil, fmt.Errorf("暂不支持压缩算法 %q", codec)
}
reader, err := gzip.NewReader(bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("gzip 解压失败:%w", err)
}
defer reader.Close()
decoded, err := io.ReadAll(io.LimitReader(reader, maxConnectFrameBytes+1))
if err != nil {
return nil, fmt.Errorf("读取 gzip 内容失败:%w", err)
}
if len(decoded) > maxConnectFrameBytes {
return nil, fmt.Errorf("gzip 解压后超过 %d 字节限制", maxConnectFrameBytes)
}
return decoded, nil
}
// decodeUnaryRequest 解析单次 RPC 请求并提取关键关联标识。
func decodeUnaryRequest(path string, payload []byte) (decodedJSON string, kind string, requestID string, conversationID string, err error) {
switch path {
case bidiAppendPath:
request := &aiserverv1.BidiAppendRequest{}
if err := proto.Unmarshal(payload, request); err != nil {
return "", "", "", "", err
}
requestID := strings.TrimSpace(request.GetRequestId().GetRequestId())
outer := marshalProtoJSON(request)
clientMessage, clientKind, decodeErr := decodeBidiClientMessage(request)
if decodeErr != nil || clientMessage == nil {
return outer, "bidi_append", requestID, "", decodeErr
}
combined := struct {
BidiAppendRequest json.RawMessage `json:"bidi_append_request"`
AgentClientKind string `json:"agent_client_kind"`
AgentClient json.RawMessage `json:"agent_client_message"`
}{
BidiAppendRequest: json.RawMessage(outer),
AgentClientKind: clientKind,
AgentClient: json.RawMessage(marshalProtoJSON(clientMessage)),
}
formatted, marshalErr := json.MarshalIndent(combined, "", " ")
return string(formatted), clientKind, requestID, conversationIDFromClientMessage(clientMessage), marshalErr
}
message, kind := unaryRequestMessage(path)
if message == nil {
return "", "", "", "", nil
}
if err := proto.Unmarshal(payload, message); err != nil {
return "", "", "", "", err
}
return marshalProtoJSON(message), kind, "", conversationIDFromUnaryRequest(message), nil
}
// decodeBidiClientMessage 解析 BidiAppend 携带的十六进制 Agent 消息。
func decodeBidiClientMessage(request *aiserverv1.BidiAppendRequest) (*agentv1.AgentClientMessage, string, error) {
if request == nil {
return nil, "", nil
}
if strings.TrimSpace(request.GetData()) != "" {
payload, err := hex.DecodeString(strings.TrimSpace(request.GetData()))
if err != nil {
return nil, "", fmt.Errorf("decode hex agent client message failed: %w", err)
}
message := &agentv1.AgentClientMessage{}
if err := proto.Unmarshal(payload, message); err != nil {
return nil, "", fmt.Errorf("decode agent client message failed: %w", err)
}
return message, activeOneofName(message), nil
}
if len(request.GetDataBinary()) == 0 {
return nil, "", nil
}
message := &agentv1.AgentClientMessage{}
if err := proto.Unmarshal(request.GetDataBinary(), message); err != nil {
return nil, "", fmt.Errorf("decode binary agent client message failed: %w", err)
}
return message, activeOneofName(message), nil
}
// conversationIDFromClientMessage 从 Agent 消息的会话字段提取会话标识。
func conversationIDFromClientMessage(message *agentv1.AgentClientMessage) string {
if message == nil {
return ""
}
if runRequest := message.GetRunRequest(); runRequest != nil {
return strings.TrimSpace(runRequest.GetConversationId())
}
if prewarmRequest := message.GetPrewarmRequest(); prewarmRequest != nil {
return strings.TrimSpace(prewarmRequest.GetConversationId())
}
return ""
}
// conversationIDFromUnaryRequest 从已知 RPC 请求中提取会话标识。
func conversationIDFromUnaryRequest(message proto.Message) string {
switch typed := message.(type) {
case *agentv1.NotifyConversationCloneRequest:
return strings.TrimSpace(typed.GetConversationId())
case *agentv1.UploadConversationBlobsRequest:
return strings.TrimSpace(typed.GetConversationId())
default:
return ""
}
}
// decodeUnaryResponse 解析单次 RPC 响应并生成 JSON 视图。
func decodeUnaryResponse(path string, payload []byte) (decodedJSON string, kind string, err error) {
message, kind := unaryResponseMessage(path)
if message == nil {
return "", "", nil
}
if err := proto.Unmarshal(payload, message); err != nil {
return "", "", err
}
return marshalProtoJSON(message), kind, nil
}
// hydrateStoredExchange 为历史捕获补齐正文和 Connect 帧视图。
func hydrateStoredExchange(exchange *Exchange) bool {
if exchange == nil || (exchange.State != "completed" && exchange.State != "streaming") {
return false
}
changed := false
if messageType := streamingRequestMessageType(exchange.Path); messageType != "" &&
len(exchange.Request.Frames) == 0 && exchange.Request.RawHex != "" && !exchange.Request.RawTruncated {
frames, err := decodeStoredConnectFrames(exchange.Request.RawHex, messageType, exchange.Request.ContentCodec)
if err != nil {
exchange.Request.DecodeError = err.Error()
} else if len(frames) > 0 {
exchange.Request.Frames = frames
for _, frame := range frames {
if frame.Kind != "" && frame.Kind != "end_stream" {
exchange.RequestKind = frame.Kind
}
if frame.RequestID != "" {
exchange.RequestID = frame.RequestID
}
}
}
changed = true
}
if messageType := streamingResponseMessageType(exchange.Path); messageType != "" &&
len(exchange.Response.Frames) == 0 && exchange.Response.RawHex != "" && !exchange.Response.RawTruncated {
frames, err := decodeStoredConnectFrames(exchange.Response.RawHex, messageType, exchange.Response.ContentCodec)
if err != nil {
exchange.Response.DecodeError = err.Error()
} else if len(frames) > 0 {
exchange.Response.Frames = frames
exchange.FrameCount = len(frames)
for _, frame := range frames {
if frame.Kind != "" && frame.Kind != "end_stream" {
exchange.ResponseKind = frame.Kind
}
}
}
changed = true
}
if isUnaryProtoContentType(exchange.Request.ContentType) && exchange.Request.DecodedJSON == "" && !exchange.Request.RawTruncated {
payload, err := decodeStoredRawPayload(exchange.Request.RawHex, exchange.Request.ContentCodec)
if err == nil {
decoded, kind, requestID, conversationID, decodeErr := decodeUnaryRequest(exchange.Path, payload)
if decodeErr != nil {
err = decodeErr
} else if decoded != "" {
exchange.Request.DecodedJSON = decoded
exchange.Request.DecodedLang = "json"
exchange.RequestKind = kind
if requestID != "" {
exchange.RequestID = requestID
}
if conversationID != "" {
exchange.ConversationID = conversationID
}
changed = true
}
}
if err != nil {
exchange.Request.DecodeError = err.Error()
changed = true
}
}
if isUnaryProtoContentType(exchange.Response.ContentType) && exchange.Response.DecodedJSON == "" && !exchange.Response.RawTruncated {
payload, err := decodeStoredRawPayload(exchange.Response.RawHex, exchange.Response.ContentCodec)
if err == nil {
decoded, kind, decodeErr := decodeUnaryResponse(exchange.Path, payload)
if decodeErr != nil {
err = decodeErr
} else if decoded != "" {
exchange.Response.DecodedJSON = decoded
exchange.Response.DecodedLang = "json"
exchange.ResponseKind = kind
changed = true
}
}
if err != nil {
exchange.Response.DecodeError = err.Error()
changed = true
}
}
if exchange.Request.DecodedJSON == "" && exchange.Request.RawHex != "" &&
!isProtoContentType(exchange.Request.ContentType) && streamingRequestMessageType(exchange.Path) == "" {
if hydrateStoredTextPayload(&exchange.Request) {
changed = true
}
}
if exchange.Response.DecodedJSON == "" && exchange.Response.RawHex != "" &&
!isProtoContentType(exchange.Response.ContentType) && streamingResponseMessageType(exchange.Path) == "" {
if hydrateStoredTextPayload(&exchange.Response) {
changed = true
}
}
return changed
}
// hydrateStoredTextPayload 为历史文本载荷补齐 JSON 视图。
func hydrateStoredTextPayload(payload *Payload) bool {
raw, err := hex.DecodeString(strings.TrimSpace(payload.RawHex))
if err != nil {
payload.DecodeError = fmt.Sprintf("解析已存储正文失败:%v", err)
return true
}
decoded, language, decodeErr := decodeCapturedContent(raw, payload.ContentType, payload.ContentCodec)
if decoded == "" && decodeErr == nil {
return false
}
payload.DecodedJSON = decoded
payload.DecodedLang = language
if decodeErr != nil {
payload.DecodeError = decodeErr.Error()
}
return true
}
// decodeCapturedContent 按媒体类型和压缩编码解码任意捕获正文。
-397
View File
@@ -1,397 +0,0 @@
// decode_stored.go 负责从持久化捕获记录恢复文本、帧和 protobuf 视图。
package main
import (
"bytes"
"compress/zlib"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"mime"
"net/url"
"strings"
"unicode"
"unicode/utf8"
"github.com/andybalholm/brotli"
agentv1 "github.com/leookun/cursor-byok/cursor-proto/gen/agent/v1"
aiserverv1 "github.com/leookun/cursor-byok/cursor-proto/gen/aiserver/v1"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/reflect/protoregistry"
"google.golang.org/protobuf/types/dynamicpb"
)
// decodeCapturedContent 按内容类型和压缩编码生成可读正文视图。
func decodeCapturedContent(payload []byte, contentType, codec string) (string, string, error) {
decoded, err := decodeHTTPContent(payload, codec)
if err != nil {
return "", "", err
}
if len(decoded) == 0 {
return "", "", nil
}
mediaType := normalizedMediaType(contentType)
if json.Valid(decoded) {
var formatted bytes.Buffer
if err := json.Indent(&formatted, decoded, "", " "); err != nil {
return string(decoded), "json", err
}
return formatted.String(), "json", nil
}
if mediaType == "application/x-www-form-urlencoded" && utf8.Valid(decoded) {
values, parseErr := url.ParseQuery(string(decoded))
if parseErr != nil {
return string(decoded), "plaintext", parseErr
}
formatted, marshalErr := json.MarshalIndent(values, "", " ")
return string(formatted), "json", marshalErr
}
if !isTextMediaType(mediaType) || !utf8.Valid(decoded) {
return "", "", nil
}
if strings.ContainsRune(string(decoded), '\x00') {
return "", "", nil
}
language := textLanguage(mediaType)
if strings.HasSuffix(mediaType, "+json") || mediaType == "application/json" {
return string(decoded), "json", fmt.Errorf("JSON 正文格式无效")
}
return string(decoded), language, nil
}
// decodeHTTPContent 解压 HTTP 内容编码并返回正文副本。
func decodeHTTPContent(payload []byte, codec string) ([]byte, error) {
encodings := strings.Split(strings.TrimSpace(codec), ",")
decoded := payload
for index := len(encodings) - 1; index >= 0; index-- {
encoding := strings.ToLower(strings.TrimSpace(encodings[index]))
switch encoding {
case "", "identity":
case "gzip", "x-gzip":
var err error
decoded, err = decompressPayload(decoded, "gzip")
if err != nil {
return nil, err
}
case "deflate":
reader, err := zlib.NewReader(bytes.NewReader(decoded))
if err != nil {
return nil, fmt.Errorf("deflate 解压失败:%w", err)
}
result, readErr := io.ReadAll(io.LimitReader(reader, maxConnectFrameBytes+1))
closeErr := reader.Close()
if readErr != nil {
return nil, fmt.Errorf("读取 deflate 内容失败:%w", readErr)
}
if closeErr != nil {
return nil, fmt.Errorf("关闭 deflate 内容失败:%w", closeErr)
}
if len(result) > maxConnectFrameBytes {
return nil, fmt.Errorf("deflate 解压后超过 %d 字节限制", maxConnectFrameBytes)
}
decoded = result
case "br":
result, readErr := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(decoded)), maxConnectFrameBytes+1))
if readErr != nil {
return nil, fmt.Errorf("读取 Brotli 内容失败:%w", readErr)
}
if len(result) > maxConnectFrameBytes {
return nil, fmt.Errorf("Brotli 解压后超过 %d 字节限制", maxConnectFrameBytes)
}
decoded = result
default:
return nil, fmt.Errorf("暂不支持内容编码 %q", encoding)
}
}
return decoded, nil
}
// normalizedMediaType 删除参数并统一媒体类型大小写。
func normalizedMediaType(contentType string) string {
mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(contentType))
if err == nil {
return strings.ToLower(mediaType)
}
return strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]))
}
// isProtoContentType 判断媒体类型是否表示 protobuf 二进制。
func isProtoContentType(contentType string) bool {
return strings.Contains(normalizedMediaType(contentType), "proto")
}
// isTextMediaType 判断媒体类型是否适合直接作为文本展示。
func isTextMediaType(mediaType string) bool {
return strings.HasPrefix(mediaType, "text/") || strings.HasSuffix(mediaType, "+json") ||
strings.HasSuffix(mediaType, "+xml") || mediaType == "application/json" ||
mediaType == "application/xml" || mediaType == "application/javascript" ||
mediaType == "application/x-javascript" || mediaType == "application/graphql"
}
// textLanguage 为前端编辑器选择文本语言。
func textLanguage(mediaType string) string {
switch {
case strings.Contains(mediaType, "json"):
return "json"
case strings.Contains(mediaType, "xml"):
return "xml"
case strings.Contains(mediaType, "html"):
return "html"
case strings.Contains(mediaType, "javascript"):
return "javascript"
case strings.Contains(mediaType, "css"):
return "css"
default:
return "plaintext"
}
}
// decodeStoredConnectFrames 从持久化十六进制载荷恢复流式帧。
func decodeStoredConnectFrames(rawHexValue, messageType, codec string) ([]FrameView, error) {
payload, err := hex.DecodeString(strings.TrimSpace(rawHexValue))
if err != nil {
return nil, fmt.Errorf("解析已存储 Connect 正文失败:%w", err)
}
frames := make([]FrameView, 0)
decoder := newConnectFrameDecoder(messageType, codec, defaultMaxFrames, func(frame FrameView) {
frames = append(frames, frame)
})
decoder.Write(payload)
decoder.Close()
return frames, nil
}
// isUnaryProtoContentType 判断媒体类型是否为可直接解码的 protobuf。
func isUnaryProtoContentType(contentType string) bool {
mediaType := normalizedMediaType(contentType)
return mediaType == "application/proto" || mediaType == "application/protobuf" || mediaType == "application/x-protobuf"
}
// decodeStoredRawPayload 解码持久化原始载荷并应用压缩处理。
func decodeStoredRawPayload(rawHexValue, codec string) ([]byte, error) {
payload, err := hex.DecodeString(strings.TrimSpace(rawHexValue))
if err != nil {
return nil, fmt.Errorf("解析已存储正文失败:%w", err)
}
if codec == "" || strings.EqualFold(codec, "identity") {
return payload, nil
}
return decompressPayload(payload, codec)
}
// unaryRequestMessage 根据 RPC 路径创建请求消息和稳定类型名。
func unaryRequestMessage(path string) (proto.Message, string) {
switch path {
case forkBackgroundComposerPath:
return &aiserverv1.ForkBackgroundComposerRequest{}, "fork_background_composer_request"
case notifyConversationClonePath:
return &agentv1.NotifyConversationCloneRequest{}, "notify_conversation_clone_request"
case uploadConversationBlobsPath:
return &agentv1.UploadConversationBlobsRequest{}, "upload_conversation_blobs_request"
case cppAvailableModelsPath:
return &aiserverv1.AvailableCppModelsRequest{}, "available_cpp_models_request"
case aiAvailableModelsPath:
return &aiserverv1.AvailableModelsRequest{}, "available_models_request"
case aiGetDefaultModelPath:
return &aiserverv1.GetDefaultModelRequest{}, "get_default_model_request"
case aiDefaultModelNudgeDataPath:
return &aiserverv1.GetDefaultModelNudgeDataRequest{}, "get_default_model_nudge_data_request"
case mcpGetKnownServersPath:
return &aiserverv1.GetKnownServersRequest{}, "get_known_servers_request"
case serverGetConfigPath:
return &aiserverv1.GetServerConfigRequest{}, "get_server_config_request"
default:
method := rpcMethodDescriptor(path)
if method == nil || method.IsStreamingClient() || method.IsStreamingServer() {
return nil, ""
}
return dynamicpb.NewMessage(method.Input()), protoMessageKind(method.Input())
}
}
// unaryResponseMessage 根据 RPC 路径创建响应消息和稳定类型名。
func unaryResponseMessage(path string) (proto.Message, string) {
switch path {
case forkBackgroundComposerPath:
return &aiserverv1.ForkBackgroundComposerResponse{}, "fork_background_composer_response"
case notifyConversationClonePath:
return &agentv1.NotifyConversationCloneResponse{}, "notify_conversation_clone_response"
case uploadConversationBlobsPath:
return &agentv1.UploadConversationBlobsResponse{}, "upload_conversation_blobs_response"
case cppAvailableModelsPath:
return &aiserverv1.AvailableCppModelsResponse{}, "available_cpp_models_response"
case aiAvailableModelsPath:
return &aiserverv1.AvailableModelsResponse{}, "available_models_response"
case aiGetDefaultModelPath:
return &aiserverv1.GetDefaultModelResponse{}, "get_default_model_response"
case aiDefaultModelNudgeDataPath:
return &aiserverv1.GetDefaultModelNudgeDataResponse{}, "get_default_model_nudge_data_response"
case mcpGetKnownServersPath:
return &aiserverv1.GetKnownServersResponse{}, "get_known_servers_response"
case serverGetConfigPath:
return &aiserverv1.GetServerConfigResponse{}, "get_server_config_response"
default:
method := rpcMethodDescriptor(path)
if method == nil || method.IsStreamingClient() || method.IsStreamingServer() {
return nil, ""
}
return dynamicpb.NewMessage(method.Output()), protoMessageKind(method.Output())
}
}
// streamingRequestMessageType 返回流式请求的 protobuf 类型名。
func streamingRequestMessageType(path string) string {
if path == runSSEPath {
return "aiserver.v1.BidiRequestId"
}
method := rpcMethodDescriptor(path)
if method == nil || (!method.IsStreamingClient() && !method.IsStreamingServer()) {
return ""
}
return string(method.Input().FullName())
}
// streamingResponseMessageType 返回流式响应的 protobuf 类型名。
func streamingResponseMessageType(path string) string {
if path == runSSEPath {
return "agent.v1.AgentServerMessage"
}
method := rpcMethodDescriptor(path)
if method == nil || (!method.IsStreamingClient() && !method.IsStreamingServer()) {
return ""
}
return string(method.Output().FullName())
}
// decodesUnaryRequest 判断是否存在已知的一元请求解码器。
func decodesUnaryRequest(path string) bool {
if path == bidiAppendPath {
return true
}
message, _ := unaryRequestMessage(path)
return message != nil
}
// decodesUnaryResponse 判断是否存在已知的一元响应解码器。
func decodesUnaryResponse(path string) bool {
message, _ := unaryResponseMessage(path)
return message != nil
}
// newMessage 按完整 protobuf 类型名从注册表创建消息实例。
func newMessage(messageType string) proto.Message {
switch messageType {
case "aiserver.v1.BidiRequestId":
return &aiserverv1.BidiRequestId{}
case "agent.v1.AgentServerMessage":
return &agentv1.AgentServerMessage{}
default:
descriptor, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(messageType))
if err != nil {
return nil
}
messageDescriptor, ok := descriptor.(protoreflect.MessageDescriptor)
if !ok {
return nil
}
return dynamicpb.NewMessage(messageDescriptor)
}
}
// rpcMethodDescriptor 通过完整 RPC 路径查找注册表中的方法描述。
func rpcMethodDescriptor(path string) protoreflect.MethodDescriptor {
parts := strings.Split(strings.Trim(strings.TrimSpace(path), "/"), "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return nil
}
descriptor, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(parts[0]))
if err != nil {
return nil
}
service, ok := descriptor.(protoreflect.ServiceDescriptor)
if !ok {
return nil
}
return service.Methods().ByName(protoreflect.Name(parts[1]))
}
// protoMessageKind 从消息描述推导稳定的 JSON kind 名称。
func protoMessageKind(descriptor protoreflect.MessageDescriptor) string {
if descriptor == nil {
return ""
}
return snakeCase(string(descriptor.Name()))
}
// snakeCase 将 protobuf 名称转换为前端稳定的下划线命名。
func snakeCase(value string) string {
var result strings.Builder
for index, character := range value {
if unicode.IsUpper(character) {
if index > 0 {
result.WriteByte('_')
}
result.WriteRune(unicode.ToLower(character))
continue
}
result.WriteRune(character)
}
return result.String()
}
// marshalProtoJSON 把 protobuf 消息编码为前端可读 JSON。
func marshalProtoJSON(message proto.Message) string {
if message == nil {
return ""
}
payload, err := (protojson.MarshalOptions{
UseProtoNames: true,
EmitUnpopulated: false,
Indent: " ",
}).Marshal(message)
if err != nil {
return ""
}
return string(payload)
}
// activeOneofName 返回 Agent 消息当前激活的 oneof 名称。
func activeOneofName(message proto.Message) string {
if message == nil {
return ""
}
reflected := message.ProtoReflect()
oneofs := reflected.Descriptor().Oneofs()
for index := 0; index < oneofs.Len(); index++ {
oneof := oneofs.Get(index)
field := reflected.WhichOneof(oneof)
if field != nil {
return string(field.Name())
}
}
return string(reflected.Descriptor().Name())
}
// prettyJSON 尝试格式化 JSON,失败时返回原始文本。
func prettyJSON(payload []byte) string {
var target any
if err := json.Unmarshal(payload, &target); err != nil {
return string(payload)
}
formatted, err := json.MarshalIndent(target, "", " ")
if err != nil {
return string(payload)
}
return string(formatted)
}
// clippedHex 限制原始载荷展示长度并标记省略部分。
func clippedHex(payload []byte, max int) string {
if len(payload) > max {
return hex.EncodeToString(payload[:max]) + "..."
}
return hex.EncodeToString(payload)
}
-25
View File
@@ -1,25 +0,0 @@
module github.com/leookun/cursor-proxy-debugger
go 1.25.8
require (
github.com/andybalholm/brotli v1.2.0
github.com/leookun/cursor-byok/cursor-proto v0.0.0
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.50.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
replace github.com/leookun/cursor-byok/cursor-proto => ../cursor-proto
-62
View File
@@ -1,62 +0,0 @@
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-50
View File
@@ -1,50 +0,0 @@
// cursor-proxy-debugger 提供独立 Cursor API 调试服务的进程入口。
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/pkg/browser"
)
// main 解析启动参数,并管理调试服务的完整生命周期。
func main() {
config := Config{}
openBrowser := true
flag.StringVar(&config.ServiceAddr, "addr", defaultServiceAddr, "Cursor API 调试服务监听地址")
flag.IntVar(&config.MaxExchanges, "max-exchanges", 200, "内存中保留的最大请求数")
flag.StringVar(&config.DatabasePath, "db", "", "SQLite 数据库路径(默认使用用户配置目录)")
flag.BoolVar(&openBrowser, "open", true, "启动后打开浏览器")
flag.Parse()
server, err := New(config)
if err != nil {
log.Fatal(err)
}
if err := server.Start(); err != nil {
log.Fatal(err)
}
fmt.Printf("Cursor API 调试服务已启动\n")
fmt.Printf("服务地址: http://%s\n", server.ServiceAddr())
fmt.Printf("固定上游: %s\n", defaultUpstreamURL)
fmt.Printf("调试界面: %s\n", server.UIURL())
fmt.Printf("SQLite: %s\n", server.DatabasePath())
if openBrowser {
_ = browser.OpenURL(server.UIURL())
}
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
<-signals
signal.Stop(signals)
if err := server.Close(); err != nil {
log.Printf("关闭调试服务失败:%v", err)
}
}
-144
View File
@@ -1,144 +0,0 @@
// server.go 负责固定上游服务、流量捕获和调试界面的生命周期。
package main
import (
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"sync"
"sync/atomic"
"time"
)
// Server 运行 Cursor API 转发服务及其本机调试界面。
type Server struct {
config Config
upstream *url.URL
store *exchangeStore
counter atomic.Uint64
serviceServer *http.Server
serviceLn net.Listener
runMu sync.Mutex
captureMu sync.RWMutex
}
// New 创建固定转发到 Cursor API 的协议调试服务。
func New(config Config) (*Server, error) {
config = config.normalized()
if err := validateLoopbackAddress(config.ServiceAddr); err != nil {
return nil, fmt.Errorf("服务监听地址无效:%w", err)
}
upstream, err := url.Parse(defaultUpstreamURL)
if err != nil {
return nil, fmt.Errorf("解析固定上游地址:%w", err)
}
store, err := newPersistentExchangeStore(config.DatabasePath, config.MaxExchanges)
if err != nil {
return nil, err
}
server := &Server{
config: config,
upstream: upstream,
store: store,
}
server.counter.Store(store.maxNumericID())
server.serviceServer = &http.Server{
Handler: server.newServiceHandler(),
ErrorLog: log.New(io.Discard, "", 0),
}
return server, nil
}
// Start 启动同时承载 API 转发和调试界面的单端口服务。
func (server *Server) Start() error {
server.runMu.Lock()
defer server.runMu.Unlock()
if server.serviceLn != nil {
return errors.New("Cursor API 调试服务已经启动")
}
serviceListener, err := net.Listen("tcp", server.config.ServiceAddr)
if err != nil {
return fmt.Errorf("启动 API 服务监听失败:%w", err)
}
server.serviceLn = serviceListener
go func() { _ = server.serviceServer.Serve(serviceListener) }()
return nil
}
// Close 立即关闭监听器、活跃连接并释放捕获存储。
func (server *Server) Close() error {
server.runMu.Lock()
serviceServer := server.serviceServer
server.serviceLn = nil
server.runMu.Unlock()
var errorsList []error
if serviceServer != nil {
if err := serviceServer.Close(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errorsList = append(errorsList, err)
}
}
if server.store != nil {
if err := server.store.close(); err != nil {
errorsList = append(errorsList, err)
}
}
return errors.Join(errorsList...)
}
// ServiceAddr 返回 Cursor API 服务监听地址。
func (server *Server) ServiceAddr() string { return server.config.ServiceAddr }
// UIURL 返回可在浏览器中打开的调试界面地址。
func (server *Server) UIURL() string {
return "http://" + browserAddress(server.config.ServiceAddr) + debugBasePath + "/"
}
// DatabasePath 返回捕获数据库路径。
func (server *Server) DatabasePath() string {
return server.config.DatabasePath
}
// newServiceHandler 创建单端口调试路由和固定上游流式转发。
func (server *Server) newServiceHandler() http.Handler {
reverseProxy := httputil.NewSingleHostReverseProxy(server.upstream)
reverseProxy.FlushInterval = -1
reverseProxy.ErrorLog = log.New(io.Discard, "", 0)
originalDirector := reverseProxy.Director
reverseProxy.Director = func(request *http.Request) {
originalDirector(request)
request.Host = server.upstream.Host
request.Header["X-Forwarded-For"] = nil
}
reverseProxy.Transport = &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
DisableCompression: true,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
reverseProxy.ModifyResponse = server.captureResponse
reverseProxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, upstreamErr error) {
server.failExchange(request, upstreamErr)
http.Error(writer, "Cursor API upstream unavailable", http.StatusBadGateway)
}
forwardHandler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
reverseProxy.ServeHTTP(writer, server.captureRequest(request))
})
debugHandler := http.StripPrefix(debugBasePath, server.newUIHandler())
mux := http.NewServeMux()
mux.Handle(debugBasePath+"/", debugHandler)
mux.HandleFunc(debugBasePath, func(writer http.ResponseWriter, request *http.Request) {
http.Redirect(writer, request, debugBasePath+"/", http.StatusTemporaryRedirect)
})
mux.Handle("/", forwardHandler)
return mux
}
-243
View File
@@ -1,243 +0,0 @@
// store.go 管理调试捕获的内存索引、SQLite 持久化和订阅通知。
package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
// exchangeStore 保存有限内存窗口以及可选的持久化数据库连接。
type exchangeStore struct {
mu sync.RWMutex
max int
order []string
exchanges map[string]*Exchange
subscribers map[chan storeEvent]struct{}
db *sql.DB
databasePath string
lastError string
}
// newExchangeStore 创建仅使用内存的捕获存储。
func newExchangeStore(max int) *exchangeStore {
return &exchangeStore{
max: max,
exchanges: make(map[string]*Exchange),
subscribers: make(map[chan storeEvent]struct{}),
}
}
// newPersistentExchangeStore 创建 SQLite 持久化捕获存储并恢复最近记录。
func newPersistentExchangeStore(path string, max int) (*exchangeStore, error) {
path = strings.TrimSpace(path)
if path == "" {
return nil, fmt.Errorf("SQLite 数据库路径不能为空")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("创建 SQLite 数据目录失败: %w", err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("打开 SQLite 数据库失败: %w", err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
store := newExchangeStore(max)
store.db = db
store.databasePath = path
if err := store.initializeDatabase(context.Background()); err != nil {
_ = db.Close()
return nil, err
}
if err := store.backfillDecodedExchanges(context.Background()); err != nil {
_ = db.Close()
return nil, err
}
if err := store.loadRecent(context.Background()); err != nil {
_ = db.Close()
return nil, err
}
return store, nil
}
// backfillDecodedExchanges 为旧记录补齐解码视图并写回数据库。
func (store *exchangeStore) backfillDecodedExchanges(ctx context.Context) error {
rows, err := store.db.QueryContext(ctx, "SELECT payload_json FROM exchanges")
if err != nil {
return fmt.Errorf("读取待回填的 SQLite 抓包记录失败: %w", err)
}
var exchanges []Exchange
for rows.Next() {
var payload []byte
if err := rows.Scan(&payload); err != nil {
_ = rows.Close()
return err
}
var exchange Exchange
if err := json.Unmarshal(payload, &exchange); err != nil {
_ = rows.Close()
return fmt.Errorf("解析待回填的 SQLite 抓包记录失败: %w", err)
}
if hydrateStoredExchange(&exchange) {
exchanges = append(exchanges, exchange)
}
}
if err := rows.Close(); err != nil {
return err
}
if err := rows.Err(); err != nil {
return err
}
if len(exchanges) == 0 {
return nil
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
for index := range exchanges {
payload, marshalErr := json.Marshal(&exchanges[index])
if marshalErr != nil {
return marshalErr
}
if _, err := tx.ExecContext(ctx, `UPDATE exchanges SET payload_json = ?, conversation_id = ?,
request_id = ?, updated_at_ms = ? WHERE id = ?`, payload, exchanges[index].ConversationID,
exchanges[index].RequestID, time.Now().UnixMilli(), exchanges[index].ID); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
committed = true
return nil
}
// initializeDatabase 创建调试器使用的 SQLite 表结构。
func (store *exchangeStore) initializeDatabase(ctx context.Context) error {
for _, statement := range []string{
"PRAGMA journal_mode = WAL",
"PRAGMA busy_timeout = 5000",
"PRAGMA secure_delete = ON",
`CREATE TABLE IF NOT EXISTS exchanges (
id TEXT PRIMARY KEY,
started_at_ms INTEGER NOT NULL,
conversation_id TEXT NOT NULL DEFAULT '',
request_id TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
request_bytes INTEGER NOT NULL DEFAULT 0,
response_bytes INTEGER NOT NULL DEFAULT 0,
payload_json BLOB NOT NULL,
updated_at_ms INTEGER NOT NULL
)`,
"CREATE INDEX IF NOT EXISTS exchanges_conversation_started_idx ON exchanges(conversation_id, started_at_ms DESC)",
"CREATE INDEX IF NOT EXISTS exchanges_request_idx ON exchanges(request_id)",
} {
if _, err := store.db.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("初始化 SQLite 数据库失败: %w", err)
}
}
return nil
}
// loadRecent 从数据库恢复内存窗口中的最新捕获。
func (store *exchangeStore) loadRecent(ctx context.Context) error {
rows, err := store.db.QueryContext(ctx, `SELECT payload_json, conversation_id
FROM exchanges ORDER BY started_at_ms DESC, id DESC LIMIT ?`, store.max)
if err != nil {
return fmt.Errorf("读取 SQLite 抓包记录失败: %w", err)
}
defer rows.Close()
for rows.Next() {
var payload []byte
var conversationID string
if err := rows.Scan(&payload, &conversationID); err != nil {
return err
}
var exchange Exchange
if err := json.Unmarshal(payload, &exchange); err != nil {
return fmt.Errorf("解析 SQLite 抓包记录失败: %w", err)
}
exchange.ConversationID = conversationID
store.exchanges[exchange.ID] = &exchange
store.order = append(store.order, exchange.ID)
}
return rows.Err()
}
// create 添加一条新的捕获并通知订阅者。
func (store *exchangeStore) create(exchange *Exchange) {
store.mu.Lock()
store.exchanges[exchange.ID] = exchange
store.order = append([]string{exchange.ID}, store.order...)
for len(store.order) > store.max {
oldest := store.order[len(store.order)-1]
store.order = store.order[:len(store.order)-1]
delete(store.exchanges, oldest)
}
store.persistLocked(exchange)
store.mu.Unlock()
store.publish(storeEvent{Type: "created", ID: exchange.ID})
}
// update 持久化修改并发布最终捕获快照。
func (store *exchangeStore) update(id string, apply func(*Exchange)) {
store.updateWithPersistence(id, apply, true)
}
// updateTransient 只更新内存并发布流式过程快照。
func (store *exchangeStore) updateTransient(id string, apply func(*Exchange)) {
store.updateWithPersistence(id, apply, false)
}
// updateWithPersistence 在统一锁内完成修改、关联和可选持久化。
func (store *exchangeStore) updateWithPersistence(id string, apply func(*Exchange), persist bool) {
store.mu.Lock()
exchange := store.exchanges[id]
if exchange == nil && store.db != nil {
var err error
exchange, err = store.loadPersistedLocked(id)
if err != nil {
store.lastError = err.Error()
}
if exchange != nil {
store.exchanges[id] = exchange
store.order = append([]string{id}, store.order...)
for len(store.order) > store.max {
oldest := store.order[len(store.order)-1]
store.order = store.order[:len(store.order)-1]
delete(store.exchanges, oldest)
}
}
}
if exchange != nil {
previousRequestID := exchange.RequestID
previousConversationID := exchange.ConversationID
apply(exchange)
if exchange.RequestID != previousRequestID || exchange.ConversationID != previousConversationID {
store.associateConversationLocked(exchange)
}
if persist {
store.persistLocked(exchange)
}
}
store.mu.Unlock()
store.publish(storeEvent{Type: "updated", ID: id})
}
// summaries 返回按时间倒序排列的请求摘要。
-354
View File
@@ -1,354 +0,0 @@
// store_queries.go 负责调试捕获记录的查询、持久化辅助和订阅通知。
package main
import (
"database/sql"
"encoding/json"
"sort"
"time"
)
// summaries 返回指定会话的捕获摘要列表。
func (store *exchangeStore) summaries(conversationID string) ([]ExchangeSummary, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if store.db != nil {
query := `SELECT payload_json, conversation_id FROM exchanges`
arguments := []any{}
if conversationID != "" {
query += " WHERE conversation_id = ?"
arguments = append(arguments, conversationID)
}
query += " ORDER BY started_at_ms DESC, id DESC"
rows, err := store.db.Query(query, arguments...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]ExchangeSummary, 0)
for rows.Next() {
var payload []byte
var persistedConversationID string
if err := rows.Scan(&payload, &persistedConversationID); err != nil {
return nil, err
}
var exchange Exchange
if err := json.Unmarshal(payload, &exchange); err != nil {
return nil, err
}
exchange.ConversationID = persistedConversationID
if current := store.exchanges[exchange.ID]; current != nil {
result = append(result, current.ExchangeSummary)
} else {
result = append(result, exchange.ExchangeSummary)
}
}
return result, rows.Err()
}
result := make([]ExchangeSummary, 0, len(store.order))
for _, id := range store.order {
if exchange := store.exchanges[id]; exchange != nil {
result = append(result, exchange.ExchangeSummary)
}
}
return result, nil
}
// get 返回内存或数据库中的完整捕获副本。
func (store *exchangeStore) get(id string) (Exchange, bool, error) {
store.mu.RLock()
defer store.mu.RUnlock()
exchange := store.exchanges[id]
if exchange != nil {
return cloneExchange(*exchange), true, nil
}
if store.db == nil {
return Exchange{}, false, nil
}
persisted, err := store.loadPersistedLocked(id)
if err != nil {
return Exchange{}, false, err
}
if persisted == nil {
return Exchange{}, false, nil
}
return *persisted, true, nil
}
// loadPersistedLocked 从 SQLite 读取单条捕获并在必要时解码回填。
func (store *exchangeStore) loadPersistedLocked(id string) (*Exchange, error) {
var payload []byte
var conversationID string
err := store.db.QueryRow("SELECT payload_json, conversation_id FROM exchanges WHERE id = ?", id).Scan(&payload, &conversationID)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
var persisted Exchange
if err := json.Unmarshal(payload, &persisted); err != nil {
return nil, err
}
persisted.ConversationID = conversationID
return &persisted, nil
}
// clear 清除数据库、内存索引和会话关联。
func (store *exchangeStore) clear() error {
store.mu.Lock()
var err error
if store.db != nil {
_, err = store.db.Exec("DELETE FROM exchanges")
if err != nil {
store.lastError = err.Error()
}
}
if err == nil {
store.order = nil
store.exchanges = make(map[string]*Exchange)
store.lastError = ""
}
store.mu.Unlock()
if err == nil {
store.publish(storeEvent{Type: "cleared"})
}
return err
}
// conversations 按会话聚合持久化捕获统计。
func (store *exchangeStore) conversations() ([]ConversationSummary, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if store.db == nil {
groups := make(map[string]*ConversationSummary)
for _, exchange := range store.exchanges {
group := groups[exchange.ConversationID]
if group == nil {
group = &ConversationSummary{ConversationID: exchange.ConversationID}
groups[exchange.ConversationID] = group
}
group.ExchangeCount++
group.RequestBytes += exchange.RequestBytes
group.ResponseBytes += exchange.ResponseBytes
if exchange.StartedAt.After(group.LastStartedAt) {
group.LastStartedAt = exchange.StartedAt
}
}
result := make([]ConversationSummary, 0, len(groups))
for _, group := range groups {
result = append(result, *group)
}
sort.Slice(result, func(i, j int) bool { return result[i].LastStartedAt.After(result[j].LastStartedAt) })
return result, nil
}
rows, err := store.db.Query(`SELECT conversation_id, COUNT(*), MAX(started_at_ms),
COALESCE(SUM(request_bytes), 0), COALESCE(SUM(response_bytes), 0)
FROM exchanges GROUP BY conversation_id ORDER BY MAX(started_at_ms) DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]ConversationSummary, 0)
for rows.Next() {
var summary ConversationSummary
var startedAtMS int64
if err := rows.Scan(&summary.ConversationID, &summary.ExchangeCount, &startedAtMS, &summary.RequestBytes, &summary.ResponseBytes); err != nil {
return nil, err
}
summary.LastStartedAt = time.UnixMilli(startedAtMS)
result = append(result, summary)
}
return result, rows.Err()
}
// persistLocked 将当前捕获快照写入 SQLite。
func (store *exchangeStore) persistLocked(exchange *Exchange) {
if store.db == nil || exchange == nil {
return
}
payload, err := json.Marshal(exchange)
if err == nil {
_, err = store.db.Exec(`INSERT INTO exchanges (
id, started_at_ms, conversation_id, request_id, state, request_bytes,
response_bytes, payload_json, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
started_at_ms = excluded.started_at_ms,
conversation_id = excluded.conversation_id,
request_id = excluded.request_id,
state = excluded.state,
request_bytes = excluded.request_bytes,
response_bytes = excluded.response_bytes,
payload_json = excluded.payload_json,
updated_at_ms = excluded.updated_at_ms`,
exchange.ID, exchange.StartedAt.UnixMilli(), exchange.ConversationID,
exchange.RequestID, exchange.State, exchange.RequestBytes,
exchange.ResponseBytes, payload, time.Now().UnixMilli())
}
if err != nil {
store.lastError = err.Error()
} else {
store.lastError = ""
}
}
// associateConversationLocked 根据请求标识补齐会话关联。
func (store *exchangeStore) associateConversationLocked(exchange *Exchange) {
if exchange.RequestID == "" {
return
}
if exchange.ConversationID == "" {
for _, candidate := range store.exchanges {
if candidate.RequestID == exchange.RequestID && candidate.ConversationID != "" {
exchange.ConversationID = candidate.ConversationID
break
}
}
}
if exchange.ConversationID == "" && store.db != nil {
_ = store.db.QueryRow(`SELECT conversation_id FROM exchanges
WHERE request_id = ? AND conversation_id != ''
ORDER BY started_at_ms DESC LIMIT 1`, exchange.RequestID).Scan(&exchange.ConversationID)
}
if exchange.ConversationID == "" {
return
}
for _, candidate := range store.exchanges {
if candidate.RequestID == exchange.RequestID && candidate.ConversationID == "" {
candidate.ConversationID = exchange.ConversationID
store.persistLocked(candidate)
}
}
if store.db != nil {
if _, err := store.db.Exec(`UPDATE exchanges SET conversation_id = ?, updated_at_ms = ?
WHERE request_id = ? AND conversation_id = ''`, exchange.ConversationID, time.Now().UnixMilli(), exchange.RequestID); err != nil {
store.lastError = err.Error()
}
}
}
// maxNumericID 返回数据库中已使用的最大数字捕获编号。
func (store *exchangeStore) maxNumericID() uint64 {
store.mu.RLock()
defer store.mu.RUnlock()
var maximum uint64
if store.db != nil {
_ = store.db.QueryRow("SELECT COALESCE(MAX(CAST(id AS INTEGER)), 0) FROM exchanges").Scan(&maximum)
}
return maximum
}
// close 关闭数据库连接并终止后续订阅通知。
func (store *exchangeStore) close() error {
store.mu.Lock()
defer store.mu.Unlock()
if store.db == nil {
return nil
}
err := store.db.Close()
store.db = nil
return err
}
// status 返回数据库路径和最近一次数据库错误。
func (store *exchangeStore) status() (string, string) {
store.mu.RLock()
defer store.mu.RUnlock()
return store.databasePath, store.lastError
}
// subscribe 注册一个捕获变化订阅者。
func (store *exchangeStore) subscribe() (<-chan storeEvent, func()) {
updates := make(chan storeEvent, 32)
store.mu.Lock()
store.subscribers[updates] = struct{}{}
store.mu.Unlock()
return updates, func() {
store.mu.Lock()
if _, ok := store.subscribers[updates]; ok {
delete(store.subscribers, updates)
close(updates)
}
store.mu.Unlock()
}
}
// publish 非阻塞地广播捕获变化事件。
func (store *exchangeStore) publish(event storeEvent) {
store.mu.RLock()
defer store.mu.RUnlock()
for subscriber := range store.subscribers {
select {
case subscriber <- event:
default:
}
}
}
// cloneExchange 深拷贝捕获及其请求响应载荷。
func cloneExchange(exchange Exchange) Exchange {
exchange.Request = clonePayload(exchange.Request)
exchange.Response = clonePayload(exchange.Response)
return exchange
}
// clonePayload 深拷贝头信息和 Connect 帧切片。
func clonePayload(payload Payload) Payload {
payload.Headers = append([]Header(nil), payload.Headers...)
payload.Frames = append([]FrameView(nil), payload.Frames...)
return payload
}
// elapsedMS 计算从开始时间到当前时间的毫秒耗时。
func elapsedMS(startedAt time.Time) int64 {
if startedAt.IsZero() {
return 0
}
return time.Since(startedAt).Milliseconds()
}
// sortedHeaders 生成脱敏且按名称排序的请求头列表。
func sortedHeaders(headers map[string][]string) []Header {
result := make([]Header, 0, len(headers))
for name, values := range headers {
value := ""
for index, item := range values {
if index > 0 {
value += ", "
}
value += item
}
if isSensitiveHeader(name) && value != "" {
value = "[已隐藏]"
}
result = append(result, Header{Name: name, Value: value})
}
sort.Slice(result, func(left, right int) bool {
return result[left].Name < result[right].Name
})
return result
}
// isSensitiveHeader 判断请求头是否包含鉴权或隐私信息。
func isSensitiveHeader(name string) bool {
switch httpCanonicalLower(name) {
case "authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key":
return true
default:
return false
}
}
// httpCanonicalLower 将请求头名称规范化为小写形式。
func httpCanonicalLower(value string) string {
buffer := make([]byte, len(value))
for index := range value {
character := value[index]
if character >= 'A' && character <= 'Z' {
character += 'a' - 'A'
}
buffer[index] = character
}
return string(buffer)
}
-189
View File
@@ -1,189 +0,0 @@
// types.go 定义协议调试器配置、捕获详情和会话摘要模型。
package main
import (
"os"
"path/filepath"
"time"
)
// 默认值限制调试器只监听本机并约束内存捕获规模。
const (
defaultServiceAddr = "127.0.0.1:9090"
defaultUpstreamURL = "https://api2.cursor.sh"
debugBasePath = "/__debuger__"
defaultMaxExchanges = 200
defaultMaxCaptureBytes = 2 << 20
defaultMaxFrames = 2000
defaultDatabaseName = "cursor-proxy-debugger.db"
)
// Config 控制独立协议调试器的监听和存储限制。
type Config struct {
// ServiceAddr 是 Cursor API 调试服务监听地址。
ServiceAddr string
// MaxExchanges 是内存保留的最大请求数。
MaxExchanges int
// MaxCaptureBytes 是单向载荷保存上限。
MaxCaptureBytes int
// MaxFrames 是单条流保存的 Connect 帧上限。
MaxFrames int
// DatabasePath 是 SQLite 捕获数据库路径。
DatabasePath string
}
// normalized 补齐空值并拒绝无效的容量配置。
func (config Config) normalized() Config {
if config.ServiceAddr == "" {
config.ServiceAddr = defaultServiceAddr
}
if config.MaxExchanges <= 0 {
config.MaxExchanges = defaultMaxExchanges
}
if config.MaxCaptureBytes <= 0 {
config.MaxCaptureBytes = defaultMaxCaptureBytes
}
if config.MaxFrames <= 0 {
config.MaxFrames = defaultMaxFrames
}
if config.DatabasePath == "" {
config.DatabasePath = defaultDatabasePath()
}
return config
}
// defaultDatabasePath 返回当前用户配置目录下的默认数据库路径。
func defaultDatabasePath() string {
configDir, err := os.UserConfigDir()
if err != nil || configDir == "" {
return defaultDatabaseName
}
return filepath.Join(configDir, "cursor-byok", defaultDatabaseName)
}
// ExchangeSummary 是请求列表使用的紧凑捕获摘要。
type ExchangeSummary struct {
// ID 是进程内递增的捕获标识。
ID string `json:"id"`
// StartedAt 是请求开始时间。
StartedAt time.Time `json:"startedAt"`
// Method 是 HTTP 方法。
Method string `json:"method"`
// URL 是完整请求地址。
URL string `json:"url"`
// Host 是请求目标主机。
Host string `json:"host"`
// Path 是 RPC 或 HTTP 路径。
Path string `json:"path"`
// Status 是 HTTP 响应状态码。
Status int `json:"status"`
// State 是捕获处理阶段。
State string `json:"state"`
// DurationMS 是请求总耗时毫秒数。
DurationMS int64 `json:"durationMs"`
// RequestBytes 是完整请求体字节数。
RequestBytes int64 `json:"requestBytes"`
// ResponseBytes 是完整响应体字节数。
ResponseBytes int64 `json:"responseBytes"`
// RequestID 是协议请求标识。
RequestID string `json:"requestId,omitempty"`
// ConversationID 是关联会话标识。
ConversationID string `json:"conversationId,omitempty"`
// RequestKind 是解码后的请求消息类型。
RequestKind string `json:"requestKind,omitempty"`
// ResponseKind 是解码后的响应消息类型。
ResponseKind string `json:"responseKind,omitempty"`
// FrameCount 是双向 Connect 帧总数。
FrameCount int `json:"frameCount"`
// Error 是转发或解码错误。
Error string `json:"error,omitempty"`
}
// Exchange 保存调试界面展示的请求和响应详情。
type Exchange struct {
ExchangeSummary
// Request 是请求方向载荷。
Request Payload `json:"request"`
// Response 是响应方向载荷。
Response Payload `json:"response"`
}
// Payload 保存请求头、原始副本、解码正文和协议帧。
type Payload struct {
// Headers 是脱敏且排序稳定的 HTTP 请求头。
Headers []Header `json:"headers"`
// ContentType 是规范化媒体类型。
ContentType string `json:"contentType,omitempty"`
// ContentCodec 是内容压缩算法。
ContentCodec string `json:"contentCodec,omitempty"`
// Size 是完整方向载荷字节数。
Size int64 `json:"size"`
// RawHex 是受限原始副本的十六进制文本。
RawHex string `json:"rawHex,omitempty"`
// RawTruncated 表示原始副本达到保存上限。
RawTruncated bool `json:"rawTruncated,omitempty"`
// DecodedJSON 是格式化后的结构化正文。
DecodedJSON string `json:"decodedJson,omitempty"`
// DecodedLang 是前端编辑器使用的语言标识。
DecodedLang string `json:"decodedLanguage,omitempty"`
// DecodeError 是不影响转发的解码错误。
DecodeError string `json:"decodeError,omitempty"`
// Frames 是 Connect 流的逐帧视图。
Frames []FrameView `json:"frames,omitempty"`
}
// Header 是排序稳定的 HTTP 请求头键值对。
type Header struct {
// Name 是请求头名称。
Name string `json:"name"`
// Value 是已脱敏的请求头值。
Value string `json:"value"`
}
// FrameView 描述一条 Connect 流式信封。
type FrameView struct {
// Index 是帧在当前方向的序号。
Index int `json:"index"`
// Flags 是 Connect 原始标志位。
Flags uint8 `json:"flags"`
// Length 是解压前帧载荷长度。
Length int `json:"length"`
// Compressed 表示帧载荷使用压缩。
Compressed bool `json:"compressed"`
// EndStream 表示帧携带流结束标志。
EndStream bool `json:"endStream"`
// Kind 是解码后的业务消息类型。
Kind string `json:"kind,omitempty"`
// MessageType 是 protobuf 完整消息名。
MessageType string `json:"messageType,omitempty"`
// RequestID 是帧中解析出的请求标识。
RequestID string `json:"requestId,omitempty"`
// JSON 是 protobuf 的 JSON 视图。
JSON string `json:"json,omitempty"`
// RawHex 是无法解码时保留的载荷文本。
RawHex string `json:"rawHex,omitempty"`
// Error 是当前帧的解压或解码错误。
Error string `json:"error,omitempty"`
}
// storeEvent 是 SSE 通知使用的最小变化事件。
type storeEvent struct {
// Type 是捕获记录变化类型。
Type string `json:"type"`
// ID 是关联捕获标识。
ID string `json:"id,omitempty"`
}
// ConversationSummary 描述按会话聚合的持久化流量。
type ConversationSummary struct {
// ConversationID 是会话稳定标识。
ConversationID string `json:"conversationId"`
// ExchangeCount 是会话捕获记录数。
ExchangeCount int `json:"exchangeCount"`
// LastStartedAt 是会话最近请求时间。
LastStartedAt time.Time `json:"lastStartedAt"`
// RequestBytes 是会话累计请求字节数。
RequestBytes int64 `json:"requestBytes"`
// ResponseBytes 是会话累计响应字节数。
ResponseBytes int64 `json:"responseBytes"`
}
-141
View File
@@ -1,141 +0,0 @@
// web.go 提供调试器只读 API、SSE 更新流和内嵌静态页面。
package main
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"strings"
"time"
)
// webAssets 保存无需外部文件即可启动的调试页面资源。
//
//go:embed web/*
var webAssets embed.FS
// newUIHandler 注册只绑定本机界面的调试 API 和静态资源。
func (server *Server) newUIHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/status", server.handleStatus)
mux.HandleFunc("GET /api/exchanges", server.handleExchangeList)
mux.HandleFunc("GET /api/exchanges/{id}", server.handleExchangeDetail)
mux.HandleFunc("GET /api/conversations", server.handleConversationList)
mux.HandleFunc("DELETE /api/exchanges", server.handleClearExchanges)
mux.HandleFunc("GET /api/events", server.handleEvents)
assets, _ := fs.Sub(webAssets, "web")
fileServer := http.FileServer(http.FS(assets))
mux.Handle("/", fileServer)
return securityHeaders(mux)
}
// handleStatus 返回监听地址、固定上游和数据库状态。
func (server *Server) handleStatus(writer http.ResponseWriter, _ *http.Request) {
databasePath, databaseError := server.store.status()
writeJSON(writer, http.StatusOK, map[string]any{
"serviceAddr": server.config.ServiceAddr,
"debugPath": debugBasePath + "/",
"upstreamURL": server.upstream.String(),
"running": true,
"databasePath": databasePath,
"databaseError": databaseError,
})
}
// handleExchangeList 按可选会话标识列出请求摘要。
func (server *Server) handleExchangeList(writer http.ResponseWriter, request *http.Request) {
conversationID := strings.TrimSpace(request.URL.Query().Get("conversation_id"))
summaries, err := server.store.summaries(conversationID)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(writer, http.StatusOK, summaries)
}
// handleExchangeDetail 返回单条请求的完整捕获详情。
func (server *Server) handleExchangeDetail(writer http.ResponseWriter, request *http.Request) {
id := strings.TrimSpace(request.PathValue("id"))
exchange, ok, err := server.store.get(id)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !ok {
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "请求记录不存在"})
return
}
writeJSON(writer, http.StatusOK, exchange)
}
// handleClearExchanges 清除内存和 SQLite 中的捕获记录。
func (server *Server) handleClearExchanges(writer http.ResponseWriter, _ *http.Request) {
if err := server.clearExchanges(); err != nil {
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writer.WriteHeader(http.StatusNoContent)
}
// handleConversationList 返回持久化流量的会话分组。
func (server *Server) handleConversationList(writer http.ResponseWriter, _ *http.Request) {
conversations, err := server.store.conversations()
if err != nil {
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(writer, http.StatusOK, conversations)
}
// handleEvents 通过 SSE 推送捕获记录变化和保活心跳。
func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Request) {
flusher, ok := writer.(http.Flusher)
if !ok {
http.Error(writer, "当前响应不支持流式刷新", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "text/event-stream")
writer.Header().Set("Cache-Control", "no-cache")
writer.Header().Set("Connection", "keep-alive")
updates, unsubscribe := server.store.subscribe()
defer unsubscribe()
fmt.Fprint(writer, "event: ready\ndata: {}\n\n")
flusher.Flush()
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-request.Context().Done():
return
case event, open := <-updates:
if !open {
return
}
payload, _ := json.Marshal(event)
fmt.Fprintf(writer, "event: update\ndata: %s\n\n", payload)
flusher.Flush()
case <-heartbeat.C:
fmt.Fprint(writer, ": heartbeat\n\n")
flusher.Flush()
}
}
}
// writeJSON 写入统一 JSON 响应。
func writeJSON(writer http.ResponseWriter, status int, payload any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(payload)
}
// securityHeaders 为本地调试页面添加最小浏览器安全策略。
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("Referrer-Policy", "no-referrer")
writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self'; worker-src 'self' blob:")
next.ServeHTTP(writer, request)
})
}
-500
View File
@@ -1,500 +0,0 @@
// app.js 管理协议调试器列表筛选、详情编辑器和实时事件交互。
import { getLocale, setLocale, t, translateDocument } from "./i18n.js";
import { bindEvents, renderPauseState } from "./app_events.js";
import { escapeHTML, formatBytes, formatDuration, formatHex, formatState, renderDecodeError, renderTruncated } from "./view_helpers.js";
const monacoReady = loadMonaco();
const editorSlots = {
request: { editor: null, model: null, host: null, token: 0, value: "", language: "plaintext" },
response: { editor: null, model: null, host: null, token: 0, value: "", language: "plaintext" },
};
const state = {
status: null,
exchanges: [],
conversations: [],
selectedId: null,
selected: null,
search: "",
requestId: "",
conversationId: "",
endpoint: "all",
bidiMessageKinds: new Set(),
showOptions: false,
sortOrder: "desc",
paused: false,
pendingRefresh: false,
connection: { connected: false, key: "status.connecting", values: {} },
tabs: {
request: "body",
response: "body",
},
};
const elements = {
statusDot: document.querySelector("#status-dot"),
statusText: document.querySelector("#status-text"),
serviceAddress: document.querySelector("#service-address"),
upstreamURL: document.querySelector("#upstream-url"),
connectionLabel: document.querySelector("#connection-label"),
trafficSummary: document.querySelector("#traffic-summary"),
searchInput: document.querySelector("#search-input"),
requestIdInput: document.querySelector("#request-id-input"),
conversationSelect: document.querySelector("#conversation-select"),
endpointFilter: document.querySelector("#endpoint-filter"),
bidiMessageFilter: document.querySelector("#bidi-message-filter"),
bidiMessageOptions: document.querySelector("#bidi-message-options"),
showOptionsCheckbox: document.querySelector("#show-options-checkbox"),
sortOrder: document.querySelector("#sort-order"),
requestCount: document.querySelector("#request-count"),
requestList: document.querySelector("#request-list"),
emptyState: document.querySelector("#empty-state"),
selectionSummary: document.querySelector("#selection-summary"),
requestContent: document.querySelector("#request-content"),
responseContent: document.querySelector("#response-content"),
pauseButton: document.querySelector("#pause-button"),
clearButton: document.querySelector("#clear-button"),
localeSelect: document.querySelector("#locale-select"),
workspace: document.querySelector("#workspace"),
splitter: document.querySelector("#horizontal-splitter"),
};
async function fetchJSON(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
if (response.status === 204) return null;
return response.json();
}
async function loadStatus() {
state.status = await fetchJSON("api/status");
elements.statusDot.classList.toggle("online", Boolean(state.status.running));
renderRuntimeStatus();
elements.serviceAddress.textContent = `http://${state.status.serviceAddr}`;
elements.upstreamURL.textContent = state.status.upstreamURL;
elements.showOptionsCheckbox.checked = state.showOptions;
}
async function refreshList() {
const query = state.conversationId ? `?conversation_id=${encodeURIComponent(state.conversationId)}` : "";
[state.exchanges, state.conversations] = await Promise.all([
fetchJSON(`api/exchanges${query}`),
fetchJSON("api/conversations"),
]);
renderConversationOptions();
renderBidiMessageFilter();
renderList();
renderTrafficSummary();
if (state.selectedId && state.exchanges.some((item) => item.id === state.selectedId)) {
await refreshDetail(state.selectedId);
} else if (state.selectedId) {
state.selectedId = null;
state.selected = null;
renderDetail();
}
}
function renderConversationOptions() {
const selected = state.conversationId;
const options = [`<option value="">${escapeHTML(t("filters.allConversations"))}</option>`];
for (const conversation of state.conversations) {
if (!conversation.conversationId) continue;
const label = `${conversation.conversationId} (${conversation.exchangeCount})`;
options.push(`<option value="${escapeHTML(conversation.conversationId)}">${escapeHTML(label)}</option>`);
}
elements.conversationSelect.innerHTML = options.join("");
elements.conversationSelect.value = selected;
}
async function refreshDetail(id) {
if (!id) return;
try {
const detail = await fetchJSON(`api/exchanges/${encodeURIComponent(id)}`);
if (state.selectedId !== id) return;
state.selected = detail;
renderDetail();
} catch (error) {
if (state.selectedId === id) {
state.selected = null;
renderDetailError(error);
}
}
}
function scheduleRefresh() {
if (state.paused) {
state.pendingRefresh = true;
return;
}
if (state.pendingRefresh) return;
state.pendingRefresh = true;
window.setTimeout(async () => {
state.pendingRefresh = false;
try {
await refreshList();
} catch (error) {
setConnectionState(false, "connection.refreshFailed", { message: error.message });
}
}, 90);
}
function connectEvents() {
const events = new EventSource("api/events");
events.addEventListener("open", () => setConnectionState(true, "connection.live"));
events.addEventListener("update", scheduleRefresh);
events.addEventListener("error", () => setConnectionState(false, "connection.retrying"));
}
function setConnectionState(connected, key, values = {}) {
state.connection = { connected, key, values };
renderConnectionState();
}
function renderRuntimeStatus() {
if (!state.status) {
elements.statusText.textContent = t("status.connecting");
return;
}
elements.statusText.textContent = t(state.status.running ? "status.running" : "status.stopped");
}
function renderConnectionState() {
const { connected, key, values } = state.connection;
elements.connectionLabel.textContent = t(key, values);
elements.statusDot.classList.toggle("online", connected && Boolean(state.status?.running));
}
function filteredExchanges() {
const query = state.search.trim().toLowerCase();
const requestId = state.requestId.trim().toLowerCase();
const direction = state.sortOrder === "asc" ? 1 : -1;
return state.exchanges
.filter((item) => {
if (!state.showOptions && String(item.method || "").toUpperCase() === "OPTIONS") return false;
if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false;
if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false;
if (state.endpoint === "bidiappend" && state.bidiMessageKinds.size > 0 && !state.bidiMessageKinds.has(item.requestKind || "")) return false;
if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false;
if (!query) return true;
return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query));
})
.sort((left, right) => {
const startedAtDelta = new Date(left.startedAt).getTime() - new Date(right.startedAt).getTime();
if (startedAtDelta !== 0) return startedAtDelta * direction;
return left.id.localeCompare(right.id, undefined, { numeric: true }) * direction;
});
}
function renderBidiMessageFilter() {
const visible = state.endpoint === "bidiappend";
elements.bidiMessageFilter.hidden = !visible;
if (!visible) elements.bidiMessageFilter.open = false;
const availableKinds = state.exchanges
.filter((item) => item.path.toLowerCase().includes("bidiappend") && item.requestKind)
.map((item) => item.requestKind);
const kinds = [...new Set([...availableKinds, ...state.bidiMessageKinds])].sort((left, right) => left.localeCompare(right));
elements.bidiMessageFilter.querySelector("summary").textContent = state.bidiMessageKinds.size
? t("filters.selectedMessageTypes", { count: state.bidiMessageKinds.size })
: t("filters.allMessageTypes");
elements.bidiMessageOptions.innerHTML = [
`<label class="multi-select-option all-option"><input type="checkbox" value=""${state.bidiMessageKinds.size === 0 ? " checked" : ""}><span>${escapeHTML(t("filters.allMessageTypes"))}</span></label>`,
...kinds.map((kind) => `<label class="multi-select-option"><input type="checkbox" value="${escapeHTML(kind)}"${state.bidiMessageKinds.has(kind) ? " checked" : ""}><span title="${escapeHTML(kind)}">${escapeHTML(kind)}</span></label>`),
].join("");
}
function renderList() {
const exchanges = filteredExchanges();
elements.requestCount.textContent = t("count.requests", { count: exchanges.length });
elements.emptyState.classList.toggle("hidden", exchanges.length > 0);
const groups = new Map();
for (const item of exchanges) {
const conversationID = item.conversationId || "";
if (!groups.has(conversationID)) groups.set(conversationID, []);
groups.get(conversationID).push(item);
}
elements.requestList.innerHTML = [...groups.entries()]
.map(([conversationID, items]) => {
const label = conversationID || t("groups.unassigned");
const header = `<tr class="conversation-group"><td colspan="9"><span>${escapeHTML(t("groups.conversation"))}</span><code title="${escapeHTML(label)}">${escapeHTML(label)}</code><strong>${items.length}</strong></td></tr>`;
const rows = items.map((item) => {
const selected = item.id === state.selectedId ? " selected" : "";
const statusClass = item.status >= 400 ? "error" : item.status ? "success" : "";
const kind = item.responseKind || item.requestKind || "-";
return `<tr class="${selected.trim()}" data-id="${escapeHTML(item.id)}">
<td><span class="row-state ${escapeHTML(item.state)}"></span></td>
<td><code>${escapeHTML(item.id)}</code></td>
<td title="${escapeHTML(item.url)}"><code>${escapeHTML(item.url)}</code></td>
<td title="${escapeHTML(item.requestId || "")}"><code class="request-id-text">${escapeHTML(item.requestId || "-")}</code></td>
<td><span class="kind-text">${escapeHTML(kind)}</span></td>
<td><span class="method-text">${escapeHTML(item.method)}</span></td>
<td><span class="status-text ${statusClass}">${item.status || "-"}</span></td>
<td>${formatBytes(item.responseBytes)}</td>
<td>${formatDuration(item.durationMs)}</td>
</tr>`;
}).join("");
return header + rows;
})
.join("");
}
function renderTrafficSummary() {
const totals = state.exchanges.reduce(
(result, item) => {
result.up += item.requestBytes || 0;
result.down += item.responseBytes || 0;
return result;
},
{ up: 0, down: 0 },
);
elements.trafficSummary.textContent = `${formatBytes(totals.up)} ↓ ${formatBytes(totals.down)}`;
}
function renderDetail() {
if (!state.selected) {
disposeEditor("request");
disposeEditor("response");
elements.selectionSummary.innerHTML = `<span class="method-badge">POST</span><span class="status-badge">${escapeHTML(t("selection.waiting"))}</span><code>${escapeHTML(t("selection.prompt"))}</code>`;
elements.requestContent.classList.remove("editor-active");
elements.responseContent.classList.remove("editor-active");
elements.requestContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noRequest"))}</div>`;
elements.responseContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noResponse"))}</div>`;
return;
}
const item = state.selected;
const statusClass = item.status >= 200 && item.status < 400 ? "success" : "";
elements.selectionSummary.innerHTML = `<span class="method-badge">${escapeHTML(item.method)}</span><span class="status-badge ${statusClass}">${escapeHTML(item.status || formatState(item.state))}</span><code>${escapeHTML(item.url)}</code>`;
renderPayload("request", item.request, state.tabs.request);
renderPayload("response", item.response, state.tabs.response);
}
function renderDetailError(error) {
disposeEditor("request");
disposeEditor("response");
elements.requestContent.classList.remove("editor-active");
elements.responseContent.classList.remove("editor-active");
elements.requestContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
elements.responseContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
}
function renderPayload(side, payload, tab) {
const container = elements[`${side}Content`];
if (!payload) {
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noContent"))}</div>`);
return;
}
if (tab === "headers") {
renderStaticPayload(side, renderHeaders(payload.headers));
return;
}
if (tab === "raw") {
if (!payload.rawHex) {
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noRaw"))}</div>`);
return;
}
renderEditorPayload(side, formatHex(payload.rawHex), "plaintext", renderTruncated(payload.rawTruncated));
return;
}
if (tab === "frames") {
const document = frameEditorDocument(payload.frames);
if (!document) {
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noFrames"))}</div>`);
return;
}
renderEditorPayload(side, document, "json", "");
return;
}
if (payload.decodedJson) {
renderEditorPayload(side, payload.decodedJson, payload.decodedLanguage || "json", `${renderDecodeError(payload.decodeError)}${renderTruncated(payload.rawTruncated)}`);
return;
}
if (payload.frames?.length) {
renderEditorPayload(side, frameEditorDocument(payload.frames), "json", "");
return;
}
if (payload.decodeError) {
renderStaticPayload(side, `<div class="notice error">${escapeHTML(payload.decodeError)}</div>`);
return;
}
container.classList.remove("editor-active");
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noBody"))}</div>`);
}
function renderHeaders(headers = []) {
const items = Array.isArray(headers) ? headers : [];
if (!items.length) return `<div class="notice">${escapeHTML(t("notices.noHeaders"))}</div>`;
return `<table class="headers-table"><tbody>${items
.map((header) => `<tr><th>${escapeHTML(header.name)}</th><td>${escapeHTML(header.value)}</td></tr>`)
.join("")}</tbody></table>`;
}
function frameEditorDocument(frames = []) {
const items = Array.isArray(frames) ? frames : [];
if (!items.length) return "";
const normalized = items.map((frame) => {
let message = frame.rawHex || null;
if (frame.json) {
try {
message = JSON.parse(frame.json);
} catch {
message = frame.json;
}
}
return {
index: frame.index,
kind: frame.kind || frame.messageType || t("notices.unknown"),
messageType: frame.messageType || undefined,
flags: `0x${Number(frame.flags || 0).toString(16).padStart(2, "0")}`,
length: frame.length,
compressed: Boolean(frame.compressed),
endStream: Boolean(frame.endStream),
requestId: frame.requestId || undefined,
error: frame.error || undefined,
message,
};
});
return JSON.stringify(normalized, null, 2);
}
function renderStaticPayload(side, markup) {
disposeEditor(side);
const container = elements[`${side}Content`];
container.classList.remove("editor-active");
container.innerHTML = markup;
}
function renderEditorPayload(side, value, language, notices) {
const container = elements[`${side}Content`];
const slot = editorSlots[side];
slot.value = value;
slot.language = language;
container.classList.add("editor-active");
let host = container.querySelector(".editor-host");
if (!host || slot.host !== host) {
disposeEditor(side);
slot.value = value;
slot.language = language;
container.innerHTML = `<div class="editor-host"><pre class="editor-fallback">${escapeHTML(value)}</pre></div><div class="editor-notices">${notices}</div>`;
host = container.querySelector(".editor-host");
void createEditor(side, host, value, language);
return;
}
container.querySelector(".editor-notices").innerHTML = notices;
const fallback = host.querySelector(".editor-fallback");
if (fallback) fallback.textContent = value;
updateEditor(slot, value, language);
}
async function createEditor(side, host, value, language) {
const slot = editorSlots[side];
const token = ++slot.token;
slot.host = host;
try {
const monaco = await monacoReady;
if (token !== slot.token || !host.isConnected) return;
host.textContent = "";
const model = monaco.editor.createModel(slot.value || value, slot.language || language);
const editor = monaco.editor.create(host, {
model,
theme: "vs-dark",
readOnly: true,
domReadOnly: true,
automaticLayout: true,
fontFamily: "SFMono-Regular, Consolas, Liberation Mono, monospace",
fontSize: 12,
lineHeight: 19,
minimap: { enabled: false },
glyphMargin: false,
folding: true,
lineNumbersMinChars: 3,
overviewRulerLanes: 0,
overviewRulerBorder: false,
renderLineHighlight: "none",
scrollBeyondLastLine: false,
smoothScrolling: true,
wordWrap: "off",
padding: { top: 8, bottom: 16 },
stickyScroll: { enabled: false },
contextmenu: true,
});
slot.editor = editor;
slot.model = model;
slot.host = host;
} catch {
// Monaco 初始化失败时保留文本回退视图。
}
}
function updateEditor(slot, value, language) {
if (!slot.editor || !slot.model) return;
const monaco = window.monaco;
if (monaco && slot.model.getLanguageId() !== language) monaco.editor.setModelLanguage(slot.model, language);
if (slot.model.getValue() === value) return;
const viewState = slot.editor.saveViewState();
slot.model.setValue(value);
if (viewState) slot.editor.restoreViewState(viewState);
}
function disposeEditor(side) {
const slot = editorSlots[side];
slot.token += 1;
slot.editor?.dispose();
slot.model?.dispose();
slot.editor = null;
slot.model = null;
slot.host = null;
slot.value = "";
slot.language = "plaintext";
}
function loadMonaco() {
return new Promise((resolve, reject) => {
const amdRequire = window.require;
if (typeof amdRequire !== "function" || typeof amdRequire.config !== "function") {
reject(new Error("Monaco loader is unavailable"));
return;
}
amdRequire.config({ paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.56.0/min/vs" } });
amdRequire(["vs/editor/editor.main"], () => resolve(window.monaco), reject);
});
}
function applyLocale() {
translateDocument();
elements.localeSelect.value = getLocale();
renderRuntimeStatus();
renderConnectionState();
renderPauseState(state, elements);
renderConversationOptions();
renderBidiMessageFilter();
renderList();
renderTrafficSummary();
renderDetail();
}
bindEvents({
state,
elements,
fetchJSON,
refreshList,
refreshDetail,
renderList,
renderDetail,
renderBidiMessageFilter,
setConnectionState,
applyLocale,
});
async function bootstrap() {
applyLocale();
renderDetail();
try {
await Promise.all([loadStatus(), refreshList()]);
connectEvents();
} catch (error) {
setConnectionState(false, "connection.connectFailed", { message: error.message });
}
}
void bootstrap();
-159
View File
@@ -1,159 +0,0 @@
// app_events.js 绑定调试器筛选、详情、暂停和布局交互事件。
import { t } from "./i18n.js";
import { currentCopyText } from "./view_helpers.js";
// renderPauseState 更新暂停按钮的文本和可访问性属性。
export function renderPauseState(state, elements) {
elements.pauseButton.textContent = state.paused ? "▶" : "Ⅱ";
const actionKey = state.paused ? "actions.resume" : "actions.pause";
elements.pauseButton.title = t(actionKey);
elements.pauseButton.setAttribute("aria-label", t(actionKey));
}
// bindEvents 绑定调试器页面的筛选、详情、暂停和布局交互。
export function bindEvents({ state, elements, fetchJSON, refreshList, refreshDetail, renderList, renderDetail, renderBidiMessageFilter, setConnectionState, applyLocale }) {
elements.requestList.addEventListener("click", async (event) => {
const row = event.target.closest("tr[data-id]");
if (!row) return;
state.selectedId = row.dataset.id;
state.selected = null;
renderList();
renderDetail();
await refreshDetail(state.selectedId);
});
elements.searchInput.addEventListener("input", (event) => {
state.search = event.target.value;
renderList();
});
elements.requestIdInput.addEventListener("input", (event) => {
state.requestId = event.target.value;
renderList();
});
elements.conversationSelect.addEventListener("change", async (event) => {
state.conversationId = event.target.value;
state.selectedId = null;
state.selected = null;
await refreshList();
renderDetail();
});
elements.endpointFilter.addEventListener("click", (event) => {
const button = event.target.closest("button[data-value]");
if (!button) return;
state.endpoint = button.dataset.value;
for (const item of elements.endpointFilter.querySelectorAll("button")) {
item.classList.toggle("active", item === button);
}
renderBidiMessageFilter();
renderList();
});
elements.bidiMessageOptions.addEventListener("change", (event) => {
const checkbox = event.target.closest('input[type="checkbox"]');
if (!checkbox) return;
if (!checkbox.value) {
state.bidiMessageKinds.clear();
} else if (checkbox.checked) {
state.bidiMessageKinds.add(checkbox.value);
} else {
state.bidiMessageKinds.delete(checkbox.value);
}
renderBidiMessageFilter();
elements.bidiMessageFilter.open = true;
renderList();
});
document.addEventListener("click", (event) => {
if (!elements.bidiMessageFilter.contains(event.target)) elements.bidiMessageFilter.open = false;
});
elements.sortOrder.addEventListener("click", (event) => {
const button = event.target.closest("button[data-value]");
if (!button) return;
state.sortOrder = button.dataset.value;
for (const item of elements.sortOrder.querySelectorAll("button")) {
item.classList.toggle("active", item === button);
}
renderList();
});
document.querySelectorAll(".payload-panel").forEach((panel) => {
panel.querySelector(".tabs").addEventListener("click", (event) => {
const button = event.target.closest("button[data-tab]");
if (!button) return;
const side = panel.dataset.side;
state.tabs[side] = button.dataset.tab;
panel.querySelectorAll(".tabs button").forEach((item) => item.classList.toggle("active", item === button));
renderDetail();
});
});
document.querySelectorAll("[data-copy-side]").forEach((button) => {
button.addEventListener("click", async () => {
const text = currentCopyText(button.dataset.copySide, state);
if (!text) return;
await navigator.clipboard.writeText(text);
button.textContent = t("actions.copied");
window.setTimeout(() => {
button.textContent = t("actions.copy");
}, 900);
});
});
elements.pauseButton.addEventListener("click", async () => {
state.paused = !state.paused;
elements.pauseButton.classList.toggle("active", state.paused);
renderPauseState(state, elements);
setConnectionState(!state.paused, state.paused ? "connection.paused" : "connection.live");
if (!state.paused && state.pendingRefresh) {
state.pendingRefresh = false;
await refreshList();
}
});
elements.localeSelect.addEventListener("change", (event) => {
setLocale(event.target.value);
applyLocale();
});
elements.showOptionsCheckbox.addEventListener("change", (event) => {
state.showOptions = event.target.checked;
if (!state.showOptions && String(state.selected?.method || "").toUpperCase() === "OPTIONS") {
state.selectedId = null;
state.selected = null;
renderDetail();
}
renderList();
});
elements.clearButton.addEventListener("click", async () => {
await fetchJSON("api/exchanges", { method: "DELETE" });
state.selectedId = null;
state.selected = null;
state.conversationId = "";
await refreshList();
renderDetail();
});
let draggingSplitter = false;
elements.splitter.addEventListener("pointerdown", (event) => {
draggingSplitter = true;
elements.splitter.classList.add("dragging");
elements.splitter.setPointerCapture(event.pointerId);
});
elements.splitter.addEventListener("pointermove", (event) => {
if (!draggingSplitter) return;
const bounds = elements.workspace.getBoundingClientRect();
const top = Math.max(180, Math.min(bounds.height - 225, event.clientY - bounds.top));
elements.workspace.style.gridTemplateRows = `${top}px 5px minmax(220px, 1fr)`;
});
elements.splitter.addEventListener("pointerup", () => {
draggingSplitter = false;
elements.splitter.classList.remove("dragging");
});
}
-189
View File
@@ -1,189 +0,0 @@
// i18n.js 提供协议调试器中英文消息和运行时语言切换。
const SOURCE_LOCALE = "zh-CN";
const DEFAULT_LOCALE = "en-US";
const STORAGE_KEY = "cursor-proxy-debugger:locale:v1";
const SUPPORTED_LOCALES = [SOURCE_LOCALE, DEFAULT_LOCALE];
const messages = {
"zh-CN": {
"app.title": "Cursor 协议调试器",
"status.connecting": "正在连接",
"status.running": "服务运行中",
"status.stopped": "服务已停止",
"actions.pause": "暂停界面更新",
"actions.resume": "继续界面更新",
"actions.clear": "清空",
"actions.copy": "复制",
"actions.copied": "已复制",
"language.label": "界面语言",
"filters.region": "请求过滤器",
"filters.urlPlaceholder": "过滤 URL、请求类型或状态",
"filters.requestIdPlaceholder": "按 Request ID 过滤",
"filters.conversation": "按 Conversation ID 查询",
"filters.allConversations": "全部会话",
"filters.endpoint": "接口过滤",
"filters.all": "全部",
"filters.allMessageTypes": "全部消息类型",
"filters.selectedMessageTypes": "已选 {count} 种消息",
"filters.showOptions": "显示 OPTIONS",
"filters.sort": "排序方向",
"filters.ascending": "正序",
"filters.descending": "倒序",
"count.requests": "{count} 条",
"groups.conversation": "会话",
"groups.unassigned": "未关联会话",
"table.url": "网址",
"table.message": "消息",
"table.method": "方法",
"table.status": "状态",
"table.response": "响应",
"table.duration": "耗时",
"empty.waitingForCursor": "等待来自 Cursor 的请求",
"splitter.resize": "调整详情区域高度",
"selection.waiting": "等待选择",
"selection.prompt": "选择一条请求查看详情",
"panel.request": "请求",
"panel.response": "响应",
"panel.requestDetails": "请求详情",
"panel.responseDetails": "响应详情",
"tabs.headers": "标头",
"tabs.body": "正文",
"tabs.frames": "帧",
"tabs.raw": "原始",
"notices.noRequest": "暂无请求内容",
"notices.noResponse": "暂无响应内容",
"notices.noContent": "暂无内容",
"notices.noRaw": "暂无原始数据",
"notices.noBody": "暂无可显示的正文",
"notices.noHeaders": "暂无标头",
"notices.noFrames": "尚未收到完整帧",
"notices.unknown": "未识别",
"notices.truncated": "原始正文已达到本地抓取上限,转发内容未被截断",
"connection.live": "实时连接中",
"connection.retrying": "实时连接正在重试",
"connection.refreshFailed": "刷新失败:{message}",
"connection.paused": "界面更新已暂停",
"connection.connectFailed": "连接失败:{message}",
"state.pending": "等待中",
"state.streaming": "传输中",
"state.completed": "已完成",
"state.error": "错误",
},
"en-US": {
"app.title": "Cursor Protocol Debugger",
"status.connecting": "Connecting",
"status.running": "Service running",
"status.stopped": "Service stopped",
"actions.pause": "Pause UI updates",
"actions.resume": "Resume UI updates",
"actions.clear": "Clear",
"actions.copy": "Copy",
"actions.copied": "Copied",
"language.label": "Interface language",
"filters.region": "Request filters",
"filters.urlPlaceholder": "Filter by URL, message type, or status",
"filters.requestIdPlaceholder": "Filter by Request ID",
"filters.conversation": "Query by Conversation ID",
"filters.allConversations": "All conversations",
"filters.endpoint": "Endpoint filter",
"filters.all": "All",
"filters.allMessageTypes": "All message types",
"filters.selectedMessageTypes": "{count} message types",
"filters.showOptions": "Show OPTIONS",
"filters.sort": "Sort order",
"filters.ascending": "Oldest first",
"filters.descending": "Newest first",
"count.requests": "{count} requests",
"groups.conversation": "Conversation",
"groups.unassigned": "Unassigned",
"table.url": "URL",
"table.message": "Message",
"table.method": "Method",
"table.status": "Status",
"table.response": "Response",
"table.duration": "Duration",
"empty.waitingForCursor": "Waiting for requests from Cursor",
"splitter.resize": "Resize details area",
"selection.waiting": "No selection",
"selection.prompt": "Select a request to inspect its details",
"panel.request": "Request",
"panel.response": "Response",
"panel.requestDetails": "Request details",
"panel.responseDetails": "Response details",
"tabs.headers": "Headers",
"tabs.body": "Body",
"tabs.frames": "Frames",
"tabs.raw": "Raw",
"notices.noRequest": "No request content",
"notices.noResponse": "No response content",
"notices.noContent": "No content",
"notices.noRaw": "No raw data",
"notices.noBody": "No body available",
"notices.noHeaders": "No headers",
"notices.noFrames": "No complete frames received yet",
"notices.unknown": "Unknown",
"notices.truncated": "Raw body reached the local capture limit; forwarded data was not truncated",
"connection.live": "Live connection",
"connection.retrying": "Reconnecting live updates",
"connection.refreshFailed": "Refresh failed: {message}",
"connection.paused": "UI updates paused",
"connection.connectFailed": "Connection failed: {message}",
"state.pending": "Pending",
"state.streaming": "Streaming",
"state.completed": "Completed",
"state.error": "Error",
},
};
function matchLocale(locale) {
const normalized = String(locale || "").trim().replaceAll("_", "-").toLowerCase();
if (!normalized) return "";
const exact = SUPPORTED_LOCALES.find((candidate) => candidate.toLowerCase() === normalized);
if (exact) return exact;
return normalized.split("-")[0] === "zh" ? SOURCE_LOCALE : normalized.split("-")[0] === "en" ? DEFAULT_LOCALE : "";
}
function resolveInitialLocale() {
const stored = matchLocale(window.localStorage.getItem(STORAGE_KEY));
if (stored) return stored;
for (const candidate of navigator.languages || [navigator.language]) {
const matched = matchLocale(candidate);
if (matched) return matched;
}
return DEFAULT_LOCALE;
}
let currentLocale = resolveInitialLocale();
export function getLocale() {
return currentLocale;
}
export function t(key, values = {}) {
const template = messages[currentLocale]?.[key] || messages[SOURCE_LOCALE][key] || key;
return template.replace(/\{(\w+)\}/g, (_match, name) => String(values[name] ?? ""));
}
export function translateDocument(root = document) {
document.documentElement.lang = currentLocale;
document.title = t("app.title");
for (const element of root.querySelectorAll("[data-i18n]")) {
element.textContent = t(element.dataset.i18n);
}
for (const [attribute, dataAttribute] of [
["aria-label", "i18nAriaLabel"],
["placeholder", "i18nPlaceholder"],
["title", "i18nTitle"],
]) {
for (const element of root.querySelectorAll(`[data-${dataAttribute.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}]`)) {
element.setAttribute(attribute, t(element.dataset[dataAttribute]));
}
}
}
export function setLocale(locale) {
currentLocale = matchLocale(locale) || DEFAULT_LOCALE;
window.localStorage.setItem(STORAGE_KEY, currentLocale);
translateDocument();
return currentLocale;
}
-139
View File
@@ -1,139 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Cursor 协议调试器</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="app" class="app-shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<strong data-i18n="app.title">Cursor 协议调试器</strong>
</div>
<div class="runtime-status" aria-live="polite">
<span id="status-dot" class="status-dot"></span>
<span id="status-text" data-i18n="status.connecting">正在连接</span>
<code id="service-address"></code>
</div>
<div class="toolbar-actions">
<label class="locale-picker">
<span class="visually-hidden" data-i18n="language.label">界面语言</span>
<select id="locale-select" aria-label="界面语言" title="界面语言" data-i18n-aria-label="language.label" data-i18n-title="language.label">
<option value="zh-CN">中文</option>
<option value="en-US">EN</option>
</select>
</label>
<button id="pause-button" class="icon-button" type="button" title="暂停界面更新" aria-label="暂停界面更新" data-i18n-title="actions.pause" data-i18n-aria-label="actions.pause"></button>
<button id="clear-button" class="button danger" type="button" data-i18n="actions.clear">清空</button>
</div>
</header>
<section class="filterbar" aria-label="请求过滤器" data-i18n-aria-label="filters.region">
<div class="search-box url-filter">
<span aria-hidden="true"></span>
<input id="search-input" type="search" placeholder="过滤 URL、请求类型或状态" data-i18n-placeholder="filters.urlPlaceholder" autocomplete="off" />
</div>
<div class="search-box request-id-filter">
<span aria-hidden="true"></span>
<input id="request-id-input" type="search" placeholder="按 Request ID 过滤" data-i18n-placeholder="filters.requestIdPlaceholder" autocomplete="off" />
</div>
<label class="conversation-filter">
<span class="visually-hidden" data-i18n="filters.conversation">会话</span>
<select id="conversation-select" aria-label="按 Conversation ID 查询" data-i18n-aria-label="filters.conversation">
<option value="" data-i18n="filters.allConversations">全部会话</option>
</select>
</label>
<div id="endpoint-filter" class="segmented-control" role="group" aria-label="接口过滤" data-i18n-aria-label="filters.endpoint">
<button class="active" type="button" data-value="all" data-i18n="filters.all">全部</button>
<button type="button" data-value="runsse">RunSSE</button>
<button type="button" data-value="bidiappend">BidiAppend</button>
</div>
<details id="bidi-message-filter" class="multi-select-filter" hidden>
<summary data-i18n="filters.allMessageTypes">全部消息类型</summary>
<div id="bidi-message-options" class="multi-select-menu"></div>
</details>
<label class="checkbox-control">
<input id="show-options-checkbox" type="checkbox" />
<span data-i18n="filters.showOptions">显示 OPTIONS</span>
</label>
<div id="sort-order" class="segmented-control sort-control" role="group" aria-label="排序方向" data-i18n-aria-label="filters.sort">
<button type="button" data-value="asc" data-i18n="filters.ascending">正序</button>
<button class="active" type="button" data-value="desc" data-i18n="filters.descending">倒序</button>
</div>
<span id="request-count" class="request-count">0 条</span>
</section>
<main id="workspace" class="workspace">
<section class="request-list-pane">
<table class="request-table">
<thead>
<tr>
<th class="status-column"></th>
<th class="index-column">#</th>
<th data-i18n="table.url">网址</th>
<th class="request-id-column">Request ID</th>
<th class="kind-column" data-i18n="table.message">消息</th>
<th class="method-column" data-i18n="table.method">方法</th>
<th class="code-column" data-i18n="table.status">状态</th>
<th class="size-column" data-i18n="table.response">响应</th>
<th class="time-column" data-i18n="table.duration">耗时</th>
</tr>
</thead>
<tbody id="request-list"></tbody>
</table>
<div id="empty-state" class="empty-state" data-i18n="empty.waitingForCursor">等待来自 Cursor 的请求</div>
</section>
<div id="horizontal-splitter" class="horizontal-splitter" role="separator" aria-label="调整详情区域高度" data-i18n-aria-label="splitter.resize"></div>
<section id="detail-pane" class="detail-pane">
<div id="selection-summary" class="selection-summary">
<span class="method-badge">POST</span>
<span class="status-badge" data-i18n="selection.waiting">等待选择</span>
<code data-i18n="selection.prompt">选择一条请求查看详情</code>
</div>
<div class="detail-columns">
<section class="payload-panel" data-side="request">
<div class="panel-header">
<strong data-i18n="panel.request">请求</strong>
<nav class="tabs" aria-label="请求详情" data-i18n-aria-label="panel.requestDetails">
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
<button type="button" data-tab="body" class="active" data-i18n="tabs.body">正文</button>
<button type="button" data-tab="frames" data-i18n="tabs.frames"></button>
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
</nav>
<button class="copy-button" type="button" data-copy-side="request" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
</div>
<div id="request-content" class="panel-content"></div>
</section>
<section class="payload-panel" data-side="response">
<div class="panel-header">
<strong data-i18n="panel.response">响应</strong>
<nav class="tabs" aria-label="响应详情" data-i18n-aria-label="panel.responseDetails">
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
<button type="button" data-tab="body" class="active" data-i18n="tabs.body">正文</button>
<button type="button" data-tab="frames" data-i18n="tabs.frames"></button>
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
</nav>
<button class="copy-button" type="button" data-copy-side="response" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
</div>
<div id="response-content" class="panel-content"></div>
</section>
</div>
</section>
</main>
<footer class="statusbar">
<span id="connection-label" data-i18n="connection.live">实时连接中</span>
<span id="traffic-summary">↑ 0 B ↓ 0 B</span>
<span id="upstream-url"></span>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.56.0/min/vs/loader.js"></script>
<script type="module" src="./app.js"></script>
</body>
</html>
-5
View File
@@ -1,5 +0,0 @@
/* styles.css 组合调试器的基础、控件、详情和响应式样式。 */
@import url("./styles_base.css");
@import url("./styles_controls.css");
@import url("./styles_detail.css");
@import url("./styles_responsive.css");
-445
View File
@@ -1,445 +0,0 @@
/* styles.css 定义协议调试器的暗色布局、组件和响应式样式。 */
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #171818;
color: #dedfdd;
font-synthesis: none;
--surface-0: #171818;
--surface-1: #1d1f1f;
--surface-2: #242626;
--surface-3: #2c2f2f;
--border: #343737;
--border-strong: #454949;
--muted: #8d9390;
--text: #dedfdd;
--accent: #4ea58b;
--accent-soft: #25473d;
--cyan: #55a8ba;
--orange: #c88762;
--danger: #c56d65;
--selection: #245b73;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: var(--surface-0);
}
button,
input,
select,
a {
font: inherit;
letter-spacing: 0;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
a:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: -1px;
}
.app-shell {
display: grid;
grid-template-rows: 48px auto minmax(0, 1fr) 26px;
min-width: 760px;
background: var(--surface-0);
}
.topbar,
.filterbar,
.statusbar {
display: flex;
align-items: center;
border-color: var(--border);
background: var(--surface-1);
}
.topbar {
justify-content: space-between;
gap: 18px;
padding: 0 14px;
border-bottom: 1px solid var(--border);
}
.brand,
.runtime-status,
.toolbar-actions {
display: flex;
align-items: center;
min-width: 0;
}
.brand {
gap: 9px;
white-space: nowrap;
}
.brand strong {
font-size: 14px;
font-weight: 650;
}
.brand-mark {
width: 12px;
height: 12px;
border: 2px solid var(--accent);
border-radius: 50%;
box-shadow: inset 0 0 0 2px var(--surface-1);
background: var(--accent);
}
.runtime-status {
justify-content: center;
gap: 7px;
min-width: 240px;
color: #bec3c0;
font-size: 12px;
}
.runtime-status code {
overflow: hidden;
max-width: 260px;
color: var(--muted);
font-family: var(--mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #7d8380;
}
.status-dot.online {
background: #42bd79;
box-shadow: 0 0 0 3px rgb(66 189 121 / 14%);
}
.toolbar-actions {
justify-content: flex-end;
gap: 7px;
}
.locale-picker {
display: flex;
}
.locale-picker select {
width: 58px;
height: 29px;
border: 1px solid var(--border-strong);
border-radius: 5px;
padding: 0 6px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
font-size: 12px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.button,
.icon-button,
.copy-button {
height: 29px;
border: 1px solid var(--border-strong);
border-radius: 5px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
text-decoration: none;
}
.button {
display: inline-flex;
align-items: center;
padding: 0 10px;
font-size: 12px;
}
.button:hover,
.icon-button:hover,
.copy-button:hover {
background: var(--surface-3);
}
.button.danger:hover {
border-color: #744640;
color: #f2b0aa;
}
.icon-button {
width: 31px;
padding: 0;
font-family: var(--mono);
font-weight: 700;
}
.icon-button.active {
border-color: var(--orange);
color: #f1bb98;
}
.filterbar {
flex-wrap: wrap;
gap: 10px;
padding: 7px 14px;
border-bottom: 1px solid var(--border);
}
.search-box {
display: flex;
align-items: center;
flex: 1;
min-width: 260px;
max-width: 640px;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
background: #191b1b;
color: var(--muted);
}
.search-box > span {
padding-left: 9px;
font-size: 17px;
}
.search-box input {
flex: 1;
min-width: 0;
height: 100%;
border: 0;
padding: 0 9px;
outline: 0;
background: transparent;
color: var(--text);
font-size: 12px;
}
.search-box input::placeholder {
color: #6f7572;
}
.url-filter {
min-width: 280px;
max-width: 420px;
}
.request-id-filter {
flex: 0 1 320px;
min-width: 220px;
max-width: 340px;
}
.conversation-filter {
flex: 0 1 300px;
min-width: 190px;
}
.conversation-filter select {
width: 100%;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
padding: 0 28px 0 9px;
background: #191b1b;
color: var(--text);
font-family: var(--mono);
font-size: 11px;
}
.segmented-control {
display: flex;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
overflow: hidden;
}
.segmented-control button {
min-width: 62px;
border: 0;
border-right: 1px solid var(--border);
padding: 0 10px;
background: #1b1d1d;
color: var(--muted);
cursor: pointer;
font-size: 12px;
}
.segmented-control button:last-child {
border-right: 0;
}
.segmented-control button.active {
background: var(--accent-soft);
color: #bce8d9;
}
.sort-control button {
min-width: 52px;
}
.checkbox-control {
display: inline-flex;
align-items: center;
gap: 7px;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
padding: 0 9px;
background: #1b1d1d;
color: var(--muted);
cursor: pointer;
font-size: 12px;
white-space: nowrap;
}
.checkbox-control:has(input:checked) {
border-color: #376858;
background: var(--accent-soft);
color: #bce8d9;
}
.checkbox-control input {
width: 14px;
height: 14px;
margin: 0;
accent-color: var(--accent);
}
.multi-select-filter {
position: relative;
flex: 0 0 170px;
height: 31px;
color: var(--text);
font-size: 12px;
}
.multi-select-filter[hidden] {
display: none;
}
.multi-select-filter summary {
overflow: hidden;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
padding: 7px 28px 0 9px;
background: #191b1b;
cursor: pointer;
list-style: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.multi-select-filter summary::-webkit-details-marker {
display: none;
}
.multi-select-filter summary::after {
position: absolute;
top: 10px;
right: 10px;
content: "";
border: 4px solid transparent;
border-top-color: var(--muted);
}
.multi-select-filter[open] summary {
border-color: var(--border-strong);
}
.multi-select-menu {
position: absolute;
z-index: 20;
top: 35px;
right: 0;
overflow: auto;
width: 260px;
max-height: 320px;
border: 1px solid var(--border-strong);
border-radius: 5px;
padding: 4px;
background: var(--surface-2);
box-shadow: 0 8px 24px rgb(0 0 0 / 34%);
}
.multi-select-option {
display: flex;
align-items: center;
gap: 8px;
height: 29px;
border-radius: 3px;
padding: 0 7px;
cursor: pointer;
}
.multi-select-option:hover {
background: var(--surface-3);
}
.multi-select-option input {
width: 14px;
height: 14px;
margin: 0;
accent-color: var(--accent);
}
.multi-select-option span {
overflow: hidden;
font-family: var(--mono);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.multi-select-option.all-option {
border-bottom: 1px solid var(--border);
border-radius: 0;
margin-bottom: 3px;
}
.request-count {
margin-left: auto;
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
}
-189
View File
@@ -1,189 +0,0 @@
/* styles_controls.css 定义调试器筛选栏、请求列表和基础交互控件。 */
.workspace {
display: grid;
grid-template-rows: minmax(180px, 52%) 5px minmax(220px, 48%);
min-height: 0;
overflow: hidden;
}
.request-list-pane {
position: relative;
min-height: 0;
overflow: auto;
background: #181a1a;
}
.request-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
}
.request-table thead {
position: sticky;
top: 0;
z-index: 2;
background: #202222;
}
.request-table th,
.request-table td {
height: 30px;
border-right: 1px solid #2c2f2f;
border-bottom: 1px solid #292c2c;
padding: 0 9px;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-table th {
color: #9da29f;
font-weight: 550;
}
.request-table tbody tr {
cursor: default;
}
.request-table tbody tr:hover {
background: #222525;
}
.request-table tbody tr.selected {
background: var(--selection);
color: #f3f8f8;
}
.request-table tbody tr.conversation-group,
.request-table tbody tr.conversation-group:hover {
cursor: default;
background: #202323;
}
.request-table tbody tr.conversation-group td {
height: 28px;
border-top: 1px solid var(--border-strong);
color: var(--muted);
}
.conversation-group span {
margin-right: 8px;
color: #9ba19e;
}
.conversation-group code {
color: #73becb;
}
.conversation-group strong {
margin-left: 8px;
color: #78817d;
font-size: 10px;
font-weight: 500;
}
.request-table code {
font-family: var(--mono);
}
.status-column {
width: 30px;
}
.index-column {
width: 54px;
}
.request-id-column {
width: 250px;
}
.kind-column {
width: 180px;
}
.method-column {
width: 72px;
}
.code-column {
width: 66px;
}
.size-column {
width: 86px;
}
.time-column {
width: 74px;
}
.row-state {
display: block;
width: 8px;
height: 8px;
margin: auto;
border-radius: 50%;
background: #7c8380;
}
.row-state.streaming {
background: #45ba77;
}
.row-state.completed {
background: var(--cyan);
}
.row-state.error {
background: var(--danger);
}
.method-text {
color: #61b9df;
font-family: var(--mono);
font-weight: 650;
}
.status-text.success {
color: #68c991;
}
.status-text.error {
color: #e18b83;
}
.kind-text {
color: #d3a17f;
font-family: var(--mono);
}
.request-id-text {
color: #8bc2cc;
}
.empty-state {
position: absolute;
inset: 34px 0 0;
display: grid;
place-items: center;
color: #686e6b;
font-size: 13px;
}
.empty-state.hidden {
display: none;
}
.horizontal-splitter {
cursor: row-resize;
background: #343737;
}
.horizontal-splitter:hover,
.horizontal-splitter.dragging {
background: var(--cyan);
}
-287
View File
@@ -1,287 +0,0 @@
/* styles_detail.css 定义请求详情、载荷面板和状态提示布局。 */
.detail-pane {
display: grid;
grid-template-rows: 38px minmax(0, 1fr);
min-height: 0;
background: var(--surface-0);
}
.selection-summary {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 0 14px;
border-bottom: 1px solid var(--border);
background: #1b1d1d;
}
.selection-summary code {
overflow: hidden;
color: #aeb4b1;
font-family: var(--mono);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.method-badge,
.status-badge,
.frame-badge {
display: inline-flex;
align-items: center;
height: 22px;
border: 1px solid var(--border-strong);
border-radius: 4px;
padding: 0 7px;
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
}
.method-badge {
border-color: #34667a;
color: #74c8e8;
}
.status-badge.success {
border-color: #3f7157;
color: #83d5a5;
}
.detail-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 0;
}
.payload-panel {
display: grid;
grid-template-rows: 36px minmax(0, 1fr);
min-width: 0;
min-height: 0;
border-right: 1px solid var(--border);
}
.payload-panel:last-child {
border-right: 0;
}
.panel-header {
display: flex;
align-items: center;
min-width: 0;
border-bottom: 1px solid var(--border);
background: #202222;
}
.panel-header > strong {
padding: 0 10px;
color: #c9cdca;
font-size: 12px;
}
.tabs {
display: flex;
align-self: stretch;
}
.tabs button {
position: relative;
min-width: 46px;
border: 0;
padding: 0 9px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 12px;
}
.tabs button:hover {
color: #d7dad8;
}
.tabs button.active {
color: #71c7e2;
}
.tabs button.active::after {
position: absolute;
right: 8px;
bottom: 0;
left: 8px;
height: 2px;
background: var(--cyan);
content: "";
}
.copy-button {
width: 48px;
height: 24px;
margin-right: 7px;
margin-left: auto;
font-size: 11px;
}
.panel-content {
min-height: 0;
overflow: auto;
background: #181a1a;
}
.panel-content.editor-active {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
overflow: hidden;
}
.editor-host {
min-width: 0;
min-height: 0;
background: #1e1e1e;
}
.editor-fallback {
min-width: 100%;
min-height: 100%;
margin: 0;
padding: 10px 14px 24px;
overflow: auto;
color: #d4d4d4;
font: 12px/1.58 var(--mono);
white-space: pre;
}
.editor-notices:empty {
display: none;
}
.code-view,
.hex-view {
min-width: 100%;
min-height: 100%;
margin: 0;
padding: 12px 14px 30px;
color: #ccd1ce;
font: 11px/1.55 var(--mono);
tab-size: 2;
white-space: pre;
}
.hex-view {
color: #b6c2bd;
}
.headers-table {
width: 100%;
border-collapse: collapse;
font: 11px/1.4 var(--mono);
}
.headers-table th,
.headers-table td {
border-bottom: 1px solid #292c2c;
padding: 7px 10px;
text-align: left;
vertical-align: top;
}
.headers-table th {
width: 38%;
color: #62b3cd;
font-weight: 500;
overflow-wrap: anywhere;
}
.headers-table td {
color: #c5c9c6;
overflow-wrap: anywhere;
}
.frame-list {
min-width: 480px;
}
.frame-item {
border-bottom: 1px solid #292c2c;
}
.frame-item summary {
display: grid;
grid-template-columns: 58px minmax(150px, 1fr) 90px 82px;
align-items: center;
height: 32px;
padding: 0 10px;
color: #c5cac7;
cursor: pointer;
font: 11px var(--mono);
list-style: none;
}
.frame-item summary::-webkit-details-marker {
display: none;
}
.frame-item summary:hover {
background: #222525;
}
.frame-item[open] summary {
background: #242727;
}
.frame-index {
color: #747b77;
}
.frame-kind {
overflow: hidden;
color: #dfaa85;
text-overflow: ellipsis;
white-space: nowrap;
}
.frame-size,
.frame-flags {
color: #7faeb7;
text-align: right;
}
.frame-error {
margin: 10px 14px;
color: #ec968e;
font: 11px/1.5 var(--mono);
}
.notice {
padding: 14px;
color: #7d8581;
font: 12px/1.6 var(--mono);
}
.notice.error {
color: #df8b83;
}
.truncated-notice {
position: sticky;
bottom: 0;
padding: 5px 10px;
border-top: 1px solid #674f3f;
background: #3d3028;
color: #e5b28e;
font-size: 11px;
}
.statusbar {
justify-content: flex-end;
gap: 16px;
padding: 0 10px;
border-top: 1px solid var(--border);
color: #848b87;
font: 10px var(--mono);
}
.statusbar span:first-child {
margin-right: auto;
}
-195
View File
@@ -1,195 +0,0 @@
/* styles_responsive.css 定义调试器在窄屏下的响应式布局。 */
@media (max-width: 920px) {
.app-shell {
grid-template-rows: 48px auto minmax(0, 1fr) 26px;
min-width: 0;
}
.filterbar {
align-content: center;
flex-wrap: wrap;
gap: 6px;
}
.url-filter,
.request-id-filter {
flex: 1 1 300px;
max-width: none;
}
.runtime-status code,
.kind-column,
.request-table td:nth-child(5) {
display: none;
}
.detail-columns {
grid-template-columns: 1fr;
grid-template-rows: minmax(180px, 1fr) minmax(180px, 1fr);
overflow: auto;
}
.payload-panel {
min-height: 260px;
border-right: 0;
border-bottom: 1px solid var(--border);
}
}
@media (max-width: 640px) {
.app-shell {
grid-template-rows: 82px auto minmax(0, 1fr) 26px;
}
.topbar {
position: relative;
align-content: center;
flex-wrap: wrap;
gap: 4px 10px;
padding: 8px 10px;
}
.brand {
flex: 1;
overflow: hidden;
}
.brand strong {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
}
.runtime-status {
order: 3;
justify-content: flex-start;
width: 100%;
min-width: 0;
}
.runtime-status code {
display: block;
max-width: none;
}
.toolbar-actions {
gap: 4px;
}
.toolbar-actions .button {
padding: 0 7px;
}
.filterbar {
align-content: center;
flex-wrap: wrap;
gap: 6px;
padding: 7px 10px;
}
.url-filter,
.request-id-filter {
flex: 0 0 100%;
width: 100%;
min-width: 0;
max-width: none;
}
.request-count {
order: 5;
margin-left: auto;
}
#endpoint-filter {
order: 3;
flex: 1;
}
.sort-control {
order: 4;
flex: 0 0 104px;
}
.segmented-control button {
flex: 1;
min-width: 0;
}
.workspace {
grid-template-rows: minmax(150px, 40%) 5px minmax(260px, 60%);
}
.request-table th,
.request-table td {
padding: 0 6px;
}
.request-table .index-column,
.request-table th:nth-child(2),
.request-table td:nth-child(2),
.size-column,
.request-table th:nth-child(8),
.request-table td:nth-child(8),
.time-column,
.request-table th:nth-child(9),
.request-table td:nth-child(9) {
display: none;
}
.request-id-column {
width: 130px;
}
.method-column {
width: 58px;
}
.code-column {
width: 50px;
}
.selection-summary {
padding: 0 8px;
}
.detail-columns {
grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr);
}
.panel-header > strong {
width: 72px;
padding: 0 7px;
font-size: 11px;
}
.tabs {
overflow-x: auto;
}
.tabs button {
min-width: 42px;
padding: 0 6px;
}
.copy-button {
width: 42px;
margin-right: 4px;
}
.frame-list {
min-width: 0;
}
.frame-item summary {
grid-template-columns: 42px minmax(100px, 1fr) 62px 72px;
padding: 0 7px;
}
.statusbar {
gap: 8px;
}
#upstream-url {
display: none;
}
}
-71
View File
@@ -1,71 +0,0 @@
// view_helpers.js 提供调试器界面使用的格式化、转义和复制文本辅助函数。
import { t } from "./i18n.js";
// renderDecodeError 将解码错误转换为安全的提示片段。
export function renderDecodeError(error) {
return error ? `<div class="frame-error">${escapeHTML(error)}</div>` : "";
}
// renderTruncated 生成正文被截断时的提示片段。
export function renderTruncated(truncated) {
return truncated ? `<div class="truncated-notice">${escapeHTML(t("notices.truncated"))}</div>` : "";
}
// formatState 将捕获状态转换为当前语言的展示文本。
export function formatState(value) {
const key = {
pending: "state.pending",
streaming: "state.streaming",
completed: "state.completed",
error: "state.error",
}[value];
return key ? t(key) : value || "-";
}
// currentCopyText 根据当前标签页提取可复制的载荷文本。
export function currentCopyText(side, state) {
const payload = state.selected?.[side];
if (!payload) return "";
const tab = state.tabs[side];
if (tab === "headers") return (payload.headers || []).map((item) => `${item.name}: ${item.value}`).join("\n");
if (tab === "raw") return payload.rawHex || "";
if (tab === "frames") return (payload.frames || []).map((frame) => frame.json || frame.rawHex || frame.error || "").join("\n\n");
return payload.decodedJson || "";
}
// formatHex 将十六进制载荷按行格式化为调试视图。
export function formatHex(value) {
const hex = String(value || "").replace(/[^0-9a-f]/gi, "");
const lines = [];
for (let index = 0; index < hex.length; index += 32) {
const chunk = hex.slice(index, index + 32);
const bytes = chunk.match(/.{1,2}/g) || [];
lines.push(`${(index / 2).toString(16).padStart(8, "0")} ${bytes.join(" ")}`);
}
return lines.join("\n");
}
// formatBytes 将字节数格式化为人类可读的单位。
export function formatBytes(value) {
const bytes = Number(value || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
// formatDuration 将毫秒耗时格式化为人类可读的单位。
export function formatDuration(value) {
const milliseconds = Number(value || 0);
if (milliseconds < 1000) return `${milliseconds} ms`;
return `${(milliseconds / 1000).toFixed(1)} s`;
}
// escapeHTML 转义用户或网络输入,避免插入界面时形成 HTML。
export function escapeHTML(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
-48
View File
@@ -1,48 +0,0 @@
# cursor-proto
`cursor-proto` 从已安装 Cursor 的 JavaScript bundle 中提取 Protobuf 定义。
## 目录
- `extractor/`Go 提取器。
- `scripts/extract.sh`:扫描 Cursor 安装目录并安全更新输出。
- `proto/`:提取结果,是项目内唯一的 Proto 输出目录;由脚本重新生成,不提交到 Git。
- `scripts/generate.sh`:根据提取结果生成可导入的 Go 消息包。
- `gen/`:供其他 Go module 使用的 Go 消息包;由脚本重新生成,不提交到 Git。
## 使用
默认从 `/Applications/Cursor.app` 提取:
```bash
./scripts/extract.sh
```
也可以指定 Cursor 应用、bundle 文件和输出目录:
```bash
./scripts/extract.sh /path/to/Cursor.app
./scripts/extract.sh /path/to/workbench.desktop.main.js /path/to/output
```
直接运行 Go 提取器时,可重复传入多个 bundle:
```bash
go run ./extractor \
-input /path/to/workbench.desktop.main.js \
-input /path/to/extensionHostProcess.js \
-output ./proto \
-strict
```
提取完成后重新生成 Go 消息包:
```bash
./scripts/generate.sh
```
## 验证
```bash
go test ./...
```
-192
View File
@@ -1,192 +0,0 @@
// extractor_test.go 验证压缩 bundle 的字段、别名、服务和合并提取行为。
package main
import "testing"
// TestParseFieldObjectSupportsShorthandType 验证字段类型简写可以解析。
func TestParseFieldObjectSupportsShorthandType(t *testing.T) {
field, err := parseFieldObject(`{no:4,name:"file_not_found",kind:"message",T,oneof:"result"}`)
if err != nil {
t.Fatalf("parse shorthand T: %v", err)
}
if field.T != "T" {
t.Fatalf("parsed shorthand T as %#v, want T", field.T)
}
}
// TestWebpackExportAliasResolvesServiceMessageType 验证 Webpack 导出别名可解析服务消息。
func TestWebpackExportAliasResolvesServiceMessageType(t *testing.T) {
const bundle = `
1:(e,t,n)=>{
n.d(t,{KS:()=>T,_B:()=>r});
var r;
class T {}
T.typeName="agent.v1.AgentClientMessage";
n.proto3.util.setEnumType(r,"agent.v1.DiagnosticSeverity",[]);
},
2:(e,t,n)=>{
var r=n(1);
const service={typeName:"agent.v1.AgentService",methods:{run:{name:"Run",I:r.KS,O:r.KS,kind:n.MethodKind.BiDiStreaming}}};
}`
moduleStarts := buildModuleStarts(bundle)
messages := []Message{{
TypeName: "agent.v1.AgentClientMessage",
VarName: "T",
InternalName: "T",
Package: "agent.v1",
Pos: 35,
ModuleStart: moduleStartForPos(moduleStarts, 35),
}}
enums := []Enum{{
TypeName: "agent.v1.DiagnosticSeverity",
VarName: "r",
Package: "agent.v1",
Pos: 100,
ModuleStart: moduleStartForPos(moduleStarts, 100),
}}
resolver := newTypeResolver(messages, enums, buildAliasIndex(bundle, moduleStarts), buildWebpackExportAliasIndex(bundle, moduleStarts))
resolver.moduleImports = buildModuleImportIndex(bundle, moduleStarts)
typeName, ok := resolver.ResolveTypeName("r.KS", len(bundle)-1, moduleStartForPos(moduleStarts, len(bundle)-1), "agent.v1", "message")
if !ok {
t.Fatal("expected webpack export alias to resolve")
}
if typeName != "agent.v1.AgentClientMessage" {
t.Fatalf("resolved r.KS to %q, want agent.v1.AgentClientMessage", typeName)
}
}
// TestResolverPrefersExpectedKindOverCurrentPackage 验证类型类别优先于当前包候选。
func TestResolverPrefersExpectedKindOverCurrentPackage(t *testing.T) {
resolver := &TypeResolver{bySymbol: map[string][]symbolDef{
"nt": {
{TypeName: "git_forge.v1.GetTagResponse", Kind: "message", Pos: 10, ModuleStart: 1},
{TypeName: "origin.v1.TeamGroupKind", Kind: "enum", Pos: 20, ModuleStart: 1},
},
}}
typeName, ok := resolver.ResolveTypeName("nt", 30, 1, "origin.v1", "message")
if !ok {
t.Fatal("expected cross-package message type to resolve")
}
if typeName != "git_forge.v1.GetTagResponse" {
t.Fatalf("resolved nt to %q, want git_forge.v1.GetTagResponse", typeName)
}
}
// TestModernFactorySyntaxExtractsInAppAdServiceTypes 验证现代工厂语法提取完整服务类型。
func TestModernFactorySyntaxExtractsInAppAdServiceTypes(t *testing.T) {
const bundle = `
42:(e,t,n)=>{
var HasSeenAdRequest=n.makeMessageType("aiserver.v1.HasSeenAdRequest",()=>[{no:1,name:"ad_id",kind:"scalar",T:9}]),
HasSeenAdResponse=n.makeMessageType("aiserver.v1.HasSeenAdResponse",()=>[{no:1,name:"has_seen",kind:"scalar",T:8}]),
MarkAdAsSeenResponse=n.makeMessageType("aiserver.v1.MarkAdAsSeenResponse",[]),
Placement=n.makeEnum("aiserver.v1.InAppAdPlacement",[{no:0,name:"IN_APP_AD_PLACEMENT_UNSPECIFIED",localName:"UNSPECIFIED"}]),
InAppAdService={typeName:"aiserver.v1.InAppAdService",methods:{hasSeenAd:{name:"HasSeenAd",I:HasSeenAdRequest,O:HasSeenAdResponse,kind:n.MethodKind.Unary},markAdAsSeen:{name:"MarkAdAsSeen",I:HasSeenAdRequest,O:MarkAdAsSeenResponse,kind:n.MethodKind.Unary}}};
}`
moduleStarts := buildModuleStarts(bundle)
messages := extractMessages(bundle, moduleStarts)
enums := extractEnums(bundle, moduleStarts)
services := extractServices(bundle, moduleStarts)
if len(messages) != 3 {
t.Fatalf("extracted %d messages, want 3", len(messages))
}
if len(messages[0].Fields) != 1 || messages[0].Fields[0].Name != "ad_id" {
t.Fatalf("unexpected request fields: %#v", messages[0].Fields)
}
if len(enums) != 1 || enums[0].TypeName != "aiserver.v1.InAppAdPlacement" {
t.Fatalf("unexpected enums: %#v", enums)
}
if len(services) != 1 || len(services[0].Methods) != 2 {
t.Fatalf("unexpected services: %#v", services)
}
resolver := newTypeResolver(messages, enums, buildAliasIndex(bundle, moduleStarts), buildWebpackExportAliasIndex(bundle, moduleStarts))
method := services[0].Methods[0]
input, inputOK := resolver.ResolveTypeName(method.InputType, services[0].Pos, services[0].ModuleStart, services[0].Package, "message")
output, outputOK := resolver.ResolveTypeName(method.OutputType, services[0].Pos, services[0].ModuleStart, services[0].Package, "message")
if !inputOK || input != "aiserver.v1.HasSeenAdRequest" {
t.Fatalf("resolved input to %q (ok=%v)", input, inputOK)
}
if !outputOK || output != "aiserver.v1.HasSeenAdResponse" {
t.Fatalf("resolved output to %q (ok=%v)", output, outputOK)
}
}
// TestAssignmentAliasResolvesStandardProtobufType 验证赋值别名解析标准协议类型。
func TestAssignmentAliasResolvesStandardProtobufType(t *testing.T) {
const bundle = `
1:(e,t,n)=>{
var Timestamp=class TimestampMessage extends Base{};
Timestamp.typeName="google.protobuf.Timestamp",Timestamp.fields=n.proto3.util.newFieldList(()=>[]),ua=Timestamp;
var Request=n.makeMessageType("aiserver.v1.Request",()=>[{no:1,name:"created_at",kind:"message",T:ua}]);
}`
moduleStarts := buildModuleStarts(bundle)
messages := extractMessages(bundle, moduleStarts)
resolver := newTypeResolver(messages, nil, buildAliasIndex(bundle, moduleStarts), nil)
typeName, ok := resolver.ResolveTypeName("ua", len(bundle)-1, moduleStartForPos(moduleStarts, len(bundle)-1), "aiserver.v1", "message")
if !ok || typeName != "google.protobuf.Timestamp" {
t.Fatalf("resolved ua to %q (ok=%v), want google.protobuf.Timestamp", typeName, ok)
}
}
// TestDeclarationCoverageReportsUnparsedTypesAndIgnoresGoogleTypes 验证覆盖率忽略标准类型并报告遗漏。
func TestDeclarationCoverageReportsUnparsedTypesAndIgnoresGoogleTypes(t *testing.T) {
const bundle = `
var Request=n.makeMessageType("aiserver.v1.Request",()=>[]);
var Missing=n.makeMessageType("aiserver.v1.Missing",()=>[]);
var Timestamp=n.makeMessageType("google.protobuf.Timestamp",()=>[]);
var Service={typeName:"aiserver.v1.TestService",methods:{}};
`
messages := []Message{{TypeName: "aiserver.v1.Request"}}
services := []Service{{TypeName: "aiserver.v1.TestService"}}
declared, extracted, missing := declarationCoverage(bundle, messages, nil, services)
if declared != 3 || extracted != 2 {
t.Fatalf("coverage=%d/%d, want 2/3", extracted, declared)
}
if len(missing) != 1 || missing[0] != "aiserver.v1.Missing" {
t.Fatalf("unexpected missing declarations: %#v", missing)
}
}
// TestExtractServicesSupportsAnonymousDescriptors 验证匿名服务描述符可以提取。
func TestExtractServicesSupportsAnonymousDescriptors(t *testing.T) {
const bundle = `services.push({typeName:"aiserver.v1.FileSyncService",methods:{sync:{name:"Sync",I:Request,O:Response,kind:n.MethodKind.Unary}}})`
services := extractServices(bundle, nil)
if len(services) != 1 || services[0].TypeName != "aiserver.v1.FileSyncService" {
t.Fatalf("unexpected services: %#v", services)
}
if len(services[0].Methods) != 1 || services[0].Methods[0].Name != "Sync" {
t.Fatalf("unexpected methods: %#v", services[0].Methods)
}
}
// TestMergeMessagesPrefersPrimaryBundleAndKeepsSupplementalTypes 验证合并优先主 bundle 并保留补充类型。
func TestMergeMessagesPrefersPrimaryBundleAndKeepsSupplementalTypes(t *testing.T) {
primary := Message{
TypeName: "aiserver.v1.Shared",
Fields: []Field{{No: 1, Name: "primary", Kind: "scalar", T: 9}},
}
supplemental := Message{
TypeName: "aiserver.v1.Shared",
Fields: []Field{{No: 1, Name: "supplemental", Kind: "scalar", T: 9}},
}
legacy := Message{TypeName: "aiserver.v1.LegacyOnly"}
merged := mergeMessagesByTypeName([]Message{primary, supplemental, legacy})
if len(merged) != 2 {
t.Fatalf("merged %d messages, want 2", len(merged))
}
if merged[0].Fields[0].Name != "primary" {
t.Fatalf("duplicate type did not preserve primary definition: %#v", merged[0])
}
if merged[1].TypeName != "aiserver.v1.LegacyOnly" {
t.Fatalf("supplemental-only type missing: %#v", merged)
}
}
-341
View File
@@ -1,341 +0,0 @@
// generator.go 计算跨包依赖并为各协议包准备完整声明集合。
package main
import (
"fmt"
"os"
)
// generateProtos 按协议包聚合声明并生成对应文件。
func generateProtos(messages []Message, enums []Enum, services []Service, resolver *TypeResolver, outputDir string) {
os.MkdirAll(outputDir, 0755)
// 按协议包聚合声明。
packages := make(map[string]struct {
messages []Message
enums []Enum
services []Service
})
for _, msg := range messages {
pkg := packages[msg.Package]
pkg.messages = append(pkg.messages, msg)
packages[msg.Package] = pkg
}
for _, enum := range enums {
pkg := packages[enum.Package]
pkg.enums = append(pkg.enums, enum)
packages[enum.Package] = pkg
}
for _, svc := range services {
pkg := packages[svc.Package]
pkg.services = append(pkg.services, svc)
packages[svc.Package] = pkg
}
// 建立跨包复制使用的全局类型索引。
allMessages := make(map[string]*Message)
allEnums := make(map[string]*Enum)
for pkgName, pkg := range packages {
if isGooglePkg(pkgName) {
continue
}
for i := range pkg.messages {
msg := &pkg.messages[i]
allMessages[msg.TypeName] = msg
}
for i := range pkg.enums {
enum := &pkg.enums[i]
allEnums[enum.TypeName] = enum
}
}
// 每轮生成前重置已复制类型索引。
copiedTypes = make(map[string]map[string]string)
for pkgName, pkg := range packages {
// Google 标准包直接使用官方协议文件。
if isGooglePkg(pkgName) {
fmt.Printf("跳过: %s (使用官方 proto 文件)\n", pkgName)
continue
}
// 把当前包引用的外部类型复制到本地。
augmentedPkg := copyAllExternalTypes(pkgName, pkg, resolver, allMessages, allEnums)
generateProtoFile(pkgName, augmentedPkg.messages, augmentedPkg.enums, pkg.services, resolver, outputDir)
}
}
// copyAllExternalTypes 递归复制当前包引用的全部外部类型。
func copyAllExternalTypes(pkgName string, pkg struct {
messages []Message
enums []Enum
services []Service
}, resolver *TypeResolver, allMessages map[string]*Message, allEnums map[string]*Enum) struct {
messages []Message
enums []Enum
services []Service
} {
if copiedTypes[pkgName] == nil {
copiedTypes[pkgName] = make(map[string]string)
}
// 建立当前包已有类型集合,并登记本地名称供字段解析使用。
localTypes := make(map[string]bool)
for _, msg := range pkg.messages {
localTypes[msg.ShortName] = true
// 空来源名表示该类型原本就在当前包。
if copiedTypes[pkgName][msg.ShortName] == "" {
copiedTypes[pkgName][msg.ShortName] = "local:" + msg.TypeName
}
}
for _, enum := range pkg.enums {
localTypes[enum.ShortName] = true
if copiedTypes[pkgName][enum.ShortName] == "" {
copiedTypes[pkgName][enum.ShortName] = "local:" + enum.TypeName
}
}
// 结果先保留当前包原始声明。
result := struct {
messages []Message
enums []Enum
services []Service
}{
messages: append([]Message{}, pkg.messages...),
enums: append([]Enum{}, pkg.enums...),
services: pkg.services,
}
totalCopied := 0
// 持续迭代,直到不再发现新的外部依赖。
for round := 1; ; round++ {
// 收集当前消息中的外部类型引用。
neededTypes := make(map[string]bool)
for _, msg := range result.messages {
preferredPkg, _ := parseTypeName(msg.TypeName)
for _, f := range msg.Fields {
collectFieldRefsSimple(f, pkgName, preferredPkg, msg.Pos, msg.ModuleStart, resolver, neededTypes, localTypes)
}
}
for _, svc := range result.services {
for _, m := range svc.Methods {
collectMethodRefsSimple(m.InputType, pkgName, svc.Pos, svc.ModuleStart, resolver, neededTypes, localTypes)
collectMethodRefsSimple(m.OutputType, pkgName, svc.Pos, svc.ModuleStart, resolver, neededTypes, localTypes)
}
}
// 复制本轮新增依赖类型。
copiedThisRound := 0
for typeName := range neededTypes {
refPkg, shortName := parseTypeName(typeName)
if refPkg == pkgName || isGooglePkg(refPkg) {
continue
}
// 已存在于本地时无需重复复制。
if localTypes[shortName] {
continue
}
// 复制消息声明。
if msg, ok := allMessages[typeName]; ok {
msgCopy := *msg
msgCopy.Package = pkgName
// 保留原始完整类型名,用于生成来源注释。
result.messages = append(result.messages, msgCopy)
copiedTypes[pkgName][shortName] = typeName // 保存原始完整类型名。
localTypes[shortName] = true
copiedThisRound++
fmt.Printf(" [%s] 轮%d 复制: %s\n", pkgName, round, typeName)
} else if enum, ok := allEnums[typeName]; ok {
// 复制枚举声明。
enumCopy := *enum
enumCopy.Package = pkgName
result.enums = append(result.enums, enumCopy)
copiedTypes[pkgName][shortName] = typeName
localTypes[shortName] = true
copiedThisRound++
fmt.Printf(" [%s] 轮%d 复制枚举: %s\n", pkgName, round, typeName)
} else {
// 未找到声明时仍登记本地引用,兼容提取结果缺少但 bundle 实际存在的类型。
copiedTypes[pkgName][shortName] = typeName
localTypes[shortName] = true
fmt.Printf(" [%s] 轮%d 警告: 类型未找到 %s,标记为本地引用\n", pkgName, round, typeName)
}
}
totalCopied += copiedThisRound
if copiedThisRound == 0 {
break // 没有新增依赖时结束迭代。
}
if round > 20 {
fmt.Printf(" [%s] 警告: 复制轮次超过20,可能存在问题\n", pkgName)
break
}
}
if totalCopied > 0 {
fmt.Printf(" [%s] 共复制 %d 个外部类型\n", pkgName, totalCopied)
}
return result
}
// collectFieldRefsSimple 收集单个字段直接引用的外部类型。
func collectFieldRefsSimple(f Field, currentPkg string, preferredPkg string, contextPos int, contextModuleStart int, resolver *TypeResolver,
neededTypes map[string]bool, localTypes map[string]bool) {
type refWithKind struct {
ref string
kind string
}
var refs []refWithKind
if f.Kind == "message" || f.Kind == "enum" {
if v, ok := f.T.(string); ok {
refs = append(refs, refWithKind{ref: v, kind: f.Kind})
}
}
if f.Kind == "map" && (f.MapValueKind == "message" || f.MapValueKind == "enum") {
if v, ok := f.MapValueT.(string); ok {
refs = append(refs, refWithKind{ref: v, kind: f.MapValueKind})
}
}
for _, item := range refs {
typeName, ok := resolver.ResolveTypeName(item.ref, contextPos, contextModuleStart, preferredPkg, item.kind)
if !ok {
continue
}
refPkg, shortName := parseTypeName(typeName)
if refPkg == "" || refPkg == currentPkg || isGooglePkg(refPkg) {
continue
}
// 已在当前包中的类型无需收集。
if localTypes[shortName] {
continue
}
neededTypes[typeName] = true
}
}
// collectMethodRefsSimple 收集服务方法输入或输出引用的外部类型。
func collectMethodRefsSimple(ref string, currentPkg string, contextPos int, contextModuleStart int, resolver *TypeResolver,
neededTypes map[string]bool, localTypes map[string]bool) {
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, "message")
if !ok {
return
}
refPkg, shortName := parseTypeName(typeName)
if refPkg == "" || refPkg == currentPkg || isGooglePkg(refPkg) {
return
}
if localTypes[shortName] {
return
}
neededTypes[typeName] = true
}
// copiedTypes 按目标包和短名称记录被复制类型的原始全限定名。
var copiedTypes = make(map[string]map[string]string)
// TypeNode 表示嵌套消息与枚举组成的类型树节点。
type TypeNode struct {
// Name 是当前嵌套层级的类型名。
Name string
// Message 保存当前节点的消息声明。
Message *Message
// Enum 保存当前节点的枚举声明。
Enum *Enum
// Children 保存下一层嵌套类型。
Children map[string]*TypeNode
}
// collectImports 只收集 Google 标准依赖,其余类型会复制到本地。
func collectImports(currentPkg string, messages []Message, services []Service, resolver *TypeResolver) map[string]bool {
imports := make(map[string]bool)
addImport := func(ref string, contextPos int, contextModuleStart int, expectedKind string) {
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, expectedKind)
if !ok {
return
}
refPkg, shortName := parseTypeName(typeName)
// 仅导入 Google 标准类型。
if refPkg == "google.protobuf" {
var importFile string
switch shortName {
case "Struct", "Value", "ListValue", "NullValue":
importFile = "google/protobuf/struct.proto"
case "Timestamp":
importFile = "google/protobuf/timestamp.proto"
case "Duration":
importFile = "google/protobuf/duration.proto"
case "Any":
importFile = "google/protobuf/any.proto"
case "Empty":
importFile = "google/protobuf/empty.proto"
case "FieldMask":
importFile = "google/protobuf/field_mask.proto"
case "BoolValue", "BytesValue", "DoubleValue", "FloatValue",
"Int32Value", "Int64Value", "StringValue", "UInt32Value", "UInt64Value":
importFile = "google/protobuf/wrappers.proto"
default:
importFile = "google/protobuf/descriptor.proto"
}
imports[importFile] = true
} else if refPkg == "google.rpc" {
var importFile string
switch shortName {
case "Status":
importFile = "google/rpc/status.proto"
case "Code":
importFile = "google/rpc/code.proto"
default:
importFile = "google/rpc/status.proto"
}
imports[importFile] = true
}
}
for _, msg := range messages {
for _, f := range msg.Fields {
if f.Kind == "message" || f.Kind == "enum" {
if ref, ok := f.T.(string); ok {
addImport(ref, msg.Pos, msg.ModuleStart, f.Kind)
}
}
// map 值类型也可能引用标准包。
if f.Kind == "map" && (f.MapValueKind == "message" || f.MapValueKind == "enum") {
if ref, ok := f.MapValueT.(string); ok {
addImport(ref, msg.Pos, msg.ModuleStart, f.MapValueKind)
}
}
}
}
for _, svc := range services {
for _, m := range svc.Methods {
addImport(m.InputType, svc.Pos, svc.ModuleStart, "message")
addImport(m.OutputType, svc.Pos, svc.ModuleStart, "message")
}
}
return imports
}
-134
View File
@@ -1,134 +0,0 @@
// main.go 提供协议提取命令的参数解析、输入保护和输出调度。
package main
import (
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
)
// inputPaths 支持命令行重复传入 bundle 路径。
type inputPaths []string
// String 返回已经登记的输入路径列表。
func (paths *inputPaths) String() string {
return fmt.Sprint([]string(*paths))
}
// Set 追加一个去除空白后的输入路径。
func (paths *inputPaths) Set(value string) error {
*paths = append(*paths, value)
return nil
}
// bailIf 在不可恢复错误时打印信息并退出。
func bailIf(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// findPrettier 定位可用的 prettier 命令。
func findPrettier() (string, error) {
// 尝试常见的 prettier 命令名
names := []string{"prettier", "prettier.cmd", "npx"}
for _, name := range names {
if path, err := exec.LookPath(name); err == nil {
return path, nil
}
}
return "", fmt.Errorf("prettier not found in PATH, please install: npm install -g prettier")
}
// main 解析参数、保护原始输入并执行协议提取。
func main() {
// 命令行参数
var inputs inputPaths
flag.Var(&inputs, "input", "Path to a JS bundle; repeat to merge multiple bundles")
outputDir := flag.String("output", "", "Output directory for proto files (default: ./cursor_proto)")
skipFormat := flag.Bool("skip-format", false, "Skip prettier formatting")
strict := flag.Bool("strict", true, "Fail when extraction validation detects unresolved/placeholder output")
flag.Parse()
// 如果没有 -input 参数,尝试从位置参数获取
if len(inputs) == 0 && flag.NArg() > 0 {
inputs = append(inputs, flag.Args()...)
}
if len(inputs) == 0 {
fmt.Fprintln(os.Stderr, "Usage: ext -input <path-to-js-file> [-input <another-js-file>] [-output <dir>] [-skip-format]")
fmt.Fprintln(os.Stderr, " ext <path-to-js-file>")
fmt.Fprintln(os.Stderr, "\nExample:")
fmt.Fprintln(os.Stderr, " ext -input /path/to/extensionHostProcess.js")
fmt.Fprintln(os.Stderr, " ext C:\\Users\\xxx\\AppData\\Local\\Programs\\cursor\\resources\\app\\out\\vs\\workbench\\api\\node\\extensionHostProcess.js")
os.Exit(1)
}
for _, inputPath := range inputs {
info, err := os.Stat(inputPath)
bailIf(err)
if info.IsDir() {
bailIf(fmt.Errorf("expected %s to be file, is dir", inputPath))
}
}
// 设置输出目录
if *outputDir == "" {
wd, err := os.Getwd()
bailIf(err)
*outputDir = filepath.Join(wd, "cursor_proto")
}
// 复制到临时文件后再格式化,避免修改 Cursor 安装目录。
fmt.Printf("Copying %d source bundle(s) to temp directory...\n", len(inputs))
tempFileNames := make([]string, 0, len(inputs))
for _, inputPath := range inputs {
originalFile, err := os.Open(inputPath)
bailIf(err)
tempFile, err := os.CreateTemp(os.TempDir(), "cursor-source-*.js")
bailIf(err)
_, err = io.Copy(tempFile, originalFile)
bailIf(err)
bailIf(originalFile.Close())
bailIf(tempFile.Close())
tempFileNames = append(tempFileNames, tempFile.Name())
fmt.Printf("Source: %s\n", inputPath)
}
if *skipFormat {
fmt.Println("Skipping formatting (--skip-format)")
} else if prettierBin, err := findPrettier(); err != nil {
fmt.Printf("Warning: %v\n", err)
fmt.Println("Skipping formatting, extraction may be less accurate...")
} else {
fmt.Println("Formatting source bundles (this may take a while)...")
for _, tempFileName := range tempFileNames {
var prettierCmd *exec.Cmd
if filepath.Base(prettierBin) == "npx" {
prettierCmd = exec.Command(prettierBin, "prettier", "--write", tempFileName)
} else {
prettierCmd = exec.Command(prettierBin, "--write", tempFileName)
}
out, formatErr := prettierCmd.CombinedOutput()
if formatErr != nil {
fmt.Printf("Prettier output: %s\n", string(out))
fmt.Println("Warning: formatting failed for one bundle, continuing anyway...")
}
}
}
// 运行提取器
fmt.Println("Extracting Proto definitions...")
SetStrictMode(*strict)
ExtractProtosFromFiles(tempFileNames, *outputDir)
for _, tempFileName := range tempFileNames {
_ = os.Remove(tempFileName)
}
fmt.Printf("\nOutput directory: %s\n", *outputDir)
}
-343
View File
@@ -1,343 +0,0 @@
// messages.go 解析消息声明、字段数组和字段类型信息。
package main
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
)
// extractMessages 从多种 bundle 语法中提取消息声明。
func extractMessages(text string, moduleStarts []int) []Message {
var messages []Message
messageExists := func(typeName, varName string) bool {
for _, existing := range messages {
if existing.TypeName == typeName && existing.VarName == varName {
return true
}
}
return false
}
// 形式一:变量引用继承基类并在类体中声明 typeName 和 fields。
// 先找所有 "变量名 = class 内部类名" 定义
// JS 变量名可以包含 $ 符号,如 B$e, qg 等
// 需要同时捕获外部变量名和内部类名,因为字段引用可能用任一个
classDefRe := regexp.MustCompile(`([\w$]+)\s*=\s*class\s+([\w$]+)\s+extends\s+[\w$.]+\s*\{`)
classMatches := classDefRe.FindAllStringSubmatchIndex(text, -1)
// 从任意包的 this.typeName 字段读取完整类型名。
typeNameRe := regexp.MustCompile(`this\.typeName\s*=\s*"([\w.]+)"`)
// 从 this.fields 的 newFieldList 回调读取字段数组。
fieldsRe := regexp.MustCompile(`this\.fields\s*=\s*\w+(?:\.proto3)?\.util\.newFieldList\s*\(\s*\(\s*\)\s*=>\s*\[`)
for _, classMatch := range classMatches {
varName := text[classMatch[2]:classMatch[3]]
internalName := text[classMatch[4]:classMatch[5]]
classStart := classMatch[0]
// 找到类的结束位置(匹配大括号)
classEnd := findClassEnd(text, classMatch[1]-1)
if classEnd == -1 {
continue
}
classBody := text[classStart:classEnd]
// 在类体内查找 typeName
typeMatch := typeNameRe.FindStringSubmatch(classBody)
if typeMatch == nil {
continue
}
typeName := typeMatch[1]
// 在类体内查找 fields
fieldsMatch := fieldsRe.FindStringIndex(classBody)
if fieldsMatch == nil {
continue
}
// 找到 fields 数组的开始位置
bracketPos := classStart + fieldsMatch[1] - 1
fields := extractFieldArray(text, bracketPos)
pkg, shortName := parseTypeName(typeName)
msg := Message{
TypeName: typeName,
VarName: varName,
InternalName: internalName,
Fields: fields,
Package: pkg,
ShortName: shortName,
Pos: classStart,
ModuleStart: moduleStartForPos(moduleStarts, classStart),
}
messages = append(messages, msg)
}
// 形式二:匹配转译或压缩 bundle 中连续赋值的消息声明。
// 例如 i.runtime=n.proto3,i.typeName="agent.v1.McpArgs",i.fields=n.proto3.util.newFieldList(()=>[{...}])。
assignmentRe := regexp.MustCompile(`([\w$]+)\.typeName\s*=\s*"([\w.]+)"\s*,\s*[\w$]+\.fields\s*=\s*\w+(?:\.\w+)*\.util\.newFieldList\s*\(\s*\(\s*\)\s*=>\s*\[`)
assignmentMatches := assignmentRe.FindAllStringSubmatchIndex(text, -1)
for _, m := range assignmentMatches {
varName := text[m[2]:m[3]]
typeName := text[m[4]:m[5]]
// 跳过已经由类体形式提取的重复消息。
if messageExists(typeName, varName) {
continue
}
// 正则停在左方括号之前,从匹配尾部定位数组起点。
start := m[1] - 1
if start < 0 || start >= len(text) || text[start] != '[' {
continue
}
fields := extractFieldArray(text, start)
pkg, shortName := parseTypeName(typeName)
messages = append(messages, Message{
TypeName: typeName,
VarName: varName,
InternalName: "",
Fields: fields,
Package: pkg,
ShortName: shortName,
Pos: m[0],
ModuleStart: moduleStartForPos(moduleStarts, m[0]),
})
}
// 形式三:匹配现代 @bufbuild/protobuf 工厂调用。
// 例如 Req=A.makeMessageType("aiserver.v1.HasSeenAdRequest",()=>[{...}])。
messageFactoryRe := regexp.MustCompile(`([\w$]+)\s*=\s*[\w$.]+\.makeMessageType\s*\(\s*["']([\w.]+)["']\s*,\s*\(\s*\)\s*=>\s*\[`)
factoryMatches := messageFactoryRe.FindAllStringSubmatchIndex(text, -1)
for _, m := range factoryMatches {
varName := text[m[2]:m[3]]
typeName := text[m[4]:m[5]]
if messageExists(typeName, varName) {
continue
}
bracketStart := m[1] - 1
if bracketStart < 0 || bracketStart >= len(text) || text[bracketStart] != '[' {
continue
}
pkg, shortName := parseTypeName(typeName)
messages = append(messages, Message{
TypeName: typeName,
VarName: varName,
Fields: extractFieldArray(text, bracketStart),
Package: pkg,
ShortName: shortName,
Pos: m[0],
ModuleStart: moduleStartForPos(moduleStarts, m[0]),
})
}
// 空消息直接传字段数组,不使用延迟回调。
// 例如 Res=A.makeMessageType("aiserver.v1.MarkAdAsSeenResponse",[])。
emptyMessageFactoryRe := regexp.MustCompile(`([\w$]+)\s*=\s*[\w$.]+\.makeMessageType\s*\(\s*["']([\w.]+)["']\s*,\s*\[`)
emptyFactoryMatches := emptyMessageFactoryRe.FindAllStringSubmatchIndex(text, -1)
for _, m := range emptyFactoryMatches {
varName := text[m[2]:m[3]]
typeName := text[m[4]:m[5]]
if messageExists(typeName, varName) {
continue
}
bracketStart := m[1] - 1
if bracketStart < 0 || bracketStart >= len(text) || text[bracketStart] != '[' {
continue
}
pkg, shortName := parseTypeName(typeName)
messages = append(messages, Message{
TypeName: typeName,
VarName: varName,
Fields: extractFieldArray(text, bracketStart),
Package: pkg,
ShortName: shortName,
Pos: m[0],
ModuleStart: moduleStartForPos(moduleStarts, m[0]),
})
}
return messages
}
// findClassEnd 查找类定义的配对右花括号。
func findClassEnd(text string, openBrace int) int {
depth := 0
for i := openBrace; i < len(text); i++ {
if text[i] == '{' {
depth++
} else if text[i] == '}' {
depth--
if depth == 0 {
return i + 1
}
}
}
return -1
}
// extractFieldArray 从左方括号位置解析完整字段数组。
func extractFieldArray(text string, start int) []Field {
// 查找字段数组的配对右方括号。
depth := 0
end := start
for i := start; i < len(text); i++ {
if text[i] == '[' {
depth++
} else if text[i] == ']' {
depth--
if depth == 0 {
end = i + 1
break
}
}
}
arrayText := text[start:end]
// 按每个花括号块解析独立字段对象。
var fields []Field
// 依次查找字段对象。
fieldObjects := extractFieldObjects(arrayText)
for _, fieldObj := range fieldObjects {
field, parseErr := parseFieldObject(fieldObj)
if parseErr != nil {
activeDiagnostics.addSkippedField(fieldObj, parseErr)
continue
}
activeDiagnostics.addParsedField()
fields = append(fields, *field)
}
return fields
}
// extractFieldObjects 从数组文本中提取独立字段对象。
func extractFieldObjects(arrayText string) []string {
var objects []string
depth := 0
start := -1
for i := 0; i < len(arrayText); i++ {
if arrayText[i] == '{' {
if depth == 0 {
start = i
}
depth++
} else if arrayText[i] == '}' {
depth--
if depth == 0 && start >= 0 {
objects = append(objects, arrayText[start:i+1])
start = -1
}
}
}
return objects
}
// parseFieldObject 解析包含编号、名称、类型和修饰符的单个字段对象。
func parseFieldObject(obj string) (*Field, error) {
// 提取字段编号。
noMatch := noRe.FindStringSubmatch(obj)
if noMatch == nil {
return nil, errors.New("missing field no")
}
no, _ := strconv.Atoi(noMatch[1])
// 提取字段名称。
nameMatch := nameRe.FindStringSubmatch(obj)
if nameMatch == nil {
return nil, errors.New("missing field name")
}
name := strings.TrimSpace(nameMatch[1])
if !fieldNameRe.MatchString(name) {
return nil, fmt.Errorf("invalid field name: %s", name)
}
// 提取字段类别。
kindMatch := kindRe.FindStringSubmatch(obj)
if kindMatch == nil {
return nil, errors.New("missing field kind")
}
kind := strings.TrimSpace(kindMatch[1])
field := &Field{
No: no,
Name: name,
Kind: kind,
}
// 类型 T 可以是标量编号、变量名或 getEnumType 枚举调用。
// 枚举优先匹配 getEnumType 调用。
if enumMatch := enumTypeRe.FindStringSubmatch(obj); enumMatch != nil {
field.T = enumMatch[1]
} else {
// 其余类型匹配普通 T 属性值。
if tMatch := tRe.FindStringSubmatch(obj); tMatch != nil {
if t, err := strconv.Atoi(tMatch[1]); err == nil {
field.T = t
} else {
field.T = tMatch[1]
}
} else if shorthandTRe.MatchString(obj) {
field.T = "T"
}
}
// 仅在当前字段对象内检查 oneof 分组。
if oneofMatch := oneofRe.FindStringSubmatch(obj); oneofMatch != nil {
candidate := strings.TrimSpace(oneofMatch[1])
if oneofNameRe.MatchString(candidate) {
field.Oneof = candidate
}
}
// 仅在当前字段对象内检查 repeated;压缩 JS 中 !0 表示真。
if repeatedRe.MatchString(obj) {
field.Repeated = true
}
// 仅在当前字段对象内检查 optional。
if optRe.MatchString(obj) {
field.Opt = true
}
// map 字段通过 K 键类型和 V 值描述共同表示。
if field.Kind == "map" {
// 提取 map 键类型。
if keyMatch := keyRe.FindStringSubmatch(obj); keyMatch != nil {
field.MapKey, _ = strconv.Atoi(keyMatch[1])
}
// 提取 map 值类型,兼容属性顺序变化。
if valueMatch := mapValueRe.FindStringSubmatch(obj); valueMatch != nil {
valueObj := valueMatch[1]
if kindMatch := mapValueKRe.FindStringSubmatch(valueObj); kindMatch != nil {
field.MapValueKind = kindMatch[1]
}
if tMatch := mapValueTRe.FindStringSubmatch(valueObj); tMatch != nil {
if t, err := strconv.Atoi(tMatch[1]); err == nil {
field.MapValueT = t
} else {
field.MapValueT = tMatch[1]
}
}
}
}
return field, nil
}
-430
View File
@@ -1,430 +0,0 @@
// modules.go 扫描模块边界、合并声明并执行提取结果校验。
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/jhump/protoreflect/desc"
"github.com/jhump/protoreflect/desc/protoparse"
)
// moduleStartRe 匹配 Webpack 数字模块的函数起点。
var moduleStartRe = regexp.MustCompile(`(?:^|,)\s*(\d+)\s*:\s*(?:function\s*\(\s*[\w$,\s]*\s*\)|\(\s*[\w$,\s]*\s*\)\s*=>)\s*\{`)
// buildModuleStarts 收集 bundle 内全部模块起始位置。
func buildModuleStarts(text string) []int {
matches := moduleStartRe.FindAllStringSubmatchIndex(text, -1)
starts := make([]int, 0, len(matches))
for _, match := range matches {
starts = append(starts, match[0])
}
return starts
}
// moduleStartForPos 查找指定源码位置所属的模块起点。
func moduleStartForPos(moduleStarts []int, pos int) int {
if len(moduleStarts) == 0 {
return 0
}
index := sort.Search(len(moduleStarts), func(i int) bool {
return moduleStarts[i] > pos
}) - 1
if index < 0 {
return 0
}
return moduleStarts[index]
}
// buildModuleImportIndex 建立模块局部变量到导入模块编号的映射。
func buildModuleImportIndex(text string, moduleStarts []int) map[int]map[string]int {
if len(moduleStarts) == 0 {
return nil
}
moduleMatches := moduleStartRe.FindAllStringSubmatchIndex(text, -1)
moduleStartByID := make(map[string]int, len(moduleMatches))
for _, match := range moduleMatches {
moduleStartByID[text[match[2]:match[3]]] = match[0]
}
importsByModule := make(map[int]map[string]int)
for index, moduleStart := range moduleStarts {
moduleEnd := len(text)
if index+1 < len(moduleStarts) {
moduleEnd = moduleStarts[index+1]
}
body := text[moduleStart:moduleEnd]
for _, match := range moduleImportRe.FindAllStringSubmatch(body, -1) {
targetModuleStart, ok := moduleStartByID[match[2]]
if !ok {
continue
}
if importsByModule[moduleStart] == nil {
importsByModule[moduleStart] = make(map[string]int)
}
importsByModule[moduleStart][match[1]] = targetModuleStart
}
}
return importsByModule
}
// ExtractProtosFromFiles 分别提取各 bundle,规范化类型引用后按全限定名合并。
// 多个 bundle 出现同名声明时优先保留靠前输入。
func ExtractProtosFromFiles(inputFiles []string, outputDir string) {
activeDiagnostics = newExtractionDiagnostics()
defer func() {
activeDiagnostics = nil
}()
var allMessages []Message
var allEnums []Enum
var allServices []Service
for _, inputFile := range inputFiles {
content, err := os.ReadFile(inputFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading file %s: %v\n", inputFile, err)
os.Exit(1)
}
text := string(content)
moduleStarts := buildModuleStarts(text)
aliases := buildAliasIndex(text, moduleStarts)
exportAliases := buildWebpackExportAliasIndex(text, moduleStarts)
messages := extractMessages(text, moduleStarts)
enums := extractEnums(text, moduleStarts)
services := extractServices(text, moduleStarts)
declared, extracted, missing := declarationCoverage(text, messages, enums, services)
activeDiagnostics.declaredTypes += declared
activeDiagnostics.extractedTypes += extracted
activeDiagnostics.missingDeclarations = append(activeDiagnostics.missingDeclarations, missing...)
resolver := newTypeResolver(messages, enums, aliases, exportAliases)
resolver.moduleImports = buildModuleImportIndex(text, moduleStarts)
normalizeTypeReferences(messages, services, resolver)
allMessages = append(allMessages, messages...)
allEnums = append(allEnums, enums...)
allServices = append(allServices, services...)
}
messages := mergeMessagesByTypeName(allMessages)
enums := mergeEnumsByTypeName(allEnums)
services := mergeServicesByTypeName(allServices)
for _, msg := range messages {
if len(msg.Fields) == 0 {
activeDiagnostics.emptyMessages = append(activeDiagnostics.emptyMessages, msg.TypeName)
}
}
sort.Strings(activeDiagnostics.missingDeclarations)
activeDiagnostics.missingDeclarations = compactStrings(activeDiagnostics.missingDeclarations)
resolver := newTypeResolver(messages, enums, nil, nil)
generateProtos(messages, enums, services, resolver, outputDir)
validateErr := validateGeneratedProtos(outputDir, activeDiagnostics)
printDiagnosticsSummary(activeDiagnostics)
if strictExtractionValidation && hasValidationFailure(activeDiagnostics, validateErr) {
if validateErr != nil {
fmt.Fprintf(os.Stderr, "Validation failed: %v\n", validateErr)
}
os.Exit(1)
}
if validateErr != nil {
fmt.Fprintf(os.Stderr, "Validation warning: %v\n", validateErr)
}
fmt.Printf("提取完成: %d 个消息, %d 个枚举, %d 个服务\n", len(messages), len(enums), len(services))
}
// normalizeTypeReferences 把字段和方法引用统一转换为全限定类型名。
func normalizeTypeReferences(messages []Message, services []Service, resolver *TypeResolver) {
resolve := func(ref any, contextPos int, moduleStart int, pkg string, kind string) any {
symbol, ok := ref.(string)
if !ok || strings.TrimSpace(symbol) == "" {
return ref
}
if typeName, resolved := resolver.ResolveTypeName(symbol, contextPos, moduleStart, pkg, kind); resolved {
return typeName
}
return ref
}
for messageIndex := range messages {
message := &messages[messageIndex]
for fieldIndex := range message.Fields {
field := &message.Fields[fieldIndex]
if field.Kind == "message" || field.Kind == "enum" {
field.T = resolve(field.T, message.Pos, message.ModuleStart, message.Package, field.Kind)
}
if field.Kind == "map" && (field.MapValueKind == "message" || field.MapValueKind == "enum") {
field.MapValueT = resolve(field.MapValueT, message.Pos, message.ModuleStart, message.Package, field.MapValueKind)
}
}
}
for serviceIndex := range services {
service := &services[serviceIndex]
for methodIndex := range service.Methods {
method := &service.Methods[methodIndex]
if typeName, ok := resolve(method.InputType, service.Pos, service.ModuleStart, service.Package, "message").(string); ok {
method.InputType = typeName
}
if typeName, ok := resolve(method.OutputType, service.Pos, service.ModuleStart, service.Package, "message").(string); ok {
method.OutputType = typeName
}
}
}
}
// mergeMessagesByTypeName 按全限定名合并消息并保留首次声明。
func mergeMessagesByTypeName(messages []Message) []Message {
seen := make(map[string]bool)
merged := make([]Message, 0, len(messages))
for _, message := range messages {
if seen[message.TypeName] {
continue
}
seen[message.TypeName] = true
merged = append(merged, message)
}
return merged
}
// mergeEnumsByTypeName 按全限定名合并枚举并保留首次声明。
func mergeEnumsByTypeName(enums []Enum) []Enum {
seen := make(map[string]bool)
merged := make([]Enum, 0, len(enums))
for _, enum := range enums {
if seen[enum.TypeName] {
continue
}
seen[enum.TypeName] = true
merged = append(merged, enum)
}
return merged
}
// mergeServicesByTypeName 按全限定名合并服务并保留首次声明。
func mergeServicesByTypeName(services []Service) []Service {
seen := make(map[string]bool)
merged := make([]Service, 0, len(services))
for _, service := range services {
if seen[service.TypeName] {
continue
}
seen[service.TypeName] = true
merged = append(merged, service)
}
return merged
}
// compactStrings 清理、去重并排序诊断字符串。
func compactStrings(values []string) []string {
if len(values) == 0 {
return nil
}
compacted := values[:1]
for _, value := range values[1:] {
if value != compacted[len(compacted)-1] {
compacted = append(compacted, value)
}
}
return compacted
}
// hasValidationFailure 判断诊断结果是否达到失败条件。
func hasValidationFailure(diag *extractionDiagnostics, validateErr error) bool {
if validateErr != nil {
return true
}
if diag == nil {
return false
}
if diag.skippedFieldObjects > 0 {
return true
}
if len(diag.unresolvedTypeRefs) > 0 {
return true
}
if len(diag.placeholderHits) > 0 {
return true
}
if len(diag.missingDeclarations) > 0 {
return true
}
return false
}
// printDiagnosticsSummary 输出提取覆盖率和异常样本摘要。
func printDiagnosticsSummary(diag *extractionDiagnostics) {
if diag == nil {
return
}
fmt.Printf(
"诊断汇总: fields %d/%d 解析成功, declarations %d/%d 已提取, skipped=%d, unresolved=%d, placeholders=%d, empty_messages=%d\n",
diag.parsedFieldObjects,
diag.totalFieldObjects,
diag.extractedTypes,
diag.declaredTypes,
diag.skippedFieldObjects,
len(diag.unresolvedTypeRefs),
len(diag.placeholderHits),
len(diag.emptyMessages),
)
if diag.skippedFieldObjects > 0 && len(diag.skippedFieldSamples) > 0 {
fmt.Println("字段解析失败样例:")
for _, sample := range diag.skippedFieldSamples {
fmt.Printf(" - %s\n", sample)
}
}
if len(diag.unresolvedTypeRefs) > 0 {
keys := make([]string, 0, len(diag.unresolvedTypeRefs))
for key := range diag.unresolvedTypeRefs {
keys = append(keys, key)
}
sort.Strings(keys)
fmt.Println("未解析类型引用:")
for _, key := range keys {
fmt.Printf(" - %s (%d)\n", key, diag.unresolvedTypeRefs[key])
}
}
if len(diag.placeholderHits) > 0 {
fmt.Println("占位字段命中:")
for i, hit := range diag.placeholderHits {
if i >= 20 {
fmt.Printf(" - ... and %d more\n", len(diag.placeholderHits)-20)
break
}
fmt.Printf(" - %s\n", hit)
}
}
if len(diag.missingDeclarations) > 0 {
fmt.Println("未提取的 Proto 声明:")
for i, typeName := range diag.missingDeclarations {
if i >= 20 {
fmt.Printf(" - ... and %d more\n", len(diag.missingDeclarations)-20)
break
}
fmt.Printf(" - %s\n", typeName)
}
}
}
// declarationCoverage 比较 bundle 声明数量与实际提取数量。
func declarationCoverage(text string, messages []Message, enums []Enum, services []Service) (int, int, []string) {
declared := make(map[string]bool)
collect := func(re *regexp.Regexp) {
for _, match := range re.FindAllStringSubmatch(text, -1) {
typeName := strings.TrimSpace(match[1])
pkg, _ := parseTypeName(typeName)
if typeName != "" && !isGooglePkg(pkg) {
declared[typeName] = true
}
}
}
collect(typeNameDeclarationRe)
collect(serviceDeclarationRe)
collect(messageDeclarationRe)
collect(enumDeclarationRe)
collect(legacyEnumDeclarationRe)
extracted := make(map[string]bool)
for _, message := range messages {
extracted[message.TypeName] = true
}
for _, enum := range enums {
extracted[enum.TypeName] = true
}
for _, service := range services {
extracted[service.TypeName] = true
}
matched := 0
missing := make([]string, 0)
for typeName := range declared {
if extracted[typeName] {
matched++
continue
}
missing = append(missing, typeName)
}
sort.Strings(missing)
return len(declared), matched, missing
}
// validateGeneratedProtos 检查生成文件语法占位和关键 Agent 结构。
func validateGeneratedProtos(outputDir string, diag *extractionDiagnostics) error {
entries, err := os.ReadDir(outputDir)
if err != nil {
return fmt.Errorf("read output dir failed: %w", err)
}
protoFiles := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasSuffix(name, ".proto") {
protoFiles = append(protoFiles, name)
}
}
if len(protoFiles) == 0 {
return errors.New("no generated proto files found")
}
sort.Strings(protoFiles)
for _, file := range protoFiles {
body, readErr := os.ReadFile(filepath.Join(outputDir, file))
if readErr != nil {
return fmt.Errorf("read generated proto failed: %s: %w", file, readErr)
}
lines := strings.Split(string(body), "\n")
for idx, line := range lines {
if placeholderRe.MatchString(line) && diag != nil {
hit := fmt.Sprintf("%s:%d: %s", file, idx+1, strings.TrimSpace(line))
diag.placeholderHits = append(diag.placeholderHits, hit)
}
}
if err := validateRequiredAgentShapes(file, string(body)); err != nil {
return err
}
}
parser := protoparse.Parser{
ImportPaths: []string{outputDir},
LookupImport: desc.LoadFileDescriptor,
}
if _, parseErr := parser.ParseFiles(protoFiles...); parseErr != nil {
return fmt.Errorf("parse generated proto failed: %w", parseErr)
}
return nil
}
// validateRequiredAgentShapes 校验 Agent 流控消息的必要字段形状。
func validateRequiredAgentShapes(file string, body string) error {
if strings.Contains(body, "message ExecClientControlMessage") && !streamCloseRe.MatchString(body) {
return fmt.Errorf("%s: ExecClientControlMessage.stream_close must be ExecClientStreamClose", file)
}
if strings.Contains(body, "message ShellStream") && !shellStdoutRe.MatchString(body) {
return fmt.Errorf("%s: ShellStream.stdout must be ShellStreamStdout", file)
}
return nil
}
-395
View File
@@ -1,395 +0,0 @@
// renderer.go 把协议声明树渲染为稳定的 proto 文本。
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// generateProtoFile 把单个协议包的声明渲染并写入文件。
func generateProtoFile(pkgName string, messages []Message, enums []Enum, services []Service, resolver *TypeResolver, outputDir string) {
// 先收集全部跨包标准依赖。
imports := collectImports(pkgName, messages, services, resolver)
var sb strings.Builder
sb.WriteString(`syntax = "proto3";` + "\n\n")
sb.WriteString(fmt.Sprintf("package %s;\n\n", pkgName))
// 按稳定顺序写入 import。
if len(imports) > 0 {
sortedImports := make([]string, 0, len(imports))
for imp := range imports {
sortedImports = append(sortedImports, imp)
}
sort.Strings(sortedImports)
for _, imp := range sortedImports {
sb.WriteString(fmt.Sprintf("import \"%s\";\n", imp))
}
sb.WriteString("\n")
}
goPackagePath := strings.ReplaceAll(pkgName, ".", "/")
goPackageName := strings.ReplaceAll(pkgName, ".", "")
sb.WriteString(fmt.Sprintf(`option go_package = "github.com/leookun/cursor-byok/cursor-proto/gen/%s;%s";`+"\n\n", goPackagePath, goPackageName))
// 建立嵌套类型树。
root := &TypeNode{Children: make(map[string]*TypeNode)}
for i := range messages {
msg := &messages[i]
path := getNestedPath(msg.ShortName)
insertMessage(root, path, msg)
}
for i := range enums {
enum := &enums[i]
path := getNestedPath(enum.ShortName)
insertEnum(root, path, enum)
}
// 写入全部顶层类型。
writeTypeTree(root, &sb, resolver, 0, pkgName)
// 写入服务声明。
sort.Slice(services, func(i, j int) bool {
return services[i].ShortName < services[j].ShortName
})
for _, svc := range services {
// 写入服务来源注释。
sb.WriteString(fmt.Sprintf("// Source: %s (var: %s)\n", svc.TypeName, svc.VarName))
sb.WriteString(fmt.Sprintf("service %s {\n", svc.ShortName))
for _, m := range svc.Methods {
inputType := resolveMethodType(m.InputType, resolver, pkgName, svc.Pos, svc.ModuleStart)
outputType := resolveMethodType(m.OutputType, resolver, pkgName, svc.Pos, svc.ModuleStart)
switch m.Kind {
case "ServerStreaming":
sb.WriteString(fmt.Sprintf(" rpc %s(%s) returns (stream %s) {}\n", m.Name, inputType, outputType))
case "ClientStreaming":
sb.WriteString(fmt.Sprintf(" rpc %s(stream %s) returns (%s) {}\n", m.Name, inputType, outputType))
case "BiDiStreaming":
sb.WriteString(fmt.Sprintf(" rpc %s(stream %s) returns (stream %s) {}\n", m.Name, inputType, outputType))
default: // 默认为一元调用。
sb.WriteString(fmt.Sprintf(" rpc %s(%s) returns (%s) {}\n", m.Name, inputType, outputType))
}
}
sb.WriteString("}\n\n")
}
// 每个协议包写入扁平输出目录中的单个文件。
fileName := strings.ReplaceAll(pkgName, ".", "_") + ".proto"
filePath := filepath.Join(outputDir, fileName)
os.WriteFile(filePath, []byte(sb.String()), 0644)
fmt.Printf("Generated: %s (%d messages, %d enums, %d services)\n", filePath, len(messages), len(enums), len(services))
}
// resolveMethodType 解析方法消息类型并处理本地复制类型。
func resolveMethodType(ref string, resolver *TypeResolver, currentPkg string, contextPos int, contextModuleStart int) string {
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, "message")
if !ok {
activeDiagnostics.addUnresolvedType("method:" + ref)
return fallbackTypeToken(ref)
}
refPkg, shortName := parseTypeName(typeName)
if refPkg == currentPkg || refPkg == "" {
return shortName
}
// 检查类型是否由其他包复制到当前包。
if copied := copiedTypes[currentPkg]; copied != nil {
if _, isCopied := copied[shortName]; isCopied {
return shortName
}
}
return refPkg + "." + shortName
}
// insertMessage 把消息插入嵌套类型树。
func insertMessage(node *TypeNode, path []string, msg *Message) {
if len(path) == 0 {
return
}
name := path[0]
if node.Children == nil {
node.Children = make(map[string]*TypeNode)
}
child, exists := node.Children[name]
if !exists {
child = &TypeNode{Name: name, Children: make(map[string]*TypeNode)}
node.Children[name] = child
}
if len(path) == 1 {
child.Message = msg
} else {
insertMessage(child, path[1:], msg)
}
}
// insertEnum 把枚举插入嵌套类型树。
func insertEnum(node *TypeNode, path []string, enum *Enum) {
if len(path) == 0 {
return
}
name := path[0]
if node.Children == nil {
node.Children = make(map[string]*TypeNode)
}
child, exists := node.Children[name]
if !exists {
child = &TypeNode{Name: name, Children: make(map[string]*TypeNode)}
node.Children[name] = child
}
if len(path) == 1 {
child.Enum = enum
} else {
insertEnum(child, path[1:], enum)
}
}
// writeTypeTree 按名称稳定输出嵌套消息和枚举。
func writeTypeTree(node *TypeNode, sb *strings.Builder, resolver *TypeResolver, indent int, currentPkg string) {
// 对子节点排序以保证输出稳定。
var names []string
for name := range node.Children {
names = append(names, name)
}
sort.Strings(names)
indentStr := strings.Repeat(" ", indent)
for _, name := range names {
child := node.Children[name]
if child.Enum != nil {
// 检查枚举是否来自其他包。
originalType := ""
if copied := copiedTypes[currentPkg]; copied != nil {
if orig, ok := copied[child.Enum.ShortName]; ok {
originalType = orig
}
}
// 写入枚举来源注释。
if originalType != "" {
sb.WriteString(fmt.Sprintf("%s// Copied from: %s (var: %s)\n", indentStr, originalType, child.Enum.VarName))
} else {
sb.WriteString(fmt.Sprintf("%s// Source: %s (var: %s)\n", indentStr, child.Enum.TypeName, child.Enum.VarName))
}
// 写入枚举声明。
sb.WriteString(fmt.Sprintf("%senum %s {\n", indentStr, name))
for _, v := range child.Enum.Values {
sb.WriteString(fmt.Sprintf("%s %s = %d;\n", indentStr, v.Name, v.No))
}
sb.WriteString(fmt.Sprintf("%s}\n\n", indentStr))
} else if child.Message != nil || len(child.Children) > 0 {
// 写入消息来源注释。
if child.Message != nil {
varInfo := child.Message.VarName
if child.Message.InternalName != "" && child.Message.InternalName != child.Message.VarName {
varInfo = fmt.Sprintf("%s, class: %s", child.Message.VarName, child.Message.InternalName)
}
// 检查消息是否来自其他包。
originalType := ""
if copied := copiedTypes[currentPkg]; copied != nil {
if orig, ok := copied[child.Message.ShortName]; ok {
originalType = orig
}
}
if originalType != "" {
sb.WriteString(fmt.Sprintf("%s// Copied from: %s (var: %s)\n", indentStr, originalType, varInfo))
} else {
sb.WriteString(fmt.Sprintf("%s// Source: %s (var: %s)\n", indentStr, child.Message.TypeName, varInfo))
}
}
// 即使节点只承载嵌套类型,也要写入消息容器。
sb.WriteString(fmt.Sprintf("%smessage %s {\n", indentStr, name))
// 先写入嵌套类型。
writeTypeTree(child, sb, resolver, indent+1, currentPkg)
// 当前节点有消息声明时再写字段。
if child.Message != nil {
writeMessageFields(child.Message, sb, resolver, indent+1)
}
sb.WriteString(fmt.Sprintf("%s}\n\n", indentStr))
}
}
}
// writeMessageFields 输出普通字段和 oneof 分组。
func writeMessageFields(msg *Message, sb *strings.Builder, resolver *TypeResolver, indent int) {
indentStr := strings.Repeat(" ", indent)
// 获取当前消息路径,用于解析相对嵌套类型。
msgPath := msg.ShortName
currentPkg := msg.Package
preferredPkg, _ := parseTypeName(msg.TypeName)
// 按 oneof 分组字段。
oneofGroups := make(map[string][]Field)
var regularFields []Field
for _, f := range msg.Fields {
if f.Oneof != "" {
oneofGroups[f.Oneof] = append(oneofGroups[f.Oneof], f)
} else {
regularFields = append(regularFields, f)
}
}
// 先写普通字段。
for _, f := range regularFields {
fieldType := resolveFieldTypeWithPkg(f, resolver, msgPath, currentPkg, preferredPkg, msg.Pos, msg.ModuleStart)
prefix := ""
if f.Repeated {
prefix = "repeated "
} else if f.Opt {
prefix = "optional "
}
sb.WriteString(fmt.Sprintf("%s%s%s %s = %d;\n", indentStr, prefix, fieldType, f.Name, f.No))
}
// 再写 oneof 字段组。
var oneofNames []string
for name := range oneofGroups {
oneofNames = append(oneofNames, name)
}
sort.Strings(oneofNames)
for _, oneofName := range oneofNames {
fields := oneofGroups[oneofName]
sb.WriteString(fmt.Sprintf("%soneof %s {\n", indentStr, oneofName))
for _, f := range fields {
fieldType := resolveFieldTypeWithPkg(f, resolver, msgPath, currentPkg, preferredPkg, msg.Pos, msg.ModuleStart)
sb.WriteString(fmt.Sprintf("%s %s %s = %d;\n", indentStr, fieldType, f.Name, f.No))
}
sb.WriteString(fmt.Sprintf("%s}\n", indentStr))
}
}
// parseTypeName 从全限定类型名拆出协议包和完整嵌套路径。
func parseTypeName(typeName string) (pkg, shortName string) {
// 优先匹配 xxx.vN.Rest 形式的版本化协议包。
versionRe := regexp.MustCompile(`^([\w.]+\.v\d+)\.(.+)$`)
if match := versionRe.FindStringSubmatch(typeName); match != nil {
return match[1], match[2]
}
// 单独处理 google.protobuf 标准类型。
if strings.HasPrefix(typeName, "google.protobuf.") {
rest := strings.TrimPrefix(typeName, "google.protobuf.")
return "google.protobuf", rest
}
// 单独处理 google.rpc 标准类型。
if strings.HasPrefix(typeName, "google.rpc.") {
rest := strings.TrimPrefix(typeName, "google.rpc.")
return "google.rpc", rest
}
// 无法识别包版本时按最后一个点回退拆分。
parts := strings.Split(typeName, ".")
if len(parts) > 1 {
return strings.Join(parts[:len(parts)-1], "."), parts[len(parts)-1]
}
return "", typeName
}
// getNestedPath 把嵌套类型名拆成逐级路径。
func getNestedPath(shortName string) []string {
return strings.Split(shortName, ".")
}
// resolveFieldTypeWithPkg 结合当前包和父消息路径解析字段类型。
func resolveFieldTypeWithPkg(f Field, resolver *TypeResolver, parentPath string, currentPkg string, preferredPkg string, contextPos int, contextModuleStart int) string {
resolveNamedType := func(ref string, expectedKind string) string {
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, preferredPkg, expectedKind)
if !ok {
activeDiagnostics.addUnresolvedType(expectedKind + ":" + ref)
return fallbackTypeToken(ref)
}
refPkg, shortName := parseTypeName(typeName)
// 类型位于同一父消息下时使用相对路径。
if parentPath != "" && strings.HasPrefix(shortName, parentPath+".") {
// 例如消息内部将 ConversationMessage.CodeChunk 缩短为 CodeChunk。
return strings.TrimPrefix(shortName, parentPath+".")
}
// 同包类型只使用短名称。
if refPkg == currentPkg || refPkg == "" {
return shortName
}
// 循环依赖中优先使用已经复制到当前包的类型。
if copied := copiedTypes[currentPkg]; copied != nil {
if _, isCopied := copied[shortName]; isCopied {
// 本地存在复制类型时使用短名称。
return shortName
}
}
// 其余跨包引用保留全限定类型名。
return refPkg + "." + shortName
}
if f.Kind == "scalar" {
if t, ok := f.T.(int); ok {
return scalarTypes[t]
}
if t, ok := f.T.(float64); ok {
return scalarTypes[int(t)]
}
}
if f.Kind == "message" || f.Kind == "enum" {
if ref, ok := f.T.(string); ok {
return resolveNamedType(ref, f.Kind)
}
}
if f.Kind == "map" {
// map 字段分别解析键和值类型。
keyType := scalarTypes[f.MapKey]
if keyType == "" {
keyType = "string" // 未知标量默认使用字符串。
}
var valueType string
if f.MapValueKind == "scalar" {
if t, ok := f.MapValueT.(int); ok {
valueType = scalarTypes[t]
} else if t, ok := f.MapValueT.(float64); ok {
valueType = scalarTypes[int(t)]
}
} else if f.MapValueKind == "message" || f.MapValueKind == "enum" {
if ref, ok := f.MapValueT.(string); ok {
valueType = resolveNamedType(ref, f.MapValueKind)
}
}
if valueType == "" {
valueType = "bytes"
}
return fmt.Sprintf("map<%s, %s>", keyType, valueType)
}
return "bytes" // 未识别字段类型时回退为字节串。
}
-423
View File
@@ -1,423 +0,0 @@
// resolver.go 解析压缩 bundle 中的局部符号、模块别名和导出别名。
package main
import (
"regexp"
"sort"
"strings"
)
// newTypeResolver 建立消息、枚举和模块别名的统一索引。
func newTypeResolver(messages []Message, enums []Enum, aliases aliasIndex, exportAliases aliasIndex) *TypeResolver {
resolver := &TypeResolver{
bySymbol: make(map[string][]symbolDef),
byAlias: make(map[string][]symbolDef),
byShort: make(map[string][]symbolDef),
}
add := func(symbol, typeName string, pos int, moduleStart int, kind string) {
symbol = strings.TrimSpace(symbol)
typeName = strings.TrimSpace(typeName)
if symbol == "" || typeName == "" {
return
}
def := symbolDef{TypeName: typeName, Pos: pos, ModuleStart: moduleStart, Kind: kind}
resolver.bySymbol[symbol] = append(resolver.bySymbol[symbol], def)
_, shortName := parseTypeName(typeName)
if shortName != "" {
resolver.byShort[shortName] = append(resolver.byShort[shortName], def)
underscoreAlias := strings.ReplaceAll(shortName, ".", "_")
if underscoreAlias != shortName {
resolver.byShort[underscoreAlias] = append(resolver.byShort[underscoreAlias], def)
}
if idx := strings.LastIndex(shortName, "."); idx > 0 && idx+1 < len(shortName) {
resolver.byShort[shortName[idx+1:]] = append(resolver.byShort[shortName[idx+1:]], def)
}
if idx := strings.LastIndex(underscoreAlias, "_"); idx > 0 && idx+1 < len(underscoreAlias) {
resolver.byShort[underscoreAlias[idx+1:]] = append(resolver.byShort[underscoreAlias[idx+1:]], def)
}
}
}
addAlias := func(symbol, typeName string, pos int, moduleStart int, kind string) {
symbol = strings.TrimSpace(symbol)
typeName = strings.TrimSpace(typeName)
if symbol == "" || typeName == "" {
return
}
resolver.byAlias[symbol] = append(resolver.byAlias[symbol], symbolDef{
TypeName: typeName, Pos: pos, ModuleStart: moduleStart, Kind: kind,
})
}
for _, msg := range messages {
add(msg.VarName, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
if msg.InternalName != "" && msg.InternalName != msg.VarName {
add(msg.InternalName, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
}
for _, alias := range aliasesForSymbols(aliases[msg.ModuleStart], msg.VarName, msg.InternalName) {
addAlias(alias, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
}
}
for _, enum := range enums {
add(enum.VarName, enum.TypeName, enum.Pos, enum.ModuleStart, "enum")
for _, alias := range aliasesForSymbols(aliases[enum.ModuleStart], enum.VarName) {
addAlias(alias, enum.TypeName, enum.Pos, enum.ModuleStart, "enum")
}
}
for _, msg := range messages {
for _, alias := range aliasesForSymbols(exportAliases[msg.ModuleStart], msg.VarName, msg.InternalName) {
addAlias(alias, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
}
}
for _, enum := range enums {
for _, alias := range aliasesForSymbols(exportAliases[enum.ModuleStart], enum.VarName) {
addAlias(alias, enum.TypeName, enum.Pos, enum.ModuleStart, "enum")
}
}
return resolver
}
// buildAliasIndex 提取变量声明和赋值形成的局部别名。
func buildAliasIndex(text string, moduleStarts []int) aliasIndex {
directByModule := make(map[int]map[string]string)
addMatches := func(matches [][]int) {
for _, match := range matches {
alias := strings.TrimSpace(text[match[2]:match[3]])
target := strings.TrimSpace(text[match[4]:match[5]])
if alias == "" || target == "" || alias == target {
continue
}
moduleStart := moduleStartForPos(moduleStarts, match[0])
if directByModule[moduleStart] == nil {
directByModule[moduleStart] = make(map[string]string)
}
directByModule[moduleStart][alias] = target
}
}
addMatches(varAliasRe.FindAllStringSubmatchIndex(text, -1))
addMatches(assignmentAliasRe.FindAllStringSubmatchIndex(text, -1))
resolveRoot := func(direct map[string]string, symbol string) string {
seen := make(map[string]bool)
current := symbol
for {
if seen[current] {
return symbol
}
seen[current] = true
next := direct[current]
if next == "" {
return current
}
current = next
}
}
aliasSets := make(map[int]map[string]map[string]bool)
addAlias := func(moduleStart int, root string, alias string) {
root = strings.TrimSpace(root)
alias = strings.TrimSpace(alias)
if root == "" || alias == "" || root == alias {
return
}
if aliasSets[moduleStart] == nil {
aliasSets[moduleStart] = make(map[string]map[string]bool)
}
if aliasSets[moduleStart][root] == nil {
aliasSets[moduleStart][root] = make(map[string]bool)
}
aliasSets[moduleStart][root][alias] = true
}
for moduleStart, direct := range directByModule {
for alias := range direct {
root := resolveRoot(direct, alias)
addAlias(moduleStart, root, alias)
}
}
if len(aliasSets) == 0 {
return nil
}
aliases := make(aliasIndex, len(aliasSets))
for moduleStart, roots := range aliasSets {
aliases[moduleStart] = make(map[string][]string, len(roots))
for root, set := range roots {
for alias := range set {
aliases[moduleStart][root] = append(aliases[moduleStart][root], alias)
}
sort.Strings(aliases[moduleStart][root])
}
}
return aliases
}
// buildWebpackExportAliasIndex 提取 Webpack 导出表中的符号别名。
func buildWebpackExportAliasIndex(text string, moduleStarts []int) aliasIndex {
aliasSets := make(map[int]map[string]map[string]bool)
addAlias := func(moduleStart int, root string, alias string) {
root = strings.TrimSpace(root)
alias = strings.TrimSpace(alias)
if root == "" || alias == "" || root == alias {
return
}
if aliasSets[moduleStart] == nil {
aliasSets[moduleStart] = make(map[string]map[string]bool)
}
if aliasSets[moduleStart][root] == nil {
aliasSets[moduleStart][root] = make(map[string]bool)
}
aliasSets[moduleStart][root][alias] = true
}
// Webpack 通过 n.d(t, { KS: () => T }) 暴露成员;服务使用 r.KS,消息定义使用局部符号 T。
for _, blockMatch := range webpackExportBlockRe.FindAllStringIndex(text, -1) {
moduleStart := moduleStartForPos(moduleStarts, blockMatch[0])
blockStart := blockMatch[1] - 1
blockEnd := findMatchingBrace(text, blockStart)
if blockEnd == -1 {
continue
}
block := text[blockStart:blockEnd]
for _, entry := range webpackExportEntryRe.FindAllStringSubmatch(block, -1) {
addAlias(moduleStart, entry[2], entry[1])
}
}
if len(aliasSets) == 0 {
return nil
}
aliases := make(aliasIndex, len(aliasSets))
for moduleStart, roots := range aliasSets {
aliases[moduleStart] = make(map[string][]string, len(roots))
for root, set := range roots {
for alias := range set {
aliases[moduleStart][root] = append(aliases[moduleStart][root], alias)
}
sort.Strings(aliases[moduleStart][root])
}
}
return aliases
}
// aliasesForSymbols 返回目标符号集合对应的去重别名。
func aliasesForSymbols(aliases map[string][]string, symbols ...string) []string {
if len(aliases) == 0 {
return nil
}
seen := make(map[string]bool)
var result []string
for _, symbol := range symbols {
for _, alias := range aliases[strings.TrimSpace(symbol)] {
if alias == "" || seen[alias] {
continue
}
seen[alias] = true
result = append(result, alias)
}
}
sort.Strings(result)
return result
}
// looksLikeFullTypeName 判断引用是否已经是全限定协议类型名。
func looksLikeFullTypeName(ref string) bool {
trimmed := strings.TrimSpace(ref)
if strings.HasPrefix(trimmed, "google.protobuf.") || strings.HasPrefix(trimmed, "google.rpc.") {
return true
}
matched, _ := regexp.MatchString(`^[\w.]+\.v\d+\.[\w.]+$`, trimmed)
return matched
}
// pickBestDefinition 按模块、类别、首选包和源码距离选择定义。
func pickBestDefinition(candidates []symbolDef, contextPos int, contextModuleStart int, preferredPkg string, expectedKind string) (symbolDef, bool) {
if len(candidates) == 0 {
return symbolDef{}, false
}
filtered := candidates
if strings.TrimSpace(expectedKind) != "" {
tmp := make([]symbolDef, 0, len(candidates))
for _, item := range candidates {
if item.Kind == expectedKind {
tmp = append(tmp, item)
}
}
if len(tmp) > 0 {
filtered = tmp
}
}
if strings.TrimSpace(preferredPkg) != "" {
tmp := make([]symbolDef, 0, len(filtered))
for _, item := range filtered {
pkg, _ := parseTypeName(item.TypeName)
if pkg == preferredPkg {
tmp = append(tmp, item)
}
}
if len(tmp) > 0 {
filtered = tmp
}
}
if contextModuleStart > 0 {
tmp := make([]symbolDef, 0, len(filtered))
for _, item := range filtered {
if item.ModuleStart == contextModuleStart {
tmp = append(tmp, item)
}
}
if len(tmp) > 0 {
filtered = tmp
}
}
// 选择绝对距离最近的定义,距离相同时优先前向定义。
bestIndex := -1
bestDistance := 0
bestIsFuture := false
for index, item := range filtered {
distance := absInt(item.Pos - contextPos)
isFuture := item.Pos > contextPos
if bestIndex == -1 {
bestIndex = index
bestDistance = distance
bestIsFuture = isFuture
continue
}
if distance < bestDistance {
bestIndex = index
bestDistance = distance
bestIsFuture = isFuture
continue
}
if distance == bestDistance {
// 距离相同时优先当前位置之前的定义。
if bestIsFuture && !isFuture {
bestIndex = index
bestIsFuture = isFuture
}
}
}
if bestIndex < 0 {
return symbolDef{}, false
}
return filtered[bestIndex], true
}
// ResolveTypeName 把局部变量、别名或短名称解析为全限定类型名。
func (resolver *TypeResolver) ResolveTypeName(ref string, contextPos int, contextModuleStart int, preferredPkg string, expectedKind string) (string, bool) {
if resolver == nil {
return "", false
}
trimmed := strings.TrimSpace(ref)
if trimmed == "" {
return "", false
}
if looksLikeFullTypeName(trimmed) {
return trimmed, true
}
resolveBySymbol := func(symbol string, preferSameModule bool) (string, bool) {
candidates := resolver.bySymbol[symbol]
if len(candidates) == 0 {
return "", false
}
moduleStart := 0
if preferSameModule {
moduleStart = contextModuleStart
}
best, ok := pickBestDefinition(candidates, contextPos, moduleStart, preferredPkg, expectedKind)
if !ok {
return "", false
}
return best.TypeName, true
}
resolveByAlias := func(symbol string, targetModuleStart int) (string, bool) {
candidates := resolver.byAlias[symbol]
if len(candidates) == 0 {
return "", false
}
best, ok := pickBestDefinition(candidates, contextPos, targetModuleStart, preferredPkg, expectedKind)
if !ok {
return "", false
}
return best.TypeName, true
}
resolveByShort := func(symbol string, preferSameModule bool) (string, bool) {
candidates := resolver.byShort[symbol]
if len(candidates) == 0 {
return "", false
}
moduleStart := 0
if preferSameModule {
moduleStart = contextModuleStart
}
best, ok := pickBestDefinition(candidates, contextPos, moduleStart, preferredPkg, expectedKind)
if !ok {
return "", false
}
return best.TypeName, true
}
if typeName, ok := resolveBySymbol(trimmed, !strings.Contains(trimmed, ".")); ok {
return typeName, true
}
if typeName, ok := resolveByAlias(trimmed, 0); ok {
return typeName, true
}
if typeName, ok := resolveByShort(trimmed, !strings.Contains(trimmed, ".")); ok {
return typeName, true
}
if strings.Contains(trimmed, ".") {
parts := strings.Split(trimmed, ".")
first := parts[0]
last := parts[len(parts)-1]
targetModuleStart := 0
if imports := resolver.moduleImports[contextModuleStart]; imports != nil {
targetModuleStart = imports[first]
}
if typeName, ok := resolveByAlias(last, targetModuleStart); ok {
return typeName, true
}
if typeName, ok := resolveBySymbol(last, false); ok {
return typeName, true
}
if typeName, ok := resolveByShort(last, false); ok {
return typeName, true
}
if typeName, ok := resolveBySymbol(first, false); ok {
return typeName, true
}
}
return "", false
}
// fallbackTypeToken 从无法解析的引用生成合法类型占位名。
func fallbackTypeToken(ref string) string {
token := strings.TrimSpace(ref)
if token == "" {
return token
}
if strings.Contains(token, ".") {
parts := strings.Split(token, ".")
return parts[len(parts)-1]
}
return token
}
// absInt 返回整数绝对值。
func absInt(value int) int {
if value < 0 {
return -value
}
return value
}
-190
View File
@@ -1,190 +0,0 @@
// services.go 解析枚举、服务方法和压缩对象的配对括号。
package main
import (
"regexp"
"strconv"
)
// extractEnums 从旧式和工厂式声明中提取枚举。
func extractEnums(text string, moduleStarts []int) []Enum {
var enums []Enum
enumExists := func(typeName, varName string) bool {
for _, existing := range enums {
if existing.TypeName == typeName && existing.VarName == varName {
return true
}
}
return false
}
// 匹配任意包中的 setEnumType(XXX, "xxx.v1.EnumName", [...]) 枚举声明。
// JS 变量名可以包含 $ 符号
enumRe := regexp.MustCompile(`setEnumType\s*\(\s*([\w$]+)\s*,\s*"([\w.]+)"\s*,\s*\[`)
matches := enumRe.FindAllStringSubmatchIndex(text, -1)
for _, match := range matches {
varName := text[match[2]:match[3]]
typeName := text[match[4]:match[5]]
// 提取枚举值数组。
bracketStart := match[1] - 1
values := extractEnumValues(text, bracketStart)
pkg, shortName := parseTypeName(typeName)
enum := Enum{
TypeName: typeName,
VarName: varName,
Values: values,
Package: pkg,
ShortName: shortName,
Pos: match[0],
ModuleStart: moduleStartForPos(moduleStarts, match[0]),
}
enums = append(enums, enum)
}
// 匹配现代 @bufbuild/protobuf 工厂形式,例如 Role=A.makeEnum("aiserver.v1.InferenceMessageRole",[{...}])。
enumFactoryRe := regexp.MustCompile(`([\w$]+)\s*=\s*[\w$.]+\.makeEnum\s*\(\s*["']([\w.]+)["']\s*,\s*\[`)
factoryMatches := enumFactoryRe.FindAllStringSubmatchIndex(text, -1)
for _, match := range factoryMatches {
varName := text[match[2]:match[3]]
typeName := text[match[4]:match[5]]
if enumExists(typeName, varName) {
continue
}
bracketStart := match[1] - 1
if bracketStart < 0 || bracketStart >= len(text) || text[bracketStart] != '[' {
continue
}
pkg, shortName := parseTypeName(typeName)
enums = append(enums, Enum{
TypeName: typeName,
VarName: varName,
Values: extractEnumValues(text, bracketStart),
Package: pkg,
ShortName: shortName,
Pos: match[0],
ModuleStart: moduleStartForPos(moduleStarts, match[0]),
})
}
return enums
}
// extractServices 从命名或匿名描述符中提取服务。
func extractServices(text string, moduleStarts []int) []Service {
var services []Service
seenTypeNames := make(map[string]bool)
appendService := func(varName, typeName string, pos, methodsStart int) {
if seenTypeNames[typeName] {
return
}
methodsEnd := findMatchingBrace(text, methodsStart)
if methodsEnd == -1 {
return
}
pkg, shortName := parseTypeName(typeName)
services = append(services, Service{
TypeName: typeName,
VarName: varName,
Methods: extractMethods(text[methodsStart:methodsEnd]),
Package: pkg,
ShortName: shortName,
Pos: pos,
ModuleStart: moduleStartForPos(moduleStarts, pos),
})
seenTypeNames[typeName] = true
}
// 匹配 VarName = { typeName: "xxx.v1.ServiceName", methods: { ... } } 服务对象。
serviceRe := regexp.MustCompile(`([\w$]+)\s*=\s*\{\s*typeName:\s*"([\w.]+)"\s*,\s*methods:\s*\{`)
matches := serviceRe.FindAllStringSubmatchIndex(text, -1)
for _, match := range matches {
varName := text[match[2]:match[3]]
typeName := text[match[4]:match[5]]
appendService(varName, typeName, match[0], match[1]-1)
}
// 部分 bundle 把服务描述符直接放入数组,不预先赋给变量。
anonymousServiceRe := regexp.MustCompile(`\{\s*typeName:\s*["']([\w.]+)["']\s*,\s*methods:\s*\{`)
for _, match := range anonymousServiceRe.FindAllStringSubmatchIndex(text, -1) {
typeName := text[match[2]:match[3]]
appendService("", typeName, match[0], match[1]-1)
}
return services
}
// extractMethods 解析服务对象中的 RPC 方法列表。
func extractMethods(methodsText string) []Method {
var methods []Method
// 匹配包含方法名、输入、输出和调用类型的方法对象。
methodRe := regexp.MustCompile(`\w+:\s*\{\s*name:\s*"([^"]+)"\s*,\s*I:\s*([\w$.]+)\s*,\s*O:\s*([\w$.]+)\s*,\s*kind:\s*[\w$.]+\.(Unary|ServerStreaming|ClientStreaming|BiDiStreaming)`)
matches := methodRe.FindAllStringSubmatch(methodsText, -1)
for _, m := range matches {
method := Method{
Name: m[1],
InputType: m[2],
OutputType: m[3],
Kind: m[4],
}
methods = append(methods, method)
}
return methods
}
// findMatchingBrace 查找花括号块的结束位置。
func findMatchingBrace(text string, start int) int {
depth := 0
for i := start; i < len(text); i++ {
if text[i] == '{' {
depth++
} else if text[i] == '}' {
depth--
if depth == 0 {
return i + 1
}
}
}
return -1
}
// extractEnumValues 从数组起点解析枚举值。
func extractEnumValues(text string, start int) []EnumValue {
// 查找数组的配对结束括号。
depth := 0
end := start
for i := start; i < len(text); i++ {
if text[i] == '[' {
depth++
} else if text[i] == ']' {
depth--
if depth == 0 {
end = i + 1
break
}
}
}
arrayText := text[start:end]
var values []EnumValue
valueRe := regexp.MustCompile(`\{\s*no:\s*(\d+)\s*,\s*name:\s*"([^"]+)"`)
matches := valueRe.FindAllStringSubmatch(arrayText, -1)
for _, m := range matches {
no, _ := strconv.Atoi(m[1])
values = append(values, EnumValue{No: no, Name: m[2]})
}
return values
}
-260
View File
@@ -1,260 +0,0 @@
// types.go 定义协议提取器的领域结构、诊断状态和基础类型映射。
package main
import (
"fmt"
"regexp"
"strings"
)
// isGooglePkg 判断是否为无需重复生成的 Google 标准包。
func isGooglePkg(pkg string) bool {
return pkg == "google.protobuf" || pkg == "google.rpc"
}
// scalarTypes 把运行时标量编号映射为 proto 类型。
var scalarTypes = map[int]string{
1: "double",
2: "float",
3: "int64",
4: "uint64",
5: "int32",
6: "fixed64",
7: "fixed32",
8: "bool",
9: "string",
12: "bytes",
13: "uint32",
15: "sfixed32",
16: "sfixed64",
17: "sint32",
18: "sint64",
}
// strictExtractionValidation 控制校验失败是否终止提取。
var strictExtractionValidation = true
// extractionDiagnostics 汇总字段解析和类型解析诊断。
type extractionDiagnostics struct {
totalFieldObjects int
parsedFieldObjects int
skippedFieldObjects int
skippedFieldSamples []string
unresolvedTypeRefs map[string]int
emptyMessages []string
placeholderHits []string
declaredTypes int
extractedTypes int
missingDeclarations []string
}
// newExtractionDiagnostics 创建一次提取任务的诊断容器。
func newExtractionDiagnostics() *extractionDiagnostics {
return &extractionDiagnostics{
unresolvedTypeRefs: make(map[string]int),
}
}
// addSkippedField 记录未能解析的字段样本和原因。
func (d *extractionDiagnostics) addSkippedField(fieldObject string, reason error) {
if d == nil {
return
}
d.totalFieldObjects++
d.skippedFieldObjects++
if len(d.skippedFieldSamples) < 20 {
trimmed := strings.TrimSpace(fieldObject)
if len(trimmed) > 140 {
trimmed = trimmed[:140] + "..."
}
if reason != nil {
d.skippedFieldSamples = append(d.skippedFieldSamples, fmt.Sprintf("%s | %s", reason.Error(), trimmed))
} else {
d.skippedFieldSamples = append(d.skippedFieldSamples, trimmed)
}
}
}
// addParsedField 累计成功解析的字段数量。
func (d *extractionDiagnostics) addParsedField() {
if d == nil {
return
}
d.totalFieldObjects++
d.parsedFieldObjects++
}
// addUnresolvedType 按引用名称累计类型解析失败次数。
func (d *extractionDiagnostics) addUnresolvedType(ref string) {
if d == nil {
return
}
key := strings.TrimSpace(ref)
if key == "" {
key = "<empty>"
}
d.unresolvedTypeRefs[key]++
}
// SetStrictMode 设置校验失败是否终止提取。
func SetStrictMode(enabled bool) {
strictExtractionValidation = enabled
}
// activeDiagnostics 指向当前提取任务的诊断状态。
var activeDiagnostics *extractionDiagnostics
// 字段解析正则覆盖压缩 bundle 的各类声明形式。
var (
noRe = regexp.MustCompile(`(?:^|[,{]\s*)no:\s*(\d+)`)
nameRe = regexp.MustCompile(`(?:^|[,{]\s*)name:\s*["']([^"']+)["']`)
kindRe = regexp.MustCompile(`(?:^|[,{]\s*)kind:\s*["']([^"']+)["']`)
enumTypeRe = regexp.MustCompile(`[,\s]T:\s*[\w$.]+\.getEnumType\s*\(\s*([\w$.]+)\s*\)`)
tRe = regexp.MustCompile(`[,\s]T:\s*([\w$.]+)`)
oneofRe = regexp.MustCompile(`oneof:\s*["']([^"']+)["']`)
repeatedRe = regexp.MustCompile(`repeated:\s*(!0|true)`)
optRe = regexp.MustCompile(`opt:\s*(!0|true)`)
keyRe = regexp.MustCompile(`[,\s]K:\s*(\d+)`)
mapValueRe = regexp.MustCompile(`V:\s*\{([^}]*)\}`)
mapValueKRe = regexp.MustCompile(`(?:^|[,{]\s*)kind:\s*["'](\w+)["']`)
mapValueTRe = regexp.MustCompile(`[,\s]T:\s*([\w$.]+)`)
shorthandTRe = regexp.MustCompile(`(?:^|[,\{])\s*T\s*(?:[,\}])`)
oneofNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
fieldNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
placeholderRe = regexp.MustCompile(`^\s*(optional\s+|repeated\s+)?[A-Za-z_][A-Za-z0-9_.<>]*\s+(field_\d+|unknown(?:_[A-Za-z0-9_]+)?)\s*=\s*\d+\s*;`)
varAliasRe = regexp.MustCompile(`\b(?:let|const|var)\s+([\w$]+)\s*=\s*([\w$]+)\s*(?:[,;])`)
assignmentAliasRe = regexp.MustCompile(`(?:^|[;,({])\s*([\w$]+)\s*=\s*([\w$]+)\s*([,;}])`)
webpackExportBlockRe = regexp.MustCompile(`[\w$]+\.d\(\s*[\w$]+\s*,\s*\{`)
webpackExportEntryRe = regexp.MustCompile(`(?:^|[,\{])\s*([\w$]+)\s*:\s*\(\s*\)\s*=>\s*([\w$]+)`)
moduleImportRe = regexp.MustCompile(`(?:\b(?:var|let|const)\s+|,)\s*([\w$]+)\s*=\s*[\w$]+\(\s*(\d+)\s*\)`)
typeNameDeclarationRe = regexp.MustCompile(`(?:\bthis|[\w$]+)\.typeName\s*=\s*["']([\w.]+)["']`)
serviceDeclarationRe = regexp.MustCompile(`\{\s*typeName\s*:\s*["']([\w.]+)["']\s*,\s*methods\s*:`)
messageDeclarationRe = regexp.MustCompile(`\.makeMessageType\s*\(\s*["']([\w.]+)["']`)
enumDeclarationRe = regexp.MustCompile(`\.makeEnum\s*\(\s*["']([\w.]+)["']`)
legacyEnumDeclarationRe = regexp.MustCompile(`\.setEnumType\s*\(\s*[\w$]+\s*,\s*["']([\w.]+)["']`)
streamCloseRe = regexp.MustCompile(`(?s)message\s+ExecClientControlMessage\s*\{.*?ExecClientStreamClose\s+stream_close\s*=\s*1\s*;`)
shellStdoutRe = regexp.MustCompile(`(?s)message\s+ShellStream\s*\{.*?ShellStreamStdout\s+stdout\s*=\s*1\s*;`)
)
// Field 描述一个待渲染的 protobuf 字段。
type Field struct {
// No 是字段编号。
No int `json:"no"`
// Name 是字段名称。
Name string `json:"name"`
// Kind 是标量、消息、枚举或映射类别。
Kind string `json:"kind"`
// T 保存标量编号或消息引用变量。
T any `json:"T"`
// Oneof 是字段所属的互斥分组。
Oneof string `json:"oneof"`
// Repeated 表示字段可以重复。
Repeated bool `json:"repeated"`
// Opt 表示字段为显式可选。
Opt bool `json:"opt"`
// MapKey 是映射键的标量编号。
MapKey int `json:"K"`
// MapValueKind 是映射值的标量或消息类别。
MapValueKind string
// MapValueT 保存映射值的标量编号或消息引用。
MapValueT any
}
// Message 描述提取出的消息及其源码位置。
type Message struct {
// TypeName 是消息的全限定类型名。
TypeName string
// VarName 是 JS 外部变量名。
VarName string
// InternalName 是 JS 内部类名。
InternalName string
// Fields 是消息字段列表。
Fields []Field
// Package 是消息所属协议包。
Package string
// ShortName 是包内嵌套类型名。
ShortName string
// Pos 是消息在 bundle 中的字节位置。
Pos int
// ModuleStart 是消息所在模块的起始位置。
ModuleStart int
}
// Enum 描述提取出的枚举及其源码位置。
type Enum struct {
// TypeName 是枚举的全限定类型名。
TypeName string
// VarName 是枚举对应的 JS 变量名。
VarName string
// Values 是枚举值列表。
Values []EnumValue
// Package 是枚举所属协议包。
Package string
// ShortName 是包内嵌套类型名。
ShortName string
// Pos 是枚举在 bundle 中的字节位置。
Pos int
// ModuleStart 是枚举所在模块的起始位置。
ModuleStart int
}
// EnumValue 描述单个枚举编号和名称。
type EnumValue struct {
// No 是枚举编号。
No int
// Name 是枚举名称。
Name string
}
// Service 描述提取出的服务及其源码位置。
type Service struct {
// TypeName 是服务的全限定类型名。
TypeName string
// VarName 是服务对应的 JS 变量名。
VarName string
// Methods 是服务方法列表。
Methods []Method
// Package 是服务所属协议包。
Package string
// ShortName 是服务包内名称。
ShortName string
// Pos 是服务在 bundle 中的字节位置。
Pos int
// ModuleStart 是服务所在模块的起始位置。
ModuleStart int
}
// Method 描述一个 RPC 方法的输入、输出和流模式。
type Method struct {
// Name 是 RPC 方法名。
Name string
// InputType 是输入消息引用变量。
InputType string
// OutputType 是输出消息引用变量。
OutputType string
// Kind 是一元或不同方向的流式调用类型。
Kind string
}
// symbolDef 保存符号对应的类型、类别和模块位置。
type symbolDef struct {
// TypeName 是符号对应的全限定类型名。
TypeName string
// Pos 是符号定义位置。
Pos int
// Kind 是消息或枚举类别。
Kind string
// ModuleStart 是符号所在模块起点。
ModuleStart int
}
// TypeResolver 通过局部符号、别名和短名称解析协议类型。
type TypeResolver struct {
bySymbol map[string][]symbolDef
byAlias map[string][]symbolDef
byShort map[string][]symbolDef
moduleImports map[int]map[string]int
}
// aliasIndex 按模块和目标符号保存别名集合。
type aliasIndex map[int]map[string][]string
-15
View File
@@ -1,15 +0,0 @@
module github.com/leookun/cursor-byok/cursor-proto
go 1.25.8
require (
github.com/jhump/protoreflect v1.18.0
google.golang.org/protobuf v1.36.11
)
require (
github.com/golang/protobuf v1.5.4 // indirect
github.com/jhump/protoreflect/v2 v2.0.0-beta.1 // indirect
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
golang.org/x/sync v0.8.0 // indirect
)
-34
View File
@@ -1,34 +0,0 @@
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/jhump/protoreflect v1.18.0 h1:TOz0MSR/0JOZ5kECB/0ufGnC2jdsgZ123Rd/k4Z5/2w=
github.com/jhump/protoreflect v1.18.0/go.mod h1:ezWcltJIVF4zYdIFM+D/sHV4Oh5LNU08ORzCGfwvTz8=
github.com/jhump/protoreflect/v2 v2.0.0-beta.1 h1:Dw1rslK/VotaUGYsv53XVWITr+5RCPXfvvlGrM/+B6w=
github.com/jhump/protoreflect/v2 v2.0.0-beta.1/go.mod h1:D9LBEowZyv8/iSu97FU2zmXG3JxVTmNw21mu63niFzU=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-142
View File
@@ -1,142 +0,0 @@
#!/usr/bin/env bash
# extract.sh 从 Cursor 安装目录安全提取 Proto 文件。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
INSTALLED_CURSOR_DEFAULT="/Applications/Cursor.app"
INPUT_DEFAULT="$INSTALLED_CURSOR_DEFAULT"
OUTPUT_DEFAULT="$PROJECT_DIR/proto"
INPUT_ROOT="${1:-$INPUT_DEFAULT}"
OUTPUT_DIR="${2:-$OUTPUT_DEFAULT}"
canonicalize_path() {
local path="$1"
local parent
local base
if [[ -d "$path" ]]; then
(cd "$path" && pwd -P)
return
fi
parent="$(dirname "$path")"
base="$(basename "$path")"
if [[ ! -d "$parent" ]]; then
echo "Parent directory does not exist: $parent" >&2
return 1
fi
printf '%s/%s\n' "$(cd "$parent" && pwd -P)" "$base"
}
# 文件输入只提取自身;目录输入扫描工作台、扩展宿主和扩展产物。
INPUT_PATHS=()
add_input() {
local candidate="$1"
local existing
if [[ ! -f "$candidate" ]]; then
return 0
fi
for existing in "${INPUT_PATHS[@]-}"; do
[[ "$existing" == "$candidate" ]] && return
done
INPUT_PATHS+=("$candidate")
}
if [[ -f "$INPUT_ROOT" ]]; then
add_input "$INPUT_ROOT"
elif [[ -d "$INPUT_ROOT" ]]; then
CANDIDATES=(
"$INPUT_ROOT/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js"
"$INPUT_ROOT/Resources/app/out/vs/workbench/workbench.desktop.main.js"
"$INPUT_ROOT/out/vs/workbench/workbench.desktop.main.js"
"$INPUT_ROOT/workbench.desktop.main.js"
"$INPUT_ROOT/Contents/Resources/app/out/vs/workbench/api/node/extensionHostProcess.js"
"$INPUT_ROOT/Resources/app/out/vs/workbench/api/node/extensionHostProcess.js"
"$INPUT_ROOT/out/vs/workbench/api/node/extensionHostProcess.js"
"$INPUT_ROOT/extensionHostProcess.js"
"$INPUT_ROOT/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
"$INPUT_ROOT/Resources/app/extensions/cursor-always-local/dist/main.js"
"$INPUT_ROOT/extensions/cursor-always-local/dist/main.js"
"$INPUT_ROOT/cursor-always-local/dist/main.js"
)
for CANDIDATE in "${CANDIDATES[@]}"; do
add_input "$CANDIDATE"
done
while IFS= read -r JS_FILE; do
add_input "$JS_FILE"
done < <(find "$INPUT_ROOT" -type f ! -path "*/node_modules/*" \( -name "workbench.desktop.main.js" -o -name "extensionHostProcess.js" -o -path "*/extensions/*/dist/main.js" \) | sort)
fi
if [[ -z "${INPUT_PATHS[*]-}" ]]; then
echo "No supported Cursor JS bundle found under: $INPUT_ROOT" >&2
echo "Install/update Cursor, or pass an explicit input bundle:" >&2
echo " $0 /path/to/Cursor.app [output-dir]" >&2
exit 1
fi
for INDEX in "${!INPUT_PATHS[@]}"; do
INPUT_PATHS[$INDEX]="$(canonicalize_path "${INPUT_PATHS[$INDEX]}")"
done
OUTPUT_DIR="$(canonicalize_path "$OUTPUT_DIR")"
CURRENT_DIR="$(pwd -P)"
case "$OUTPUT_DIR" in
"/"|"$HOME"|"$PROJECT_DIR"|"$SCRIPT_DIR"|"$CURRENT_DIR")
echo "Refusing unsafe output directory: $OUTPUT_DIR" >&2
exit 1
;;
esac
for INPUT_PATH in "${INPUT_PATHS[@]}"; do
case "$INPUT_PATH" in
"$OUTPUT_DIR"|"$OUTPUT_DIR"/*)
echo "Refusing output directory that contains an input bundle: $OUTPUT_DIR" >&2
exit 1
;;
esac
done
OUTPUT_PARENT="$(dirname "$OUTPUT_DIR")"
OUTPUT_BASENAME="$(basename "$OUTPUT_DIR")"
TEMP_DIR="$(mktemp -d "$OUTPUT_PARENT/.${OUTPUT_BASENAME}.tmp.XXXXXX")"
BACKUP_DIR=""
cleanup() {
if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then
rm -rf "$TEMP_DIR"
fi
if [[ -n "$BACKUP_DIR" && -e "$BACKUP_DIR" ]]; then
if [[ ! -e "$OUTPUT_DIR" ]]; then
mv "$BACKUP_DIR" "$OUTPUT_DIR"
else
rm -rf "$BACKUP_DIR"
fi
fi
}
trap cleanup EXIT
EXTRACT_ARGS=()
for INPUT_PATH in "${INPUT_PATHS[@]}"; do
EXTRACT_ARGS+=( -input "$INPUT_PATH" )
done
(
cd "$PROJECT_DIR"
go run ./extractor \
"${EXTRACT_ARGS[@]}" \
-output "$TEMP_DIR" \
-skip-format \
-strict
)
if [[ -e "$OUTPUT_DIR" ]]; then
BACKUP_DIR="$(mktemp -d "$OUTPUT_PARENT/.${OUTPUT_BASENAME}.backup.XXXXXX")"
rmdir "$BACKUP_DIR"
mv "$OUTPUT_DIR" "$BACKUP_DIR"
fi
mv "$TEMP_DIR" "$OUTPUT_DIR"
TEMP_DIR=""
if [[ -n "$BACKUP_DIR" ]]; then
rm -rf "$BACKUP_DIR"
BACKUP_DIR=""
fi
-33
View File
@@ -1,33 +0,0 @@
#!/usr/bin/env bash
# generate.sh 根据提取的 Proto 定义生成可供其他 Go module 使用的消息包。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
PROTO_DIR="$PROJECT_DIR/proto"
MODULE_PATH="github.com/leookun/cursor-byok/cursor-proto"
command -v protoc >/dev/null 2>&1 || {
echo "protoc is required" >&2
exit 1
}
command -v protoc-gen-go >/dev/null 2>&1 || {
echo "protoc-gen-go is required" >&2
exit 1
}
for PROTO_FILE in agent_v1.proto aiserver_v1.proto; do
if [[ ! -f "$PROTO_DIR/$PROTO_FILE" ]]; then
echo "Missing Proto source: $PROTO_DIR/$PROTO_FILE" >&2
exit 1
fi
done
protoc \
--proto_path="$PROTO_DIR" \
--go_out="$PROJECT_DIR" \
--go_opt="module=$MODULE_PATH" \
"$PROTO_DIR/agent_v1.proto" \
"$PROTO_DIR/aiserver_v1.proto"
echo "Generated Go packages under: $PROJECT_DIR/gen"
-3053
View File
File diff suppressed because it is too large Load Diff
-43
View File
@@ -1,43 +0,0 @@
[package]
name = "cursor-server"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
async-stream = "0.3"
axum = "0.8"
base64 = "0.22"
bytes = "1"
chrono = "0.4"
chrono-tz = "0.10"
eventsource-stream = "0.2"
futures-util = "0.3"
hex = "0.4"
include_dir = "0.7"
parking_lot = "0.12"
prost = "0.13"
prost-types = "0.13"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
similar = "2"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
thiserror = "2"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync", "time", "net"] }
tokio-stream = { version = "0.1", features = ["sync"] }
tokio-util = "0.7"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower-http = { version = "0.6", features = ["decompression-gzip", "fs"] }
[build-dependencies]
prost-build = "0.13"
protoc-bin-vendored = "3"
[dev-dependencies]
flate2 = "1"
tempfile = "3"
tower = { version = "0.5", features = ["util"] }
-71
View File
@@ -1,71 +0,0 @@
# cursor-server
Cursor Agent 的 Rust 服务端。它实现 `RunSSE + BidiAppend` 通信、无状态 LLM loop、客户端工具执行、Blob/KV 同步和可恢复 checkpoint。服务只接管已经实现的 Cursor 接口;其他 backend 请求原样流式转发到固定上游 `https://api2.cursor.sh`
## 启动
首次运行需要安装 Rust stable 工具链。macOS 使用 Homebrew
```bash
brew install rustup
export PATH="$(brew --prefix rustup)/bin:$PATH"
rustup default stable
cargo --version
```
`rustup` 是 keg-only;若要让后续 zsh 会话也能找到 `cargo`,将下面一行加入 `~/.zshrc`,然后重新打开终端:
```bash
export PATH="$(brew --prefix rustup)/bin:$HOME/.cargo/bin:$PATH"
```
先构建管理台:
```bash
cd console
npm install
npm run build
```
进入 `cursor-server` 后启动:
```bash
cd ../cursor-server
CURSOR_DATABASE_URL=sqlite://cursor-server.db \
cargo run
```
默认监听 `127.0.0.1:3000`。打开 `http://127.0.0.1:3000/console/` 配置 Provider、拉取并启用模型。Provider URL、API Key 和模型不再从环境变量隐式覆盖;SQLite 是唯一运行时配置源。Anthropic 模型必须在模型配置中设置最大输出 token,服务不会猜测默认值。完整启动环境变量见 `src/config.rs`
## 不变量
- 不可变 messages 与 revision 父链共同构成上下文事实源;消息只追加,不原地修改,回滚只选择旧 revision 并建立新分支。
- 每个携带用户语义的 RunRequest 生成一条 user-role/runtime-origin message;当前请求有什么上下文就加入什么,没有则省略。它使用稳定事件 ID,事务内 exactly-once 追加。
- 同一 PromptSpec/ModelSpec/Provider route 内,每轮投射结果可复现,后一轮 messages 严格以前一轮为前缀;新 Run 切换模型或模式时只替换 Cursor system root。
- canonical tool pairs 的 typed history 折叠属于 `model/projection.rs`Cursor checkpoint 与各 Provider 都依赖它,彼此不反向依赖。
- 工具每完成一个,就按实际完成顺序原子追加一组 `assistant(tool_call) → tool(result)`;投射给 LLM 的上下文没有悬空 tool call,整批完整后才继续调用 LLM。
- Blob 是 `SHA-256(data)` 的不可变 CAS;Blob 类型来自引用字段,不编码在 BlobID 中。
- 引用 Blob 的 checkpoint 只有在全部新 Blob 得到 KV SET ACK 后才能发布。
- 每个新 Blob 只对应一次 KV SET 和一个配对 ACK;拒绝、超时或同步 worker 失败直接结束当前 Cursor Run,不定时制造新 id 重试。
- checkpoint 以完整 assistant 为 staged/settled 边界:工具批次开始时 stable roots 不变、`pending_tool_calls` 内联一条完整 assistant JSON;全部工具结果提交后才进入 stable roots。最终文本 assistant 同样走 staged/settled,并在 `turn_ended` 后重发同一 settled checkpointstaged 与 settled 复用同一个已确认 Turnpresentation delta 不得消费两次。
- Provider 未报告 usage 时不伪造零值;`TurnEndedUpdate` 的 token 字段保持缺省。
- 每次真实 Provider 请求对应一条 `llm_calls`;时间使用 UTC 时间点与单调时钟耗时,usage 只保存 Provider 报告值。详细模式额外保存脱敏后的最终请求和原始 SSE 字节块。
- 用户模型公开 ID 是规范化 `URL + NUL + provider type + NUL + modelId` 的 SHA-256 前 4 bytes,表示为 8 位小写 hexAPI Key 和 displayName 不参与身份。
- `AvailableModels``GetUsableModels` 在官方响应原始 protobuf 后追加用户模型字段,不解码重编码未知字段;`requested_model.model_id` 使用公开 ID 解析 Provider 路由。
- 新 Run 通过 conversation revision 使旧 Run 的迟到事件失效。
- 每种工具只对应一个 Exec、Interaction 或 Local 通道;不存在级联 fallback。
- Loop 不保存工具名称路由;`cursor/tools/dispatch/` 是 transport dispatcher`cursor/tools/runtime.rs` 唯一拥有当前 Cursor Run 的 Exec/Interaction wire-id 和 terminal tombstone。
- Interaction approval 不是 ToolResult;只有 typed terminal result 才能进入持久化与 checkpoint。
- Exec 与 Interaction 共用当前 Run 唯一、单调且不复用的 wire-id 空间;typed terminal result 一次消费,大 payload 在核心 commit 后释放,完成墓碑保留到 ToolRound settled。
- prompt 资产编译进二进制并在启动时整体校验,不与运行时目录逐文件混用。
- `prompt/cursor/tools.json` 是 Cursor 工具 schema 唯一事实源,`prompt/cursor/modes/*.json` 只定义有序工具名或明确 variant。每个模式显式维护 `{prompt.md,runtime.md}`,不使用别名或缺失资产 fallback。
- 当前 `UserMessage.mode` 同时选择 system prompt、runtime 模板和工具集;子代理由 `subagent_type_name` 明确选择 subagent 资产,使用无 Cloud 字段的 Task variant 并增加 `UpdateCurrentStep`
- `GetMcpTools` 必须等待客户端 `McpStateExecResult` 的实时 MCP 状态,不能从初始 descriptor 快照本地完成。
- 本地精确路由优先;未匹配的 method、path/query、headers 和 body 流式转发到固定 Cursor 上游,上游 status、headers 和 body 流式返回。
- 反向代理只改写目标 authority,并剥离不能逐跳转发的 hop-by-hop headers;不存在的本地路由不能直接返回 404。
- `cursor/checkpoint/worker.rs` 独占可推进的 checkpoint builder`cursor/session.rs` 只提交 staged/settled/final job 并等待相应 barrier。
- `cursor/projection/``cursor/interaction/``cursor/tools/codec/` 分别按 JSON 编解码、UI 消息方向和 Exec wire 方向组织,不共享运行期状态。
- Provider 的取消同时覆盖等待 HTTP 响应头和读取 SSE,旧 Run 不会卡在尚未建立的流上。
- Ctrl-C/SIGTERM 先停止接受新连接并取消所有 Run/工具、关闭 RunSSEHTTP graceful shutdown 最多等待 10 秒,随后强制释放服务。
模块边界和目录是实现约束,必须与仓库根目录 README 保持一致。
-53
View File
@@ -1,53 +0,0 @@
use std::{env, path::PathBuf};
fn main() {
let manifest = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest directory"));
let proto_dir = manifest.join("../cursor-proto/proto");
let protos = [proto_dir.join("agent_v1.proto")];
let aiserver_proto = proto_dir.join("aiserver_v1.proto");
env::set_var(
"PROTOC",
protoc_bin_vendored::protoc_bin_path().expect("vendored protoc"),
);
prost_build::Config::new()
.compile_protos(
&protos,
&[
proto_dir.clone(),
protoc_bin_vendored::include_path().expect("vendored protobuf includes"),
],
)
.expect("compile Cursor protobuf schema");
for proto in protos {
println!("cargo:rerun-if-changed={}", proto.display());
}
let aiserver_source = std::fs::read_to_string(&aiserver_proto).expect("read aiserver_v1.proto");
for required in [
"message BidiAppendRequest",
"string data = 1;",
"BidiRequestId request_id = 2;",
"int64 append_seqno = 3;",
"bytes data_binary = 4;",
"message BidiAppendResponse",
"message CustomErrorDetails",
"optional bool is_retryable = 4;",
"optional bool show_request_id = 5;",
"optional bool should_show_immediate_error = 6;",
"message ErrorDetails",
"ERROR_PROVIDER_ERROR = 57;",
"CustomErrorDetails details = 2;",
"optional bool is_expected = 3;",
] {
assert!(
aiserver_source.contains(required),
"aiserver Bidi wire schema changed: missing {required}"
);
}
// The extracted aiserver file currently contains unrelated duplicate message names, so
// compiling that entire package would generate invalid Rust. `cursor/proto.rs` defines only
// the validated Bidi and ErrorDetails wire subsets; agent_v1.proto remains fully generated.
println!("cargo:rerun-if-changed={}", aiserver_proto.display());
}
-127
View File
@@ -1,127 +0,0 @@
PRAGMA foreign_keys = ON;
CREATE TABLE conversations (
conversation_id TEXT PRIMARY KEY,
current_revision_id INTEGER,
active_run_id TEXT,
updated_at_ms INTEGER NOT NULL
);
CREATE TABLE messages (
conversation_id TEXT NOT NULL,
message_id TEXT NOT NULL,
role TEXT NOT NULL,
origin TEXT NOT NULL,
payload_json TEXT NOT NULL,
runtime_event_id TEXT,
created_at_ms INTEGER NOT NULL,
PRIMARY KEY (conversation_id, message_id),
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id)
);
CREATE UNIQUE INDEX messages_runtime_event
ON messages(conversation_id, runtime_event_id)
WHERE runtime_event_id IS NOT NULL;
CREATE TABLE conversation_revisions (
revision_id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
parent_revision_id INTEGER,
state_digest BLOB NOT NULL CHECK(length(state_digest) = 32),
created_at_ms INTEGER NOT NULL,
UNIQUE (conversation_id, state_digest),
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id),
FOREIGN KEY (parent_revision_id) REFERENCES conversation_revisions(revision_id)
);
CREATE INDEX conversation_revisions_parent
ON conversation_revisions(conversation_id, parent_revision_id);
CREATE TABLE revision_messages (
revision_id INTEGER NOT NULL,
ordinal INTEGER NOT NULL,
conversation_id TEXT NOT NULL,
message_id TEXT NOT NULL,
PRIMARY KEY (revision_id, ordinal),
UNIQUE (revision_id, message_id),
FOREIGN KEY (revision_id) REFERENCES conversation_revisions(revision_id),
FOREIGN KEY (conversation_id, message_id) REFERENCES messages(conversation_id, message_id)
);
CREATE TABLE runs (
run_id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
base_revision_id INTEGER NOT NULL,
head_revision_id INTEGER NOT NULL,
parent_run_id TEXT,
parent_tool_call_id TEXT,
run_kind TEXT NOT NULL,
subagent_kind TEXT,
status TEXT NOT NULL,
provider_call_index INTEGER NOT NULL DEFAULT -1,
turn_usage_json TEXT NOT NULL DEFAULT 'null',
failure_category TEXT,
failure_summary TEXT,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id),
FOREIGN KEY (base_revision_id) REFERENCES conversation_revisions(revision_id),
FOREIGN KEY (head_revision_id) REFERENCES conversation_revisions(revision_id)
);
CREATE INDEX runs_conversation_status
ON runs(conversation_id, status);
CREATE TABLE tool_rounds (
round_id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
base_revision_id INTEGER NOT NULL,
assistant_json TEXT NOT NULL,
status TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 0,
next_completion_seq INTEGER NOT NULL DEFAULT 0,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
FOREIGN KEY (run_id) REFERENCES runs(run_id),
FOREIGN KEY (base_revision_id) REFERENCES conversation_revisions(revision_id)
);
CREATE INDEX tool_rounds_run_status
ON tool_rounds(run_id, status);
CREATE TABLE tool_round_calls (
round_id TEXT NOT NULL,
call_index INTEGER NOT NULL,
call_id TEXT NOT NULL,
model_call_id TEXT NOT NULL,
name TEXT NOT NULL,
arguments_json TEXT NOT NULL,
status TEXT NOT NULL,
completion_seq INTEGER,
result_content TEXT,
result_is_error INTEGER,
committed_revision_id INTEGER,
completed_at_ms INTEGER,
PRIMARY KEY (round_id, call_index),
UNIQUE (round_id, call_id),
UNIQUE (round_id, completion_seq),
FOREIGN KEY (round_id) REFERENCES tool_rounds(round_id),
FOREIGN KEY (committed_revision_id) REFERENCES conversation_revisions(revision_id)
);
CREATE TABLE blobs (
blob_id BLOB PRIMARY KEY CHECK(length(blob_id) = 32),
data BLOB NOT NULL,
created_at_ms INTEGER NOT NULL
);
CREATE TABLE blob_edges (
parent_blob_id BLOB NOT NULL,
child_blob_id BLOB NOT NULL,
field_name TEXT NOT NULL,
PRIMARY KEY (parent_blob_id, child_blob_id, field_name),
FOREIGN KEY (parent_blob_id) REFERENCES blobs(blob_id),
FOREIGN KEY (child_blob_id) REFERENCES blobs(blob_id)
);
CREATE INDEX blob_edges_child ON blob_edges(child_blob_id);
@@ -1,9 +0,0 @@
CREATE TABLE input_anchors (
conversation_id TEXT NOT NULL,
input_id TEXT NOT NULL,
base_revision_id INTEGER NOT NULL,
created_at_ms INTEGER NOT NULL,
PRIMARY KEY (conversation_id, input_id),
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id),
FOREIGN KEY (base_revision_id) REFERENCES conversation_revisions(revision_id)
);
@@ -1,103 +0,0 @@
CREATE TABLE provider_endpoints (
provider_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
provider_type TEXT NOT NULL,
base_url TEXT NOT NULL,
api_key TEXT NOT NULL,
custom_headers_json TEXT NOT NULL DEFAULT '{}',
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
CREATE TABLE provider_models (
model_hash TEXT PRIMARY KEY CHECK(length(model_hash) = 8),
provider_id INTEGER NOT NULL,
model_id TEXT NOT NULL,
display_name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
context_window_tokens INTEGER,
max_output_tokens INTEGER,
reasoning_enabled INTEGER NOT NULL DEFAULT 0,
reasoning_effort TEXT,
extra_params_json TEXT NOT NULL DEFAULT '{}',
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
UNIQUE(provider_id, model_id),
FOREIGN KEY(provider_id) REFERENCES provider_endpoints(provider_id) ON DELETE CASCADE
);
CREATE INDEX provider_models_enabled_sort
ON provider_models(enabled, sort_order, display_name);
CREATE TABLE service_settings (
setting_key TEXT PRIMARY KEY,
value_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
INSERT INTO service_settings(setting_key, value_json, updated_at_ms)
VALUES ('llm_detailed_logging', 'false', unixepoch('subsec') * 1000);
CREATE TABLE llm_calls (
call_id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
provider_call_index INTEGER NOT NULL,
model_hash TEXT,
provider_type TEXT NOT NULL,
provider_url TEXT NOT NULL,
model_id TEXT NOT NULL,
display_name TEXT NOT NULL,
status TEXT NOT NULL,
finish_reason TEXT,
created_at_ms INTEGER NOT NULL,
request_started_at_ms INTEGER,
response_headers_at_ms INTEGER,
first_event_at_ms INTEGER,
first_text_at_ms INTEGER,
finished_at_ms INTEGER,
queue_ms INTEGER,
ttfb_ms INTEGER,
ttft_ms INTEGER,
duration_ms INTEGER,
input_tokens INTEGER,
output_tokens INTEGER,
total_tokens INTEGER,
cache_read_tokens INTEGER,
cache_write_tokens INTEGER,
reasoning_tokens INTEGER,
usage_json TEXT,
message_count INTEGER NOT NULL,
tool_count INTEGER NOT NULL,
request_bytes INTEGER,
response_bytes INTEGER NOT NULL DEFAULT 0,
stream_event_count INTEGER NOT NULL DEFAULT 0,
http_status INTEGER,
error_kind TEXT,
error_message TEXT,
detailed INTEGER NOT NULL,
FOREIGN KEY(model_hash) REFERENCES provider_models(model_hash)
);
CREATE INDEX llm_calls_created ON llm_calls(created_at_ms DESC);
CREATE INDEX llm_calls_run ON llm_calls(run_id, provider_call_index);
CREATE INDEX llm_calls_model ON llm_calls(model_hash, created_at_ms DESC);
CREATE TABLE llm_call_requests (
call_id TEXT PRIMARY KEY,
headers_json TEXT NOT NULL,
body_json TEXT NOT NULL,
byte_count INTEGER NOT NULL,
FOREIGN KEY(call_id) REFERENCES llm_calls(call_id) ON DELETE CASCADE
);
CREATE TABLE llm_call_response_chunks (
call_id TEXT NOT NULL,
seq INTEGER NOT NULL,
received_offset_ms INTEGER NOT NULL,
data BLOB NOT NULL,
byte_count INTEGER NOT NULL,
PRIMARY KEY(call_id, seq),
FOREIGN KEY(call_id) REFERENCES llm_calls(call_id) ON DELETE CASCADE
);
-91
View File
@@ -1,91 +0,0 @@
use std::{future::IntoFuture, time::Duration};
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;
use crate::{
config::Config,
control,
cursor::{
handlers,
prompting::{PromptAssets, PromptCompiler},
CursorSessionRegistry,
},
provider::ProviderRouter,
run::RunRegistry,
store::Store,
Result,
};
pub struct App {
config: Config,
router: axum::Router,
registry: CursorSessionRegistry,
}
impl App {
pub async fn new(config: Config) -> Result<Self> {
let store = Store::connect(&config.database_url).await?;
let assets = PromptAssets::embedded()?;
let compiler = PromptCompiler::new(assets);
let provider = std::sync::Arc::new(ProviderRouter::new(
store.clone(),
config.provider_request_timeout,
));
let run_registry = RunRegistry::default();
let registry = CursorSessionRegistry::new(store.clone(), provider, compiler, run_registry);
let router = handlers::router(registry.clone())?.merge(control::router(store));
Ok(Self {
router,
registry,
config,
})
}
pub async fn serve(self) -> Result<()> {
let listener = TcpListener::bind(self.config.listen_addr).await?;
tracing::info!(address = %self.config.listen_addr, "cursor server listening");
let registry = self.registry;
let shutdown = CancellationToken::new();
let graceful = shutdown.clone();
let server = axum::serve(listener, self.router)
.with_graceful_shutdown(async move {
graceful.cancelled().await;
})
.into_future();
tokio::pin!(server);
let signal = shutdown_signal(registry, shutdown);
tokio::pin!(signal);
tokio::select! {
result = &mut server => result?,
() = &mut signal => {
match tokio::time::timeout(Duration::from_secs(10), &mut server).await {
Ok(result) => result?,
Err(_) => tracing::warn!("graceful shutdown timed out; forcing server close"),
}
}
}
Ok(())
}
}
async fn shutdown_signal(registry: CursorSessionRegistry, shutdown: CancellationToken) {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
if let Ok(mut signal) =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
signal.recv().await;
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
tracing::info!("shutdown signal received; cancelling active runs");
shutdown.cancel();
registry.shutdown().await;
}
-15
View File
@@ -1,15 +0,0 @@
use crate::model::RuntimeEvent;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClientCommand {
ToolResult {
call_id: String,
content: String,
is_error: bool,
},
RuntimeEvent(RuntimeEvent),
ClientClosed {
error: String,
},
Cancel,
}
-78
View File
@@ -1,78 +0,0 @@
use std::time::Duration;
use tokio::sync::oneshot;
use crate::model::{RevisionId, ToolCall, ToolRoundId, Usage};
use crate::run::RunOutcome;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CommitCause {
InitialMessages,
ToolRoundStarted(ToolRoundId),
ToolResult { call_id: String },
FinalTurn,
RuntimeEvent { event_id: String },
}
#[derive(Debug)]
pub enum CommitBarrier {
None,
BeforeContinue(oneshot::Sender<std::result::Result<(), String>>),
}
impl CommitBarrier {
pub fn before_continue() -> (Self, oneshot::Receiver<std::result::Result<(), String>>) {
let (sender, receiver) = oneshot::channel();
(Self::BeforeContinue(sender), receiver)
}
pub fn is_required(&self) -> bool {
matches!(self, Self::BeforeContinue(_))
}
pub fn complete(self, result: std::result::Result<(), String>) {
if let Self::BeforeContinue(sender) = self {
let _ = sender.send(result);
}
}
}
#[derive(Debug)]
pub struct StateCommitted {
pub revision_id: RevisionId,
pub tool_round_version: u64,
pub cause: CommitCause,
pub barrier: CommitBarrier,
}
#[derive(Debug)]
pub enum ClientEvent {
TextStart,
TextDelta(String),
TextEnd,
ThinkingStart,
ThinkingDelta(String),
ThinkingEnd {
duration: Duration,
},
ToolCallStart {
index: usize,
call_id: String,
name: String,
model_call_id: String,
},
ToolCallArgumentsDelta {
index: usize,
delta: String,
},
ToolCallEnd {
index: usize,
},
Usage(Usage),
ExecuteToolRound {
round_id: ToolRoundId,
calls: Vec<ToolCall>,
},
StateCommitted(StateCommitted),
Ended(RunOutcome),
}
-7
View File
@@ -1,7 +0,0 @@
mod command;
mod event;
mod session;
pub use command::*;
pub use event::*;
pub use session::*;
-28
View File
@@ -1,28 +0,0 @@
use tokio::sync::mpsc;
use super::{ClientCommand, ClientEvent};
pub struct ClientPort {
pub commands: mpsc::Receiver<ClientCommand>,
pub events: mpsc::Sender<ClientEvent>,
}
pub struct ClientSession {
pub commands: mpsc::Sender<ClientCommand>,
pub events: mpsc::Receiver<ClientEvent>,
}
pub fn session(capacity: usize) -> (ClientPort, ClientSession) {
let (commands_tx, commands_rx) = mpsc::channel(capacity);
let (events_tx, events_rx) = mpsc::channel(capacity);
(
ClientPort {
commands: commands_rx,
events: events_tx,
},
ClientSession {
commands: commands_tx,
events: events_rx,
},
)
}
-53
View File
@@ -1,53 +0,0 @@
use std::{env, net::SocketAddr, time::Duration};
use crate::{Error, Result};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProviderKind {
OpenAiChat,
OpenAiResponses,
Anthropic,
}
#[derive(Clone)]
pub struct ProviderConfig {
pub kind: ProviderKind,
pub base_url: String,
pub api_key: String,
pub custom_headers: reqwest::header::HeaderMap,
pub max_output_tokens: Option<u64>,
pub request_timeout: Duration,
}
#[derive(Clone)]
pub struct Config {
pub listen_addr: SocketAddr,
pub database_url: String,
pub provider_request_timeout: Duration,
}
impl Config {
pub fn from_env() -> Result<Self> {
let listen_addr = env::var("CURSOR_LISTEN_ADDR")
.unwrap_or_else(|_| "127.0.0.1:3000".into())
.parse()
.map_err(|error| Error::Config(format!("invalid CURSOR_LISTEN_ADDR: {error}")))?;
let request_timeout = match env::var("CURSOR_PROVIDER_TIMEOUT_SECONDS") {
Ok(value) => Duration::from_secs(value.parse().map_err(|error| {
Error::Config(format!("invalid CURSOR_PROVIDER_TIMEOUT_SECONDS: {error}"))
})?),
Err(env::VarError::NotPresent) => Duration::from_secs(300),
Err(error) => {
return Err(Error::Config(format!(
"invalid CURSOR_PROVIDER_TIMEOUT_SECONDS: {error}"
)))
}
};
Ok(Self {
listen_addr,
database_url: env::var("CURSOR_DATABASE_URL")
.unwrap_or_else(|_| "sqlite://cursor-server.db".into()),
provider_request_timeout: request_timeout,
})
}
}
-33
View File
@@ -1,33 +0,0 @@
use axum::{
extract::{Path, Query, State},
Json,
};
use serde::Deserialize;
use crate::{model::LlmCallSummary, Result};
use super::{CallDetail, ControlService};
#[derive(Deserialize)]
pub struct CallQuery {
#[serde(default = "default_limit")]
limit: i64,
}
pub async fn list(
State(service): State<ControlService>,
Query(query): Query<CallQuery>,
) -> Result<Json<Vec<LlmCallSummary>>> {
Ok(Json(service.calls(query.limit).await?))
}
pub async fn detail(
State(service): State<ControlService>,
Path(call_id): Path<String>,
) -> Result<Json<CallDetail>> {
Ok(Json(service.call(&call_id).await?))
}
fn default_limit() -> i64 {
100
}
-43
View File
@@ -1,43 +0,0 @@
mod calls;
mod models;
mod providers;
mod service;
mod settings;
use axum::{
routing::{delete, get, post, put},
Router,
};
use tower_http::services::ServeDir;
pub use service::{CallDetail, ControlService, DiscoveredModels, ObservabilitySettings};
pub fn router(service: ControlService, assets: impl AsRef<std::path::Path>) -> Router {
Router::new()
.nest_service(
"/console",
ServeDir::new(assets).append_index_html_on_directories(true),
)
.route(
"/api/providers",
get(providers::list).post(providers::create),
)
.route(
"/api/providers/{provider_id}",
put(providers::update).delete(providers::remove),
)
.route(
"/api/providers/{provider_id}/models/discover",
post(models::discover),
)
.route("/api/providers/{provider_id}/models", post(models::save))
.route("/api/models", get(models::list))
.route("/api/models/{model_hash}", delete(models::remove))
.route("/api/llm-calls", get(calls::list))
.route("/api/llm-calls/{call_id}", get(calls::detail))
.route(
"/api/settings/observability",
get(settings::get).put(settings::update),
)
.with_state(service)
}
-48
View File
@@ -1,48 +0,0 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use crate::{
model::{ProviderModel, ProviderModelInput},
Result,
};
use super::{ControlService, DiscoveredModels};
#[derive(Deserialize)]
pub struct SaveModels {
pub models: Vec<ProviderModelInput>,
}
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<ProviderModel>>> {
Ok(Json(service.models().await?))
}
pub async fn save(
State(service): State<ControlService>,
Path(provider_id): Path<i64>,
Json(input): Json<SaveModels>,
) -> Result<(StatusCode, Json<Vec<ProviderModel>>)> {
Ok((
StatusCode::CREATED,
Json(service.save_models(provider_id, &input.models).await?),
))
}
pub async fn remove(
State(service): State<ControlService>,
Path(model_hash): Path<String>,
) -> Result<StatusCode> {
service.delete_model(&model_hash).await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn discover(
State(service): State<ControlService>,
Path(provider_id): Path<i64>,
) -> Result<Json<DiscoveredModels>> {
Ok(Json(service.discover_models(provider_id).await?))
}
-44
View File
@@ -1,44 +0,0 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use crate::{
model::{ProviderEndpoint, ProviderEndpointInput},
Result,
};
use super::ControlService;
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<ProviderEndpoint>>> {
Ok(Json(service.providers().await?))
}
pub async fn create(
State(service): State<ControlService>,
Json(input): Json<ProviderEndpointInput>,
) -> Result<(StatusCode, Json<ProviderEndpoint>)> {
Ok((
StatusCode::CREATED,
Json(service.create_provider(&input).await?),
))
}
pub async fn update(
State(service): State<ControlService>,
Path(provider_id): Path<i64>,
Json(input): Json<ProviderEndpointInput>,
) -> Result<Json<ProviderEndpoint>> {
Ok(Json(
service.update_provider(provider_id, &input).await?,
))
}
pub async fn remove(
State(service): State<ControlService>,
Path(provider_id): Path<i64>,
) -> Result<StatusCode> {
service.delete_provider(provider_id).await?;
Ok(StatusCode::NO_CONTENT)
}
-239
View File
@@ -1,239 +0,0 @@
use std::collections::BTreeSet;
use reqwest::header::{HeaderName, HeaderValue};
use serde::{Deserialize, Serialize};
use crate::{
model::{
LlmCallRequest, LlmCallResponseChunk, LlmCallSummary, ProviderEndpoint,
ProviderEndpointInput, ProviderEndpointSecret, ProviderModel, ProviderModelInput,
ProviderType,
},
store::Store,
Error, Result,
};
#[derive(Clone)]
pub struct ControlService {
store: Store,
client: reqwest::Client,
}
#[derive(Clone, Debug, Serialize)]
pub struct DiscoveredModels {
pub models: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct CallDetail {
pub call: LlmCallSummary,
pub request: Option<LlmCallRequest>,
pub response_chunks: Vec<LlmCallResponseChunk>,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
pub struct ObservabilitySettings {
pub detailed: bool,
}
impl ControlService {
pub fn new(store: Store) -> Self {
Self {
store,
client: reqwest::Client::new(),
}
}
pub async fn providers(&self) -> Result<Vec<ProviderEndpoint>> {
self.store.providers().await
}
pub async fn create_provider(
&self,
input: &ProviderEndpointInput,
) -> Result<ProviderEndpoint> {
self.store.create_provider(input).await
}
pub async fn update_provider(
&self,
provider_id: i64,
input: &ProviderEndpointInput,
) -> Result<ProviderEndpoint> {
self.store.update_provider(provider_id, input).await
}
pub async fn delete_provider(&self, provider_id: i64) -> Result<()> {
self.store.delete_provider(provider_id).await
}
pub async fn models(&self) -> Result<Vec<ProviderModel>> {
self.store.provider_models(false).await
}
pub async fn save_models(
&self,
provider_id: i64,
models: &[ProviderModelInput],
) -> Result<Vec<ProviderModel>> {
let mut saved = Vec::with_capacity(models.len());
for model in models {
saved.push(self.store.save_provider_model(provider_id, model).await?);
}
Ok(saved)
}
pub async fn delete_model(&self, model_hash: &str) -> Result<()> {
self.store.delete_provider_model(model_hash).await
}
pub async fn discover_models(&self, provider_id: i64) -> Result<DiscoveredModels> {
let provider = self
.store
.provider(provider_id)
.await?
.ok_or_else(|| Error::RunNotFound(format!("provider {provider_id}")))?;
let mut models = match provider.endpoint.provider_type {
ProviderType::OpenAiChat | ProviderType::OpenAiResponses => {
openai_models(&self.client, &provider).await?
}
ProviderType::Anthropic => anthropic_models(&self.client, &provider).await?,
};
models.sort();
models.dedup();
Ok(DiscoveredModels { models })
}
pub async fn calls(&self, limit: i64) -> Result<Vec<LlmCallSummary>> {
self.store.llm_calls(limit).await
}
pub async fn call(&self, call_id: &str) -> Result<CallDetail> {
let call = self
.store
.llm_call(call_id)
.await?
.ok_or_else(|| Error::RunNotFound(format!("LLM call {call_id}")))?;
Ok(CallDetail {
request: self.store.llm_call_request(call_id).await?,
response_chunks: self.store.llm_call_chunks(call_id).await?,
call,
})
}
pub async fn observability(&self) -> Result<ObservabilitySettings> {
Ok(ObservabilitySettings {
detailed: self.store.detailed_logging().await?,
})
}
pub async fn set_observability(
&self,
settings: ObservabilitySettings,
) -> Result<ObservabilitySettings> {
self.store.set_detailed_logging(settings.detailed).await?;
Ok(settings)
}
}
async fn openai_models(
client: &reqwest::Client,
provider: &ProviderEndpointSecret,
) -> Result<Vec<String>> {
let mut request = client.get(format!("{}/models", provider.endpoint.base_url));
if !provider.api_key.is_empty() {
request = request.bearer_auth(&provider.api_key);
}
let response = apply_custom_headers(request, &provider.custom_headers)?
.send()
.await?;
let status = response.status();
let body: serde_json::Value = response.json().await?;
if !status.is_success() {
return Err(Error::Provider(format!(
"model discovery failed ({status}): {body}"
)));
}
Ok(model_ids(body.get("data").unwrap_or(&body)))
}
async fn anthropic_models(
client: &reqwest::Client,
provider: &ProviderEndpointSecret,
) -> Result<Vec<String>> {
let mut after_id = None::<String>;
let mut found = BTreeSet::new();
loop {
let mut request = client
.get(format!("{}/models", provider.endpoint.base_url))
.query(&[("limit", "100")])
.header("anthropic-version", "2023-06-01");
if !provider.api_key.is_empty() {
request = request.header("x-api-key", &provider.api_key);
}
if let Some(after_id) = &after_id {
request = request.query(&[("after_id", after_id)]);
}
let response = apply_custom_headers(request, &provider.custom_headers)?
.send()
.await?;
let status = response.status();
let body: serde_json::Value = response.json().await?;
if !status.is_success() {
return Err(Error::Provider(format!(
"model discovery failed ({status}): {body}"
)));
}
found.extend(model_ids(body.get("data").unwrap_or(&body)));
if body.get("has_more").and_then(serde_json::Value::as_bool) != Some(true) {
break;
}
after_id = body
.get("last_id")
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
if after_id.is_none() {
return Err(Error::Provider(
"Anthropic model response has_more without last_id".into(),
));
}
}
Ok(found.into_iter().collect())
}
fn model_ids(value: &serde_json::Value) -> Vec<String> {
value
.as_array()
.into_iter()
.flatten()
.filter_map(|item| match item {
serde_json::Value::String(id) => Some(id.clone()),
serde_json::Value::Object(object) => object
.get("id")
.or_else(|| object.get("name"))
.and_then(serde_json::Value::as_str)
.map(str::to_owned),
_ => None,
})
.collect()
}
fn apply_custom_headers(
mut request: reqwest::RequestBuilder,
headers: &serde_json::Value,
) -> Result<reqwest::RequestBuilder> {
let object = headers
.as_object()
.ok_or_else(|| Error::Config("custom headers must be an object".into()))?;
for (name, value) in object {
let value = value
.as_str()
.ok_or_else(|| Error::Config(format!("custom header {name} must be a string")))?;
let name = HeaderName::try_from(name)
.map_err(|error| Error::Config(format!("invalid header name: {error}")))?;
let value = HeaderValue::try_from(value)
.map_err(|error| Error::Config(format!("invalid header value: {error}")))?;
request = request.header(name, value);
}
Ok(request)
}
-15
View File
@@ -1,15 +0,0 @@
use axum::{extract::State, Json};
use crate::Result;
use super::{ControlService, ObservabilitySettings};
pub async fn get(State(service): State<ControlService>) -> Result<Json<ObservabilitySettings>> {
Ok(Json(service.observability().await?))
}
pub async fn update(
State(service): State<ControlService>,
Json(settings): Json<ObservabilitySettings>,
) -> Result<Json<ObservabilitySettings>> {
Ok(Json(service.set_observability(settings).await?))
}
-246
View File
@@ -1,246 +0,0 @@
use std::sync::Arc;
use tokio::sync::mpsc;
use crate::{
cursor::prompting::PromptCompiler,
cursor::{
blob_sync::BlobSynchronizer,
checkpoint::CheckpointBuilder,
proto::agent::v1 as pb,
request,
session::CursorSession,
tools::{
codec, result::tool_result_channel, runtime::CursorToolRuntime, ClientToolEvent,
ToolDispatcher,
},
},
provider::Provider,
run::{RunActor, RunRegistry},
store::Store,
};
use super::{inbox::OrderedInbox, CursorCommand, CursorSessionHandle};
pub struct CursorActor;
#[derive(Clone)]
pub(crate) struct RunDependencies {
pub store: Store,
pub provider: Arc<dyn Provider>,
pub compiler: PromptCompiler,
pub run_registry: RunRegistry,
}
impl CursorActor {
pub(crate) fn spawn(
handle: CursorSessionHandle,
mut receiver: mpsc::Receiver<CursorCommand>,
dependencies: RunDependencies,
blob_sync: BlobSynchronizer,
next_append_seqno: i64,
) {
tokio::spawn(async move {
let mut inbox = OrderedInbox::starting_at(next_append_seqno);
let (results_tx, results_rx) = tool_result_channel();
let tool_runtime = CursorToolRuntime::default();
let tools = ToolDispatcher::with_results(tool_runtime.clone(), results_tx.clone());
let mut run_resources = Some((results_rx, dependencies));
loop {
let command = match receiver.recv().await {
Some(command) => command,
None => {
handle.cancel();
break;
}
};
match command {
CursorCommand::Abort => {
handle.cancel();
}
CursorCommand::Finished => {
break;
}
CursorCommand::Append { seqno, message } => {
for (_seqno, message) in inbox.push(seqno, *message) {
{
match message.message {
Some(pb::agent_client_message::Message::RunRequest(
request,
)) => {
if let Some((results, dependencies)) = run_resources.take()
{
let handle = handle.clone();
let blob_sync = blob_sync.clone();
let tools = tools.clone();
let tool_runtime = tool_runtime.clone();
tokio::spawn(async move {
let mut checkpoint = CheckpointBuilder::new(
dependencies.store.clone(),
blob_sync.clone(),
handle
.parent()
.map(|parent| parent.tool_call_id.clone()),
request.conversation_state.clone(),
);
let parent = handle.parent().map(|parent| {
(
crate::model::RunId::new(&parent.run_id),
parent.tool_call_id.clone(),
)
});
let prepared = request::prepare(
handle.request_id(),
&request,
parent,
request::PrepareDependencies {
compiler: &dependencies.compiler,
store: &dependencies.store,
checkpoint: &checkpoint,
blob_sync: &blob_sync,
},
)
.await;
let (prepared, context) = match prepared {
Ok(prepared) => prepared,
Err(error) => {
tracing::error!(
request_id = handle.request_id(),
%error,
"failed to prepare Cursor Run"
);
let _ = crate::cursor::lifecycle::fail(
&handle, &error,
);
let _ = handle
.command(CursorCommand::Finished)
.await;
return;
}
};
checkpoint.configure(
prepared.model.model_id.clone(),
prepared.model.context_window_tokens,
prepared.prompt.instructions.clone(),
prepared.prompt.tools.clone(),
context.dynamic_tools.keys().cloned().collect(),
context.turn_user.clone(),
);
let cancellation = handle.cancellation();
let (port, core) = crate::client::session(256);
let actor = RunActor::new(
dependencies.store.clone(),
dependencies.provider,
dependencies.run_registry,
);
let core_run =
actor.spawn(prepared, port, cancellation).await;
let session = CursorSession::new(
handle.clone(),
dependencies.store,
context,
core,
super::session::CursorSessionRuntime {
tools,
results,
checkpoint,
tool_runtime,
},
);
if let Err(error) = session.run().await {
tracing::error!(
request_id = handle.request_id(),
%error,
"Cursor session failed"
);
handle.cancel();
let _ = crate::cursor::lifecycle::fail(
&handle, &error,
);
}
let _ = core_run.await;
let _ =
handle.command(CursorCommand::Finished).await;
});
}
}
Some(pb::agent_client_message::Message::ExecClientMessage(
message,
)) => {
match codec::client_event(&message, &tool_runtime).await {
Ok(codec::ClientExecEvent::Delta(message)) => {
let _ = handle.emit(&message);
}
Ok(codec::ClientExecEvent::Message(message)) => {
let _ = handle.emit(&message);
}
Ok(codec::ClientExecEvent::Completed(result)) => {
results_tx.send(*result)
}
Ok(codec::ClientExecEvent::Pending) => {}
Err(error) => results_tx.send_error(error),
}
}
Some(
pb::agent_client_message::Message::ExecClientControlMessage(
message,
),
) => {
use pb::exec_client_control_message::Message;
match message.message {
Some(Message::StreamClose(close)) => {
if tool_runtime.take_exec(close.id).await.is_some()
{
results_tx.send_error(crate::Error::Protocol(format!(
"Exec stream closed before result for id: {}",
close.id
)));
}
}
Some(Message::Throw(throw)) => {
match tool_runtime.take_exec(throw.id).await {
Some(pending) => results_tx.send_error(
crate::Error::Protocol(format!(
"Exec {} failed: {}",
pending.call.call_id, throw.error
)),
),
None => results_tx.send_error(
crate::Error::Protocol(format!(
"unknown ExecClientThrow id: {}",
throw.id
)),
),
}
}
Some(Message::Heartbeat(_)) | None => {}
}
}
Some(
pb::agent_client_message::Message::InteractionResponse(
message,
),
) => match tools.interaction_response(&message).await {
Ok(ClientToolEvent::Message(message)) => {
let _ = handle.emit(&message);
}
Ok(ClientToolEvent::Completed(completion)) => {
results_tx.send(*completion)
}
Err(error) => results_tx.send_error(error),
},
Some(pb::agent_client_message::Message::KvClientMessage(
message,
)) => {
let _ = blob_sync.handle_client(message).await;
}
_ => {}
}
}
}
}
}
}
});
}
}
-44
View File
@@ -1,44 +0,0 @@
use prost::Message;
use crate::{
cursor::proto::{agent::v1 as agent, aiserver::v1 as ai},
cursor::{CursorCommand, CursorParent, CursorSessionRegistry},
Error, Result,
};
pub async fn append(
registry: &CursorSessionRegistry,
request: ai::BidiAppendRequest,
parent: Option<CursorParent>,
) -> Result<ai::BidiAppendResponse> {
let request_id = request
.request_id
.as_ref()
.map(|id| id.request_id.as_str())
.filter(|id| !id.is_empty())
.ok_or_else(|| Error::Protocol("BidiAppend request_id is required".into()))?;
if !request.data_binary.is_empty() {
return Err(Error::Protocol(
"BidiAppend data_binary is not part of the captured protocol".into(),
));
}
if request.data.is_empty() {
return Err(Error::Protocol(
"BidiAppend contains no AgentClientMessage".into(),
));
}
let payload = hex::decode(&request.data)
.map_err(|error| Error::Protocol(format!("invalid BidiAppend hex: {error}")))?;
let message = agent::AgentClientMessage::decode(payload.as_slice())?;
let handle = registry.get_or_create(request_id).await?;
if let Some(parent) = parent {
handle.set_parent(parent)?;
}
handle
.command(CursorCommand::Append {
seqno: request.append_seqno,
message: Box::new(message),
})
.await?;
Ok(ai::BidiAppendResponse {})
}
-236
View File
@@ -1,236 +0,0 @@
use std::{
collections::{HashMap, HashSet},
sync::{
atomic::{AtomicU32, Ordering},
Arc,
},
time::Duration,
};
use tokio::sync::{oneshot, Mutex};
use crate::{
cursor::proto::agent::v1 as pb,
cursor::CursorSessionHandle,
store::{BlobEdge, BlobId, Store},
Error, Result,
};
type BlobSetSender = oneshot::Sender<Result<()>>;
#[derive(Clone)]
pub struct BlobSynchronizer {
inner: Arc<Inner>,
}
struct Inner {
request_id: String,
store: Store,
handle: CursorSessionHandle,
next_id: AtomicU32,
set_requests: Mutex<HashMap<u32, PendingSet>>,
acked_blobs: Mutex<HashSet<BlobId>>,
get_requests: Mutex<HashMap<u32, PendingGet>>,
}
struct PendingSet {
blob_id: BlobId,
sent_at: std::time::Instant,
result: BlobSetSender,
}
struct PendingGet {
blob_id: BlobId,
result: oneshot::Sender<Result<Option<Vec<u8>>>>,
}
impl BlobSynchronizer {
pub fn new(request_id: String, store: Store, handle: CursorSessionHandle) -> Self {
Self {
inner: Arc::new(Inner {
request_id,
store,
handle,
next_id: AtomicU32::new(1),
set_requests: Mutex::new(HashMap::new()),
acked_blobs: Mutex::new(HashSet::new()),
get_requests: Mutex::new(HashMap::new()),
}),
}
}
pub fn request_id(&self) -> &str {
&self.inner.request_id
}
pub async fn persist(&self, data: &[u8], edges: &[BlobEdge]) -> Result<BlobId> {
let id = self.inner.store.put_blob(data, edges).await?;
self.ensure_set(&id, data).await?;
Ok(id)
}
async fn ensure_set(&self, blob_id: &BlobId, data: &[u8]) -> Result<()> {
if self.inner.acked_blobs.lock().await.contains(blob_id) {
return Ok(());
}
let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
let (sender, receiver) = oneshot::channel();
self.inner.set_requests.lock().await.insert(
id,
PendingSet {
blob_id: blob_id.clone(),
sent_at: std::time::Instant::now(),
result: sender,
},
);
if let Err(error) = self.inner.handle.emit(&pb::AgentServerMessage {
ttft_breakdown: None,
message: Some(pb::agent_server_message::Message::KvServerMessage(
pb::KvServerMessage {
id,
span_context: None,
message: Some(pb::kv_server_message::Message::SetBlobArgs(
pb::SetBlobArgs {
blob_id: blob_id.as_bytes().to_vec(),
blob_data: data.to_vec(),
},
)),
},
)),
}) {
self.inner.set_requests.lock().await.remove(&id);
return Err(error);
}
let cancellation = self.inner.handle.cancellation();
let result = tokio::select! {
result = receiver => result.map_err(|_| Error::Protocol("KV SET response channel closed".into()))?,
_ = cancellation.cancelled() => Err(Error::Cancelled),
_ = tokio::time::sleep(Duration::from_secs(15)) => Err(Error::Protocol(format!("KV SET timed out: {}", blob_id.to_base64()))),
};
if result.is_err() {
self.inner.set_requests.lock().await.remove(&id);
}
result
}
pub async fn get(&self, blob_id: &BlobId) -> Result<Option<Vec<u8>>> {
if let Some(data) = self.inner.store.get_blob(blob_id).await? {
return Ok(Some(data));
}
let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
let (sender, receiver) = oneshot::channel();
self.inner.get_requests.lock().await.insert(
id,
PendingGet {
blob_id: blob_id.clone(),
result: sender,
},
);
self.inner.handle.emit(&pb::AgentServerMessage {
ttft_breakdown: None,
message: Some(pb::agent_server_message::Message::KvServerMessage(
pb::KvServerMessage {
id,
span_context: None,
message: Some(pb::kv_server_message::Message::GetBlobArgs(
pb::GetBlobArgs {
blob_id: blob_id.as_bytes().to_vec(),
},
)),
},
)),
})?;
let cancellation = self.inner.handle.cancellation();
let result = tokio::select! {
result = receiver => result.map_err(|_| Error::Protocol("KV GET response channel closed".into()))?,
_ = cancellation.cancelled() => Err(Error::Cancelled),
_ = tokio::time::sleep(Duration::from_secs(15)) => Err(Error::Protocol(format!("KV GET timed out: {}", blob_id.to_base64()))),
};
if result.is_err() {
self.inner.get_requests.lock().await.remove(&id);
}
result
}
pub async fn cache_received(&self, blob_id: &BlobId, data: &[u8]) -> Result<()> {
let actual = BlobId::digest(data);
if actual != *blob_id {
return Err(Error::Protocol(format!(
"received Blob hash mismatch: expected {}, got {}",
blob_id.to_base64(),
actual.to_base64()
)));
}
self.inner.store.put_blob(data, &[]).await?;
Ok(())
}
pub async fn handle_client(&self, message: pb::KvClientMessage) -> Result<()> {
match message.message {
Some(pb::kv_client_message::Message::SetBlobResult(result)) => {
if let Some(pending) = self.inner.set_requests.lock().await.remove(&message.id) {
if let Some(error) = result.error {
tracing::error!(
request_id = self.request_id(),
kv_id = message.id,
blob_id = pending.blob_id.to_base64(),
error = error.message,
"Cursor rejected Blob SET"
);
let _ = pending.result.send(Err(Error::Protocol(format!(
"KV SET {}: {}",
pending.blob_id.to_base64(),
error.message
))));
} else {
tracing::debug!(
request_id = self.request_id(),
kv_id = message.id,
blob_id = pending.blob_id.to_base64(),
elapsed_ms = pending.sent_at.elapsed().as_millis(),
"Cursor acknowledged Blob SET"
);
self.inner.acked_blobs.lock().await.insert(pending.blob_id);
let _ = pending.result.send(Ok(()));
}
} else {
tracing::warn!(
request_id = self.request_id(),
kv_id = message.id,
"unknown Cursor Blob SET acknowledgement"
);
}
}
Some(pb::kv_client_message::Message::GetBlobResult(result)) => {
if let Some(pending) = self.inner.get_requests.lock().await.remove(&message.id) {
let value = if let Some(error) = result.error {
Err(Error::Protocol(format!("KV GET: {}", error.message)))
} else if let Some(data) = result.blob_data {
let actual = BlobId::digest(&data);
if actual != pending.blob_id {
Err(Error::Protocol(format!(
"KV GET Blob hash mismatch: expected {}, got {}",
pending.blob_id.to_base64(),
actual.to_base64()
)))
} else {
self.inner.store.put_blob(&data, &[]).await?;
Ok(Some(data))
}
} else {
Ok(None)
};
let _ = pending.result.send(value);
} else {
tracing::warn!(
request_id = self.request_id(),
kv_id = message.id,
"unknown Cursor Blob GET response"
);
}
}
None => {}
}
Ok(())
}
}
@@ -1,226 +0,0 @@
use std::collections::HashMap;
use prost::Message;
use crate::{
cursor::{prompting::fold_derived_state, proto::agent::v1 as pb},
model::{CanonicalMessage, MessageContent},
store::BlobId,
Error, Result,
};
use super::CheckpointBuilder;
impl CheckpointBuilder {
pub(super) async fn build_derived_state(
&self,
messages: &[CanonicalMessage],
) -> Result<(Vec<BlobId>, Option<BlobId>)> {
let state = fold_derived_state(messages);
let todo_values = state
.todos
.as_ref()
.map(|value| {
value
.get("todos")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| Error::Protocol("TodoWrite state is missing todos[]".into()))
})
.transpose()?;
let mut todo_ids = Vec::new();
for (index, todo) in todo_values.into_iter().flatten().enumerate() {
let status = match todo
.get("status")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| Error::Protocol("TodoWrite item is missing status".into()))?
{
"in_progress" => pb::TodoStatus::InProgress,
"completed" => pb::TodoStatus::Completed,
"cancelled" => pb::TodoStatus::Cancelled,
"pending" => pb::TodoStatus::Pending,
status => {
return Err(Error::Protocol(format!(
"unknown TodoWrite status: {status}"
)))
}
};
let message = pb::TodoItem {
id: todo
.get("id")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| Error::Protocol("TodoWrite item is missing id".into()))?
.into(),
content: todo
.get("content")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| Error::Protocol("TodoWrite item is missing content".into()))?
.into(),
status: status as i32,
created_at: 0,
updated_at: 0,
dependencies: todo
.get("dependencies")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_str)
.map(str::to_string)
.collect(),
};
let mut encoded = Vec::new();
message.encode(&mut encoded)?;
let id = BlobId::digest(&encoded);
if self.base.todos.get(index).map(|raw| raw.as_slice()) == Some(id.as_bytes()) {
todo_ids.push(id);
} else {
todo_ids.push(self.sync.persist(&encoded, &[]).await?);
}
}
let plan_id = if let Some(value) = state.plan {
let text = value
.get("plan")
.and_then(serde_json::Value::as_str)
.or_else(|| value.as_str())
.or_else(|| value.get("overview").and_then(serde_json::Value::as_str))
.ok_or_else(|| Error::Protocol("plan state has no textual plan".into()))?;
let mut encoded = Vec::new();
pb::ConversationPlan { plan: text.into() }.encode(&mut encoded)?;
let id = BlobId::digest(&encoded);
if self.base.plan.as_deref() == Some(id.as_bytes()) {
Some(id)
} else {
Some(self.sync.persist(&encoded, &[]).await?)
}
} else {
None
};
Ok((todo_ids, plan_id))
}
}
pub(super) fn update_current_step_state(
messages: &[CanonicalMessage],
) -> Option<pb::CommunicateUpdateTurnState> {
let result_indices = messages
.iter()
.filter_map(|message| match &message.content {
MessageContent::ToolResult(result) => {
update_message_index(&result.content).map(|index| (result.call_id.as_str(), index))
}
_ => None,
})
.collect::<HashMap<_, _>>();
let mut state = pb::CommunicateUpdateTurnState::default();
for message in messages {
let MessageContent::Assistant { tool_calls, .. } = &message.content else {
continue;
};
for call in tool_calls {
if normalize(&call.name) != "updatecurrentstep" {
continue;
}
if let (Some(step), Some(message_index)) = (
call.arguments
.get("current_step")
.and_then(serde_json::Value::as_str),
result_indices.get(call.call_id.as_str()),
) {
state.history.push(pb::CommunicateUpdateHistoryEntry {
step: step.into(),
message_index: *message_index,
});
}
if let Some(summary) = call
.arguments
.get("final_summary")
.and_then(serde_json::Value::as_str)
{
state.final_summary = Some(summary.into());
}
if let Some(subtitle) = call
.arguments
.get("completed_subtitle")
.and_then(serde_json::Value::as_str)
{
state.completed_subtitle = Some(subtitle.into());
}
}
}
(!state.history.is_empty()
|| state.final_summary.is_some()
|| state.completed_subtitle.is_some())
.then_some(state)
}
fn update_message_index(output: &str) -> Option<u32> {
let value: serde_json::Value = serde_json::from_str(output).ok()?;
value
.get("success")
.and_then(|success| success.get("message_index"))
.and_then(serde_json::Value::as_u64)
.and_then(|index| u32::try_from(index).ok())
}
fn normalize(name: &str) -> String {
name.chars()
.filter(|character| character.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Origin, Role, ToolCallContent, ToolResultContent};
#[test]
fn update_current_step_is_folded_from_canonical_messages() {
let messages = vec![
CanonicalMessage {
message_id: "assistant".into(),
role: Role::Assistant,
origin: Origin::Assistant,
content: MessageContent::Assistant {
text: String::new(),
thinking: String::new(),
tool_round_id: Some("round".into()),
replay_state: None,
tool_calls: vec![ToolCallContent {
index: 0,
call_id: "call".into(),
name: "UpdateCurrentStep".into(),
arguments: serde_json::json!({
"current_step": "Inspecting protocol",
"final_summary": "Protocol verified.",
"completed_subtitle": "Verified protocol flow"
}),
}],
},
runtime_event_id: None,
},
CanonicalMessage {
message_id: "result".into(),
role: Role::Tool,
origin: Origin::Tool,
content: MessageContent::ToolResult(ToolResultContent {
call_id: "call".into(),
name: "UpdateCurrentStep".into(),
content: serde_json::json!({
"success": {"current_step": "Inspecting protocol", "message_index": 3}
})
.to_string(),
is_error: false,
}),
runtime_event_id: None,
},
];
let state = update_current_step_state(&messages).unwrap();
assert_eq!(state.history[0].step, "Inspecting protocol");
assert_eq!(state.history[0].message_index, 3);
assert_eq!(state.final_summary.as_deref(), Some("Protocol verified."));
assert_eq!(
state.completed_subtitle.as_deref(),
Some("Verified protocol flow")
);
}
}
-223
View File
@@ -1,223 +0,0 @@
mod derived;
mod recovery;
mod roots;
mod turns;
pub(crate) mod worker;
use std::collections::HashSet;
use crate::{
cursor::{
blob_sync::BlobSynchronizer, presentation::PresentationDelta, projection,
proto::agent::v1 as pb, CursorSessionHandle,
},
model::{CanonicalMessage, ToolCall, ToolDefinition, ToolRoundAssistant},
store::Store,
Result,
};
use roots::RootFrontier;
use turns::TurnFrontier;
#[derive(Clone)]
pub struct CheckpointBuilder {
store: Store,
sync: BlobSynchronizer,
parent_tool_call_id: Option<String>,
base: pb::ConversationStateStructure,
model: String,
max_context_tokens: Option<u64>,
instructions: String,
tool_definitions: Vec<ToolDefinition>,
allowed_tools: Vec<String>,
dynamic_tools: HashSet<String>,
turn_user: Option<pb::UserMessage>,
roots: Option<RootFrontier>,
turn: Option<TurnFrontier>,
turns_initialized: bool,
}
impl CheckpointBuilder {
pub fn new(
store: Store,
sync: BlobSynchronizer,
parent_tool_call_id: Option<String>,
base: Option<pb::ConversationStateStructure>,
) -> Self {
Self {
store,
sync,
parent_tool_call_id,
base: base.unwrap_or_default(),
model: String::new(),
max_context_tokens: None,
instructions: String::new(),
tool_definitions: Vec::new(),
allowed_tools: Vec::new(),
dynamic_tools: HashSet::new(),
turn_user: None,
roots: None,
turn: None,
turns_initialized: false,
}
}
pub fn configure(
&mut self,
model: String,
max_context_tokens: Option<u64>,
instructions: String,
tool_definitions: Vec<ToolDefinition>,
dynamic_tools: HashSet<String>,
turn_user: Option<pb::UserMessage>,
) {
self.model = model;
self.max_context_tokens = max_context_tokens;
self.instructions = instructions;
self.allowed_tools = tool_definitions
.iter()
.map(|tool| tool.name.clone())
.collect();
self.tool_definitions = tool_definitions;
self.dynamic_tools = dynamic_tools;
self.turn_user = turn_user;
}
pub(crate) fn record_context_tokens(&mut self, used_tokens: Option<u64>) {
let Some(used_tokens) = used_tokens else {
return;
};
let max_tokens = self
.base
.token_details
.as_ref()
.map(|details| details.max_tokens as u64)
.filter(|tokens| *tokens != 0)
.or(self.max_context_tokens);
let Some(max_tokens) = max_tokens else {
return;
};
let details = self.base.token_details.get_or_insert_with(Default::default);
details.used_tokens = used_tokens.min(u32::MAX as u64) as u32;
details.max_tokens = max_tokens.min(u32::MAX as u64) as u32;
details.prompt_context_usage_tree = None;
details.prompt_context_usage_snapshot_blob_id = None;
}
pub async fn settled(
&mut self,
messages: &[CanonicalMessage],
mode: i32,
presentation: &PresentationDelta,
) -> Result<pb::ConversationStateStructure> {
self.build_state(messages, mode, Vec::new(), presentation)
.await
}
pub async fn staged_tool_round(
&mut self,
stable_messages: &[CanonicalMessage],
mode: i32,
assistant: &ToolRoundAssistant,
calls: &[ToolCall],
started_at_ms: u64,
presentation: &PresentationDelta,
) -> Result<pb::ConversationStateStructure> {
let pending = projection::staged_tool_round(
assistant,
calls,
&self.model,
&self.allowed_tools,
&self.dynamic_tools,
started_at_ms,
)?;
self.build_state(stable_messages, mode, vec![pending], presentation)
.await
}
pub async fn staged_final(
&mut self,
stable_messages: &[CanonicalMessage],
mode: i32,
assistant: &CanonicalMessage,
started_at_ms: u64,
presentation: &PresentationDelta,
) -> Result<pb::ConversationStateStructure> {
let pending = projection::staged_final(
assistant,
&self.model,
&self.allowed_tools,
&self.dynamic_tools,
started_at_ms,
)?;
self.build_state(stable_messages, mode, vec![pending], presentation)
.await
}
async fn build_state(
&mut self,
messages: &[CanonicalMessage],
mode: i32,
pending_tool_calls: Vec<String>,
presentation: &PresentationDelta,
) -> Result<pb::ConversationStateStructure> {
let root_ids = self.project_roots(messages).await?;
let turn_ids = self.project_turns(mode, presentation).await?;
let (todo_ids, plan_id) = self.build_derived_state(messages).await?;
self.base.todos = todo_ids.iter().map(|id| id.as_bytes().to_vec()).collect();
self.base.plan = plan_id.as_ref().map(|id| id.as_bytes().to_vec());
let communicate_update_states_by_parent_tool_call_id = self
.parent_tool_call_id
.as_ref()
.and_then(|parent| {
derived::update_current_step_state(messages).map(|state| (parent.clone(), state))
})
.into_iter()
.collect();
for path in &presentation.read_paths {
if !self.base.read_paths.contains(path) {
self.base.read_paths.push(path.clone());
}
}
let mut checkpoint = self.base.clone();
checkpoint.root_prompt_messages_json =
root_ids.iter().map(|id| id.as_bytes().to_vec()).collect();
checkpoint.turns = turn_ids.iter().map(|id| id.as_bytes().to_vec()).collect();
checkpoint.pending_tool_calls = pending_tool_calls;
checkpoint.mode = Some(mode);
checkpoint.communicate_update_states_by_parent_tool_call_id =
communicate_update_states_by_parent_tool_call_id;
if let Some(details) = checkpoint.token_details.as_mut() {
details.breakdown = Some(crate::cursor::usage::breakdown(
details.used_tokens,
details.max_tokens,
details.breakdown.as_ref(),
&self.instructions,
&self.tool_definitions,
&self.dynamic_tools,
messages,
)?);
}
Ok(checkpoint)
}
pub async fn publish(
&self,
handle: &CursorSessionHandle,
checkpoint: &pb::ConversationStateStructure,
) -> Result<()> {
tracing::debug!(
request_id = self.sync.request_id(),
stable_roots = checkpoint.root_prompt_messages_json.len(),
pending_assistants = checkpoint.pending_tool_calls.len(),
"publishing Cursor checkpoint"
);
handle.emit(&pb::AgentServerMessage {
ttft_breakdown: None,
message: Some(
pb::agent_server_message::Message::ConversationCheckpointUpdate(checkpoint.clone()),
),
})
}
}
@@ -1,48 +0,0 @@
use crate::{
cursor::{projection, proto::agent::v1 as pb},
model::CanonicalMessage,
store::BlobId,
Error, Result,
};
use super::CheckpointBuilder;
impl CheckpointBuilder {
pub async fn import_prefetched(&self, blobs: &[pb::PreFetchedBlob]) -> Result<()> {
for blob in blobs {
let expected = BlobId::from_bytes(&blob.id)?;
let actual = self.store.put_blob(&blob.value, &[]).await?;
if expected != actual {
return Err(Error::Protocol(format!(
"prefetched Blob hash mismatch: {}",
expected.to_base64()
)));
}
}
Ok(())
}
pub async fn hydrate_messages(
&self,
state: Option<&pb::ConversationStateStructure>,
) -> Result<Vec<CanonicalMessage>> {
let mut messages = Vec::new();
let Some(state) = state else {
return Ok(messages);
};
for (ordinal, raw_id) in state.root_prompt_messages_json.iter().enumerate() {
let id = BlobId::from_bytes(raw_id)?;
let Some(data) = self.sync.get(&id).await? else {
return Err(Error::Protocol(format!(
"missing message Blob {}",
id.to_base64()
)));
};
messages.push(projection::decode(
&data,
format!("cursor-root:{}:{ordinal}", id.to_base64()),
)?);
}
Ok(messages)
}
}
@@ -1,106 +0,0 @@
use crate::{cursor::projection, model::CanonicalMessage, store::BlobId, Error, Result};
use super::CheckpointBuilder;
#[derive(Clone)]
pub(super) struct RootFrontier {
pub(super) ids: Vec<BlobId>,
pub(super) generated: Vec<Vec<u8>>,
pub(super) base_count: usize,
}
impl CheckpointBuilder {
pub(super) async fn project_roots(
&mut self,
messages: &[CanonicalMessage],
) -> Result<Vec<BlobId>> {
let wire_messages = projection::stable_messages(&self.instructions, messages, &self.model)?;
self.ensure_roots()?;
let replacement = self
.roots
.as_ref()
.and_then(|roots| changed_system_root(roots, &wire_messages));
if let Some(message) = replacement {
let id = self.sync.persist(&message, &[]).await?;
self.roots
.as_mut()
.ok_or_else(|| Error::Protocol("Cursor root frontier was not initialized".into()))?
.ids[0] = id;
}
let roots = self
.roots
.as_mut()
.ok_or_else(|| Error::Protocol("Cursor root frontier was not initialized".into()))?;
if wire_messages.len() < roots.ids.len() {
return Err(Error::Protocol(format!(
"Cursor stable history shrank from {} to {} roots",
roots.ids.len(),
wire_messages.len()
)));
}
for (index, expected) in roots.generated.iter().enumerate() {
let wire_index = roots.base_count + index;
if wire_messages.get(wire_index) != Some(expected) {
return Err(Error::Protocol(format!(
"Cursor stable root changed at index {wire_index}"
)));
}
}
for message in wire_messages.iter().skip(roots.ids.len()) {
roots.ids.push(self.sync.persist(message, &[]).await?);
roots.generated.push(message.clone());
}
Ok(roots.ids.clone())
}
fn ensure_roots(&mut self) -> Result<()> {
if self.roots.is_some() {
return Ok(());
}
let ids = self
.base
.root_prompt_messages_json
.iter()
.map(|id| BlobId::from_bytes(id))
.collect::<Result<Vec<_>>>()?;
self.roots = Some(RootFrontier {
base_count: ids.len(),
ids,
generated: Vec::new(),
});
Ok(())
}
}
fn changed_system_root(roots: &RootFrontier, messages: &[Vec<u8>]) -> Option<Vec<u8>> {
roots
.ids
.first()
.zip(messages.first())
.filter(|(current, message)| **current != BlobId::digest(message))
.map(|(_, message)| message.clone())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_new_prompt_replaces_only_the_system_root() {
let previous = b"previous prompt".to_vec();
let current = b"current prompt".to_vec();
let roots = RootFrontier {
ids: vec![BlobId::digest(&previous), BlobId::digest(b"user")],
generated: Vec::new(),
base_count: 2,
};
assert_eq!(
changed_system_root(&roots, &[current.clone(), b"user".to_vec()]),
Some(current)
);
assert_eq!(
changed_system_root(&roots, &[previous, b"user".to_vec()]),
None
);
}
}
@@ -1,126 +0,0 @@
use prost::Message;
use crate::{
cursor::{presentation::PresentationDelta, proto::agent::v1 as pb},
store::{BlobEdge, BlobId},
Error, Result,
};
use super::CheckpointBuilder;
#[derive(Clone)]
pub(super) struct TurnFrontier {
pub(super) preceding: Vec<BlobId>,
pub(super) current_id: Option<BlobId>,
pub(super) current: pb::AgentConversationTurnStructure,
}
impl CheckpointBuilder {
pub(super) async fn project_turns(
&mut self,
mode: i32,
presentation: &PresentationDelta,
) -> Result<Vec<BlobId>> {
self.ensure_turn(mode).await?;
let Some(turn) = self.turn.as_mut() else {
return self
.base
.turns
.iter()
.map(|id| BlobId::from_bytes(id))
.collect();
};
let changed = !presentation.steps.is_empty();
for step in &presentation.steps {
let mut encoded = Vec::new();
step.encode(&mut encoded)?;
let id = self.sync.persist(&encoded, &[]).await?;
turn.current.steps.push(id.as_bytes().to_vec());
}
if changed || turn.current_id.is_none() {
let wrapper = pb::ConversationTurnStructure {
turn: Some(
pb::conversation_turn_structure::Turn::AgentConversationTurn(
turn.current.clone(),
),
),
};
let mut encoded = Vec::new();
wrapper.encode(&mut encoded)?;
let mut edges = Vec::with_capacity(turn.current.steps.len() + 1);
edges.push(BlobEdge {
child: BlobId::from_bytes(&turn.current.user_message)?,
field_name: "agent_conversation_turn.user_message".into(),
});
for (index, raw_id) in turn.current.steps.iter().enumerate() {
edges.push(BlobEdge {
child: BlobId::from_bytes(raw_id)?,
field_name: format!("agent_conversation_turn.steps[{index}]"),
});
}
turn.current_id = Some(self.sync.persist(&encoded, &edges).await?);
}
let mut ids = turn.preceding.clone();
ids.push(
turn.current_id
.clone()
.ok_or_else(|| Error::Protocol("Cursor current Turn has no BlobID".into()))?,
);
Ok(ids)
}
async fn ensure_turn(&mut self, mode: i32) -> Result<()> {
if self.turns_initialized {
return Ok(());
}
self.turns_initialized = true;
let base_ids = self
.base
.turns
.iter()
.map(|id| BlobId::from_bytes(id))
.collect::<Result<Vec<_>>>()?;
if let Some(mut user) = self.turn_user.clone() {
user.mode = mode;
let mut encoded = Vec::new();
user.encode(&mut encoded)?;
let user_id = self.sync.persist(&encoded, &[]).await?;
self.turn = Some(TurnFrontier {
preceding: base_ids,
current_id: None,
current: pb::AgentConversationTurnStructure {
user_message: user_id.as_bytes().to_vec(),
steps: Vec::new(),
request_id: Some(self.sync.request_id().into()),
encrypted_model: None,
dynamic_tool_count: None,
send_message_step_indices: Vec::new(),
},
});
return Ok(());
}
let Some((current_id, preceding)) = base_ids.split_last() else {
return Ok(());
};
let data = self.sync.get(current_id).await?.ok_or_else(|| {
Error::Protocol(format!(
"missing current Turn Blob {}",
current_id.to_base64()
))
})?;
let wrapper = pb::ConversationTurnStructure::decode(data.as_slice())?;
let Some(pb::conversation_turn_structure::Turn::AgentConversationTurn(current)) =
wrapper.turn
else {
return Err(Error::Protocol(
"current Cursor Turn is not an agent conversation turn".into(),
));
};
self.turn = Some(TurnFrontier {
preceding: preceding.to_vec(),
current_id: Some(current_id.clone()),
current,
});
Ok(())
}
}
@@ -1,181 +0,0 @@
use tokio::sync::{mpsc, oneshot};
use crate::{
cursor::{presentation::PresentationDelta, proto::agent::v1 as pb, CursorSessionHandle},
model::{RevisionId, ToolRoundId},
store::Store,
Error, Result,
};
use super::CheckpointBuilder;
pub(crate) struct CheckpointJob {
pub kind: CheckpointKind,
pub presentation: PresentationDelta,
pub context_tokens: Option<u64>,
pub ready: Option<oneshot::Sender<std::result::Result<(), String>>>,
}
pub(crate) enum CheckpointKind {
Settled(RevisionId),
ToolStarted {
round_id: ToolRoundId,
stable_revision_id: RevisionId,
},
ToolSettled(RevisionId),
Final {
revision_id: RevisionId,
result: oneshot::Sender<Result<FinalCheckpoints>>,
},
}
pub(crate) struct FinalCheckpoints {
pub staged: pb::ConversationStateStructure,
pub settled: pb::ConversationStateStructure,
}
pub(crate) struct CheckpointWorker {
pub jobs: mpsc::Sender<CheckpointJob>,
pub failures: mpsc::Receiver<Error>,
task: tokio::task::JoinHandle<()>,
}
impl CheckpointWorker {
pub fn spawn(
store: Store,
mut builder: CheckpointBuilder,
handle: CursorSessionHandle,
mode: i32,
) -> Self {
let (jobs, mut receiver) = mpsc::channel::<CheckpointJob>(32);
let (failures, failure_receiver) = mpsc::channel(1);
let task = tokio::spawn(async move {
while let Some(job) = receiver.recv().await {
builder.record_context_tokens(job.context_tokens);
let presentation = job.presentation;
let ready = job.ready;
let result = match job.kind {
CheckpointKind::Settled(revision_id)
| CheckpointKind::ToolSettled(revision_id) => {
publish_settled(
&store,
&mut builder,
&handle,
mode,
revision_id,
&presentation,
)
.await
}
CheckpointKind::ToolStarted {
round_id,
stable_revision_id,
} => {
publish_started(
&store,
&mut builder,
&handle,
mode,
round_id,
stable_revision_id,
&presentation,
)
.await
}
CheckpointKind::Final {
revision_id,
result,
} => {
let checkpoints =
build_final(&store, &mut builder, mode, revision_id, &presentation)
.await;
let _ = result.send(checkpoints);
Ok(())
}
};
if let Err(error) = result {
if let Some(ready) = ready {
let _ = ready.send(Err(error.to_string()));
}
tracing::error!(%error, "failed to build or publish Cursor checkpoint");
let _ = failures.send(error).await;
break;
}
if let Some(ready) = ready {
let _ = ready.send(Ok(()));
}
}
});
Self {
jobs,
failures: failure_receiver,
task,
}
}
pub fn abort(&self) {
self.task.abort();
}
}
async fn publish_settled(
store: &Store,
builder: &mut CheckpointBuilder,
handle: &CursorSessionHandle,
mode: i32,
revision_id: RevisionId,
presentation: &PresentationDelta,
) -> Result<()> {
let messages = store.load_revision_messages(revision_id).await?;
let checkpoint = builder.settled(&messages, mode, presentation).await?;
builder.publish(handle, &checkpoint).await
}
async fn publish_started(
store: &Store,
builder: &mut CheckpointBuilder,
handle: &CursorSessionHandle,
mode: i32,
round_id: ToolRoundId,
stable_revision_id: RevisionId,
presentation: &PresentationDelta,
) -> Result<()> {
let round = store
.tool_round(&round_id)
.await?
.ok_or_else(|| Error::Store(format!("checkpoint tool round not found: {round_id}")))?;
let messages = store.load_revision_messages(stable_revision_id).await?;
let checkpoint = builder
.staged_tool_round(
&messages,
mode,
&round.assistant,
&round.calls,
round.created_at_ms,
presentation,
)
.await?;
builder.publish(handle, &checkpoint).await
}
async fn build_final(
store: &Store,
builder: &mut CheckpointBuilder,
mode: i32,
revision_id: RevisionId,
presentation: &PresentationDelta,
) -> Result<FinalCheckpoints> {
let messages = store.load_revision_messages(revision_id).await?;
let (assistant, stable) = messages
.split_last()
.ok_or_else(|| Error::Store("final revision contains no assistant".into()))?;
let started_at_ms = crate::cursor::tools::runtime::now_ms();
let staged = builder
.staged_final(stable, mode, assistant, started_at_ms, presentation)
.await?;
let settled = builder
.settled(&messages, mode, &PresentationDelta::default())
.await?;
Ok(FinalCheckpoints { staged, settled })
}
-11
View File
@@ -1,11 +0,0 @@
use crate::cursor::proto::agent::v1 as pb;
#[derive(Debug)]
pub enum CursorCommand {
Append {
seqno: i64,
message: Box<pb::AgentClientMessage>,
},
Abort,
Finished,
}
-121
View File
@@ -1,121 +0,0 @@
use bytes::{BufMut, Bytes, BytesMut};
use prost::Message;
use serde::Serialize;
use crate::{Error, Result};
pub const END_STREAM_FLAG: u8 = 0x02;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConnectCode {
Canceled,
InvalidArgument,
NotFound,
Unavailable,
Internal,
}
impl ConnectCode {
fn as_str(self) -> &'static str {
match self {
Self::Canceled => "canceled",
Self::InvalidArgument => "invalid_argument",
Self::NotFound => "not_found",
Self::Unavailable => "unavailable",
Self::Internal => "internal",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ConnectErrorDetail {
#[serde(rename = "type")]
pub type_name: String,
pub value: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConnectStreamError {
pub code: ConnectCode,
pub message: String,
pub details: Vec<ConnectErrorDetail>,
}
#[derive(Serialize)]
struct EndStreamResponse<'a> {
error: WireError<'a>,
}
#[derive(Serialize)]
struct WireError<'a> {
code: &'static str,
#[serde(skip_serializing_if = "str::is_empty")]
message: &'a str,
#[serde(skip_serializing_if = "details_are_empty")]
details: &'a [ConnectErrorDetail],
}
fn details_are_empty(details: &&[ConnectErrorDetail]) -> bool {
details.is_empty()
}
pub fn encode_message<M: Message>(message: &M) -> Result<Bytes> {
let len = message.encoded_len();
let mut output = BytesMut::with_capacity(5 + len);
output.put_u8(0);
output.put_u32(len as u32);
message.encode(&mut output)?;
Ok(output.freeze())
}
pub fn encode_end_stream() -> Bytes {
encode_end_stream_payload(b"{}")
}
pub fn encode_error_end_stream(error: &ConnectStreamError) -> Result<Bytes> {
let payload = serde_json::to_vec(&EndStreamResponse {
error: WireError {
code: error.code.as_str(),
message: &error.message,
details: &error.details,
},
})?;
Ok(encode_end_stream_payload(&payload))
}
fn encode_end_stream_payload(payload: &[u8]) -> Bytes {
let mut output = BytesMut::with_capacity(5 + payload.len());
output.put_u8(END_STREAM_FLAG);
output.put_u32(payload.len() as u32);
output.extend_from_slice(payload);
output.freeze()
}
pub fn decode_unary<M: Message + Default>(body: &[u8]) -> Result<M> {
if body.len() >= 5 {
let flags = body[0];
let length = u32::from_be_bytes([body[1], body[2], body[3], body[4]]) as usize;
if flags & END_STREAM_FLAG == 0 && length == body.len() - 5 {
return Ok(M::decode(&body[5..])?);
}
}
Ok(M::decode(body)?)
}
pub fn decode_frames(mut body: &[u8]) -> Result<Vec<(u8, Bytes)>> {
let mut frames = Vec::new();
while !body.is_empty() {
if body.len() < 5 {
return Err(Error::Protocol("truncated Connect envelope".into()));
}
let flags = body[0];
let length = u32::from_be_bytes([body[1], body[2], body[3], body[4]]) as usize;
body = &body[5..];
if body.len() < length {
return Err(Error::Protocol("truncated Connect payload".into()));
}
frames.push((flags, Bytes::copy_from_slice(&body[..length])));
body = &body[length..];
}
Ok(frames)
}
-122
View File
@@ -1,122 +0,0 @@
use axum::{
body::Bytes,
extract::{DefaultBodyLimit, Extension, State},
http::{header, HeaderMap, HeaderValue, Response, StatusCode},
routing::post,
Router,
};
use tower_http::decompression::RequestDecompressionLayer;
use crate::{
cursor::{
bidi_append, connect, model_catalog,
proto::{agent::v1 as agent, aiserver::v1 as ai},
proxy::{self, CursorProxy},
run_sse,
},
cursor::{CursorParent, CursorSessionRegistry},
Result,
};
pub fn router(registry: CursorSessionRegistry) -> Result<Router> {
let proxy = CursorProxy::cursor()?;
Ok(Router::new()
.route("/agent.v1.AgentService/RunSSE", post(run_sse_handler))
.route(
"/aiserver.v1.BidiService/BidiAppend",
post(bidi_append_handler),
)
.route(
"/aiserver.v1.AiService/AvailableModels",
post(model_catalog::available_models),
)
.route(
"/agent.v1.AgentService/GetUsableModels",
post(model_catalog::usable_models),
)
.route(
"/aiserver.v1.AiService/GetUsableModels",
post(model_catalog::usable_models),
)
.route_layer(DefaultBodyLimit::disable())
.route_layer(RequestDecompressionLayer::new())
.fallback(proxy::forward)
.method_not_allowed_fallback(proxy::forward)
.layer(Extension(proxy))
.with_state(registry))
}
async fn run_sse_handler(
State(registry): State<CursorSessionRegistry>,
body: Bytes,
) -> Result<Response<axum::body::Body>> {
let request: agent::BidiRequestId = connect::decode_unary(&body)?;
run_sse::stream(&registry, &request.request_id).await
}
async fn bidi_append_handler(
State(registry): State<CursorSessionRegistry>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response<axum::body::Body>> {
let request: ai::BidiAppendRequest = connect::decode_unary(&body)?;
let parent = parent_headers(&headers)?;
bidi_append::append(&registry, request, parent).await?;
let mut response = Response::new(axum::body::Body::empty());
*response.status_mut() = StatusCode::OK;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/proto"),
);
Ok(response)
}
fn parent_headers(headers: &HeaderMap) -> Result<Option<CursorParent>> {
let run_id = header_text(headers, "x-parent-request-id")?;
let tool_call_id = header_text(headers, "x-parent-agent-tool-call-id")?;
match (run_id, tool_call_id) {
(None, None) => Ok(None),
(Some(run_id), Some(tool_call_id)) => Ok(Some(CursorParent {
run_id: run_id.into(),
tool_call_id: tool_call_id.into(),
})),
_ => Err(crate::Error::Protocol(
"Cursor subagent request must include both parent headers".into(),
)),
}
}
fn header_text<'a>(headers: &'a HeaderMap, name: &str) -> Result<Option<&'a str>> {
headers
.get(name)
.map(|value| value.to_str())
.transpose()
.map_err(|error| crate::Error::Protocol(format!("invalid {name} header: {error}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subagent_parent_headers_are_an_atomic_pair() {
let mut headers = HeaderMap::new();
headers.insert(
"x-parent-request-id",
HeaderValue::from_static("parent-run"),
);
assert!(parent_headers(&headers).is_err());
headers.insert(
"x-parent-agent-tool-call-id",
HeaderValue::from_static("parent-call"),
);
assert_eq!(
parent_headers(&headers).unwrap(),
Some(CursorParent {
run_id: "parent-run".into(),
tool_call_id: "parent-call".into(),
})
);
}
}
-38
View File
@@ -1,38 +0,0 @@
use std::collections::BTreeMap;
#[derive(Debug)]
pub struct OrderedInbox<T> {
next: i64,
pending: BTreeMap<i64, T>,
}
impl<T> Default for OrderedInbox<T> {
fn default() -> Self {
Self {
next: 0,
pending: BTreeMap::new(),
}
}
}
impl<T> OrderedInbox<T> {
pub fn starting_at(next: i64) -> Self {
Self {
next,
pending: BTreeMap::new(),
}
}
pub fn push(&mut self, seqno: i64, value: T) -> Vec<(i64, T)> {
if seqno < self.next {
return Vec::new();
}
self.pending.entry(seqno).or_insert(value);
let mut ready = Vec::new();
while let Some(value) = self.pending.remove(&self.next) {
ready.push((self.next, value));
self.next += 1;
}
ready
}
}
-105
View File
@@ -1,105 +0,0 @@
mod query;
mod render;
use std::time::Duration;
use crate::{
cursor::proto::agent::v1 as pb,
model::{ToolCall, Usage},
provider::ModelEvent,
Result,
};
pub use query::tool_query;
pub(crate) use render::{edit_content_delta, edit_path_partial};
pub use render::{render_tool_call, tool_completed, tool_placeholder, tool_started};
pub fn response_event(
event: &ModelEvent,
model_call_id: &str,
) -> Result<Option<pb::AgentServerMessage>> {
use pb::interaction_update::Message;
let message = match event {
ModelEvent::TextDelta(text) => Message::TextDelta(pb::TextDeltaUpdate {
text: text.clone(),
is_server_notice: false,
}),
ModelEvent::ThinkingDelta(text) => Message::ThinkingDelta(pb::ThinkingDeltaUpdate {
text: text.clone(),
thinking_style: Some(pb::ThinkingStyle::Default as i32),
}),
ModelEvent::ToolCallStart { call_id, name, .. } => {
Message::PartialToolCall(pb::PartialToolCallUpdate {
call_id: call_id.clone(),
tool_call: Some(tool_placeholder(name, call_id)?),
args_text_delta: String::new(),
model_call_id: model_call_id.into(),
})
}
ModelEvent::ToolCallArgumentsDelta { .. } => return Ok(None),
ModelEvent::ToolCallEnd { .. }
| ModelEvent::Start { .. }
| ModelEvent::TextStart
| ModelEvent::TextEnd
| ModelEvent::ThinkingStart
| ModelEvent::ThinkingEnd
| ModelEvent::ProviderReplayState(_)
| ModelEvent::Usage(_)
| ModelEvent::Done(_) => return Ok(None),
};
Ok(Some(server_interaction(message)))
}
pub fn thinking_completed(elapsed: Duration) -> pb::AgentServerMessage {
let milliseconds = elapsed.as_millis().clamp(1, i32::MAX as u128) as i32;
server_interaction(pb::interaction_update::Message::ThinkingCompleted(
pb::ThinkingCompletedUpdate {
thinking_duration_ms: milliseconds,
},
))
}
pub fn arguments_delta(call: &ToolCall, delta: &str) -> Result<pb::AgentServerMessage> {
Ok(server_interaction(
pb::interaction_update::Message::PartialToolCall(pb::PartialToolCallUpdate {
call_id: call.call_id.clone(),
tool_call: Some(tool_placeholder(&call.name, &call.call_id)?),
args_text_delta: delta.into(),
model_call_id: call.model_call_id.clone(),
}),
))
}
pub fn turn_ended(usage: Option<Usage>) -> pb::AgentServerMessage {
server_interaction(pb::interaction_update::Message::TurnEnded(
pb::TurnEndedUpdate {
input_tokens: usage.and_then(|usage| usage.input_tokens.map(|value| value as i64)),
output_tokens: usage.and_then(|usage| usage.output_tokens.map(|value| value as i64)),
cache_read_tokens: usage
.and_then(|usage| usage.cache_read_tokens.map(|value| value as i64)),
cache_write_tokens: usage
.and_then(|usage| usage.cache_write_tokens.map(|value| value as i64)),
reasoning_tokens: usage
.and_then(|usage| usage.reasoning_tokens.map(|value| value as i64)),
},
))
}
pub fn token_delta(tokens: u64) -> pb::AgentServerMessage {
server_interaction(pb::interaction_update::Message::TokenDelta(
pb::TokenDeltaUpdate {
tokens: tokens.min(i32::MAX as u64) as i32,
},
))
}
pub fn server_interaction(message: pb::interaction_update::Message) -> pb::AgentServerMessage {
pb::AgentServerMessage {
ttft_breakdown: None,
message: Some(pb::agent_server_message::Message::InteractionUpdate(
pb::InteractionUpdate {
message: Some(message),
},
)),
}
}
@@ -1,205 +0,0 @@
use serde_json::Value;
use crate::{cursor::proto::agent::v1 as pb, model::ToolCall, Error, Result};
pub fn tool_query(id: u32, call: &ToolCall) -> Result<pb::AgentServerMessage> {
use pb::interaction_query::Query;
let string = |name: &str| {
call.arguments
.get(name)
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name)))
};
let optional_string = |name: &str| {
call.arguments
.get(name)
.and_then(Value::as_str)
.map(str::to_string)
};
let query = match normalized(&call.name).as_str() {
"askquestion" => {
let questions = call
.arguments
.get("questions")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(|question| -> Result<_> {
let required = |name: &str| {
question
.get(name)
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| Error::Protocol(format!("question is missing {name}")))
};
let options = question
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(|option| -> Result<_> {
let value = |name: &str| {
option
.get(name)
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| {
Error::Protocol(format!(
"question option is missing {name}"
))
})
};
Ok(pb::ask_question_args::Option {
id: value("id")?,
label: value("label")?,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(pb::ask_question_args::Question {
id: required("id")?,
prompt: required("prompt")?,
options,
allow_multiple: question
.get("allow_multiple")
.and_then(Value::as_bool)
.unwrap_or(false),
})
})
.collect::<Result<Vec<_>>>()?;
Query::AskQuestionInteractionQuery(pb::AskQuestionInteractionQuery {
args: Some(pb::AskQuestionArgs {
title: optional_string("title").unwrap_or_default(),
questions,
run_async: false,
async_original_tool_call_id: String::new(),
}),
tool_call_id: call.call_id.clone(),
})
}
"websearch" => Query::WebSearchRequestQuery(pb::WebSearchRequestQuery {
args: Some(pb::WebSearchArgs {
search_term: string("search_term")?,
tool_call_id: call.call_id.clone(),
}),
}),
"webfetch" => Query::WebFetchRequestQuery(pb::WebFetchRequestQuery {
args: Some(pb::WebFetchArgs {
url: string("url")?,
tool_call_id: call.call_id.clone(),
}),
skip_approval: false,
smart_mode_approval: smart_mode_approval(
call,
"requestSmartModeApproval",
"smartModeBlockReason",
)?,
}),
"switchmode" => Query::SwitchModeRequestQuery(pb::SwitchModeRequestQuery {
args: Some(pb::SwitchModeArgs {
target_mode_id: string("target_mode_id")?,
explanation: optional_string("explanation"),
tool_call_id: call.call_id.clone(),
}),
}),
"createplan" => {
let todos = call
.arguments
.get("todos")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(|todo| pb::TodoItem {
id: todo
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.into(),
content: todo
.get("content")
.and_then(Value::as_str)
.unwrap_or_default()
.into(),
status: pb::TodoStatus::Pending as i32,
created_at: 0,
updated_at: 0,
dependencies: Vec::new(),
})
.collect();
Query::CreatePlanRequestQuery(pb::CreatePlanRequestQuery {
args: Some(pb::CreatePlanArgs {
plan: string("plan")?,
todos,
overview: string("overview")?,
name: string("name")?,
is_project: false,
phases: Vec::new(),
}),
tool_call_id: call.call_id.clone(),
})
}
"generateimage" => Query::GenerateImageRequestQuery(pb::GenerateImageRequestQuery {
args: Some(pb::GenerateImageArgs {
description: string("description")?,
file_path: optional_string("filename"),
reference_image_paths: call
.arguments
.get("reference_image_paths")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_string)
.collect(),
aspect_ratio: optional_string("aspect_ratio"),
}),
tool_call_id: call.call_id.clone(),
}),
other => {
return Err(Error::Protocol(format!(
"tool {other} is not an InteractionQuery"
)))
}
};
Ok(pb::AgentServerMessage {
ttft_breakdown: None,
message: Some(pb::agent_server_message::Message::InteractionQuery(
pb::InteractionQuery {
id,
query: Some(query),
},
)),
})
}
fn smart_mode_approval(
call: &ToolCall,
request_field: &str,
reason_field: &str,
) -> Result<Option<pb::SmartModeApproval>> {
if !call
.arguments
.get(request_field)
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Ok(None);
}
let reason = call
.arguments
.get(reason_field)
.and_then(Value::as_str)
.ok_or_else(|| Error::Protocol(format!("{} requires {reason_field}", call.name)))?;
Ok(Some(pb::SmartModeApproval {
request_id: call.call_id.clone(),
reason: reason.to_string(),
}))
}
fn normalized(value: &str) -> String {
value
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
}
@@ -1,392 +0,0 @@
use serde_json::Value;
use crate::{
cursor::{
proto::agent::v1 as pb,
tools::{
codec, edit,
result::{self as tool_result, ToolCompletion},
},
},
model::ToolCall,
Error, Result,
};
use super::server_interaction;
pub(crate) fn edit_path_partial(call: &ToolCall, path: &str) -> pb::AgentServerMessage {
server_interaction(pb::interaction_update::Message::PartialToolCall(
pb::PartialToolCallUpdate {
call_id: call.call_id.clone(),
tool_call: Some(pb::ToolCall {
hook_additional_contexts: Vec::new(),
tool_call_id: Some(call.call_id.clone()),
started_at_ms: None,
completed_at_ms: None,
tool: Some(pb::tool_call::Tool::EditToolCall(pb::EditToolCall {
args: Some(pb::EditArgs {
path: path.into(),
stream_content: None,
}),
result: None,
})),
}),
args_text_delta: String::new(),
model_call_id: call.model_call_id.clone(),
},
))
}
pub(crate) fn edit_content_delta(call: &ToolCall, content: String) -> pb::AgentServerMessage {
server_interaction(pb::interaction_update::Message::ToolCallDelta(Box::new(
pb::ToolCallDeltaUpdate {
call_id: call.call_id.clone(),
tool_call_delta: Some(Box::new(pb::ToolCallDelta {
delta: Some(pb::tool_call_delta::Delta::EditToolCallDelta(
pb::EditToolCallDelta {
stream_content_delta: content,
},
)),
})),
model_call_id: call.model_call_id.clone(),
},
)))
}
pub fn tool_started(call: &ToolCall) -> Result<pb::AgentServerMessage> {
Ok(server_interaction(
pb::interaction_update::Message::ToolCallStarted(pb::ToolCallStartedUpdate {
call_id: call.call_id.clone(),
tool_call: Some(render_tool_call(call, false)?),
model_call_id: call.model_call_id.clone(),
}),
))
}
pub fn tool_completed(call: &ToolCall, completion: &ToolCompletion) -> pb::AgentServerMessage {
server_interaction(pb::interaction_update::Message::ToolCallCompleted(
pb::ToolCallCompletedUpdate {
call_id: call.call_id.clone(),
tool_call: Some(completion.tool_call().clone()),
model_call_id: call.model_call_id.clone(),
},
))
}
pub fn tool_placeholder(name: &str, call_id: &str) -> Result<pb::ToolCall> {
use pb::tool_call::Tool;
let tool = match normalized(name).as_str() {
"shell" => Tool::ShellToolCall(pb::ShellToolCall::default()),
"delete" => Tool::DeleteToolCall(pb::DeleteToolCall::default()),
"glob" => Tool::GlobToolCall(pb::GlobToolCall::default()),
"grep" => Tool::GrepToolCall(pb::GrepToolCall::default()),
"read" => Tool::ReadToolCall(pb::ReadToolCall::default()),
"todowrite" => Tool::UpdateTodosToolCall(pb::UpdateTodosToolCall::default()),
"strreplace" | "editnotebook" | "write" => Tool::EditToolCall(pb::EditToolCall::default()),
"readlints" => Tool::ReadLintsToolCall(pb::ReadLintsToolCall::default()),
"callmcptool" => Tool::McpToolCall(pb::McpToolCall::default()),
"createplan" => Tool::CreatePlanToolCall(pb::CreatePlanToolCall::default()),
"websearch" => Tool::WebSearchToolCall(pb::WebSearchToolCall::default()),
"task" => Tool::TaskToolCall(pb::TaskToolCall::default()),
"fetchmcpresource" => Tool::ReadMcpResourceToolCall(pb::ReadMcpResourceToolCall::default()),
"askquestion" => Tool::AskQuestionToolCall(pb::AskQuestionToolCall::default()),
"webfetch" => Tool::WebFetchToolCall(pb::WebFetchToolCall::default()),
"switchmode" => Tool::SwitchModeToolCall(pb::SwitchModeToolCall::default()),
"generateimage" => Tool::GenerateImageToolCall(pb::GenerateImageToolCall::default()),
"updatecurrentstep" => {
Tool::CommunicateUpdateToolCall(pb::CommunicateUpdateToolCall::default())
}
"awaitshell" => Tool::AwaitToolCall(pb::AwaitToolCall::default()),
"getmcptools" => Tool::GetMcpToolsToolCall(pb::GetMcpToolsToolCall::default()),
_ => return Err(Error::Protocol(format!("unsupported tool: {name}"))),
};
Ok(pb::ToolCall {
hook_additional_contexts: Vec::new(),
tool_call_id: Some(call_id.into()),
started_at_ms: None,
completed_at_ms: None,
tool: Some(tool),
})
}
pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall> {
let mut output = tool_placeholder(&call.name, &call.call_id)?;
let timestamp = now_ms();
output.started_at_ms = Some(timestamp);
if completed {
output.completed_at_ms = Some(timestamp);
}
let string = |name: &str| {
call.arguments
.get(name)
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
};
let optional = |name: &str| {
call.arguments
.get(name)
.and_then(Value::as_str)
.map(str::to_string)
};
match output.tool.as_mut() {
Some(pb::tool_call::Tool::ShellToolCall(tool)) => {
tool.args = Some(pb::ShellArgs {
command: string("command"),
working_directory: optional("working_directory").unwrap_or_default(),
tool_call_id: call.call_id.clone(),
..Default::default()
})
}
Some(pb::tool_call::Tool::DeleteToolCall(tool)) => {
tool.args = Some(pb::DeleteArgs {
path: string("path"),
tool_call_id: call.call_id.clone(),
})
}
Some(pb::tool_call::Tool::GlobToolCall(tool)) => {
tool.args = Some(pb::GlobToolArgs {
target_directory: optional("target_directory"),
glob_pattern: string("glob_pattern"),
})
}
Some(pb::tool_call::Tool::GrepToolCall(tool)) => {
tool.args = Some(pb::GrepArgs {
pattern: string("pattern"),
path: optional("path"),
glob: optional("glob"),
output_mode: optional("output_mode"),
tool_call_id: call.call_id.clone(),
..Default::default()
})
}
Some(pb::tool_call::Tool::ReadToolCall(tool)) => {
tool.args = Some(pb::ReadToolArgs {
path: string("path"),
offset: call
.arguments
.get("offset")
.and_then(Value::as_i64)
.map(|value| value as i32),
limit: call
.arguments
.get("limit")
.and_then(Value::as_i64)
.map(|value| value as i32),
include_line_numbers: call
.arguments
.get("include_line_numbers")
.and_then(Value::as_bool),
})
}
Some(pb::tool_call::Tool::UpdateTodosToolCall(tool)) => {
tool.args = Some(pb::UpdateTodosArgs {
todos: tool_result::todo_items(&call.arguments),
merge: call
.arguments
.get("merge")
.and_then(Value::as_bool)
.unwrap_or(false),
})
}
Some(pb::tool_call::Tool::EditToolCall(tool)) => {
let stream_content = if normalized(&call.name) == "write" {
optional("contents").unwrap_or_default()
} else {
optional("new_string").unwrap_or_default()
};
tool.args = Some(pb::EditArgs {
path: if normalized(&call.name) == "editnotebook" {
string("target_notebook")
} else {
string("path")
},
stream_content: Some(edit::normalize_newlines(&stream_content)),
})
}
Some(pb::tool_call::Tool::ReadLintsToolCall(tool)) => {
tool.args = Some(pb::ReadLintsToolArgs {
paths: call
.arguments
.get("paths")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_string)
.collect(),
})
}
Some(pb::tool_call::Tool::McpToolCall(tool)) => {
tool.args = Some(pb::McpArgs {
name: optional("toolName").unwrap_or_default(),
args: call
.arguments
.get("arguments")
.and_then(Value::as_object)
.map(codec::json_object_to_prost)
.unwrap_or_default(),
tool_call_id: call.call_id.clone(),
tool_name: optional("toolName").unwrap_or_default(),
server_identifier: string("server"),
..Default::default()
})
}
Some(pb::tool_call::Tool::CreatePlanToolCall(tool)) => {
tool.args = Some(pb::CreatePlanArgs {
plan: string("plan"),
todos: tool_result::todo_items(&call.arguments),
overview: string("overview"),
name: string("name"),
is_project: false,
phases: Vec::new(),
})
}
Some(pb::tool_call::Tool::WebSearchToolCall(tool)) => {
tool.args = Some(pb::WebSearchArgs {
search_term: string("search_term"),
tool_call_id: call.call_id.clone(),
})
}
Some(pb::tool_call::Tool::TaskToolCall(tool)) => {
tool.args = Some(pb::TaskArgs {
description: string("description"),
prompt: string("prompt"),
subagent_type: Some(subagent_type(&string("subagent_type"))),
model: optional("model"),
resume: optional("resume"),
agent_id: None,
attachments: call
.arguments
.get("file_attachments")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_string)
.collect(),
mode: 0,
responding_to_message_ids: Vec::new(),
environment: execution_environment(optional("environment").as_deref()),
machine: None,
})
}
Some(pb::tool_call::Tool::ReadMcpResourceToolCall(tool)) => {
tool.args = Some(pb::ReadMcpResourceExecArgs {
server: string("server"),
uri: string("uri"),
download_path: optional("downloadPath"),
tool_call_id: call.call_id.clone(),
smart_mode_approval: None,
})
}
Some(pb::tool_call::Tool::WebFetchToolCall(tool)) => {
tool.args = Some(pb::WebFetchArgs {
url: string("url"),
tool_call_id: call.call_id.clone(),
})
}
Some(pb::tool_call::Tool::SwitchModeToolCall(tool)) => {
tool.args = Some(pb::SwitchModeArgs {
target_mode_id: string("target_mode_id"),
explanation: optional("explanation"),
tool_call_id: call.call_id.clone(),
})
}
Some(pb::tool_call::Tool::GenerateImageToolCall(tool)) => {
tool.args = Some(pb::GenerateImageArgs {
description: string("description"),
file_path: optional("filename"),
reference_image_paths: call
.arguments
.get("reference_image_paths")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_string)
.collect(),
aspect_ratio: optional("aspect_ratio"),
})
}
Some(pb::tool_call::Tool::CommunicateUpdateToolCall(tool)) => {
tool.args = Some(pb::CommunicateUpdateArgs {
current_step: optional("current_step"),
final_summary: optional("final_summary"),
completed_subtitle: optional("completed_subtitle"),
})
}
Some(pb::tool_call::Tool::WriteShellStdinToolCall(tool)) => {
tool.args = Some(pb::WriteShellStdinArgs {
shell_id: call
.arguments
.get("shell_id")
.and_then(Value::as_u64)
.unwrap_or_default() as u32,
chars: string("chars"),
})
}
Some(pb::tool_call::Tool::AwaitToolCall(tool)) => {
tool.args = Some(pb::AwaitArgs {
task_id: string("shell_id"),
block_until_ms: call
.arguments
.get("block_until_ms")
.and_then(Value::as_u64)
.map(|v| v as u32),
regex: optional("pattern"),
})
}
Some(pb::tool_call::Tool::GetMcpToolsToolCall(tool)) => {
tool.args = Some(pb::GetMcpToolsArgs {
server: optional("server"),
tool_name: optional("toolName"),
pattern: optional("pattern"),
tool_call_id: call.call_id.clone(),
})
}
_ => {}
}
Ok(output)
}
fn subagent_type(name: &str) -> pb::SubagentType {
use pb::subagent_type::Type;
let r#type = match name.to_ascii_lowercase().as_str() {
"" | "generalpurpose" => Type::Unspecified(pb::SubagentTypeUnspecified {}),
"explore" => Type::Explore(pb::SubagentTypeExplore {}),
"browser-use" | "browseruse" => Type::BrowserUse(pb::SubagentTypeBrowserUse {}),
"shell" => Type::Shell(pb::SubagentTypeShell {}),
"bash" => Type::Bash(pb::SubagentTypeBash {}),
"debug" => Type::Debug(pb::SubagentTypeDebug {}),
"cursor-guide" | "cursorguide" => Type::CursorGuide(pb::SubagentTypeCursorGuide {}),
"computer-use" | "computeruse" => Type::ComputerUse(pb::SubagentTypeComputerUse {}),
_ => Type::Custom(pb::SubagentTypeCustom { name: name.into() }),
};
pb::SubagentType {
r#type: Some(r#type),
}
}
fn execution_environment(value: Option<&str>) -> i32 {
match value {
Some("cloud") => pb::SubagentExecutionEnvironment::Cloud as i32,
Some("local") | None => pb::SubagentExecutionEnvironment::Local as i32,
Some(_) => pb::SubagentExecutionEnvironment::Unspecified as i32,
}
}
fn normalized(value: &str) -> String {
value
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
-269
View File
@@ -1,269 +0,0 @@
use crate::{Error, Result};
#[derive(Debug, PartialEq)]
pub(crate) enum StringFieldEvent {
Delta { name: String, text: String },
End { name: String },
}
#[derive(Default)]
pub(crate) struct JsonStringFields {
state: State,
key: String,
string: JsonString,
skipped: SkippedValue,
}
#[derive(Default)]
enum State {
#[default]
Object,
Key,
KeyString,
Colon,
Value,
ValueString,
SkipValue,
AfterValue,
Done,
}
impl JsonStringFields {
pub fn push(&mut self, input: &str) -> Result<Vec<StringFieldEvent>> {
let mut events = Vec::new();
for character in input.chars() {
self.consume(character, &mut events)?;
}
Ok(events)
}
fn consume(&mut self, character: char, events: &mut Vec<StringFieldEvent>) -> Result<()> {
match self.state {
State::Object => match character {
'{' => self.state = State::Key,
value if value.is_whitespace() => {}
_ => return Err(protocol("tool arguments must start with an object")),
},
State::Key => match character {
'"' => {
self.key.clear();
self.string.clear();
self.state = State::KeyString;
}
'}' => self.state = State::Done,
value if value.is_whitespace() => {}
_ => return Err(protocol("expected a tool argument name")),
},
State::KeyString => match self.string.push(character)? {
StringStep::Text(text) => self.key.push_str(&text),
StringStep::End => self.state = State::Colon,
StringStep::Pending => {}
},
State::Colon => match character {
':' => self.state = State::Value,
value if value.is_whitespace() => {}
_ => return Err(protocol("expected ':' after tool argument name")),
},
State::Value => match character {
'"' => {
self.string.clear();
self.state = State::ValueString;
}
value if value.is_whitespace() => {}
value => {
self.skipped.start(value);
self.state = State::SkipValue;
}
},
State::ValueString => match self.string.push(character)? {
StringStep::Text(text) => push_delta(events, &self.key, text),
StringStep::End => {
events.push(StringFieldEvent::End {
name: self.key.clone(),
});
self.state = State::AfterValue;
}
StringStep::Pending => {}
},
State::SkipValue => {
if let Some(terminal) = self.skipped.push(character) {
self.state = match terminal {
',' => State::Key,
'}' => State::Done,
_ => return Err(protocol("invalid skipped JSON value terminator")),
};
}
}
State::AfterValue => match character {
',' => self.state = State::Key,
'}' => self.state = State::Done,
value if value.is_whitespace() => {}
_ => return Err(protocol("expected ',' after tool argument value")),
},
State::Done if character.is_whitespace() => {}
State::Done => return Err(protocol("data after tool arguments object")),
}
Ok(())
}
}
fn push_delta(events: &mut Vec<StringFieldEvent>, name: &str, text: String) {
if let Some(StringFieldEvent::Delta {
name: previous_name,
text: previous_text,
}) = events.last_mut()
{
if previous_name == name {
previous_text.push_str(&text);
return;
}
}
events.push(StringFieldEvent::Delta {
name: name.into(),
text,
});
}
#[derive(Default)]
struct JsonString {
escape: String,
}
enum StringStep {
Text(String),
End,
Pending,
}
impl JsonString {
fn clear(&mut self) {
self.escape.clear();
}
fn push(&mut self, character: char) -> Result<StringStep> {
if self.escape.is_empty() {
return match character {
'"' => Ok(StringStep::End),
'\\' => {
self.escape.push(character);
Ok(StringStep::Pending)
}
value if value < '\u{20}' => Err(protocol("control character in JSON string")),
value => Ok(StringStep::Text(value.to_string())),
};
}
self.escape.push(character);
let complete = match self.escape.as_bytes() {
[b'\\', b'u', a, b, c, d]
if [a, b, c, d].iter().all(|value| value.is_ascii_hexdigit()) =>
{
let code = u16::from_str_radix(&self.escape[2..], 16)
.map_err(|_| protocol("invalid JSON unicode escape"))?;
!(0xD800..=0xDBFF).contains(&code)
}
[b'\\', b'u', ..] if self.escape.len() < 6 => false,
[b'\\', b'u', a, b, c, d, b'\\', b'u', e, f, g, h]
if [a, b, c, d, e, f, g, h]
.iter()
.all(|value| value.is_ascii_hexdigit()) =>
{
true
}
[b'\\', b'u', ..] if self.escape.len() < 12 => false,
[b'\\', b'"' | b'\\' | b'/' | b'b' | b'f' | b'n' | b'r' | b't'] => true,
[b'\\'] => false,
_ => return Err(protocol("invalid JSON string escape")),
};
if !complete {
return Ok(StringStep::Pending);
}
let quoted = format!("\"{}\"", self.escape);
let decoded: String = serde_json::from_str(&quoted)
.map_err(|error| protocol(&format!("invalid JSON string escape: {error}")))?;
self.escape.clear();
Ok(StringStep::Text(decoded))
}
}
#[derive(Default)]
struct SkippedValue {
depth: usize,
string: bool,
escaped: bool,
}
impl SkippedValue {
fn start(&mut self, first: char) {
*self = Self::default();
self.observe(first);
}
fn push(&mut self, character: char) -> Option<char> {
if !self.string && self.depth == 0 && matches!(character, ',' | '}') {
return Some(character);
}
self.observe(character);
None
}
fn observe(&mut self, character: char) {
if self.string {
if self.escaped {
self.escaped = false;
} else if character == '\\' {
self.escaped = true;
} else if character == '"' {
self.string = false;
}
return;
}
match character {
'"' => self.string = true,
'{' | '[' => self.depth += 1,
'}' | ']' => self.depth = self.depth.saturating_sub(1),
_ => {}
}
}
}
fn protocol(message: &str) -> Error {
Error::Protocol(message.into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn streams_top_level_strings_and_decodes_split_escapes() {
let mut fields = JsonStringFields::default();
let mut events = fields
.push("{\"path\":\"/tmp/a\",\"count\":1,\"contents\":\"a\\n\\uD8")
.unwrap();
events.extend(fields.push("3D\\uDE00b\"}").unwrap());
assert_eq!(
events,
vec![
StringFieldEvent::Delta {
name: "path".into(),
text: "/tmp/a".into()
},
StringFieldEvent::End {
name: "path".into()
},
StringFieldEvent::Delta {
name: "contents".into(),
text: "a\n".into()
},
StringFieldEvent::Delta {
name: "contents".into(),
text: "😀b".into()
},
StringFieldEvent::End {
name: "contents".into()
},
]
);
}
}
-81
View File
@@ -1,81 +0,0 @@
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine};
use prost::Message;
use crate::{
cursor::CursorSessionHandle,
cursor::{
connect::{
encode_end_stream, encode_error_end_stream, ConnectCode, ConnectErrorDetail,
ConnectStreamError,
},
proto::aiserver::v1 as ai,
},
Error, Result,
};
pub fn finish_success(handle: &CursorSessionHandle) {
handle.emit_frame(encode_end_stream());
handle.close_output();
}
pub fn fail(handle: &CursorSessionHandle, error: &Error) -> Result<()> {
let stream_error = match error {
Error::Provider(_) | Error::Http(_) => provider_error(error),
Error::Protocol(_) | Error::Decode(_) | Error::Json(_) => {
plain_error(ConnectCode::InvalidArgument, error)
}
Error::RunNotFound(_) => plain_error(ConnectCode::NotFound, error),
Error::Cancelled => plain_error(ConnectCode::Canceled, error),
Error::Config(_)
| Error::Store(_)
| Error::Database(_)
| Error::Migration(_)
| Error::Encode(_)
| Error::Io(_) => plain_error(ConnectCode::Internal, error),
};
handle.emit_frame(encode_error_end_stream(&stream_error)?);
handle.close_output();
Ok(())
}
pub fn cancel(handle: &CursorSessionHandle) -> Result<()> {
handle.emit_frame(encode_error_end_stream(&ConnectStreamError {
code: ConnectCode::Canceled,
message: "run was cancelled".into(),
details: Vec::new(),
})?);
handle.close_output();
Ok(())
}
fn plain_error(code: ConnectCode, error: &Error) -> ConnectStreamError {
ConnectStreamError {
code,
message: error.to_string(),
details: Vec::new(),
}
}
fn provider_error(error: &Error) -> ConnectStreamError {
let detail = ai::ErrorDetails {
error: ai::error_details::Error::ProviderError as i32,
details: Some(ai::CustomErrorDetails {
title: "Server Error".into(),
detail: error.to_string(),
allow_command_links_potentially_unsafe_please_only_use_for_handwritten_trusted_markdown:
Some(true),
is_retryable: Some(true),
show_request_id: Some(true),
should_show_immediate_error: Some(false),
}),
is_expected: Some(false),
};
ConnectStreamError {
code: ConnectCode::Unavailable,
message: error.to_string(),
details: vec![ConnectErrorDetail {
type_name: "aiserver.v1.ErrorDetails".into(),
value: STANDARD_NO_PAD.encode(detail.encode_to_vec()),
}],
}
}
-26
View File
@@ -1,26 +0,0 @@
mod actor;
pub mod bidi_append;
pub mod blob_sync;
pub mod checkpoint;
pub mod connect;
pub mod handlers;
mod inbox;
pub mod interaction;
mod json_stream;
pub(crate) mod lifecycle;
mod model_catalog;
mod presentation;
mod projection;
pub mod prompting;
pub mod proto;
pub mod proxy;
pub mod request;
pub mod run_sse;
pub mod session;
pub mod sessions;
pub mod tools;
mod usage;
pub use command::CursorCommand;
pub use sessions::{CursorParent, CursorSessionHandle, CursorSessionRegistry};
mod command;

Some files were not shown because too many files have changed in this diff Show More