import WebSocket from 'ws'; import { BaseWebSocketConnection } from '../base-websocket'; import type { EnvVarsProvider } from '../env-vars-filter'; import { BrowserSessionManager } from '../mcp/tools/browser/browser-session-manager'; /** * API Server → Agent メッセージ */ export interface VsCodeServerMessage { type: 'vscode_open' | 'vscode_close' | 'port_forward_open' | 'port_forward_close' | 'http_request' | 'ws_frame' | 'auth_success' | 'error' | 'browser_open' | 'browser_close' | 'browser_navigate' | 'browser_go_back' | 'browser_go_forward' | 'browser_reload' | 'browser_mouse_click' | 'browser_mouse_move' | 'browser_mouse_down' | 'browser_mouse_up' | 'browser_mouse_wheel' | 'browser_keyboard_type' | 'browser_keyboard_press' | 'browser_screenshot' | 'browser_viewport' | 'browser_execute_script' | 'browser_set_file' | 'browser_get_selection' | 'browser_set_input_value'; filePaths?: string[]; files?: Array<{ name: string; mimeType: string; dataBase64: string; }>; sessionId?: string; /** * Tenant code the API server attaches to `vscode_open`/`port_forward_open`/ * `browser_open` messages. When present, it is checked against this * connection's own (trusted, constructor-supplied) tenant code as a * defense-in-depth measure — see `validateTenantCode`. Optional and not * enforced when absent, for backward compatibility with callers that do * not yet send it. */ tenantCode?: string; requestId?: string; subSocketId?: string; projectDir?: string; targetPort?: number; method?: string; path?: string; headers?: Record; body?: string; data?: string; isOpen?: boolean; isClosed?: boolean; message?: string; conversationId?: string; url?: string; /** * Browser resume request. When `true` on a `browser_open`, the agent reuses an * existing live browser session for this `sessionId` and immediately re-sends * the current frame/URL/ready state instead of creating a fresh session. If no * live session exists, the agent replies `resume_failed` so the Web client can * fall back to a normal (non-resume) open. Absent/false → current new-session * behavior (backward compatible). */ resume?: boolean; x?: number; y?: number; button?: string; clickCount?: number; deltaX?: number; deltaY?: number; text?: string; key?: string; modifiers?: string[]; width?: number; height?: number; deviceId?: string; script?: string; value?: string; selectionStart?: number; selectionEnd?: number; } /** * `resume_failed` メッセージの `reason` が取り得る値。 * - `'not_found'`: 対象 sessionId のセッションが Map に存在しない * - `'dead'`: セッションは存在するが live ではない(事前チェック時、または * getOrCreate 後の TOCTOU 再確認でアイドルクローズ完了を検知した場合) */ export type ResumeFailureReason = 'not_found' | 'dead'; /** * Agent → API Server メッセージ */ export interface VsCodeAgentMessage { type: 'vscode_ready' | 'vscode_stopped' | 'port_forward_ready' | 'port_forward_stopped' | 'http_response' | 'ws_frame' | 'error' | 'browser_ready' | 'browser_frame' | 'browser_screenshot_result' | 'browser_selection_result' | 'browser_action_log' | 'browser_stopped' | 'browser_script_result' | 'browser_script_progress' | 'browser_file_chooser_opened' | 'browser_cursor_update' | 'browser_focus_changed' | 'resume_failed'; sessionId?: string; targetPort?: number; requestId?: string; subSocketId?: string; port?: number; projectDir?: string; statusCode?: number; headers?: Record; body?: string; bodyChunkIndex?: number; bodyChunkTotal?: number; data?: string; isOpen?: boolean; isClosed?: boolean; message?: string; conversationId?: string; currentUrl?: string; pageTitle?: string; text?: string; timestamp?: number; /** * 失敗・停止理由。`resume_failed` の場合は {@link ResumeFailureReason} * (`'not_found' | 'dead'`)に限定される。`browser_stopped` 等では * 任意のエラーメッセージ文字列が入るため型は `string` のまま。 */ reason?: string; entries?: Array<{ timestamp: number; source: string; action: string; details: string; }>; success?: boolean; completedSteps?: number; totalSteps?: number; results?: Array<{ line: string; success: boolean; error?: string; }>; failedLine?: string; fallbackToChat?: boolean; step?: number; line?: string; script?: string; /** CSS cursor value at the last mouse-move point (browser_cursor_update) */ cursor?: string; focused?: boolean; rect?: { x: number; y: number; width: number; height: number; }; value?: string; selectionStart?: number; selectionEnd?: number; multiline?: boolean; inputType?: string; maxLength?: number; fontSize?: number; lineHeight?: number; textAlign?: string; paddingTop?: number; paddingLeft?: number; caretColor?: string; } /** * VS Code トンネル WebSocket * * TerminalWebSocket と同じパターンで BaseWebSocketConnection を継承し、 * API Server とのトンネル WebSocket 経由で code-server へのアクセスを提供する。 * ブラウザライブビューメッセージも同じ WebSocket 接続で処理する。 */ export declare class VsCodeTunnelWebSocket extends BaseWebSocketConnection { private readonly token; private readonly agentId; /** * code-server の起動ディレクトリ(= reposDir = `/workspace/repos`)。 * VS Code はリポジトリ群のあるディレクトリで開く。 */ private readonly projectDir?; /** * ブラウザのファイルチューザーで選択されたワークスペース相対パスを解決する * ルート(= `/workspace`)。`projectDir`(reposDir)とは異なる * ディレクトリで、ファイルピッカーが一覧する基点と一致させる必要がある。 */ private readonly workspaceDir?; /** * code-server セッション起動時に最新の envVars を取り出す関数。 * Web 設定が agent プロセス起動後に到着するため関数渡しで遅延評価する。 */ private readonly envVarsProvider?; private readonly onAuthRejected?; /** * This connection's own, trusted tenant code (established out-of-band at * connection setup — e.g. from the agent's provisioning config), used as * the comparison baseline for `validateTenantCode`. Optional and placed * last so existing positional call sites are unaffected; when absent, * tenantCode validation is skipped entirely (no baseline to compare * against). */ private readonly tenantCode?; private readonly wsUrl; private vsCodeServer; /** * 起動中の vsCodeServer に注入した envVars の signature。 * 後続セッション要求時に envVars に変化があれば code-server を再起動する。 * sorted key=value を join したもの。空オブジェクトは ''。 */ private vsCodeServerEnvSignature; private wsProxy; private readonly portForwardSessions; readonly browserSessionManager: BrowserSessionManager; private browserLocalServer; private browserLocalPort; private readonly pendingFileChoosers; /** * Last CSS cursor value sent per browser session. Used to suppress redundant * browser_cursor_update messages: only send when the cursor shape changes. */ private readonly lastSentCursor; constructor(apiUrl: string, token: string, agentId: string, /** * code-server の起動ディレクトリ(= reposDir = `/workspace/repos`)。 * VS Code はリポジトリ群のあるディレクトリで開く。 */ projectDir?: string | undefined, /** * ブラウザのファイルチューザーで選択されたワークスペース相対パスを解決する * ルート(= `/workspace`)。`projectDir`(reposDir)とは異なる * ディレクトリで、ファイルピッカーが一覧する基点と一致させる必要がある。 */ workspaceDir?: string | undefined, /** * code-server セッション起動時に最新の envVars を取り出す関数。 * Web 設定が agent プロセス起動後に到着するため関数渡しで遅延評価する。 */ envVarsProvider?: EnvVarsProvider | undefined, onAuthRejected?: (() => void) | undefined, /** * This connection's own, trusted tenant code (established out-of-band at * connection setup — e.g. from the agent's provisioning config), used as * the comparison baseline for `validateTenantCode`. Optional and placed * last so existing positional call sites are unaffected; when absent, * tenantCode validation is skipped entirely (no baseline to compare * against). */ tenantCode?: string | undefined); /** * Get the port of the browser local HTTP server (0 if not started). */ getBrowserLocalPort(): number; protected createWebSocket(): WebSocket; /** Promise that resolves when the browser local server has started */ private browserLocalServerStartPromise; protected onOpen(_ws: WebSocket, resolve: (value: void) => void): void; /** * Wait for the browser local server to be ready and return its port. * Returns 0 if the server failed to start or is not initialized. */ waitForBrowserLocalPort(): Promise; protected onParsedMessage(msg: VsCodeServerMessage): void; protected onDisconnect(): void; /** * Server-side permanent authentication rejection (invalid token, or Agent ID * token-binding mismatch). Reconnecting to resume is not possible — the * connection will never be re-established with the same credentials — so * this is a genuine teardown just like an explicit disconnect: release * code-server, port-forward proxies, and browser sessions rather than * leaving them running indefinitely. */ protected onPermanentClose(): void; private handleVsCodeOpen; private handleVsCodeClose; private handleHttpRequest; /** * レスポンスをチャンク分割して送信する。 * ボディが HTTP_RESPONSE_CHUNK_SIZE 以下の場合は単一メッセージで送信する。 */ private sendHttpResponse; /** * レスポンスボディを HTTP_RESPONSE_CHUNK_SIZE ごとに分割して複数メッセージで送信する。 */ private sendChunkedHttpResponse; private handleWsFrame; private handlePortForwardOpen; private handlePortForwardClose; /** * Wire up browser session event listeners (action log, file chooser, focus * change) that relay in-process browser operations to the Web UI. Shared by * handleBrowserOpen (interactive browser_open) and openLiveViewSession * (E2E-dedicated session). */ private wireBrowserSessionListeners; /** * Start live-view frame streaming for a session and notify the API that it * is ready to receive browser_frame messages. Shared by handleBrowserOpen * (interactive browser_open) and openLiveViewSession (E2E-dedicated * session). This is what makes the Web live-view preview start receiving * frames — without it, the preview stays stuck on "starting" forever even * though a browser session exists. */ private startLiveViewAndNotifyReady; private handleBrowserOpen; /** * Start live-view streaming for a browser session without navigating * anywhere, wiring the same listeners and sending the same `browser_ready` * notification as an interactive `browser_open` (see handleBrowserOpen). * * Used by E2E test execution to make its dedicated browser session (created * via `browserSessionManager.getOrCreate` — see agent-transport.ts) start * relaying `browser_frame`/`browser_ready` to the Web live-view preview. * Without this, the E2E-dedicated session is only ever inserted into * BrowserSessionManager's Map and the Web preview stays stuck on "starting" * forever, since nothing ever calls session.startLiveView(...) or sends * browser_ready for it. * * Resume and conversationId linking are intentionally not handled here: * E2E execution always uses a fresh, dedicated session * (`e2e-${executionId}`) that is never resumed, and is not tied to a chat * conversation. * * On failure, a `browser_stopped` message is still sent (useful signal for * the Web UI), but the error is always re-thrown so callers (ultimately * agent-transport.ts's getOrCreateBrowserSession, then * e2e-test-executor.ts) see the rejection and can report it as a failed * execution instead of silently proceeding as if live view had started. */ openLiveViewSession(sessionId: string): Promise; private handleBrowserClose; private handleBrowserNavigate; private handleBrowserGoBack; private handleBrowserGoForward; private handleBrowserReload; private handleBrowserMouseClick; private handleBrowserMouseMove; private handleBrowserMouseDown; private handleBrowserMouseUp; private handleBrowserMouseWheel; private handleBrowserKeyboardType; private handleBrowserKeyboardPress; private handleBrowserScreenshot; private handleBrowserGetSelection; private handleBrowserSetInputValue; private handleBrowserExecuteScript; private handleBrowserViewport; private handleBrowserSetFile; /** * Branch 1: Agent FS paths chosen directly by the user via the workspace file * explorer. These arrive as workspace-relative paths (e.g. `repos/app.ts`) — * Playwright's setFiles requires absolute paths, so each is resolved against * the workspace root before being forwarded. Already absolute paths are * accepted as-is for backward compatibility, but every resolved path must * stay inside the workspace root (traversal + symlink-escape guard). */ private handleBrowserSetFileByPaths; /** * Branch 2: base64 file contents uploaded from the web client. * * Decodes each file into an in-memory Buffer and passes the buffer objects * directly to Playwright's FileChooser.setFiles(). Playwright supports the * {name, mimeType, buffer} payload form and sends file content to the browser * via CDP without writing anything to the agent file system. * * This avoids the race condition that exists when temp files are used: * setFiles() resolves as soon as the CDP command is acknowledged, but the * browser reads the file from disk *after* that point. Deleting temp files in * a finally block immediately after accept() resolves therefore causes the * browser to see a "file not found" error when it tries to read the file. */ private handleBrowserSetFileByContent; /** * Resolve user-chosen file-explorer paths into absolute paths suitable for * Playwright's `setFiles`, enforcing that every resolved path stays inside * the agent workspace root. * * Resolution rules per path: * - Absolute paths are kept as-is (backward compatibility for callers that * already send absolute agent-FS paths). * - Relative paths (e.g. `repos/app.ts`) are resolved against the workspace * directory (`this.workspaceDir` = `/workspace`). * * Traversal guard (lexical): after resolution, the absolute path must be the * workspace root itself or a descendant of it. `path.relative(workspaceDir, * resolved)` starting with `..` (or being absolute) means the path escaped the * workspace and is rejected. This rejects both relative escapes * (`../../etc/passwd`) and absolute paths pointing outside the workspace. * * Symlink-escape guard (physical): a path that is lexically inside the * workspace may still resolve, through a symlink, to a target OUTSIDE it * (e.g. a `link` inside the workspace pointing at `../../.ssh/id_rsa`). After * the lexical check, each existing path's real (canonical) location is * resolved via `fs.realpath` and re-checked against the workspace root. Paths * whose real location escapes the workspace are rejected. Non-existent paths * cannot be canonicalized; they are left as-is (Playwright's setFiles will * fail on them) rather than rejected here, preserving the lexical guarantee. * * Returns the resolved absolute paths on success, or an error message string * describing why the upload was rejected (never silently dropped). */ private resolveWorkspaceFilePaths; /** * Cancel a pending file chooser by applying an empty file list so the remote * `` is cleared rather than left pending. Failures are * swallowed (best-effort) since the user has already been told why the upload * was rejected. */ private cancelFileChooser; private cleanup; /** * Resolve a value from a sessionId-keyed lookup using a message's * sessionId, or undefined when sessionId is absent or unregistered. * Shared by `getSessionForMsg` (browserSessionManager) and the * port-forward session lookups (portForwardSessions) below. */ private resolveForMsg; /** * Resolve the BrowserSession for a message's sessionId, or undefined when * sessionId is absent or no session is registered for it. Shared by the * best-effort browser action handlers (goBack/mouseClick/keyboard/etc.) * that silently no-op rather than replying with a "session not found" error. */ private getSessionForMsg; /** * Resolve the port-forward session for a message's sessionId, or undefined * when sessionId is absent or no port-forward session is registered for it. */ private getPortForwardSessionForMsg; private sendMissingSessionIdError; /** * MEDIUM defense-in-depth: verify a message's `tenantCode` (when present) * against this connection's own trusted tenant code (`this.tenantCode`, * fixed at construction time). Per CLAUDE.md's WebSocket tenant-isolation * rule, `vscode_open`/`port_forward_open`/`browser_open` messages should * carry `tenantCode` so a mismatch (server bug, misrouted relay, or a * compromised path upstream) can be caught here rather than silently * acting for the wrong tenant. * * Intentionally NOT enforced (returns true) when either side is absent: * - `msgTenantCode` absent: backward compatible with callers/API versions * that do not yet send it. * - `this.tenantCode` absent: this connection has no established baseline * to compare against (nothing to validate). * Sends a `tenant mismatch` error and returns false on a genuine mismatch. */ private validateTenantCode; private sendBrowserSessionNotFoundError; /** * Walk up from `candidate` toward the filesystem root and return the * deepest path segment that actually exists on disk. * * Used for symlink-aware containment checks: `fs.realpath` on a path whose * leaf does not exist yet rejects outright, which would skip validation of * any intermediate symlink. Finding the deepest *existing* ancestor first * lets the caller canonicalize that instead — any symlink placed anywhere * along the path is necessarily part of an existing ancestor, so this * always surfaces it regardless of whether the final leaf exists. */ private findDeepestExistingAncestor; /** * Shared failure path for the best-effort browser action handlers * (goBack/mouseClick/keyboard/etc.): log a warning and forward an `error` * message to the web client using a consistent `" failed: "` * shape. Not used by handlers that intentionally suppress the client-facing * error (e.g. mouseMove, mouseWheel) — those log directly. */ private reportActionFailure; private send; } //# sourceMappingURL=vscode-tunnel-websocket.d.ts.map