mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 03:33:43 +08:00
206 lines
6.4 KiB
TypeScript
206 lines
6.4 KiB
TypeScript
import WebSocket from 'ws'
|
|
import {
|
|
readFile,
|
|
writeFile,
|
|
editFile,
|
|
listDir,
|
|
statPath,
|
|
executeShell,
|
|
LocalToolError,
|
|
} from './localToolsExecutor'
|
|
import { requestApproval, clearApprovalCache } from './localToolsApproval'
|
|
|
|
// ─── Desktop → server local-tool tunnel (client side) ────────────────────────
|
|
// Opens a WebSocket to the backend's /api/v1/desktop/ws endpoint, advertises the
|
|
// local tool capabilities, and services "call" frames the server forwards when a
|
|
// cloud agent invokes a local_* tool. File/shell work runs through the executor
|
|
// (whitelist-enforced) and approval (native dialog) modules. Reconnects with
|
|
// backoff while the desktop is meant to be online.
|
|
|
|
const PROTOCOL_VERSION = 1
|
|
const CAPABILITIES = ['read', 'list', 'stat', 'write', 'edit', 'shell']
|
|
const RECONNECT_MIN_MS = 2000
|
|
const RECONNECT_MAX_MS = 30_000
|
|
|
|
type TokenProvider = () => Promise<string | null>
|
|
type UrlProvider = () => string
|
|
|
|
export class LocalBridge {
|
|
private ws: WebSocket | null = null
|
|
private shouldRun = false
|
|
private reconnectDelay = RECONNECT_MIN_MS
|
|
private reconnectTimer: NodeJS.Timeout | null = null
|
|
|
|
constructor(
|
|
private readonly getBackendUrl: UrlProvider,
|
|
private readonly getToken: TokenProvider
|
|
) {}
|
|
|
|
// Begin maintaining a connection. Safe to call repeatedly.
|
|
start(): void {
|
|
if (this.shouldRun) return
|
|
this.shouldRun = true
|
|
void this.connect()
|
|
}
|
|
|
|
// Tear down the tunnel and stop reconnecting (e.g. on logout or app quit).
|
|
stop(): void {
|
|
this.shouldRun = false
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer)
|
|
this.reconnectTimer = null
|
|
}
|
|
clearApprovalCache()
|
|
if (this.ws) {
|
|
try {
|
|
this.ws.close()
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
this.ws = null
|
|
}
|
|
}
|
|
|
|
isConnected(): boolean {
|
|
return this.ws?.readyState === WebSocket.OPEN
|
|
}
|
|
|
|
private buildWsUrl(token: string): string | null {
|
|
const base = this.getBackendUrl()
|
|
if (!base) return null
|
|
const wsBase = base.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:')
|
|
return `${wsBase}/api/v1/desktop/ws?token=${encodeURIComponent(token)}`
|
|
}
|
|
|
|
private async connect(): Promise<void> {
|
|
if (!this.shouldRun) return
|
|
|
|
const token = await this.getToken()
|
|
if (!token) {
|
|
// Not logged in yet — retry shortly without escalating backoff.
|
|
this.scheduleReconnect(RECONNECT_MIN_MS)
|
|
return
|
|
}
|
|
const url = this.buildWsUrl(token)
|
|
if (!url) {
|
|
this.scheduleReconnect(RECONNECT_MIN_MS)
|
|
return
|
|
}
|
|
|
|
console.log('[LocalBridge] Connecting tunnel…')
|
|
// rejectUnauthorized:false mirrors the app's handling of enterprise
|
|
// self-signed certificates for remote servers the user chose to trust.
|
|
const ws = new WebSocket(url, { rejectUnauthorized: false })
|
|
this.ws = ws
|
|
|
|
ws.on('open', () => {
|
|
console.log('[LocalBridge] Tunnel connected')
|
|
this.reconnectDelay = RECONNECT_MIN_MS
|
|
this.send({
|
|
type: 'hello',
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
capabilities: CAPABILITIES,
|
|
platform: process.platform,
|
|
})
|
|
})
|
|
|
|
ws.on('message', (raw: WebSocket.RawData) => {
|
|
void this.onMessage(raw.toString())
|
|
})
|
|
|
|
ws.on('close', () => {
|
|
console.log('[LocalBridge] Tunnel closed')
|
|
this.ws = null
|
|
if (this.shouldRun) this.scheduleReconnect(this.reconnectDelay)
|
|
})
|
|
|
|
ws.on('error', (err: Error) => {
|
|
console.warn('[LocalBridge] Tunnel error:', err.message)
|
|
// 'close' fires after 'error'; reconnect is scheduled there.
|
|
})
|
|
}
|
|
|
|
private scheduleReconnect(delay: number): void {
|
|
if (!this.shouldRun || this.reconnectTimer) return
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.reconnectTimer = null
|
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS)
|
|
void this.connect()
|
|
}, delay)
|
|
}
|
|
|
|
private send(obj: unknown): void {
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
this.ws.send(JSON.stringify(obj))
|
|
}
|
|
}
|
|
|
|
private async onMessage(text: string): Promise<void> {
|
|
let frame: any
|
|
try {
|
|
frame = JSON.parse(text)
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
if (frame.type === 'hello-ack') {
|
|
if (frame.ok === false) console.warn('[LocalBridge] Handshake rejected:', frame.error)
|
|
return
|
|
}
|
|
if (frame.type === 'pong') return
|
|
if (frame.type !== 'call') return
|
|
|
|
const { id, method, params } = frame
|
|
try {
|
|
const data = await this.dispatch(method, params || {})
|
|
this.send({ type: 'result', id, ok: true, data })
|
|
} catch (e) {
|
|
const code = e instanceof LocalToolError ? e.code : 'ERROR'
|
|
const error = e instanceof Error ? e.message : String(e)
|
|
this.send({ type: 'result', id, ok: false, code, error })
|
|
}
|
|
}
|
|
|
|
private async dispatch(method: string, params: any): Promise<unknown> {
|
|
switch (method) {
|
|
case 'read_file':
|
|
return readFile(params.filePath, params.startLine, params.endLine)
|
|
case 'list_dir':
|
|
return listDir(params.dirPath)
|
|
case 'stat':
|
|
return statPath(params.path)
|
|
case 'write_file': {
|
|
await this.approveOrThrow('write_file', params.filePath,
|
|
`文件: ${params.filePath}\n\n内容预览:\n${preview(params.content)}`)
|
|
return writeFile(params.filePath, params.content)
|
|
}
|
|
case 'edit_file': {
|
|
await this.approveOrThrow('edit_file', params.filePath,
|
|
`文件: ${params.filePath}\n\n替换:\n- ${preview(params.oldText, 200)}\n+ ${preview(params.newText, 200)}`)
|
|
return editFile(params.filePath, params.oldText, params.newText, !!params.replaceAll)
|
|
}
|
|
case 'execute_shell': {
|
|
await this.approveOrThrow('execute_shell', params.command,
|
|
`命令:\n${params.command}`)
|
|
return executeShell(params.command, params.timeoutSeconds || 60)
|
|
}
|
|
default:
|
|
throw new LocalToolError('UNKNOWN_METHOD', `Unknown method: ${method}`)
|
|
}
|
|
}
|
|
|
|
private async approveOrThrow(
|
|
kind: 'write_file' | 'edit_file' | 'execute_shell',
|
|
subject: string,
|
|
detail: string
|
|
): Promise<void> {
|
|
const { approved } = await requestApproval({ kind, subject, detail })
|
|
if (!approved) throw new LocalToolError('DENIED', 'User denied')
|
|
}
|
|
}
|
|
|
|
function preview(text: string | undefined, max = 500): string {
|
|
const s = text ?? ''
|
|
return s.length > max ? `${s.slice(0, max)}\n…(${s.length - max} more chars)` : s
|
|
}
|