diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8fbcb5f..b3723b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,6 +52,7 @@ jobs: - name: Run VS Code Extension Host tests env: + WEBCODE_EVAL_VSCODE_PATH: download VSCODE_TEST_VERSION: "1.106.1" run: xvfb-run -a pnpm test:extension diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3787644..50b9018 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -96,5 +96,6 @@ jobs: - name: Run Extension Host tests env: + WEBCODE_EVAL_VSCODE_PATH: download VSCODE_TEST_VERSION: ${{ steps.vscode-version.outputs.version }} run: xvfb-run -a pnpm test:extension diff --git a/.gitignore b/.gitignore index e4dbb46..d2e15c1 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,6 @@ skills-lock.json /evals/runs/ /evals/live-profiles/ /evals/manual-browser-profiles/ +/evals/manual-browser-extensions/ /bridge-browser/public/generated/ /evals/.playwright-cli/ diff --git a/bridge-browser/manifest.json b/bridge-browser/manifest.json index 8e8073a..46e61ff 100644 --- a/bridge-browser/manifest.json +++ b/bridge-browser/manifest.json @@ -1,5 +1,6 @@ { "manifest_version": 3, + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr/Dq8HZWC/iTQjLSY7aijxZFNROrBVft2FG9GNitQk8SP0cNXD6pJlC4SBGrCjz9g/27bPdz0gxhMKm6qZYWPtpRkQr3tACiQfvOh8IUaPmvlAnyHDM0BYrYv1x3k1N8vssjvJ4jDFsXZ9wRxgxOZxmdW3V63R8ZFu1UPQkdTKn2RSXnGpBMaQEd3lP1BHZlFCARiou+cfZVK/tX0GL08IHk/sitULt/gnZl6/t4vvrqA/yiDDV2SAEgpEKEAJfz+J3YELz+H6PtnN0HX55f7mHQOBepeFHyLoGGwM99T/1Nk9pvk64uU0oK1PTaSGR1WV5w6im36Emqg6GoaUPIaQIDAQAB", "minimum_chrome_version": "111", "name": "__MSG_extensionName__", "version": "1.0.1", diff --git a/bridge-browser/src/background/bridge_redemption.ts b/bridge-browser/src/background/bridge_redemption.ts new file mode 100644 index 0000000..48ae49e --- /dev/null +++ b/bridge-browser/src/background/bridge_redemption.ts @@ -0,0 +1,84 @@ +import { isRecord } from "../types"; + +export interface RedeemedBridgeSession { + bridgeProtocolVersion: number; + idleTimeoutMs: number; + siteId: string; + targetOrigin: string; + targetUrl: string; + token: string; + vscodeExtensionVersion: string; + workspaceId: string; +} + +interface BridgeSessionPayload extends Record { + bridgeProtocolVersion: number; + idleTimeoutMs: number; + siteId: string; + success: true; + targetOrigin: string; + targetUrl: string; + token: string; + vscodeExtensionVersion: string; + workspaceId: string; +} + +const REQUIRED_STRING_FIELDS = [ + "token", + "siteId", + "targetOrigin", + "targetUrl", + "vscodeExtensionVersion", + "workspaceId", +] as const; + +export function normalizeRedeemedBridgeSession(value: unknown): RedeemedBridgeSession | null { + if (!isRecord(value) || !isBridgeSessionPayload(value)) { + return null; + } + + let parsedTarget: URL; + try { + parsedTarget = new URL(value.targetUrl); + } catch { + return null; + } + if ( + (parsedTarget.protocol !== "https:" && parsedTarget.protocol !== "http:") || + parsedTarget.origin !== value.targetOrigin + ) { + return null; + } + + return { + bridgeProtocolVersion: value.bridgeProtocolVersion, + idleTimeoutMs: value.idleTimeoutMs, + siteId: value.siteId, + targetOrigin: value.targetOrigin, + targetUrl: parsedTarget.href, + token: value.token, + vscodeExtensionVersion: value.vscodeExtensionVersion, + workspaceId: value.workspaceId, + }; +} + +export function getBridgeRedemptionError(value: unknown): string { + return isRecord(value) && typeof value.error === "string" && value.error.trim() + ? value.error + : "Bridge code expired or already used. Launch the site again from VS Code."; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isBridgeSessionPayload(value: Record): value is BridgeSessionPayload { + return value.success === true && + REQUIRED_STRING_FIELDS.every((field) => isNonEmptyString(value[field])) && + typeof value.bridgeProtocolVersion === "number" && + Number.isInteger(value.bridgeProtocolVersion) && + value.bridgeProtocolVersion > 0 && + typeof value.idleTimeoutMs === "number" && + Number.isFinite(value.idleTimeoutMs) && + value.idleTimeoutMs > 0; +} diff --git a/bridge-browser/src/background/connection.ts b/bridge-browser/src/background/connection.ts index 59d6c6e..d937bd4 100644 --- a/bridge-browser/src/background/connection.ts +++ b/bridge-browser/src/background/connection.ts @@ -1,7 +1,12 @@ -import { BRANDING } from '@webcode/shared'; +import { BRANDING, BRIDGE_PROTOCOL_VERSION } from '@webcode/shared'; -import { type HandshakeResponse, isStoredSession, type MessageRequest } from '../types'; +import { type HandshakeMessageRequest, type HandshakeResponse, isStoredSession } from '../types'; import { updateBadge } from './badge'; +import { + getBridgeRedemptionError, + normalizeRedeemedBridgeSession, + type RedeemedBridgeSession, +} from './bridge_redemption'; import { fetchInitDataFromGateway } from './init_sync'; import { getSessionPresetSettings } from './presets'; import { clearSessionExpiryCheck, scheduleSessionExpiryCheck } from './session_health'; @@ -9,17 +14,23 @@ import { removeSession, saveSession } from './sessions'; interface HandshakeParams { port: number; - token: string; - siteId: string; + bridgeCode: string; + bridgeProtocolVersion: number; force?: boolean; - workspaceId: string; - targetOrigin: string; - targetUrl: string; vscodeExtensionVersion: string; browserExtensionVersion: string; } -export async function handleHandshake(request: MessageRequest, tabId: number | null | undefined): Promise { +type BridgeRedemptionResult = + | { success: true; session: RedeemedBridgeSession } + | { success: false; error: string }; + +const BRIDGE_REDEMPTION_TIMEOUT_MS = 5000; + +export async function handleHandshake( + request: HandshakeMessageRequest, + tabId: number | null | undefined +): Promise { const params = getHandshakeParams(request); if (!tabId) {return { success: false, error: "No Tab ID" };} @@ -42,15 +53,34 @@ export async function handleHandshake(request: MessageRequest, tabId: number | n } } } + + const redemption = await redeemBridgeCode( + params.port, + params.bridgeCode, + params.browserExtensionVersion, + params.bridgeProtocolVersion + ); + if (!redemption.success) { + return redemption; + } + const session = redemption.session; + if (session.vscodeExtensionVersion !== params.browserExtensionVersion) { + return { success: false, error: "VS Code and browser extension versions do not match." }; + } + if (session.bridgeProtocolVersion !== BRIDGE_PROTOCOL_VERSION) { + return { success: false, error: "VS Code and browser bridge protocol versions do not match." }; + } + await bindSession(tabId, { port: params.port, - token: params.token, - workspaceId: params.workspaceId, - siteId: params.siteId, - targetOrigin: params.targetOrigin, - targetUrl: params.targetUrl, + token: session.token, + workspaceId: session.workspaceId, + siteId: session.siteId, + targetOrigin: session.targetOrigin, + targetUrl: session.targetUrl, + idleTimeoutMs: session.idleTimeoutMs, }); - return { success: true }; + return { success: true, targetUrl: session.targetUrl }; } interface BindSessionOptions { @@ -60,6 +90,7 @@ interface BindSessionOptions { siteId: string; targetOrigin: string; targetUrl: string; + idleTimeoutMs: number; } export async function bindSession(tabId: number, options: BindSessionOptions) { @@ -73,12 +104,13 @@ export async function bindSession(tabId: number, options: BindSessionOptions) { autoApproveTools: presetSettings.defaultAutoApproveTools, workspaceId: options.workspaceId, lastGatewayActivityAt, + gatewayIdleTimeoutMs: options.idleTimeoutMs, siteId: options.siteId, targetOrigin: options.targetOrigin, targetUrl: options.targetUrl, }; await saveSession(tabId, session); - scheduleSessionExpiryCheck(tabId, lastGatewayActivityAt); + scheduleSessionExpiryCheck(tabId, lastGatewayActivityAt, options.idleTimeoutMs); console.log(`${BRANDING.logPrefix} Tab ${tabId} bound to Port ${options.port} [Workspace: ${options.workspaceId}]`); updateBadge(tabId, true); // [Sync] Notify Content Script @@ -98,13 +130,10 @@ function ignoreRuntimeError(_error: unknown): void { void chrome.runtime.lastError; } -function getHandshakeParams(request: MessageRequest): HandshakeParams | null { +function getHandshakeParams(request: HandshakeMessageRequest): HandshakeParams | null { if ( !isValidPort(request.port) || - !isNonEmptyString(request.token) || - !isNonEmptyString(request.siteId) || - !isNonEmptyString(request.targetOrigin) || - !isNonEmptyString(request.targetUrl) || + !isNonEmptyString(request.bridgeCode) || !isCompatibleExtensionVersions(request) ) { return null; @@ -112,37 +141,68 @@ function getHandshakeParams(request: MessageRequest): HandshakeParams | null { return { port: request.port, - token: request.token, - siteId: request.siteId, + bridgeCode: request.bridgeCode, + bridgeProtocolVersion: request.bridgeProtocolVersion, force: request.force, - workspaceId: request.workspaceId ?? 'global', - targetOrigin: request.targetOrigin, - targetUrl: request.targetUrl, vscodeExtensionVersion: request.vscodeExtensionVersion, browserExtensionVersion: request.browserExtensionVersion, }; } function isValidPort(value: unknown): value is number { - return typeof value === "number" && Number.isInteger(value) && value > 0; + return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535; } function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } -function isCompatibleExtensionVersions(request: MessageRequest): request is MessageRequest & { - vscodeExtensionVersion: string; - browserExtensionVersion: string; -} { +function isCompatibleExtensionVersions(request: HandshakeMessageRequest): boolean { const currentBrowserVersion = chrome.runtime.getManifest().version; return isNonEmptyString(request.vscodeExtensionVersion) && isNonEmptyString(request.browserExtensionVersion) && + request.bridgeProtocolVersion === BRIDGE_PROTOCOL_VERSION && request.vscodeExtensionVersion === currentBrowserVersion && request.browserExtensionVersion === currentBrowserVersion; } +async function redeemBridgeCode( + port: number, + bridgeCode: string, + browserExtensionVersion: string, + bridgeProtocolVersion: number +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), BRIDGE_REDEMPTION_TIMEOUT_MS); + + try { + const response = await fetch(`http://127.0.0.1:${port}/v1/bridge/redeem`, { + method: "POST", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ bridgeCode, browserExtensionVersion, bridgeProtocolVersion }), + signal: controller.signal, + }); + const body: unknown = await response.json().catch(() => null); + if (!response.ok) { + return { + success: false, + error: getBridgeRedemptionError(body), + }; + } + + const session = normalizeRedeemedBridgeSession(body); + return session + ? { success: true, session } + : { success: false, error: "Gateway returned an invalid bridge session." }; + } catch { + return { success: false, error: "Gateway bridge redemption failed." }; + } finally { + clearTimeout(timeoutId); + } +} + async function findConflictTabId(port: number, tabId: number): Promise { const all = await chrome.storage.local.get(null) as Record; for (const [key, val] of Object.entries(all)) { diff --git a/bridge-browser/src/background/gateway.ts b/bridge-browser/src/background/gateway.ts index 094682a..91a8250 100644 --- a/bridge-browser/src/background/gateway.ts +++ b/bridge-browser/src/background/gateway.ts @@ -1,6 +1,12 @@ import { PROTOCOL } from '@webcode/shared'; -import { isRecord, type MessageRequest, type ToolExecutionTransportPayload } from '../types'; +import { + type ApproveToolMessageRequest, + type ExecuteToolMessageRequest, + isRecord, + type PreflightToolMessageRequest, + type ToolExecutionTransportPayload, +} from '../types'; import { formatGatewayToolResultData, parseGatewayToolResult } from '../modules/tool_result'; import { getErrorMessage } from './errors'; import { expireGatewaySession, recordGatewayActivity } from './session_health'; @@ -21,7 +27,7 @@ export type ToolPreflightResponse = { }; export async function executeTool( - request: MessageRequest, + request: ExecuteToolMessageRequest, tabId: number | null | undefined, senderUrl?: string ) { @@ -72,7 +78,7 @@ export async function executeTool( } export async function preflightTool( - request: MessageRequest, + request: PreflightToolMessageRequest, tabId: number | null | undefined, senderUrl?: string ): Promise { @@ -118,7 +124,7 @@ export async function preflightTool( } export async function approveTool( - request: MessageRequest, + request: ApproveToolMessageRequest, tabId: number | null | undefined, senderUrl?: string ): Promise<{ success: boolean; approvalToken?: string; error?: string }> { @@ -215,7 +221,9 @@ function isCommandRisk(value: unknown): value is NonNullable typeof reason === "string"); } -function getToolPayload(request: MessageRequest): ToolExecutionTransportPayload | null { +function getToolPayload( + request: ExecuteToolMessageRequest | PreflightToolMessageRequest +): ToolExecutionTransportPayload | null { return request.payload && typeof request.payload.name === "string" ? request.payload : null; } diff --git a/bridge-browser/src/background/messages.ts b/bridge-browser/src/background/messages.ts index d427407..f56fd59 100644 --- a/bridge-browser/src/background/messages.ts +++ b/bridge-browser/src/background/messages.ts @@ -1,4 +1,19 @@ -import { isMessageRequest, type MessageRequest, type SessionDisconnectReason } from '../types'; +import { + type BackgroundActionRuntimeMessageRequest, + type BackgroundRuntimeMessageRequest, + type GetStatusMessageRequest, + type HandshakeMessageRequest, + isBackgroundRuntimeMessageRequest, + isMessageRequest, + isSettingsRuntimeMessageRequest, + type RequestUserAttentionMessageRequest, + type SessionDisconnectReason, + type SetAutoApproveToolsMessageRequest, + type SetAutoSendMessageRequest, + type SetDefaultAutoApproveToolsMessageRequest, + type SetLogVisibleMessageRequest, + type SettingsRuntimeMessageRequest, +} from '../types'; import { playAttentionSound } from './attention_sound'; import { handleHandshake } from './connection'; import { getErrorMessage } from './errors'; @@ -21,7 +36,7 @@ export function handleRuntimeMessage( sender: chrome.runtime.MessageSender, sendResponse: SendResponse ): boolean { - if (!isMessageRequest(request)) { + if (!isMessageRequest(request) || !isBackgroundRuntimeMessageRequest(request)) { return false; } @@ -29,20 +44,28 @@ export function handleRuntimeMessage( } function dispatchRuntimeMessage( - request: MessageRequest, + request: BackgroundRuntimeMessageRequest, sender: chrome.runtime.MessageSender, sendResponse: SendResponse ): boolean { const currentTabId = sender.tab ? sender.tab.id : null; - if (dispatchSettingsRuntimeMessage(request, currentTabId, sendResponse)) { - return true; + if (isSettingsRuntimeMessageRequest(request)) { + return dispatchSettingsRuntimeMessage(request, currentTabId, sendResponse); } + return dispatchActionRuntimeMessage(request, sender, currentTabId, sendResponse); +} + +function dispatchActionRuntimeMessage( + request: BackgroundActionRuntimeMessageRequest, + sender: chrome.runtime.MessageSender, + currentTabId: number | null | undefined, + sendResponse: SendResponse +): boolean { switch (request.type) { case "HANDSHAKE": - respondAsync(handleHandshake(request, currentTabId), sendResponse); - return true; + return handleHandshakeRuntimeMessage(request, sender, currentTabId, sendResponse); case "GET_STATUS": handleGetStatus(request, sender, sendResponse); return true; @@ -72,8 +95,42 @@ function dispatchRuntimeMessage( } } +function handleHandshakeRuntimeMessage( + request: HandshakeMessageRequest, + sender: chrome.runtime.MessageSender, + currentTabId: number | null | undefined, + sendResponse: SendResponse +): boolean { + if (!isValidHandshakeSender(request, sender)) { + sendResponse({ success: false, error: "Invalid handshake source" }); + return true; + } + + respondAsync(handleHandshake(request, currentTabId), sendResponse); + return true; +} + +function isValidHandshakeSender( + request: HandshakeMessageRequest, + sender: chrome.runtime.MessageSender +): boolean { + if (sender.id !== chrome.runtime.id || !sender.tab?.id || !sender.url || typeof request.port !== "number") { + return false; + } + + try { + const url = new URL(sender.url); + return url.protocol === "http:" && + (url.hostname === "127.0.0.1" || url.hostname === "localhost") && + url.pathname === "/bridge" && + Number(url.port) === request.port; + } catch { + return false; + } +} + function dispatchSettingsRuntimeMessage( - request: MessageRequest, + request: SettingsRuntimeMessageRequest, currentTabId: number | null | undefined, sendResponse: SendResponse ): boolean { @@ -96,7 +153,7 @@ function dispatchSettingsRuntimeMessage( } function handleGetStatus( - request: MessageRequest, + request: GetStatusMessageRequest, sender: chrome.runtime.MessageSender, sendResponse: SendResponse ): void { @@ -172,7 +229,7 @@ function getDisconnectReasonForHealthStatus(status: GatewayHealthStatus): Sessio } function handleSetLogVisible( - request: MessageRequest, + request: SetLogVisibleMessageRequest, currentTabId: number | null | undefined, sendResponse: SendResponse ): void { @@ -196,7 +253,7 @@ function handleSetLogVisible( } function handleSetAutoSend( - request: MessageRequest, + request: SetAutoSendMessageRequest, currentTabId: number | null | undefined, sendResponse: SendResponse ): void { @@ -225,7 +282,7 @@ function handleSetAutoSend( } function handleSetAutoApproveTools( - request: MessageRequest, + request: SetAutoApproveToolsMessageRequest, currentTabId: number | null | undefined, sendResponse: SendResponse ): void { @@ -259,7 +316,7 @@ function handleSetAutoApproveTools( } function handleSetDefaultAutoApproveTools( - request: MessageRequest, + request: SetDefaultAutoApproveToolsMessageRequest, sendResponse: SendResponse ): void { if (typeof request.defaultAutoApproveTools !== "boolean") { @@ -286,7 +343,7 @@ function handleSetDefaultAutoApproveTools( } async function requestUserAttention( - request: MessageRequest, + request: RequestUserAttentionMessageRequest, sender: chrome.runtime.MessageSender ): Promise<{ success: boolean; error?: string; skipped?: boolean; sound?: "played" | "failed"; soundError?: string }> { const attentionResult = await updateWindowAttention(sender, true); diff --git a/bridge-browser/src/background/notifications.ts b/bridge-browser/src/background/notifications.ts index 829e4a0..10a7cc0 100644 --- a/bridge-browser/src/background/notifications.ts +++ b/bridge-browser/src/background/notifications.ts @@ -1,6 +1,6 @@ import { BRANDING } from '@webcode/shared'; -import { type MessageRequest } from '../types'; +import { type ShowNotificationMessageRequest } from '../types'; import { getErrorMessage } from './errors'; const NOTIFICATION_ID_PREFIX = "webcode-tab"; @@ -32,7 +32,7 @@ export async function updateWindowAttention( } export async function showNotification( - request: MessageRequest, + request: ShowNotificationMessageRequest, sender: chrome.runtime.MessageSender ): Promise<{ success: boolean; skipped?: boolean; notificationId?: string; error?: string }> { try { @@ -76,7 +76,7 @@ async function shouldShowNotificationForSender(sender: chrome.runtime.MessageSen } function createNotification( - request: MessageRequest, + request: ShowNotificationMessageRequest, sender: chrome.runtime.MessageSender ): Promise { return new Promise((resolve, reject) => { diff --git a/bridge-browser/src/background/session_health.ts b/bridge-browser/src/background/session_health.ts index df001f8..e297f70 100644 --- a/bridge-browser/src/background/session_health.ts +++ b/bridge-browser/src/background/session_health.ts @@ -46,13 +46,21 @@ export async function checkGatewayHealth( export async function recordGatewayActivity(tabId: number): Promise { const session = await updateSessionGatewayActivity(tabId); if (session) { - scheduleSessionExpiryCheck(tabId, session.lastGatewayActivityAt); + scheduleSessionExpiryCheck( + tabId, + session.lastGatewayActivityAt, + session.gatewayIdleTimeoutMs + ); } } -export function scheduleSessionExpiryCheck(tabId: number, lastGatewayActivityAt?: number): void { +export function scheduleSessionExpiryCheck( + tabId: number, + lastGatewayActivityAt?: number, + gatewayIdleTimeoutMs?: number +): void { const activityAt = getValidTimestamp(lastGatewayActivityAt) ?? Date.now(); - const expiresAt = activityAt + GATEWAY_IDLE_TIMEOUT_MS + GATEWAY_IDLE_GRACE_MS; + const expiresAt = activityAt + getGatewayIdleTimeoutMs(gatewayIdleTimeoutMs) + GATEWAY_IDLE_GRACE_MS; const delayMs = Math.max(1000, expiresAt - Date.now()); void chrome.alarms.create(getSessionExpiryAlarmName(tabId), { @@ -92,7 +100,7 @@ export async function handleSessionExpiryAlarm(alarm: chrome.alarms.Alarm): Prom const expiresAt = getSessionExpiresAt(session); if (Date.now() < expiresAt) { - scheduleSessionExpiryCheck(tabId, session.lastGatewayActivityAt); + scheduleSessionExpiryCheck(tabId, session.lastGatewayActivityAt, session.gatewayIdleTimeoutMs); return; } @@ -109,7 +117,7 @@ export async function handleSessionExpiryAlarm(alarm: chrome.alarms.Alarm): Prom async function scheduleStoredSessionExpiryCheck(tabId: number): Promise { const session = await getCurrentProtocolSession(tabId); if (session) { - scheduleSessionExpiryCheck(tabId, session.lastGatewayActivityAt); + scheduleSessionExpiryCheck(tabId, session.lastGatewayActivityAt, session.gatewayIdleTimeoutMs); } } @@ -121,7 +129,7 @@ function rescheduleSessionExpiryRecheck(tabId: number): void { function getSessionExpiresAt(session: CurrentProtocolSession): number { const activityAt = getValidTimestamp(session.lastGatewayActivityAt) ?? Date.now(); - return activityAt + GATEWAY_IDLE_TIMEOUT_MS + GATEWAY_IDLE_GRACE_MS; + return activityAt + getGatewayIdleTimeoutMs(session.gatewayIdleTimeoutMs) + GATEWAY_IDLE_GRACE_MS; } function getSessionExpiryAlarmName(tabId: number): string { @@ -144,3 +152,9 @@ function getDisconnectReasonForHealthStatus(status: GatewayHealthStatus): Sessio function getValidTimestamp(value: number | undefined): number | null { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; } + +function getGatewayIdleTimeoutMs(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? value + : GATEWAY_IDLE_TIMEOUT_MS; +} diff --git a/bridge-browser/src/content/bridge_loader.ts b/bridge-browser/src/content/bridge_loader.ts index e7f32b0..e870ae5 100644 --- a/bridge-browser/src/content/bridge_loader.ts +++ b/bridge-browser/src/content/bridge_loader.ts @@ -1,4 +1,4 @@ -import { BRANDING } from '@webcode/shared'; +import { BRANDING, BRIDGE_PROTOCOL_VERSION } from '@webcode/shared'; import { type HandshakeResponse } from '../types'; type BridgeLoaderI18n = { @@ -9,7 +9,9 @@ type BridgeLoaderI18n = { connectionConflictTitle: string; connectionConflictBody: (port: number) => string; versionMismatchTitle: string; - versionMismatchBody: (vscodeVersion: string, browserVersion: string) => string; + browserBridgeOutdatedBody: (vscodeVersion: string, browserVersion: string) => string; + vscodeExtensionOutdatedBody: (vscodeVersion: string, browserVersion: string) => string; + protocolMismatchBody: (gatewayProtocol: string, browserProtocol: number) => string; connectHere: string; switchingConnection: string; connectionFailed: (message: string) => string; @@ -30,19 +32,22 @@ type ReadyHandshakeElements = { type HandshakeParams = { port: number; - token: string; - target: string; - siteId: string; - targetOrigin?: string; + bridgeCode: string; + bridgeProtocolVersion: number; vscodeExtensionVersion: string; browserExtensionVersion: string; - workspaceId: string; }; type ReadHandshakeParamsResult = | { status: "ready"; params: HandshakeParams } | { status: "invalid" } - | { status: "version-mismatch"; vscodeExtensionVersion: string; browserExtensionVersion: string }; + | { + status: "version-mismatch"; + reason: "browser-outdated" | "vscode-outdated" | "protocol-mismatch"; + vscodeExtensionVersion: string; + browserExtensionVersion: string; + gatewayProtocolVersion?: number; + }; const I18N: Record<"en" | "zh", BridgeLoaderI18n> = { en: { @@ -54,8 +59,12 @@ const I18N: Record<"en" | "zh", BridgeLoaderI18n> = { connectionConflictBody: (port: number) => `VS Code (Port ${port}) is already connected to another tab.
Do you want to switch the connection here?`, versionMismatchTitle: "Version Mismatch", - versionMismatchBody: (vscodeVersion: string, browserVersion: string) => - `VS Code extension version: ${vscodeVersion}
Browser extension version: ${browserVersion}
Please update both extensions to the same version, then reconnect.`, + browserBridgeOutdatedBody: (vscodeVersion: string, browserVersion: string) => + `VS Code extension: ${vscodeVersion}
Browser bridge: ${browserVersion}
The browser bridge is older. For an isolated browser, return to VS Code and restart it; otherwise update the browser extension.`, + vscodeExtensionOutdatedBody: (vscodeVersion: string, browserVersion: string) => + `VS Code extension: ${vscodeVersion}
Browser bridge: ${browserVersion}
Update or reload the VS Code extension, then reconnect.`, + protocolMismatchBody: (gatewayProtocol: string, browserProtocol: number) => + `VS Code bridge protocol: ${gatewayProtocol}
Browser bridge protocol: ${browserProtocol}
Update the older extension. For an isolated browser, restart it from VS Code.`, connectHere: "Yes, Connect Here", switchingConnection: "Switching connection...", connectionFailed: (message: string) => `Connection Failed: ${message}`, @@ -69,8 +78,12 @@ const I18N: Record<"en" | "zh", BridgeLoaderI18n> = { connectionConflictTitle: "⚠️ 连接冲突", connectionConflictBody: (port: number) => `VS Code(端口 ${port})当前已连接到另一个标签页。
要切换到这个页面吗?`, versionMismatchTitle: "版本不一致", - versionMismatchBody: (vscodeVersion: string, browserVersion: string) => - `VS Code 扩展版本:${vscodeVersion}
浏览器扩展版本:${browserVersion}
请将两个扩展升级到相同版本后重新连接。`, + browserBridgeOutdatedBody: (vscodeVersion: string, browserVersion: string) => + `VS Code 扩展:${vscodeVersion}
浏览器桥接:${browserVersion}
浏览器桥接版本较旧。如果使用隔离浏览器,请回到 VS Code 重启隔离浏览器;否则请更新浏览器扩展。`, + vscodeExtensionOutdatedBody: (vscodeVersion: string, browserVersion: string) => + `VS Code 扩展:${vscodeVersion}
浏览器桥接:${browserVersion}
请更新或重新加载 VS Code 扩展,然后重新连接。`, + protocolMismatchBody: (gatewayProtocol: string, browserProtocol: number) => + `VS Code 桥接协议:${gatewayProtocol}
浏览器桥接协议:${browserProtocol}
请更新较旧的一端;隔离浏览器请从 VS Code 中重启。`, connectHere: "是的,连接到这里", switchingConnection: "正在切换连接...", connectionFailed: (message: string) => `连接失败:${message}`, @@ -116,28 +129,45 @@ function getHandshakeElements(): HandshakeElements { function readHandshakeParams(): ReadHandshakeParamsResult { const params = new URLSearchParams(window.location.search); const bridgeData = readBridgeData(); - const token = bridgeData.token; - const target = bridgeData.target ?? params.get("target"); - const siteId = bridgeData.siteId ?? params.get("siteId"); - const vscodeExtensionVersion = bridgeData.vscodeExtensionVersion; + const bridgeCode = params.get("bridgeCode"); + const vscodeExtensionVersion = bridgeData.currentVscodeExtensionVersion ?? bridgeData.vscodeExtensionVersion; + const gatewayProtocolVersion = bridgeData.bridgeProtocolVersion; const browserExtensionVersion = chrome.runtime.getManifest().version; const portStr = window.location.port; - if (!token || !target || !siteId || !portStr) { + if (bridgeCode) { + stripBridgeCodeFromAddressBar(); + } + + if (!bridgeCode || !portStr) { return { status: "invalid" }; } if (!vscodeExtensionVersion) { return { status: "version-mismatch", + reason: "protocol-mismatch", vscodeExtensionVersion: "unknown", browserExtensionVersion, }; } + if (gatewayProtocolVersion !== BRIDGE_PROTOCOL_VERSION) { + return { + status: "version-mismatch", + reason: "protocol-mismatch", + vscodeExtensionVersion, + browserExtensionVersion, + gatewayProtocolVersion, + }; + } + if (vscodeExtensionVersion !== browserExtensionVersion) { return { status: "version-mismatch", + reason: compareVersions(vscodeExtensionVersion, browserExtensionVersion) > 0 + ? "browser-outdated" + : "vscode-outdated", vscodeExtensionVersion, browserExtensionVersion, }; @@ -147,23 +177,16 @@ function readHandshakeParams(): ReadHandshakeParamsResult { status: "ready", params: { port: Number.parseInt(portStr, 10), - token, - target, - siteId, - targetOrigin: readTargetOrigin(target), + bridgeCode, + bridgeProtocolVersion: gatewayProtocolVersion, vscodeExtensionVersion, browserExtensionVersion, - workspaceId: bridgeData.workspaceId ?? "global", }, }; } -function readTargetOrigin(target: string): string | undefined { - try { - return new URL(target).origin; - } catch { - return undefined; - } +function stripBridgeCodeFromAddressBar(): void { + window.history.replaceState(null, document.title, window.location.pathname); } function attemptHandshake(params: HandshakeParams, elements: HandshakeElements, force = false): void { @@ -171,13 +194,10 @@ function attemptHandshake(params: HandshakeParams, elements: HandshakeElements, { type: "HANDSHAKE", port: params.port, - token: params.token, - siteId: params.siteId, - targetOrigin: params.targetOrigin, - targetUrl: params.target, + bridgeCode: params.bridgeCode, + bridgeProtocolVersion: params.bridgeProtocolVersion, vscodeExtensionVersion: params.vscodeExtensionVersion, browserExtensionVersion: params.browserExtensionVersion, - workspaceId: params.workspaceId, force, }, (response: HandshakeResponse) => { @@ -198,10 +218,7 @@ function showVersionMismatch( elements.loader.style.display = "none"; elements.statusText.innerHTML = ` ${i18n.versionMismatchTitle}
- ${i18n.versionMismatchBody( - result.vscodeExtensionVersion, - result.browserExtensionVersion - )} + ${getVersionMismatchBody(result)} `; } @@ -220,7 +237,11 @@ function handleHandshakeResponse( } if (response?.success) { - showConnected(params.target, elements); + if (!response.targetUrl) { + showConnectionFailed({ success: false, error: i18n.unknownError }, elements); + return; + } + showConnected(response.targetUrl, elements); } else if (response?.error === "BUSY") { showConnectionConflict(params, elements); } else { @@ -300,7 +321,41 @@ function showConnectionFailed(response: HandshakeResponse, elements: ReadyHandsh elements.statusText.style.color = "#ff6b6b"; } -function readBridgeData(): Partial> { +function getVersionMismatchBody( + result: Extract +): string { + if (result.reason === "browser-outdated") { + return i18n.browserBridgeOutdatedBody(result.vscodeExtensionVersion, result.browserExtensionVersion); + } + if (result.reason === "vscode-outdated") { + return i18n.vscodeExtensionOutdatedBody(result.vscodeExtensionVersion, result.browserExtensionVersion); + } + return i18n.protocolMismatchBody( + result.gatewayProtocolVersion?.toString() ?? "unknown", + BRIDGE_PROTOCOL_VERSION + ); +} + +function compareVersions(left: string, right: string): number { + const leftParts = parseNumericVersion(left); + const rightParts = parseNumericVersion(right); + if (!leftParts || !rightParts) { + return 0; + } + for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) { + const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (difference !== 0) { + return difference; + } + } + return 0; +} + +function parseNumericVersion(version: string): number[] | null { + return /^\d+(?:\.\d+){1,3}$/.test(version) ? version.split(".").map(Number) : null; +} + +function readBridgeData(): Partial & { currentVscodeExtensionVersion?: string } { const dataEl = document.getElementById("mcp-data"); const rawData = dataEl?.textContent ?? ""; try { @@ -310,11 +365,9 @@ function readBridgeData(): Partial, key: string): strin const value = data[key]; return typeof value === "string" && value ? value : undefined; } + +function readBridgeDataNumber(data: Record, key: string): number | undefined { + const value = data[key]; + return typeof value === "number" && Number.isInteger(value) ? value : undefined; +} diff --git a/bridge-browser/src/content/main.ts b/bridge-browser/src/content/main.ts index c53b466..aa636c4 100644 --- a/bridge-browser/src/content/main.ts +++ b/bridge-browser/src/content/main.ts @@ -6,7 +6,7 @@ import { looksLikeToolCall, parseToolCall } from "../modules/toolCallProtocol"; import { BRANDING, PROTOCOL } from "@webcode/shared"; import { getSiteNetworkCaptureConfig, getSyncedAiSites, isMessageRequest, isSiteSelectors, isStatusResponse, - type MessageRequest, type SyncedAiSite, + type StatusUpdateMessageRequest, type SyncedAiSite, } from "../types"; import { AutoInitPromptController } from "./auto_init_prompt"; import { createApprovalState, parseStoredApprovalEntries, type ApprovalState } from "./approval_policy"; @@ -90,7 +90,7 @@ function handleRuntimeMessage(request: unknown, sendResponse: RuntimeSendRespons } } -function handleStatusUpdate(request: MessageRequest): void { +function handleStatusUpdate(request: StatusUpdateMessageRequest): void { const wasConnected = isClientConnected; const wasWorkspaceId = currentWorkspaceId; const wasSiteId = currentSiteId; @@ -114,7 +114,7 @@ function handleStatusUpdate(request: MessageRequest): void { } } -function applyStatusUpdateFields(request: MessageRequest): void { +function applyStatusUpdateFields(request: StatusUpdateMessageRequest): void { isClientConnected = request.connected === true; if (typeof request.workspaceId === "string") {currentWorkspaceId = request.workspaceId;} if (typeof request.siteId === "string") {currentSiteId = request.siteId;} diff --git a/bridge-browser/src/modules/approval_modal.ts b/bridge-browser/src/modules/approval_modal.ts index 6ab1dab..bdaa55e 100644 --- a/bridge-browser/src/modules/approval_modal.ts +++ b/bridge-browser/src/modules/approval_modal.ts @@ -317,15 +317,25 @@ function bindModalActions( mandatory: boolean ): void { const state: ModalState = { rejectStep: 0 }; - elements.btnConfirm.onclick = () => confirmScope(false, callbacks, closeModal); - elements.btnAlways.onclick = () => showAlwaysApprovalView(elements, details.isCommandScopedApproval); - elements.btnConfirmAlways.onclick = () => confirmScope("exact", callbacks, closeModal); + elements.btnConfirm.onclick = (event) => confirmTrustedScope(event, false, callbacks, closeModal); + elements.btnAlways.onclick = (event) => { + if (event.isTrusted) {showAlwaysApprovalView(elements, details.isCommandScopedApproval);} + }; + elements.btnConfirmAlways.onclick = (event) => confirmTrustedScope(event, "exact", callbacks, closeModal); bindScopeButton(elements.btnAllowExact, "exact", callbacks, closeModal); bindScopeButton(elements.btnAllowExecutable, "executable", callbacks, closeModal, details.executableKey); bindScopeButton(elements.btnAllowPrefix, "prefix", callbacks, closeModal, details.prefixKey); bindRejectFlow(elements, state, callbacks, closeModal); bindBackButton(elements, state, mandatory); - bindReasonInput(elements); +} + +function confirmTrustedScope( + event: MouseEvent, + scope: CommandApprovalScope, + callbacks: ModalCallbacks, + closeModal: () => void +): void { + if (event.isTrusted) {confirmScope(scope, callbacks, closeModal);} } function confirmScope( @@ -348,7 +358,7 @@ function bindScopeButton( return; } - button.onclick = () => confirmScope(scope, callbacks, closeModal); + button.onclick = (event) => confirmTrustedScope(event, scope, callbacks, closeModal); } function showAlwaysApprovalView(elements: ModalElements, isCommandScopedApproval: boolean): void { @@ -367,7 +377,7 @@ function bindRejectFlow( callbacks: ModalCallbacks, closeModal: () => void ): void { - elements.btnReject.onclick = () => { + const reject = () => { if (state.rejectStep === 0) { showRejectReasonView(elements, state); return; @@ -377,6 +387,10 @@ function bindRejectFlow( closeModal(); callbacks.onReject(reason); }; + elements.btnReject.onclick = (event) => { + if (event.isTrusted) {reject();} + }; + bindReasonInput(elements, reject); } function showRejectReasonView(elements: ModalElements, state: ModalState): void { @@ -391,8 +405,8 @@ function showRejectReasonView(elements: ModalElements, state: ModalState): void } function bindBackButton(elements: ModalElements, state: ModalState, mandatory: boolean): void { - elements.btnBack.onclick = () => { - resetModalView(elements, state, mandatory); + elements.btnBack.onclick = (event) => { + if (event.isTrusted) {resetModalView(elements, state, mandatory);} }; } @@ -410,12 +424,12 @@ function resetModalView(elements: ModalElements, state: ModalState, mandatory: b elements.btnConfirmAlways.style.display = "none"; } -function bindReasonInput(elements: ModalElements): void { +function bindReasonInput(elements: ModalElements, reject: () => void): void { elements.inputReason.onkeydown = (event) => { - if (event.key === "Enter") { + if (event.isTrusted && event.key === "Enter") { event.preventDefault(); event.stopPropagation(); - elements.btnReject.click(); + reject(); } }; } diff --git a/bridge-browser/src/types.ts b/bridge-browser/src/types.ts index 30f8bb1..0044826 100644 --- a/bridge-browser/src/types.ts +++ b/bridge-browser/src/types.ts @@ -12,36 +12,169 @@ export type { Session, SiteNetworkCaptureConfig, ToolExecutionPayload, ToolExecu // === Extension-Internal Types === -export interface MessageRequest { - type: string; - tabId?: number; - port?: number; - token?: string; - siteId?: string; - targetOrigin?: string; - targetUrl?: string; - vscodeExtensionVersion?: string; - browserExtensionVersion?: string; - workspaceId?: string; +export interface HandshakeMessageRequest { + type: "HANDSHAKE"; + port: number; + bridgeCode: string; + bridgeProtocolVersion: number; + vscodeExtensionVersion: string; + browserExtensionVersion: string; force?: boolean; - show?: boolean; +} + +export interface GetStatusMessageRequest { + type: "GET_STATUS"; + tabId?: number; +} + +export interface RequestUserAttentionMessageRequest { + type: "REQUEST_USER_ATTENTION"; + playSound?: boolean; +} + +export interface ClearWindowAttentionMessageRequest { + type: "CLEAR_WINDOW_ATTENTION"; +} + +export interface ExecuteToolMessageRequest { + type: "EXECUTE_TOOL"; + payload: ToolExecutionTransportPayload; + approvalToken?: string; +} + +export interface PreflightToolMessageRequest { + type: "PREFLIGHT_TOOL"; + payload: ToolExecutionTransportPayload; +} + +export interface ApproveToolMessageRequest { + type: "APPROVE_TOOL"; + challengeId: string; +} + +export interface ShowNotificationMessageRequest { + type: "SHOW_NOTIFICATION"; title?: string; message?: string; onlyWhenWindowInBackground?: boolean; - playSound?: boolean; - connected?: boolean; +} + +export interface SyncConfigMessageRequest { + type: "SYNC_CONFIG"; +} + +export interface SetLogVisibleMessageRequest { + type: "SET_LOG_VISIBLE"; + tabId?: number; + show: boolean; +} + +export interface SetAutoSendMessageRequest { + type: "SET_AUTO_SEND"; + tabId?: number; + autoSend: boolean; +} + +export interface SetAutoApproveToolsMessageRequest { + type: "SET_AUTO_APPROVE_TOOLS"; + tabId?: number; + autoApproveTools: boolean; +} + +export interface SetDefaultAutoApproveToolsMessageRequest { + type: "SET_DEFAULT_AUTO_APPROVE_TOOLS"; + defaultAutoApproveTools: boolean; +} + +export interface ManualInitMessageRequest { + type: "MANUAL_INIT"; +} + +export interface ToggleLogMessageRequest { + type: "TOGGLE_LOG"; + show: boolean; +} + +export interface StatusUpdateMessageRequest { + type: "STATUS_UPDATE"; + connected: boolean; + siteId?: string; + workspaceId?: string; autoSend?: boolean; autoApproveTools?: boolean; - defaultAutoApproveTools?: boolean; - payload?: ToolExecutionTransportPayload; - approvalToken?: string; - challengeId?: string; } +export interface LogVisibleChangedMessageRequest { + type: "LOG_VISIBLE_CHANGED"; + tabId: number; + show: boolean; +} + +export interface AutoSendChangedMessageRequest { + type: "AUTO_SEND_CHANGED"; + tabId: number; + autoSend: boolean; +} + +export interface AutoApproveToolsChangedMessageRequest { + type: "AUTO_APPROVE_TOOLS_CHANGED"; + tabId: number; + autoApproveTools: boolean; +} + +export interface DefaultAutoApproveToolsChangedMessageRequest { + type: "DEFAULT_AUTO_APPROVE_TOOLS_CHANGED"; + defaultAutoApproveTools: boolean; +} + +export type MessageRequest = + | HandshakeMessageRequest + | GetStatusMessageRequest + | RequestUserAttentionMessageRequest + | ClearWindowAttentionMessageRequest + | ExecuteToolMessageRequest + | PreflightToolMessageRequest + | ApproveToolMessageRequest + | ShowNotificationMessageRequest + | SyncConfigMessageRequest + | SetLogVisibleMessageRequest + | SetAutoSendMessageRequest + | SetAutoApproveToolsMessageRequest + | SetDefaultAutoApproveToolsMessageRequest + | ManualInitMessageRequest + | ToggleLogMessageRequest + | StatusUpdateMessageRequest + | LogVisibleChangedMessageRequest + | AutoSendChangedMessageRequest + | AutoApproveToolsChangedMessageRequest + | DefaultAutoApproveToolsChangedMessageRequest; + +export type BackgroundActionRuntimeMessageRequest = + | HandshakeMessageRequest + | GetStatusMessageRequest + | RequestUserAttentionMessageRequest + | ClearWindowAttentionMessageRequest + | ExecuteToolMessageRequest + | PreflightToolMessageRequest + | ApproveToolMessageRequest + | ShowNotificationMessageRequest + | SyncConfigMessageRequest; + +export type SettingsRuntimeMessageRequest = + | SetLogVisibleMessageRequest + | SetAutoSendMessageRequest + | SetAutoApproveToolsMessageRequest + | SetDefaultAutoApproveToolsMessageRequest; + +export type BackgroundRuntimeMessageRequest = + | BackgroundActionRuntimeMessageRequest + | SettingsRuntimeMessageRequest; + export interface HandshakeResponse { success: boolean; error?: string; conflictTabId?: string; + targetUrl?: string; } export interface StatusResponse { @@ -78,6 +211,7 @@ export interface StoredSession { autoApproveTools?: boolean; workspaceId?: string; lastGatewayActivityAt?: number; + gatewayIdleTimeoutMs?: number; siteId?: string; targetOrigin?: string; targetUrl?: string; @@ -98,8 +232,99 @@ export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +type MessageRequestValidator = (value: Record) => boolean; + +const MESSAGE_REQUEST_VALIDATORS = { + HANDSHAKE: (value) => + typeof value.port === "number" && + typeof value.bridgeCode === "string" && + typeof value.bridgeProtocolVersion === "number" && + typeof value.vscodeExtensionVersion === "string" && + typeof value.browserExtensionVersion === "string" && + isOptionalBoolean(value.force), + GET_STATUS: (value) => isOptionalNumber(value.tabId), + REQUEST_USER_ATTENTION: (value) => isOptionalBoolean(value.playSound), + CLEAR_WINDOW_ATTENTION: () => true, + EXECUTE_TOOL: (value) => + isToolExecutionTransportPayload(value.payload) && + isOptionalString(value.approvalToken), + PREFLIGHT_TOOL: (value) => isToolExecutionTransportPayload(value.payload), + APPROVE_TOOL: (value) => typeof value.challengeId === "string", + SHOW_NOTIFICATION: (value) => + isOptionalString(value.title) && + isOptionalString(value.message) && + isOptionalBoolean(value.onlyWhenWindowInBackground), + SYNC_CONFIG: () => true, + SET_LOG_VISIBLE: (value) => + isOptionalNumber(value.tabId) && typeof value.show === "boolean", + SET_AUTO_SEND: (value) => + isOptionalNumber(value.tabId) && typeof value.autoSend === "boolean", + SET_AUTO_APPROVE_TOOLS: (value) => + isOptionalNumber(value.tabId) && typeof value.autoApproveTools === "boolean", + SET_DEFAULT_AUTO_APPROVE_TOOLS: (value) => + typeof value.defaultAutoApproveTools === "boolean", + MANUAL_INIT: () => true, + TOGGLE_LOG: (value) => typeof value.show === "boolean", + STATUS_UPDATE: (value) => + typeof value.connected === "boolean" && + isOptionalString(value.siteId) && + isOptionalString(value.workspaceId) && + isOptionalBoolean(value.autoSend) && + isOptionalBoolean(value.autoApproveTools), + LOG_VISIBLE_CHANGED: (value) => + typeof value.tabId === "number" && typeof value.show === "boolean", + AUTO_SEND_CHANGED: (value) => + typeof value.tabId === "number" && typeof value.autoSend === "boolean", + AUTO_APPROVE_TOOLS_CHANGED: (value) => + typeof value.tabId === "number" && typeof value.autoApproveTools === "boolean", + DEFAULT_AUTO_APPROVE_TOOLS_CHANGED: (value) => + typeof value.defaultAutoApproveTools === "boolean", +} satisfies Record; + +const BACKGROUND_RUNTIME_MESSAGE_TYPES = new Set([ + "HANDSHAKE", + "GET_STATUS", + "REQUEST_USER_ATTENTION", + "CLEAR_WINDOW_ATTENTION", + "EXECUTE_TOOL", + "PREFLIGHT_TOOL", + "APPROVE_TOOL", + "SHOW_NOTIFICATION", + "SYNC_CONFIG", + "SET_LOG_VISIBLE", + "SET_AUTO_SEND", + "SET_AUTO_APPROVE_TOOLS", + "SET_DEFAULT_AUTO_APPROVE_TOOLS", +]); + +const SETTINGS_RUNTIME_MESSAGE_TYPES = new Set([ + "SET_LOG_VISIBLE", + "SET_AUTO_SEND", + "SET_AUTO_APPROVE_TOOLS", + "SET_DEFAULT_AUTO_APPROVE_TOOLS", +]); + +function isMessageRequestType(value: unknown): value is MessageRequest["type"] { + return typeof value === "string" && + Object.prototype.hasOwnProperty.call(MESSAGE_REQUEST_VALIDATORS, value); +} + export function isMessageRequest(value: unknown): value is MessageRequest { - return isRecord(value) && typeof value.type === "string"; + return isRecord(value) && + isMessageRequestType(value.type) && + MESSAGE_REQUEST_VALIDATORS[value.type](value); +} + +export function isBackgroundRuntimeMessageRequest( + value: MessageRequest +): value is BackgroundRuntimeMessageRequest { + return BACKGROUND_RUNTIME_MESSAGE_TYPES.has(value.type); +} + +export function isSettingsRuntimeMessageRequest( + value: BackgroundRuntimeMessageRequest +): value is SettingsRuntimeMessageRequest { + return SETTINGS_RUNTIME_MESSAGE_TYPES.has(value.type); } export function isSuccessResponse(value: unknown): value is SuccessResponse { @@ -123,15 +348,7 @@ export function isStoredSession(value: unknown): value is StoredSession { return typeof value.port === "number" && typeof value.token === "string" && - isOptionalBoolean(value.showLog) && - isOptionalBoolean(value.autoSend) && - isOptionalBoolean(value.autoApproveTools) && - isOptionalString(value.workspaceId) && - isOptionalNumber(value.lastGatewayActivityAt) && - isOptionalString(value.siteId) && - isOptionalString(value.targetOrigin) && - isOptionalString(value.targetUrl) && - isOptionalStringArray(value.allowedOrigins); + hasValidStoredSessionOptions(value); } export function normalizeSession(value: unknown): Session | null { @@ -145,6 +362,7 @@ export function normalizeSession(value: unknown): Session | null { autoApproveTools: value.autoApproveTools ?? false, workspaceId: value.workspaceId ?? "global", lastGatewayActivityAt: value.lastGatewayActivityAt, + gatewayIdleTimeoutMs: value.gatewayIdleTimeoutMs, siteId: value.siteId, targetOrigin: value.targetOrigin ?? value.allowedOrigins?.[0], targetUrl: value.targetUrl, @@ -202,3 +420,18 @@ function isOptionalStringArray(value: unknown): boolean { return value === undefined || (Array.isArray(value) && value.every((item) => typeof item === "string")); } + +function isToolExecutionTransportPayload(value: unknown): value is ToolExecutionTransportPayload { + return isRecord(value) && + typeof value.name === "string" && + (value.arguments === undefined || isRecord(value.arguments)) && + isOptionalString(value.purpose) && + isOptionalString(value.internal_call_id); +} + +function hasValidStoredSessionOptions(value: Record): boolean { + return [value.showLog, value.autoSend, value.autoApproveTools].every(isOptionalBoolean) && + [value.workspaceId, value.siteId, value.targetOrigin, value.targetUrl].every(isOptionalString) && + [value.lastGatewayActivityAt, value.gatewayIdleTimeoutMs].every(isOptionalNumber) && + isOptionalStringArray(value.allowedOrigins); +} diff --git a/bridge-browser/test/bridge_redemption.test.ts b/bridge-browser/test/bridge_redemption.test.ts new file mode 100644 index 0000000..ba326ac --- /dev/null +++ b/bridge-browser/test/bridge_redemption.test.ts @@ -0,0 +1,56 @@ +import { + getBridgeRedemptionError, + normalizeRedeemedBridgeSession, +} from "../src/background/bridge_redemption"; +import { normalizeSession } from "../src/types"; +import { BRIDGE_PROTOCOL_VERSION } from "@webcode/shared"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) {throw new Error(message);} +} + +function main(): void { + const valid = normalizeRedeemedBridgeSession({ + success: true, + bridgeProtocolVersion: BRIDGE_PROTOCOL_VERSION, + idleTimeoutMs: 60 * 60 * 1000, + siteId: "chatgpt", + targetOrigin: "https://chatgpt.com", + targetUrl: "https://chatgpt.com/", + token: "session-token", + vscodeExtensionVersion: "1.0.1", + workspaceId: "workspace", + }); + assert(valid?.token === "session-token", "valid bridge redemption was rejected"); + assert(valid.idleTimeoutMs === 60 * 60 * 1000, "gateway idle timeout was not retained"); + + const mismatchedOrigin = normalizeRedeemedBridgeSession({ + ...valid, + success: true, + targetOrigin: "https://attacker.example", + }); + assert(mismatchedOrigin === null, "mismatched target origin was accepted"); + + const unsafeTarget = normalizeRedeemedBridgeSession({ + ...valid, + success: true, + targetOrigin: "null", + targetUrl: "file:///tmp/secret", + }); + assert(unsafeTarget === null, "non-HTTP bridge target was accepted"); + + const stored = normalizeSession({ + port: 34567, + token: valid.token, + gatewayIdleTimeoutMs: valid.idleTimeoutMs, + }); + assert(stored?.gatewayIdleTimeoutMs === valid.idleTimeoutMs, "session idle timeout was not persisted"); + + assert( + getBridgeRedemptionError({ error: "expired" }) === "expired", + "gateway redemption error was not preserved" + ); + console.log("PASS validates bridge redemption sessions and idle timeout metadata"); +} + +main(); diff --git a/bridge-browser/test/message_request.test.ts b/bridge-browser/test/message_request.test.ts new file mode 100644 index 0000000..7203c31 --- /dev/null +++ b/bridge-browser/test/message_request.test.ts @@ -0,0 +1,128 @@ +import { + isBackgroundRuntimeMessageRequest, + isMessageRequest, + type MessageRequest, +} from "../src/types"; + +type MessageByType = { + [Type in MessageRequest["type"]]: Extract; +}; + +const validMessages = { + HANDSHAKE: { + type: "HANDSHAKE", + port: 43125, + bridgeCode: "bridge-code", + bridgeProtocolVersion: 1, + vscodeExtensionVersion: "1.0.1", + browserExtensionVersion: "1.0.1", + force: false, + }, + GET_STATUS: { type: "GET_STATUS", tabId: 42 }, + REQUEST_USER_ATTENTION: { type: "REQUEST_USER_ATTENTION", playSound: true }, + CLEAR_WINDOW_ATTENTION: { type: "CLEAR_WINDOW_ATTENTION" }, + EXECUTE_TOOL: { + type: "EXECUTE_TOOL", + payload: { name: "read_file", arguments: { path: "README.md" } }, + approvalToken: "approval-token", + }, + PREFLIGHT_TOOL: { + type: "PREFLIGHT_TOOL", + payload: { name: "execute_command", arguments: { command: "git status" } }, + }, + APPROVE_TOOL: { type: "APPROVE_TOOL", challengeId: "challenge-id" }, + SHOW_NOTIFICATION: { + type: "SHOW_NOTIFICATION", + title: "Complete", + message: "Task completed", + onlyWhenWindowInBackground: true, + }, + SYNC_CONFIG: { type: "SYNC_CONFIG" }, + SET_LOG_VISIBLE: { type: "SET_LOG_VISIBLE", tabId: 42, show: true }, + SET_AUTO_SEND: { type: "SET_AUTO_SEND", tabId: 42, autoSend: true }, + SET_AUTO_APPROVE_TOOLS: { + type: "SET_AUTO_APPROVE_TOOLS", + tabId: 42, + autoApproveTools: true, + }, + SET_DEFAULT_AUTO_APPROVE_TOOLS: { + type: "SET_DEFAULT_AUTO_APPROVE_TOOLS", + defaultAutoApproveTools: true, + }, + MANUAL_INIT: { type: "MANUAL_INIT" }, + TOGGLE_LOG: { type: "TOGGLE_LOG", show: true }, + STATUS_UPDATE: { + type: "STATUS_UPDATE", + connected: true, + siteId: "chatgpt", + workspaceId: "workspace", + autoSend: true, + autoApproveTools: false, + }, + LOG_VISIBLE_CHANGED: { type: "LOG_VISIBLE_CHANGED", tabId: 42, show: true }, + AUTO_SEND_CHANGED: { type: "AUTO_SEND_CHANGED", tabId: 42, autoSend: true }, + AUTO_APPROVE_TOOLS_CHANGED: { + type: "AUTO_APPROVE_TOOLS_CHANGED", + tabId: 42, + autoApproveTools: true, + }, + DEFAULT_AUTO_APPROVE_TOOLS_CHANGED: { + type: "DEFAULT_AUTO_APPROVE_TOOLS_CHANGED", + defaultAutoApproveTools: true, + }, +} satisfies MessageByType; + +const backgroundMessageTypes = new Set([ + "HANDSHAKE", + "GET_STATUS", + "REQUEST_USER_ATTENTION", + "CLEAR_WINDOW_ATTENTION", + "EXECUTE_TOOL", + "PREFLIGHT_TOOL", + "APPROVE_TOOL", + "SHOW_NOTIFICATION", + "SYNC_CONFIG", + "SET_LOG_VISIBLE", + "SET_AUTO_SEND", + "SET_AUTO_APPROVE_TOOLS", + "SET_DEFAULT_AUTO_APPROVE_TOOLS", +]); + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) {throw new Error(message);} +} + +function main(): void { + for (const message of Object.values(validMessages)) { + assert(isMessageRequest(message), `valid ${message.type} message was rejected`); + assert( + isBackgroundRuntimeMessageRequest(message) === backgroundMessageTypes.has(message.type), + `${message.type} was assigned to the wrong runtime destination` + ); + } + + const invalidMessages: unknown[] = [ + null, + {}, + { type: "UNKNOWN" }, + { ...validMessages.HANDSHAKE, port: "43125" }, + { type: "GET_STATUS", tabId: "42" }, + { type: "EXECUTE_TOOL", payload: { arguments: {} } }, + { type: "PREFLIGHT_TOOL", payload: { name: "execute_command", arguments: [] } }, + { type: "APPROVE_TOOL" }, + { type: "SHOW_NOTIFICATION", onlyWhenWindowInBackground: "yes" }, + { type: "SET_AUTO_SEND", tabId: 42 }, + { type: "STATUS_UPDATE", connected: "yes" }, + { type: "LOG_VISIBLE_CHANGED", tabId: 42, show: "yes" }, + ]; + + for (const message of invalidMessages) { + assert(!isMessageRequest(message), `invalid message was accepted: ${JSON.stringify(message)}`); + } + + const handshake: MessageRequest = validMessages.HANDSHAKE; + assert(handshake.type === "HANDSHAKE" && handshake.port === 43125, "handshake narrowing failed"); + console.log("PASS validates and narrows runtime message requests"); +} + +main(); diff --git a/bridge-browser/vite.config.ts b/bridge-browser/vite.config.ts index 70e8387..770dd17 100644 --- a/bridge-browser/vite.config.ts +++ b/bridge-browser/vite.config.ts @@ -10,9 +10,17 @@ interface SharedBrandingConfig { repositoryUrl: string; } +interface SharedBridgeProtocolConfig { + version: number; +} + const sharedIndexPath = normalizePath(resolve(__dirname, '../shared/src/index.ts')); const sharedBrandingPath = resolve(__dirname, '../shared/src/branding.json'); +const sharedBridgeProtocolPath = resolve(__dirname, '../shared/src/bridgeProtocol.json'); const sharedBrandingConfig = JSON.parse(readFileSync(sharedBrandingPath, 'utf8')) as SharedBrandingConfig; +const sharedBridgeProtocolConfig = JSON.parse( + readFileSync(sharedBridgeProtocolPath, 'utf8') +) as SharedBridgeProtocolConfig; const extensionManifest = defineManifest({ ...manifest, content_scripts: manifest.content_scripts.map(script => ({ @@ -25,9 +33,9 @@ function normalizePath(path: string): string { return path.replace(/\\/g, '/'); } -function inlineSharedBrandingConfig(): Plugin { +function inlineSharedConfig(): Plugin { return { - name: 'webcode:inline-shared-branding-config', + name: 'webcode:inline-shared-config', apply: 'serve', enforce: 'pre', transform(code, id) { @@ -35,19 +43,23 @@ function inlineSharedBrandingConfig(): Plugin { return null; } - // CRXJS dev file-writer treats Vite's external-root JSON import - // (/@fs/.../branding.json?import) as an asset and reads it from this - // package root. Inline this tiny config in dev to avoid that bad path. - return code.replace( - /import\s+brandConfig\s+from\s+['"]\.\/branding\.json['"];?/, - `const brandConfig = ${JSON.stringify(sharedBrandingConfig)} as const;` - ); + // CRXJS dev file-writer treats Vite's external-root JSON imports as assets + // and reads them from this package root. Inline these tiny configs in dev. + return code + .replace( + /import\s+brandConfig\s+from\s+['"]\.\/branding\.json['"];?/, + `const brandConfig = ${JSON.stringify(sharedBrandingConfig)} as const;` + ) + .replace( + /import\s+bridgeProtocolConfig\s+from\s+['"]\.\/bridgeProtocol\.json['"];?/, + `const bridgeProtocolConfig = ${JSON.stringify(sharedBridgeProtocolConfig)} as const;` + ); }, }; } export default defineConfig({ - plugins: [inlineSharedBrandingConfig(), crx({ manifest: extensionManifest })], + plugins: [inlineSharedConfig(), crx({ manifest: extensionManifest })], server: { port: 5173, strictPort: true, diff --git a/doc/PLATFORM_GUIDE.md b/doc/PLATFORM_GUIDE.md index b38f015..f8c3400 100644 --- a/doc/PLATFORM_GUIDE.md +++ b/doc/PLATFORM_GUIDE.md @@ -194,12 +194,12 @@ const BUILTIN_AI_SITES: ResolvedAiSiteConfig[] = [ 从 VS Code 打开站点时: 1. 用户在 VS Code 状态栏菜单选择某个站点。 -2. VS Code 打开 `/bridge?bridgeToken=...&siteId=&target=
`。 -3. Gateway 校验 `bridgeToken`、`siteId` 和 `target`;`target` 必须属于该站点的 `address`。 -4. Gateway 在 bridge 页面里写入 VS Code 扩展版本。 -5. 浏览器 bridge 页面从页面数据读取 token,再读取浏览器扩展版本,要求它和 VS Code 扩展版本完全一致。 -6. 版本一致后,bridge 页面握手,把 `siteId`、`targetOrigin`、`targetUrl` 写进 `session_`。 -7. background 异步请求 `/v1/init`,把 prompts 和 `syncedAiSites` 写进 `chrome.storage.local`。 +2. Gateway 生成一个绑定 `siteId` 和目标地址、60 秒后过期且只能使用一次的 bridge code。 +3. VS Code 只打开 `/bridge?bridgeCode=...`,长期 API Token 不进入 URL 或浏览器启动参数。 +4. Gateway 返回不可缓存的 bridge 页面,并在页面数据里写入 VS Code 扩展版本。 +5. bridge 页面读取 code 后立即从地址栏移除它,并要求浏览器扩展版本和 VS Code 扩展版本完全一致。 +6. 版本一致后,background 调用 `/v1/bridge/redeem`;Gateway 原子消费 code,再返回仅对当前 Gateway 运行有效的新会话 Token、`siteId`、`targetOrigin` 和 `targetUrl`。 +7. background 把会话数据写进 `session_`,再异步请求 `/v1/init`,把 prompts 和 `syncedAiSites` 写进 `chrome.storage.local`。 8. 目标 AI 页面里的 content script 通过 `GET_STATUS` 拿到 `siteId`。 9. content script 用 `siteId` 在 `syncedAiSites` 中查 selectors 和可选 capture 配置。 diff --git a/doc/PLATFORM_GUIDE_en.md b/doc/PLATFORM_GUIDE_en.md index 12d3927..db3ecd8 100644 --- a/doc/PLATFORM_GUIDE_en.md +++ b/doc/PLATFORM_GUIDE_en.md @@ -175,12 +175,12 @@ Custom sites do not inherit defaults, so the selector set must be complete. ## Runtime Flow 1. The user picks a site from the VS Code status bar menu. -2. VS Code opens `/bridge?bridgeToken=...&siteId=&target=
`. -3. The gateway validates `bridgeToken`, `siteId`, and `target`; `target` must belong to the selected site. -4. The gateway writes the VS Code extension version into the bridge page data. -5. The browser bridge reads the token from page data, then compares that version with the browser extension manifest version. -6. If the versions match, the handshake stores `siteId`, `targetOrigin`, and `targetUrl` in `session_`. -7. The background script fetches `/v1/init` and writes prompts plus `syncedAiSites` to `chrome.storage.local`. +2. The gateway issues a bridge code that is bound to the site and target, expires after 60 seconds, and can be used only once. +3. VS Code opens only `/bridge?bridgeCode=...`; the long-lived API credential never enters the URL or browser launch arguments. +4. The gateway returns a non-cacheable bridge page containing the VS Code extension version. +5. The bridge page removes the code from the address bar after reading it and requires the browser and VS Code extension versions to match. +6. The background script calls `/v1/bridge/redeem`; the gateway atomically consumes the code and returns a new session token valid only for the current gateway run, plus `siteId`, `targetOrigin`, and `targetUrl`. +7. The background script stores the session in `session_`, then fetches `/v1/init` and writes prompts plus `syncedAiSites` to `chrome.storage.local`. 8. The target page content script calls `GET_STATUS` to get `siteId`. 9. The content script uses `siteId` to find selectors and optional capture settings in `syncedAiSites`. diff --git a/evals/.vscode-test.deepseek.mjs b/evals/.vscode-test.deepseek.mjs index d2b71b8..3cd528e 100644 --- a/evals/.vscode-test.deepseek.mjs +++ b/evals/.vscode-test.deepseek.mjs @@ -1,8 +1,10 @@ import { defineConfig } from '@vscode/test-cli'; import { fileURLToPath } from 'node:url'; +import path from 'node:path'; const extensionDevelopmentPath = fileURLToPath(new URL('../gateway-vscode', import.meta.url)); const workspaceFolder = requireEnvironmentPath('WEBCODE_EVAL_WORKSPACE'); +const runDirectory = requireEnvironmentPath('WEBCODE_EVAL_RUN_DIR'); const vscodeExecutablePath = process.env.WEBCODE_EVAL_VSCODE_PATH?.trim(); export default defineConfig({ @@ -16,6 +18,7 @@ export default defineConfig({ '--skip-welcome', ], env: { + WEBCODE_BROWSER_EXTENSION_ROOT: path.join(runDirectory, 'browser-extensions'), WEBCODE_EVAL_MODE: '1', WEBCODE_EVAL_BROWSER_PATH: process.env.WEBCODE_EVAL_BROWSER_PATH, WEBCODE_EVAL_RUN_DIR: process.env.WEBCODE_EVAL_RUN_DIR, diff --git a/evals/.vscode-test.mjs b/evals/.vscode-test.mjs index 1162f43..70a1737 100644 --- a/evals/.vscode-test.mjs +++ b/evals/.vscode-test.mjs @@ -1,8 +1,10 @@ import { defineConfig } from '@vscode/test-cli'; import { fileURLToPath } from 'node:url'; +import path from 'node:path'; const extensionDevelopmentPath = fileURLToPath(new URL('../gateway-vscode', import.meta.url)); const workspaceFolder = requireEnvironmentPath('WEBCODE_EVAL_WORKSPACE'); +const runDirectory = requireEnvironmentPath('WEBCODE_EVAL_RUN_DIR'); const vscodeExecutablePath = process.env.WEBCODE_EVAL_VSCODE_PATH?.trim(); export default defineConfig({ @@ -16,6 +18,7 @@ export default defineConfig({ '--skip-welcome', ], env: { + WEBCODE_BROWSER_EXTENSION_ROOT: path.join(runDirectory, 'browser-extensions'), WEBCODE_EVAL_MODE: '1', WEBCODE_EVAL_BROWSER_PATH: process.env.WEBCODE_EVAL_BROWSER_PATH, WEBCODE_EVAL_RUN_DIR: process.env.WEBCODE_EVAL_RUN_DIR, diff --git a/evals/.vscode-test.qa.mjs b/evals/.vscode-test.qa.mjs index db30430..c4a4352 100644 --- a/evals/.vscode-test.qa.mjs +++ b/evals/.vscode-test.qa.mjs @@ -22,6 +22,7 @@ export default defineConfig({ `--user-data-dir=${path.join(runDirectory, 'vscode-user-data')}`, ], env: { + WEBCODE_BROWSER_EXTENSION_ROOT: path.join(runDirectory, 'browser-extensions'), WEBCODE_EVAL_MODE: '1', WEBCODE_EVAL_RUN_DIR: process.env.WEBCODE_EVAL_RUN_DIR, WEBCODE_EVAL_TRACE_PATH: process.env.WEBCODE_EVAL_TRACE_PATH, diff --git a/evals/package.json b/evals/package.json index 2e153ef..58807b7 100644 --- a/evals/package.json +++ b/evals/package.json @@ -7,14 +7,14 @@ "pretest": "pnpm run build", "test": "mocha --ui tdd \"out/unit-test/**/*.test.js\" && node --test \"scripts/**/*.test.mjs\" && node scripts/self-test-agent-scenarios.mjs", "scenarios": "pnpm run build && node scripts/agent-scenarios.mjs", - "e2e:minimal:prepare": "pnpm --filter @webcode/shared run build && pnpm --filter bridge-browser run build && pnpm --filter gateway-vscode run compile && pnpm run build", + "e2e:minimal:prepare": "pnpm --filter @webcode/shared run build && pnpm --filter gateway-vscode run prepare-browser-extension && pnpm --filter gateway-vscode run compile && pnpm run build", "e2e:minimal": "pnpm run e2e:minimal:prepare && node scripts/run-minimal-e2e.mjs", "e2e:command-risk": "pnpm run e2e:minimal:prepare && node scripts/run-minimal-e2e.mjs command-risk-approval commandRiskE2E.test.js", - "live:deepseek:prepare": "pnpm --filter @webcode/shared run build && pnpm --filter bridge-browser run build && pnpm --filter gateway-vscode run compile && pnpm run build", + "live:deepseek:prepare": "pnpm --filter @webcode/shared run build && pnpm --filter gateway-vscode run prepare-browser-extension && pnpm --filter gateway-vscode run compile && pnpm run build", "live:deepseek": "pnpm run live:deepseek:prepare && node scripts/run-deepseek-live.mjs", "qa:manual:prepare": "pnpm --filter @webcode/shared run build && pnpm --filter gateway-vscode run prepare-browser-extension && pnpm --filter gateway-vscode run compile", "qa:manual": "pnpm run qa:manual:prepare && node scripts/manual-qa.mjs", - "qa:prepare": "pnpm --filter @webcode/shared run build && pnpm --filter bridge-browser run build && pnpm --filter gateway-vscode run compile && pnpm run build", + "qa:prepare": "pnpm --filter @webcode/shared run build && pnpm --filter gateway-vscode run prepare-browser-extension && pnpm --filter gateway-vscode run compile && pnpm run build", "qa:start": "pnpm run qa:prepare && node scripts/qa-start.mjs", "qa:pw": "node scripts/qa-playwright.mjs", "qa:ctl": "node scripts/qa-control.mjs", diff --git a/evals/scripts/extension-host-isolation.test.mjs b/evals/scripts/extension-host-isolation.test.mjs new file mode 100644 index 0000000..c8d8933 --- /dev/null +++ b/evals/scripts/extension-host-isolation.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +const configNames = ['.vscode-test.qa.mjs', '.vscode-test.mjs', '.vscode-test.deepseek.mjs']; +const testRoot = path.join(os.tmpdir(), 'webcode-host-isolation-test'); + +for (const configName of configNames) { + test(`${configName} overrides inherited bridge roots with a separate directory for each run`, () => { + const roots = ['run-a', 'run-b'].map(runName => { + const runDirectory = path.join(testRoot, runName); + const result = readHostConfig(configName, runDirectory); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + const config = JSON.parse(result.stdout); + const installRoot = config.env.WEBCODE_BROWSER_EXTENSION_ROOT; + assert.equal(installRoot, path.join(runDirectory, 'browser-extensions')); + assert.notEqual(installRoot, path.join(runDirectory, 'browser-extension')); + return installRoot; + }); + assert.notEqual(roots[0], roots[1]); + }); + + test(`${configName} rejects a missing run directory instead of using the shared installation`, () => { + const result = readHostConfig(configName, ''); + assert.ifError(result.error); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Missing required environment variable WEBCODE_EVAL_RUN_DIR/); + }); +} + +function readHostConfig(configName, runDirectory) { + const configUrl = new URL(`../${configName}`, import.meta.url); + return spawnSync(process.execPath, [ + '--input-type=module', + '--eval', + 'const { default: config } = await import(process.argv[1]); process.stdout.write(JSON.stringify(config));', + configUrl.href, + ], { + encoding: 'utf8', + timeout: 10_000, + env: { + ...process.env, + WEBCODE_EVAL_WORKSPACE: path.join(testRoot, 'workspace'), + WEBCODE_EVAL_RUN_DIR: runDirectory, + WEBCODE_EVAL_VSCODE_PATH: '', + WEBCODE_QA_VSCODE_CDP_PORT: '9222', + WEBCODE_BROWSER_EXTENSION_ROOT: path.join(testRoot, 'shared-installation'), + }, + }); +} diff --git a/evals/scripts/manual-qa.mjs b/evals/scripts/manual-qa.mjs index a37bc87..840e51c 100644 --- a/evals/scripts/manual-qa.mjs +++ b/evals/scripts/manual-qa.mjs @@ -35,6 +35,7 @@ const userDataDirectory = path.join(sessionDirectory, 'user-data'); const extensionsDirectory = path.join(sessionDirectory, 'extensions'); const settingsDirectory = path.join(userDataDirectory, 'User'); const browserProfileRoot = path.join(evalsRoot, 'manual-browser-profiles'); +const browserExtensionRoot = path.join(evalsRoot, 'manual-browser-extensions'); await fs.mkdir(settingsDirectory, { recursive: true }); await fs.mkdir(extensionsDirectory, { recursive: true }); @@ -54,6 +55,7 @@ console.log('Manual webcode test environment ready to launch.'); console.log(`Workspace: ${workspacePath}`); console.log(`VS Code user data: ${userDataDirectory}`); console.log(`Browser profiles: ${browserProfileRoot}`); +console.log(`Browser bridge: ${browserExtensionRoot}`); console.log('Start Gateway and choose the AI site inside the Extension Development Host.'); console.log('Close the browser and VS Code windows manually when testing is complete.'); @@ -91,8 +93,11 @@ async function requireWorkspaceDirectory(workspaceDirectory) { function launchVsCode(executablePath, args) { return new Promise((resolve, reject) => { const child = spawn(executablePath, args, { - cwd: workspacePath, - env: process.env, + cwd: workspacePath, + env: { + ...process.env, + WEBCODE_BROWSER_EXTENSION_ROOT: browserExtensionRoot, + }, shell: false, stdio: 'inherit', windowsHide: false, diff --git a/evals/scripts/qa-control.mjs b/evals/scripts/qa-control.mjs index 35e2151..027dba8 100644 --- a/evals/scripts/qa-control.mjs +++ b/evals/scripts/qa-control.mjs @@ -307,8 +307,10 @@ function redactUrlSecret(value) { } try { const url = new URL(value); - if (url.searchParams.has('bridgeToken')) { - url.searchParams.set('bridgeToken', '[redacted]'); + for (const sensitiveParameter of ['bridgeCode', 'bridgeToken']) { + if (url.searchParams.has(sensitiveParameter)) { + url.searchParams.set(sensitiveParameter, '[redacted]'); + } } return url.toString(); } catch { diff --git a/evals/scripts/qa-start.mjs b/evals/scripts/qa-start.mjs index 80752c3..70a62fd 100644 --- a/evals/scripts/qa-start.mjs +++ b/evals/scripts/qa-start.mjs @@ -208,7 +208,7 @@ function startBrowserHost(bridgeUrl, targetUrl) { } async function prepareBrowserExtension() { - const sourcePath = path.join(repoRoot, 'bridge-browser', 'dist'); + const sourcePath = path.join(repoRoot, 'gateway-vscode', 'browser-extension'); await fs.cp(sourcePath, browserExtensionPath, { recursive: true, errorOnExist: true, diff --git a/evals/src/extension-test/commandRiskE2E.test.ts b/evals/src/extension-test/commandRiskE2E.test.ts index 7069486..791833b 100644 --- a/evals/src/extension-test/commandRiskE2E.test.ts +++ b/evals/src/extension-test/commandRiskE2E.test.ts @@ -135,7 +135,7 @@ async function configureEvaluationSite(fixtureUrl: string): Promise { async function launchBrowser(runDirectory: string, browserPath: string): Promise { const repoRoot = path.resolve(__dirname, '..', '..', '..'); - const bridgeExtensionPath = path.join(repoRoot, 'bridge-browser', 'dist'); + const bridgeExtensionPath = path.join(repoRoot, 'gateway-vscode', 'browser-extension'); return chromium.launchPersistentContext(path.join(runDirectory, 'browser-profile'), { executablePath: browserPath, headless: false, diff --git a/evals/src/extension-test/deepseekLive.test.ts b/evals/src/extension-test/deepseekLive.test.ts index ff1f3d1..bbb6709 100644 --- a/evals/src/extension-test/deepseekLive.test.ts +++ b/evals/src/extension-test/deepseekLive.test.ts @@ -142,7 +142,7 @@ async function configureGateway(mcpServers: Array<{ async function launchLiveBrowser(browserPath: string, profilePath: string): Promise { const repoRoot = path.resolve(__dirname, '..', '..', '..'); - const bridgeExtensionPath = path.join(repoRoot, 'bridge-browser', 'dist'); + const bridgeExtensionPath = path.join(repoRoot, 'gateway-vscode', 'browser-extension'); return chromium.launchPersistentContext(profilePath, { executablePath: browserPath, headless: false, diff --git a/evals/src/extension-test/minimalE2E.test.ts b/evals/src/extension-test/minimalE2E.test.ts index 75d14e6..ed00831 100644 --- a/evals/src/extension-test/minimalE2E.test.ts +++ b/evals/src/extension-test/minimalE2E.test.ts @@ -69,7 +69,7 @@ suite('Deterministic minimal E2E', () => { }); const repoRoot = path.resolve(__dirname, '..', '..', '..'); - const bridgeExtensionPath = path.join(repoRoot, 'bridge-browser', 'dist'); + const bridgeExtensionPath = path.join(repoRoot, 'gateway-vscode', 'browser-extension'); browserContext = await chromium.launchPersistentContext(path.join(runDirectory, 'browser-profile'), { executablePath: browserPath, headless: false, diff --git a/gateway-vscode/.vscode-test.mjs b/gateway-vscode/.vscode-test.mjs index 41e5a36..169144a 100644 --- a/gateway-vscode/.vscode-test.mjs +++ b/gateway-vscode/.vscode-test.mjs @@ -1,12 +1,23 @@ import { defineConfig } from '@vscode/test-cli'; import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +import { resolveVsCodePath } from '../evals/scripts/runtime-paths.mjs'; const version = process.env.VSCODE_TEST_VERSION?.trim() || '1.106.1'; +const extensionRoot = fileURLToPath(new URL('.', import.meta.url)); +const vscodeExecutablePath = resolveVsCodePath(); export default defineConfig({ files: 'out/extension-test/**/*.test.js', version, workspaceFolder: fileURLToPath(new URL('./src/extension-test/workspace', import.meta.url)), + ...(vscodeExecutablePath + ? { useInstallation: { fromPath: vscodeExecutablePath } } + : {}), + env: { + WEBCODE_BROWSER_EXTENSION_ROOT: path.join(extensionRoot, '.vscode-test', 'browser-extensions'), + }, mocha: { timeout: 20_000, }, diff --git a/gateway-vscode/package.json b/gateway-vscode/package.json index e48f781..5ad5e19 100644 --- a/gateway-vscode/package.json +++ b/gateway-vscode/package.json @@ -174,6 +174,13 @@ "default": 34567, "description": "The port the local gateway server will listen on." }, + "webcodeGateway.idleTimeoutMinutes": { + "type": "integer", + "default": 30, + "minimum": 5, + "maximum": 240, + "description": "Minutes without authenticated gateway activity before automatic shutdown. Health checks, rejected requests, and invalid bridge codes do not extend the timeout." + }, "webcodeGateway.browser": { "type": "string", "default": "isolated-edge", diff --git a/gateway-vscode/scripts/copy-browser-extension.mjs b/gateway-vscode/scripts/copy-browser-extension.mjs index 9f605f8..8eb1616 100644 --- a/gateway-vscode/scripts/copy-browser-extension.mjs +++ b/gateway-vscode/scripts/copy-browser-extension.mjs @@ -1,17 +1,86 @@ -import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +const BUILD_DESCRIPTOR_FILE = 'bridge-build.json'; + const scriptDir = dirname(fileURLToPath(import.meta.url)); const extensionRoot = resolve(scriptDir, '..'); const repoRoot = resolve(extensionRoot, '..'); const sourceDir = resolve(repoRoot, 'bridge-browser', 'dist'); const targetDir = resolve(extensionRoot, 'browser-extension'); +const bridgeProtocolPath = resolve(repoRoot, 'shared', 'src', 'bridgeProtocol.json'); if (!existsSync(resolve(sourceDir, 'manifest.json'))) { throw new Error(`Browser extension build not found at ${sourceDir}`); } +const sourceManifestPath = resolve(repoRoot, 'bridge-browser', 'manifest.json'); +const sourceManifest = JSON.parse(readFileSync(sourceManifestPath, 'utf8')); +const manifest = JSON.parse(readFileSync(resolve(sourceDir, 'manifest.json'), 'utf8')); +// ZIP and VSIX builds share the Chrome Web Store public identity from the source manifest. +if (typeof sourceManifest.key !== 'string' || !sourceManifest.key || manifest.key !== sourceManifest.key) { + throw new Error('Browser extension build has an unexpected identity key. Rebuild bridge-browser before packaging.'); +} + rmSync(targetDir, { recursive: true, force: true }); mkdirSync(targetDir, { recursive: true }); cpSync(sourceDir, targetDir, { recursive: true }); + +const bridgeProtocol = JSON.parse(readFileSync(bridgeProtocolPath, 'utf8')); +const buildDescriptor = { + schemaVersion: 1, + extensionVersion: manifest.version, + bridgeProtocolVersion: bridgeProtocol.version, + buildHash: hashExtensionFiles(targetDir), + builtAt: new Date().toISOString(), +}; +writeFileSync( + resolve(targetDir, BUILD_DESCRIPTOR_FILE), + `${JSON.stringify(buildDescriptor, null, 2)}\n`, + 'utf8' +); + +function hashExtensionFiles(rootDir) { + // Match runtime validation: sort normalized relative paths by UTF-16 code units. + const files = collectFiles(rootDir) + .map(filePath => relative(rootDir, filePath).replace(/\\/g, '/')) + .sort(); + const hash = createHash('sha256'); + for (const relativePath of files) { + if (relativePath === BUILD_DESCRIPTOR_FILE) { + continue; + } + hash.update(relativePath, 'utf8'); + hash.update('\0'); + hash.update(readFileSync(resolve(rootDir, relativePath))); + hash.update('\0'); + } + return hash.digest('hex'); +} + +function collectFiles(directory) { + const files = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const entryPath = resolve(directory, entry.name); + if (entry.isSymbolicLink()) { + throw new Error(`Browser extension build contains a symbolic link: ${entryPath}`); + } + if (entry.isDirectory()) { + files.push(...collectFiles(entryPath)); + } else if (entry.isFile() && statSync(entryPath).isFile()) { + files.push(entryPath); + } + } + return files; +} diff --git a/gateway-vscode/src/extension.ts b/gateway-vscode/src/extension.ts index 09f03b9..ecd7049 100644 --- a/gateway-vscode/src/extension.ts +++ b/gateway-vscode/src/extension.ts @@ -1,7 +1,8 @@ import * as vscode from 'vscode'; import { registerGatewayConfigurationWatcher } from './extension/configurationWatcher'; -import { buildBridgeUrl } from './extension/browserLauncher'; +import { buildBridgeUrl } from './extension/bridgeUrl'; +import { createBrowserExtensionManager } from './extension/browserExtensionManager'; import { registerGatewayConnectCommand } from './extension/connectCommand'; import { registerCopyContextCommand } from './extension/copyContextCommand'; import { registerIsolatedProfileCleanupCommand } from './extension/isolatedProfileCleanupCommand'; @@ -71,8 +72,8 @@ export async function activate(context: vscode.ExtensionContext): Promise { - runtime.serviceController?.markAutoStopped(); + idleTimeoutMs => { + runtime.serviceController?.markAutoStopped(idleTimeoutMs); }, createGatewayRuntimeTraceSinkFromEnvironment() ); @@ -98,7 +99,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { await serviceController.start(); const state = serviceController.getState(); - if (!state.isRunning || !state.currentPort || !state.currentToken) { + if (!state.isRunning || !state.currentPort) { throw new Error('Evaluation Gateway failed to start.'); } - return buildBridgeUrl(state.currentPort, state.currentToken, siteId, targetUrl); + const bridgeCode = serviceController.issueBridgeCode(siteId, targetUrl); + return buildBridgeUrl(state.currentPort, bridgeCode); }, async stop(): Promise { await serviceController.stop(); diff --git a/gateway-vscode/src/extension/bridgeUrl.ts b/gateway-vscode/src/extension/bridgeUrl.ts new file mode 100644 index 0000000..b976da2 --- /dev/null +++ b/gateway-vscode/src/extension/bridgeUrl.ts @@ -0,0 +1,4 @@ +export function buildBridgeUrl(currentPort: number, bridgeCode: string): string { + const params = new URLSearchParams({ bridgeCode }); + return `http://127.0.0.1:${currentPort}/bridge?${params.toString()}`; +} diff --git a/gateway-vscode/src/extension/browserExtensionInstall.ts b/gateway-vscode/src/extension/browserExtensionInstall.ts new file mode 100644 index 0000000..5440e66 --- /dev/null +++ b/gateway-vscode/src/extension/browserExtensionInstall.ts @@ -0,0 +1,384 @@ +import * as crypto from 'crypto'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { withBrowserExtensionFileLock } from './browserExtensionLock'; + +export interface BrowserExtensionBuild { + schemaVersion: 1; + extensionVersion: string; + bridgeProtocolVersion: number; + buildHash: string; + builtAt: string; +} + +export type PrepareBrowserExtensionResult = + | { status: 'ready'; extensionPath: string; build: BrowserExtensionBuild } + | { status: 'staged'; extensionPath: string; stagingPath: string; build: BrowserExtensionBuild } + | { status: 'newer-installed'; installedBuild: BrowserExtensionBuild }; + +export interface BrowserExtensionInstallOptions { + sourceDir: string; + rootDir: string; + lockTimeoutMs?: number; +} + +export interface ResolveBrowserExtensionRootOptions { + developmentStorageRoot?: string; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + homeDir?: string; +} + +export interface BrowserExtensionInstallLockOptions { + rootDir: string; + lockTimeoutMs?: number; +} + +export interface BrowserExtensionInstallLease { + readonly rootDir: string; + prepare(sourceDir: string): Promise; + activate(expectedBuild: BrowserExtensionBuild): Promise; +} + +export const BROWSER_EXTENSION_BUILD_FILE = 'bridge-build.json'; +export const BROWSER_EXTENSION_ACTIVE_DIR_NAME = 'bridge'; + +const PRODUCT_DATA_DIR_NAME = 'webcode'; +const BROWSER_EXTENSION_ROOT_DIR_NAME = 'browser-extensions'; +const INSTALL_RECORD_FILE = 'install.json'; +const PREVIOUS_DIR_NAME = 'previous'; +const DEFAULT_LOCK_TIMEOUT_MS = 15_000; + +export function resolveBrowserExtensionRoot(options: ResolveBrowserExtensionRootOptions = {}): string { + const platform = options.platform ?? os.platform(); + const env = options.env ?? process.env; + const platformPath = platform === 'win32' ? path.win32 : path.posix; + const override = env.WEBCODE_BROWSER_EXTENSION_ROOT?.trim(); + if (override) { + if (!platformPath.isAbsolute(override)) { + throw new Error(`WEBCODE_BROWSER_EXTENSION_ROOT must be absolute: ${override}`); + } + return platformPath.resolve(override); + } + if (options.developmentStorageRoot) { + return platformPath.join(options.developmentStorageRoot, 'browser-extensions-development'); + } + return resolveDefaultBrowserExtensionRoot(platform, env, options.homeDir); +} + +export function resolveDefaultBrowserExtensionRoot( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = process.env, + homeDir: string = os.homedir() +): string { + const platformPath = platform === 'win32' ? path.win32 : path.posix; + if (platform === 'win32') { + const localAppData = env.LOCALAPPDATA && platformPath.isAbsolute(env.LOCALAPPDATA) + ? env.LOCALAPPDATA + : platformPath.join(homeDir, 'AppData', 'Local'); + return platformPath.join(localAppData, PRODUCT_DATA_DIR_NAME, BROWSER_EXTENSION_ROOT_DIR_NAME); + } + + if (platform === 'darwin') { + return platformPath.join( + homeDir, + 'Library', + 'Application Support', + PRODUCT_DATA_DIR_NAME, + BROWSER_EXTENSION_ROOT_DIR_NAME + ); + } + + const dataHome = env.XDG_DATA_HOME && platformPath.isAbsolute(env.XDG_DATA_HOME) + ? env.XDG_DATA_HOME + : platformPath.join(homeDir, '.local', 'share'); + return platformPath.join(dataHome, PRODUCT_DATA_DIR_NAME, BROWSER_EXTENSION_ROOT_DIR_NAME); +} + +export async function prepareBrowserExtensionInstall( + options: BrowserExtensionInstallOptions +): Promise { + return withBrowserExtensionInstallLock(options, lease => lease.prepare(options.sourceDir)); +} + +export async function activatePreparedBrowserExtension( + options: Pick, + expectedBuild: BrowserExtensionBuild +): Promise { + return withBrowserExtensionInstallLock(options, lease => lease.activate(expectedBuild)); +} + +export async function withBrowserExtensionInstallLock( + options: BrowserExtensionInstallLockOptions, + callback: (lease: BrowserExtensionInstallLease) => Promise +): Promise { + const rootDir = path.resolve(options.rootDir); + const lease: BrowserExtensionInstallLease = { + rootDir, + prepare: sourceDir => prepareBrowserExtensionInstallUnderLock(path.resolve(sourceDir), rootDir), + activate: expectedBuild => activatePreparedBrowserExtensionUnderLock(rootDir, expectedBuild) + }; + return withBrowserExtensionFileLock( + rootDir, + options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS, + () => callback(lease) + ); +} + +async function prepareBrowserExtensionInstallUnderLock( + sourceDir: string, + rootDir: string +): Promise { + const sourceBuild = await readAndValidateBrowserExtensionBuild(sourceDir); + const activePath = path.join(rootDir, BROWSER_EXTENSION_ACTIVE_DIR_NAME); + const activeBuild = await tryReadBrowserExtensionBuild(activePath); + if (activeBuild?.buildHash === sourceBuild.buildHash) { + await writeInstallRecord(rootDir, sourceBuild).catch(() => undefined); + return { status: 'ready', extensionPath: activePath, build: sourceBuild }; + } + if (activeBuild && compareBrowserExtensionBuilds(activeBuild, sourceBuild) > 0) { + return { status: 'newer-installed', installedBuild: activeBuild }; + } + + const stagingPath = getStagingPath(rootDir, sourceBuild); + const stagedBuild = await tryReadBrowserExtensionBuild(stagingPath); + if (stagedBuild?.buildHash !== sourceBuild.buildHash) { + await fs.rm(stagingPath, { recursive: true, force: true }); + await fs.cp(sourceDir, stagingPath, { + recursive: true, + errorOnExist: true, + force: false + }); + const copiedBuild = await readAndValidateBrowserExtensionBuild(stagingPath); + if (copiedBuild.buildHash !== sourceBuild.buildHash) { + throw new Error('The copied browser bridge build does not match its source build.'); + } + } + + const newerStagedBuild = await cleanOtherStagingDirectories(rootDir, stagingPath, sourceBuild); + if (newerStagedBuild) { + return { status: 'newer-installed', installedBuild: newerStagedBuild }; + } + + return { status: 'staged', extensionPath: activePath, stagingPath, build: sourceBuild }; +} + +async function activatePreparedBrowserExtensionUnderLock( + rootDir: string, + expectedBuild: BrowserExtensionBuild +): Promise { + const activePath = path.join(rootDir, BROWSER_EXTENSION_ACTIVE_DIR_NAME); + const activeBuild = await tryReadBrowserExtensionBuild(activePath); + if (activeBuild?.buildHash === expectedBuild.buildHash) { + await writeInstallRecord(rootDir, expectedBuild).catch(() => undefined); + return { status: 'ready', extensionPath: activePath, build: expectedBuild }; + } + if (activeBuild && compareBrowserExtensionBuilds(activeBuild, expectedBuild) > 0) { + return { status: 'newer-installed', installedBuild: activeBuild }; + } + + const stagingPath = getStagingPath(rootDir, expectedBuild); + const stagedBuild = await readAndValidateBrowserExtensionBuild(stagingPath); + if (stagedBuild.buildHash !== expectedBuild.buildHash) { + throw new Error('The prepared browser bridge build changed before activation.'); + } + + const previousPath = path.join(rootDir, PREVIOUS_DIR_NAME); + await fs.rm(previousPath, { recursive: true, force: true }); + const hadActiveDirectory = await pathExists(activePath); + if (hadActiveDirectory) { + await fs.rename(activePath, previousPath); + } + + try { + await fs.rename(stagingPath, activePath); + const installedBuild = await readAndValidateBrowserExtensionBuild(activePath); + if (installedBuild.buildHash !== expectedBuild.buildHash) { + throw new Error('The activated browser bridge build failed validation.'); + } + } catch (error: unknown) { + await fs.rm(activePath, { recursive: true, force: true }).catch(() => undefined); + if (hadActiveDirectory && await pathExists(previousPath)) { + await fs.rename(previousPath, activePath).catch(() => undefined); + } + throw error; + } + + await writeInstallRecord(rootDir, expectedBuild).catch(() => undefined); + return { status: 'ready', extensionPath: activePath, build: expectedBuild }; +} + +export async function readAndValidateBrowserExtensionBuild( + extensionDir: string +): Promise { + const descriptorPath = path.join(extensionDir, BROWSER_EXTENSION_BUILD_FILE); + const parsed: unknown = JSON.parse(await fs.readFile(descriptorPath, 'utf8')); + const build = parseBrowserExtensionBuild(parsed); + if (!build) { + throw new Error(`Invalid browser bridge build descriptor: ${descriptorPath}`); + } + + const manifestPath = path.join(extensionDir, 'manifest.json'); + const manifest: unknown = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + if (!isRecord(manifest) || manifest.version !== build.extensionVersion) { + throw new Error(`Browser bridge manifest version does not match ${BROWSER_EXTENSION_BUILD_FILE}.`); + } + + const actualHash = await calculateBrowserExtensionBuildHash(extensionDir); + if (actualHash !== build.buildHash) { + throw new Error(`Browser bridge files do not match ${BROWSER_EXTENSION_BUILD_FILE}.`); + } + return build; +} + +export async function calculateBrowserExtensionBuildHash(extensionDir: string): Promise { + // Match the packaging script: sort normalized relative paths by UTF-16 code units. + const files = (await collectExtensionFiles(extensionDir)) + .map(filePath => path.relative(extensionDir, filePath).replace(/\\/g, '/')) + .sort(); + const hash = crypto.createHash('sha256'); + for (const relativePath of files) { + if (relativePath === BROWSER_EXTENSION_BUILD_FILE) { + continue; + } + hash.update(relativePath, 'utf8'); + hash.update('\0'); + hash.update(await fs.readFile(path.join(extensionDir, relativePath))); + hash.update('\0'); + } + return hash.digest('hex'); +} + +export function compareBrowserExtensionBuilds( + left: BrowserExtensionBuild, + right: BrowserExtensionBuild +): number { + const versionComparison = compareVersions(left.extensionVersion, right.extensionVersion); + if (versionComparison !== 0) { + return versionComparison; + } + + return Date.parse(left.builtAt) - Date.parse(right.builtAt); +} + +async function tryReadBrowserExtensionBuild(extensionDir: string): Promise { + try { + return await readAndValidateBrowserExtensionBuild(extensionDir); + } catch { + return null; + } +} + +async function collectExtensionFiles(directory: string): Promise { + const entries = await fs.readdir(directory, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + throw new Error(`Browser bridge build contains a symbolic link: ${entryPath}`); + } + if (entry.isDirectory()) { + files.push(...await collectExtensionFiles(entryPath)); + } else if (entry.isFile()) { + files.push(entryPath); + } + } + return files; +} + +async function cleanOtherStagingDirectories( + rootDir: string, + currentStagingPath: string, + sourceBuild: BrowserExtensionBuild +): Promise { + const entries = await fs.readdir(rootDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith('staging-')) { + continue; + } + const candidatePath = path.join(rootDir, entry.name); + if (path.resolve(candidatePath) === path.resolve(currentStagingPath)) { + continue; + } + const candidateBuild = await tryReadBrowserExtensionBuild(candidatePath); + if (candidateBuild && compareBrowserExtensionBuilds(candidateBuild, sourceBuild) > 0) { + return candidateBuild; + } + await fs.rm(candidatePath, { recursive: true, force: true }); + } + return null; +} + +function getStagingPath(rootDir: string, build: BrowserExtensionBuild): string { + return path.join(rootDir, `staging-${build.buildHash.slice(0, 16)}`); +} + +function parseBrowserExtensionBuild(value: unknown): BrowserExtensionBuild | null { + if (!isRecord(value) || + value.schemaVersion !== 1 || + typeof value.extensionVersion !== 'string' || + !value.extensionVersion || + typeof value.bridgeProtocolVersion !== 'number' || + !Number.isInteger(value.bridgeProtocolVersion) || + value.bridgeProtocolVersion <= 0 || + typeof value.buildHash !== 'string' || + !/^[a-f0-9]{64}$/.test(value.buildHash) || + typeof value.builtAt !== 'string' || + !Number.isFinite(Date.parse(value.builtAt))) { + return null; + } + return value as unknown as BrowserExtensionBuild; +} + +function compareVersions(left: string, right: string): number { + const leftParts = parseVersion(left); + const rightParts = parseVersion(right); + if (!leftParts || !rightParts) { + return left.localeCompare(right, undefined, { numeric: true }); + } + for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) { + const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (difference !== 0) { + return difference; + } + } + return 0; +} + +function parseVersion(version: string): number[] | null { + const match = /^(\d+)\.(\d+)\.(\d+)(?:\.([0-9]+))?$/.exec(version); + return match ? match.slice(1).filter(Boolean).map(Number) : null; +} + +async function writeInstallRecord(rootDir: string, build: BrowserExtensionBuild): Promise { + const targetPath = path.join(rootDir, INSTALL_RECORD_FILE); + const temporaryPath = path.join(rootDir, `${INSTALL_RECORD_FILE}.tmp-${process.pid}-${crypto.randomUUID()}`); + await fs.writeFile(temporaryPath, `${JSON.stringify({ + ...build, + activeDirectory: BROWSER_EXTENSION_ACTIVE_DIR_NAME + }, null, 2)}\n`, 'utf8'); + await fs.rm(targetPath, { force: true }); + await fs.rename(temporaryPath, targetPath); +} + +async function pathExists(filePath: string): Promise { + try { + await fs.stat(filePath); + return true; + } catch (error: unknown) { + if (hasErrorCode(error, 'ENOENT')) { + return false; + } + throw error; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code; +} diff --git a/gateway-vscode/src/extension/browserExtensionLock.ts b/gateway-vscode/src/extension/browserExtensionLock.ts new file mode 100644 index 0000000..ad1b2fd --- /dev/null +++ b/gateway-vscode/src/extension/browserExtensionLock.ts @@ -0,0 +1,199 @@ +import * as crypto from 'crypto'; +import type { BigIntStats } from 'fs'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const INSTALL_LOCK_DIR_NAME = '.install-lock'; +const INSTALL_LOCK_OWNER_FILE = 'owner.json'; +const INSTALL_LOCK_RECLAIMED_PREFIX = '.install-lock-reclaimed-'; +const INSTALL_LOCK_RECLAIM_GUARD_FILE = '.reclaim-guard'; +const STALE_LOCK_MS = 2 * 60_000; +const LOCK_RETRY_MS = 50; + +interface InstallLockSnapshot { + identity: string; + isStale: boolean; +} + +export async function withBrowserExtensionFileLock( + rootDir: string, + timeoutMs: number, + callback: () => Promise +): Promise { + const release = await acquireInstallLock(rootDir, timeoutMs); + try { + return await callback(); + } finally { + await release(); + } +} + +async function acquireInstallLock( + rootDir: string, + timeoutMs: number +): Promise<() => Promise> { + await fs.mkdir(rootDir, { recursive: true }); + const lockPath = path.join(rootDir, INSTALL_LOCK_DIR_NAME); + const ownerPath = path.join(lockPath, INSTALL_LOCK_OWNER_FILE); + const token = crypto.randomUUID(); + const deadline = Date.now() + timeoutMs; + + while (true) { + try { + await fs.mkdir(lockPath); + try { + await fs.writeFile(ownerPath, JSON.stringify({ + pid: process.pid, + token, + createdAt: new Date().toISOString() + }), 'utf8'); + } catch (error: unknown) { + await fs.rm(lockPath, { recursive: true, force: true }).catch(() => undefined); + throw error; + } + return async () => { + const owner = await readLockOwner(ownerPath); + if (owner?.token === token) { + await fs.rm(lockPath, { recursive: true, force: true }); + } + }; + } catch (error: unknown) { + if (!hasErrorCode(error, 'EEXIST')) { + throw error; + } + } + + if (await reclaimStaleInstallLock(rootDir, lockPath)) { + continue; + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for browser bridge installation lock at ${lockPath}.`); + } + await delay(LOCK_RETRY_MS); + } +} + +async function reclaimStaleInstallLock(rootDir: string, lockPath: string): Promise { + const snapshot = await readInstallLockSnapshot(lockPath); + if (!snapshot?.isStale) { + return false; + } + + // Ensure even a half-created empty lock becomes a non-empty tombstone. POSIX rename + // may replace an empty directory, but cannot replace this guarded destination. + try { + await fs.writeFile( + path.join(lockPath, INSTALL_LOCK_RECLAIM_GUARD_FILE), + snapshot.identity, + { flag: 'a' } + ); + } catch (error: unknown) { + if (hasErrorCode(error, 'ENOENT')) { + return false; + } + throw error; + } + + // Concurrent reclaimers target the same generation-specific destination. Once one + // rename succeeds, a delayed reclaimer cannot move a subsequently acquired lock. + const reclaimedPath = path.join(rootDir, `${INSTALL_LOCK_RECLAIMED_PREFIX}${snapshot.identity}`); + try { + await fs.rename(lockPath, reclaimedPath); + return true; + } catch (error: unknown) { + if (await pathExists(reclaimedPath) || !await pathExists(lockPath)) { + return false; + } + throw error; + } +} + +async function readInstallLockSnapshot(lockPath: string): Promise { + const before = await readLockDirectoryStats(lockPath); + if (!before) { + return null; + } + + const owner = await readLockOwner(path.join(lockPath, INSTALL_LOCK_OWNER_FILE)); + const after = await readLockDirectoryStats(lockPath); + // The path may have disappeared or been reused while reading owner.json. A missing + // owner must never turn metadata from the previous directory into a stale snapshot. + if (!after || !isSameDirectorySnapshot(before, after)) { + return null; + } + + // Retained tombstones keep their inodes allocated. Derive the generation from the + // directory itself, independently of owner reads and timestamps changed by the guard. + const identitySource = [after.dev, after.ino].join(':'); + return { + identity: crypto.createHash('sha256').update(identitySource).digest('hex').slice(0, 32), + isStale: owner ? !isProcessAlive(owner.pid) : Date.now() - Number(after.mtimeMs) > STALE_LOCK_MS + }; +} + +async function readLockDirectoryStats(lockPath: string): Promise { + try { + return await fs.stat(lockPath, { bigint: true }); + } catch (error: unknown) { + if (hasErrorCode(error, 'ENOENT')) { + return null; + } + throw error; + } +} + +function isSameDirectorySnapshot(before: BigIntStats, after: BigIntStats): boolean { + return before.dev === after.dev && + before.ino === after.ino && + before.birthtimeNs === after.birthtimeNs && + before.mtimeNs === after.mtimeNs && + before.ctimeNs === after.ctimeNs; +} + +async function readLockOwner(ownerPath: string): Promise<{ pid: number; token: string } | null> { + try { + const value: unknown = JSON.parse(await fs.readFile(ownerPath, 'utf8')); + return isRecord(value) && + typeof value.pid === 'number' && + Number.isInteger(value.pid) && + value.pid > 0 && + typeof value.token === 'string' + ? { pid: value.pid, token: value.token } + : null; + } catch { + return null; + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error: unknown) { + return hasErrorCode(error, 'EPERM'); + } +} + +async function pathExists(filePath: string): Promise { + try { + await fs.stat(filePath); + return true; + } catch (error: unknown) { + if (hasErrorCode(error, 'ENOENT')) { + return false; + } + throw error; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code; +} + +function delay(milliseconds: number): Promise { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} diff --git a/gateway-vscode/src/extension/browserExtensionManager.ts b/gateway-vscode/src/extension/browserExtensionManager.ts new file mode 100644 index 0000000..98e03b1 --- /dev/null +++ b/gateway-vscode/src/extension/browserExtensionManager.ts @@ -0,0 +1,307 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { BRIDGE_PROTOCOL_VERSION } from '@webcode/shared'; + +import { t } from '../i18n'; +import { + resolveBrowserExtensionRoot, + withBrowserExtensionInstallLock, + type BrowserExtensionBuild, + type BrowserExtensionInstallLease, + type PrepareBrowserExtensionResult +} from './browserExtensionInstall'; +import { getErrorMessage } from './errorUtils'; +import type { BrowserFamily } from './isolatedBrowserProfiles'; +import { + getBrowserBridgeProcesses, + getBrowserProfileProcessIds, + stopBrowserBridgeProcesses, + stopBrowserProfileProcesses, + waitForBrowserProfileBridgeProcess, + type BrowserBridgeProcess +} from './processDetection'; + +export type BrowserExtensionLaunch = (extensionPath: string) => Promise; + +export interface BrowserExtensionLaunchOptions { + browserFamily: BrowserFamily; + profileDir: string; + launch: BrowserExtensionLaunch; +} + +export interface BrowserExtensionManager { + prepareInBackground(): void; + launchWithReadyExtension(options: BrowserExtensionLaunchOptions): Promise; +} + +interface BrowserRestartPlan { + browserFamily: BrowserFamily; + profileDir: string; + extensionPath: string; + bridgeProcesses: BrowserBridgeProcess[]; + restartTargetProfile: boolean; +} + +const PREPARATION_PROGRESS_DELAY_MS = 300; +const COORDINATION_LOCK_TIMEOUT_MS = 5 * 60_000; + +export function createBrowserExtensionManager( + context: vscode.ExtensionContext, + outputChannel: vscode.OutputChannel +): BrowserExtensionManager { + return new DefaultBrowserExtensionManager(context, outputChannel); +} + +class DefaultBrowserExtensionManager implements BrowserExtensionManager { + private readonly rootDir: string; + private backgroundPreparation: Promise | undefined; + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly outputChannel: vscode.OutputChannel + ) { + this.rootDir = resolveBrowserExtensionRoot({ + developmentStorageRoot: context.extensionMode === vscode.ExtensionMode.Production + ? undefined + : context.globalStorageUri.fsPath + }); + } + + prepareInBackground(): void { + void this.getBackgroundPreparation().catch(error => { + this.log(`Background preparation failed: ${getErrorMessage(error)}`); + }); + } + + async launchWithReadyExtension(options: BrowserExtensionLaunchOptions): Promise { + try { + await this.waitForBackgroundPreparationWithProgress(); + const sourceDir = this.requireBundledSource(); + return await withBrowserExtensionInstallLock({ + rootDir: this.rootDir, + lockTimeoutMs: COORDINATION_LOCK_TIMEOUT_MS + }, async lease => { + let prepared = this.validateProtocol(await lease.prepare(sourceDir)); + if (prepared.status === 'newer-installed') { + this.showNewerInstalledMessage(prepared.installedBuild); + return false; + } + + const restartPlan = await this.createRestartPlan( + prepared, + options.browserFamily, + options.profileDir + ); + if (restartPlan && !await this.confirmAndStopRunningBrowsers(restartPlan)) { + return false; + } + + if (prepared.status === 'staged') { + prepared = this.validateProtocol(await lease.activate(prepared.build)); + } + + if (prepared.status === 'newer-installed') { + this.showNewerInstalledMessage(prepared.installedBuild); + return false; + } + if (prepared.status !== 'ready') { + throw new Error('The prepared browser bridge was not activated.'); + } + + const launched = await options.launch(prepared.extensionPath); + if (!launched) { + return false; + } + if (!await waitForBrowserProfileBridgeProcess( + options.browserFamily, + options.profileDir, + prepared.extensionPath + )) { + throw new Error('The isolated browser did not expose its WebCode bridge process in time.'); + } + + this.log(`Using browser bridge ${prepared.build.extensionVersion} from ${prepared.extensionPath}.`); + return true; + }); + } catch (error: unknown) { + const message = getErrorMessage(error); + this.log(`Preparation failed: ${message}`); + void vscode.window.showErrorMessage(t('browser_bridge_prepare_failed', { + path: this.rootDir, + message + })); + return false; + } + } + + private getBackgroundPreparation(): Promise { + if (this.backgroundPreparation) { + return this.backgroundPreparation; + } + + const preparation = this.prepareAndActivateWhenSafe(); + this.backgroundPreparation = preparation; + void preparation.catch(() => { + if (this.backgroundPreparation === preparation) { + this.backgroundPreparation = undefined; + } + }); + return preparation; + } + + private async prepareAndActivateWhenSafe(): Promise { + const sourceDir = this.requireBundledSource(); + await withBrowserExtensionInstallLock({ rootDir: this.rootDir }, async lease => { + const prepared = this.validateProtocol(await lease.prepare(sourceDir)); + await this.activateBackgroundPreparationWhenSafe(prepared, lease); + }); + } + + private async activateBackgroundPreparationWhenSafe( + prepared: PrepareBrowserExtensionResult, + lease: BrowserExtensionInstallLease + ): Promise { + if (prepared.status === 'newer-installed') { + this.log(`A newer browser bridge ${prepared.installedBuild.extensionVersion} is already installed.`); + return; + } + if (prepared.status === 'ready') { + this.log(`Browser bridge ${prepared.build.extensionVersion} is ready.`); + return; + } + + const runningBrowsers = await getBrowserBridgeProcesses(prepared.extensionPath); + if (runningBrowsers.length > 0) { + this.log(`Browser bridge ${prepared.build.extensionVersion} is staged until the isolated browser restarts.`); + return; + } + + const activated = this.validateProtocol(await lease.activate(prepared.build)); + if (activated.status === 'ready') { + this.log(`Browser bridge ${activated.build.extensionVersion} prepared at ${activated.extensionPath}.`); + } + } + + private validateProtocol(result: PrepareBrowserExtensionResult): PrepareBrowserExtensionResult { + if (result.status !== 'newer-installed' && + result.build.bridgeProtocolVersion !== BRIDGE_PROTOCOL_VERSION) { + throw new Error( + `Browser bridge protocol ${result.build.bridgeProtocolVersion} does not match ` + + `gateway protocol ${BRIDGE_PROTOCOL_VERSION}.` + ); + } + return result; + } + + private requireBundledSource(): string { + const sourceDir = resolveBundledBrowserExtensionSource(this.context); + if (!sourceDir) { + throw new Error(t('browser_extension_missing')); + } + return sourceDir; + } + + private async waitForBackgroundPreparationWithProgress(): Promise { + const preparation = this.getBackgroundPreparation(); + let timer: NodeJS.Timeout | undefined; + const outcome = await Promise.race([ + preparation.then(() => 'ready' as const), + new Promise<'slow'>(resolve => { + timer = setTimeout(() => resolve('slow'), PREPARATION_PROGRESS_DELAY_MS); + }) + ]); + if (timer) { + clearTimeout(timer); + } + if (outcome === 'ready') { + return; + } + + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: t('browser_bridge_preparing'), + cancellable: false + }, () => preparation); + } + + private async createRestartPlan( + prepared: Exclude, + browserFamily: BrowserFamily, + profileDir: string + ): Promise { + const [allBridgeProcesses, profileProcessIds] = await Promise.all([ + getBrowserBridgeProcesses(prepared.extensionPath), + getBrowserProfileProcessIds(browserFamily, profileDir) + ]); + const bridgeProcessIds = new Set(allBridgeProcesses.map(processInfo => processInfo.pid)); + const targetUsesActiveBridge = profileProcessIds.some(processId => bridgeProcessIds.has(processId)); + const bridgeProcesses = prepared.status === 'staged' ? allBridgeProcesses : []; + const restartTargetProfile = profileProcessIds.length > 0 && + (prepared.status === 'staged' || !targetUsesActiveBridge); + if (bridgeProcesses.length === 0 && !restartTargetProfile) { + return null; + } + return { + browserFamily, + profileDir, + extensionPath: prepared.extensionPath, + bridgeProcesses, + restartTargetProfile + }; + } + + private async confirmAndStopRunningBrowsers(plan: BrowserRestartPlan): Promise { + const browserFamilies = plan.bridgeProcesses.map(processInfo => processInfo.browserFamily); + if (plan.restartTargetProfile) { + browserFamilies.push(plan.browserFamily); + } + const browserNames = [...new Set(browserFamilies.map(getBrowserDisplayName))].join(' / '); + const restartButton = t('browser_bridge_restart_button'); + const selection = await vscode.window.showWarningMessage( + t('browser_bridge_restart_required', { browsers: browserNames }), + { modal: true }, + restartButton + ); + if (selection !== restartButton) { + return false; + } + + const bridgeStopped = plan.bridgeProcesses.length === 0 || + await stopBrowserBridgeProcesses(plan.extensionPath).catch(() => false); + const profileStopped = !plan.restartTargetProfile || + await stopBrowserProfileProcesses(plan.browserFamily, plan.profileDir).catch(() => false); + if (bridgeStopped && profileStopped) { + return true; + } + + void vscode.window.showErrorMessage(t('browser_bridge_restart_failed', { browsers: browserNames })); + return false; + } + + private showNewerInstalledMessage(build: BrowserExtensionBuild): void { + void vscode.window.showErrorMessage(t('browser_bridge_newer_installed', { + version: build.extensionVersion + })); + } + + private log(message: string): void { + this.outputChannel.appendLine(`[Browser Bridge] ${message}`); + } +} + +function resolveBundledBrowserExtensionSource(context: vscode.ExtensionContext): string | null { + const candidates = [ + path.join(context.extensionPath, 'browser-extension'), + path.resolve(context.extensionPath, '..', 'bridge-browser', 'dist'), + path.resolve(context.extensionPath, '..', '..', 'bridge-browser', 'dist') + ]; + return candidates.find(candidate => + fs.existsSync(path.join(candidate, 'manifest.json')) && + fs.existsSync(path.join(candidate, 'bridge-build.json')) + ) ?? null; +} + +function getBrowserDisplayName(browserFamily: 'edge' | 'chrome'): string { + return browserFamily === 'edge' ? 'Microsoft Edge' : 'Chrome / Chromium'; +} diff --git a/gateway-vscode/src/extension/browserLauncher.ts b/gateway-vscode/src/extension/browserLauncher.ts index 6e98e89..0185b73 100644 --- a/gateway-vscode/src/extension/browserLauncher.ts +++ b/gateway-vscode/src/extension/browserLauncher.ts @@ -10,40 +10,50 @@ import { launchFirstAvailableBrowser, type BrowserLaunchCommand } from './browse import { getErrorMessage } from './errorUtils'; import { prepareIsolatedProfileDirForLaunch } from './isolatedProfileLaunch'; import { expandHomePath, type BrowserFamily } from './isolatedBrowserProfiles'; -import { isBrowserProcessRunning } from './processDetection'; +import { + getBrowserBridgeMarkerArgument, + getBrowserProfileMarkerArgument, + isBrowserProcessRunning +} from './processDetection'; import type { AISiteConfig } from './types'; +import { buildBridgeUrl } from './bridgeUrl'; +import type { BrowserExtensionManager } from './browserExtensionManager'; interface LaunchBridgeOptions { context: vscode.ExtensionContext; siteId: string; - targetUrl: string; browserMode: string; currentPort: number; - currentToken: string; + issueBridgeCode: () => string; + browserExtensionManager: BrowserExtensionManager; } const ISOLATED_EDGE_PROFILE_HOME_URL = 'edge://newtab/'; -export function launchBridge(options: LaunchBridgeOptions): void { - const bridgeUrl = buildBridgeUrl(options.currentPort, options.currentToken, options.siteId, options.targetUrl); +export async function launchBridge(options: LaunchBridgeOptions): Promise { const finalBrowser = resolveBrowser(options.siteId, options.browserMode); - - openBrowser(bridgeUrl, finalBrowser, options.context); + await openBrowser( + () => buildBridgeUrl(options.currentPort, options.issueBridgeCode()), + finalBrowser, + options.context, + options.browserExtensionManager + ); } -export function launchIsolatedEdgeProfile(context: vscode.ExtensionContext): void { - void openIsolatedBrowser(ISOLATED_EDGE_PROFILE_HOME_URL, 'edge', context).catch(error => { +export async function launchIsolatedEdgeProfile( + context: vscode.ExtensionContext, + browserExtensionManager: BrowserExtensionManager +): Promise { + try { + await openIsolatedBrowser( + () => ISOLATED_EDGE_PROFILE_HOME_URL, + 'edge', + context, + browserExtensionManager + ); + } catch (error: unknown) { void vscode.window.showErrorMessage(t('open_browser_failed', { message: getErrorMessage(error) })); - }); -} - -export function buildBridgeUrl(currentPort: number, currentToken: string, siteId: string, targetUrl: string): string { - const params = new URLSearchParams({ - bridgeToken: currentToken, - siteId, - target: targetUrl - }); - return `http://127.0.0.1:${currentPort}/bridge?${params.toString()}`; + } } function resolveBrowser(siteId: string, browserMode: string): string { @@ -64,28 +74,46 @@ function resolveBrowser(siteId: string, browserMode: string): string { return config.get('browser') ?? 'isolated-edge'; } -function openBrowser(url: string, browserType: string, context: vscode.ExtensionContext): void { - void openBrowserAsync(url, browserType, context).catch(error => { +async function openBrowser( + getUrl: () => string, + browserType: string, + context: vscode.ExtensionContext, + browserExtensionManager: BrowserExtensionManager +): Promise { + try { + await openBrowserAsync(getUrl, browserType, context, browserExtensionManager); + } catch (error: unknown) { void vscode.window.showErrorMessage(t('open_browser_failed', { message: getErrorMessage(error) })); - }); + } } -async function openBrowserAsync(url: string, browserType: string, context: vscode.ExtensionContext): Promise { +async function openBrowserAsync( + getUrl: () => string, + browserType: string, + context: vscode.ExtensionContext, + browserExtensionManager: BrowserExtensionManager +): Promise { if (browserType === 'default') { - await vscode.env.openExternal(vscode.Uri.parse(url)); + await vscode.env.openExternal(vscode.Uri.parse(getUrl())); return; } if (browserType === 'isolated-chrome' || browserType === 'isolated-edge') { - await openIsolatedBrowser(url, browserType === 'isolated-edge' ? 'edge' : 'chrome', context); + await openIsolatedBrowser( + getUrl, + browserType === 'isolated-edge' ? 'edge' : 'chrome', + context, + browserExtensionManager + ); return; } if (browserType === 'user-profile-chrome' || browserType === 'user-profile-edge') { - await openUserProfileKeepaliveBrowser(url, browserType === 'user-profile-edge' ? 'edge' : 'chrome'); + await openUserProfileKeepaliveBrowser(getUrl(), browserType === 'user-profile-edge' ? 'edge' : 'chrome'); return; } + const url = getUrl(); const command = buildBrowserCommand(url, browserType, os.platform()); if (command) { @@ -100,19 +128,17 @@ async function openBrowserAsync(url: string, browserType: string, context: vscod void vscode.env.openExternal(vscode.Uri.parse(url)); } -async function openIsolatedBrowser(url: string, browserFamily: BrowserFamily, context: vscode.ExtensionContext): Promise { - const extensionPath = resolveBundledBrowserExtensionPath(context); - if (!extensionPath) { - void vscode.window.showErrorMessage(t('browser_extension_missing')); - return; - } - +async function openIsolatedBrowser( + getUrl: () => string, + browserFamily: BrowserFamily, + context: vscode.ExtensionContext, + browserExtensionManager: BrowserExtensionManager +): Promise { const profileDir = await prepareIsolatedProfileDirForLaunch(browserFamily, context); if (!profileDir) { return; } - const browserArgs = buildIsolatedBrowserArgs(url, profileDir, extensionPath); if (browserFamily === 'chrome') { const invalidConfiguredPath = getInvalidConfiguredChromeForTestingPath(); if (invalidConfiguredPath) { @@ -129,7 +155,14 @@ async function openIsolatedBrowser(url: string, browserFamily: BrowserFamily, co return; } - launchFirstAvailableBrowser(launchCommands, browserArgs, getBrowserDisplayName(browserFamily)); + await browserExtensionManager.launchWithReadyExtension({ + browserFamily, + profileDir, + launch: async extensionPath => { + const browserArgs = buildIsolatedBrowserArgs(getUrl(), profileDir, extensionPath); + return launchFirstAvailableBrowser(launchCommands, browserArgs, getBrowserDisplayName(browserFamily)); + } + }); } async function openUserProfileKeepaliveBrowser(url: string, browserFamily: BrowserFamily): Promise { @@ -140,7 +173,7 @@ async function openUserProfileKeepaliveBrowser(url: string, browserFamily: Brows } const launchCommands = getUserProfileBrowserLaunchCommands(browserFamily, os.platform()); - launchFirstAvailableBrowser(launchCommands, buildKeepaliveBrowserArgs(url), browserName); + await launchFirstAvailableBrowser(launchCommands, buildKeepaliveBrowserArgs(url), browserName); } function buildIsolatedBrowserArgs(url: string, profileDir: string, extensionPath: string): string[] { @@ -149,9 +182,12 @@ function buildIsolatedBrowserArgs(url: string, profileDir: string, extensionPath return [ `--user-data-dir=${normalizedProfileDir}`, `--load-extension=${normalizedExtensionPath}`, + getBrowserProfileMarkerArgument(profileDir), + getBrowserBridgeMarkerArgument(extensionPath), '--no-first-run', '--no-default-browser-check', '--disable-sync', + '--disable-background-mode', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', @@ -176,20 +212,6 @@ function normalizeBrowserPath(filePath: string): string { return path.resolve(filePath).replace(/\\/g, '/'); } -function resolveBundledBrowserExtensionPath(context: vscode.ExtensionContext): string | null { - const candidates = [ - path.join(context.extensionPath, 'browser-extension'), - path.resolve(context.extensionPath, '..', 'bridge-browser', 'dist'), - path.resolve(context.extensionPath, '..', '..', 'bridge-browser', 'dist') - ]; - - return candidates.find(isUnpackedBrowserExtension) ?? null; -} - -function isUnpackedBrowserExtension(extensionPath: string): boolean { - return fs.existsSync(path.join(extensionPath, 'manifest.json')); -} - function getBrowserLaunchCommands(browserFamily: BrowserFamily, platform: NodeJS.Platform): BrowserLaunchCommand[] { if (platform === 'win32') { return getWindowsBrowserLaunchCommands(browserFamily); diff --git a/gateway-vscode/src/extension/browserProcessLauncher.ts b/gateway-vscode/src/extension/browserProcessLauncher.ts index ef9a498..18b8e88 100644 --- a/gateway-vscode/src/extension/browserProcessLauncher.ts +++ b/gateway-vscode/src/extension/browserProcessLauncher.ts @@ -8,84 +8,87 @@ export interface BrowserLaunchCommand { prefixArgs: string[]; } +interface BrowserLaunchAttempt { + success: boolean; + failure?: string; +} + const BROWSER_STABLE_LAUNCH_MS = 1000; -export function launchFirstAvailableBrowser( +export async function launchFirstAvailableBrowser( launchCommands: BrowserLaunchCommand[], browserArgs: string[], browserName: string -): void { - const tryLaunch = (index: number, lastFailure?: string) => { - const launchCommand = launchCommands[index]; - if (!launchCommand) { - showLaunchFailure(browserName, lastFailure); - return; +): Promise { + let lastFailure: string | undefined; + for (const launchCommand of launchCommands) { + const result = await launchBrowserCandidate(launchCommand, browserArgs, browserName); + if (result.success) { + return true; } + lastFailure = result.failure ?? lastFailure; + } - launchBrowserCandidate(launchCommand, browserArgs, browserName, failure => { - tryLaunch(index + 1, failure ?? lastFailure); - }); - }; - - tryLaunch(0); + showLaunchFailure(browserName, lastFailure); + return false; } function launchBrowserCandidate( launchCommand: BrowserLaunchCommand, browserArgs: string[], - browserName: string, - onFailure: (failure?: string) => void -): void { - let settled = false; - let stableTimer: NodeJS.Timeout | undefined; - const child = spawn(launchCommand.command, [...launchCommand.prefixArgs, ...browserArgs], { - detached: true, - stdio: 'ignore', - windowsHide: false - }); - - const continueWithFailure = (failure: string | undefined) => { - if (settled) { - return; - } - - settled = true; - if (stableTimer) { - clearTimeout(stableTimer); - } - - onFailure(failure); - }; - - child.once('error', (error: NodeJS.ErrnoException) => { - continueWithFailure(error.code === 'ENOENT' ? undefined : error.message); - }); - - child.once('spawn', () => { - stableTimer = setTimeout(() => { - settled = true; - child.unref(); - }, BROWSER_STABLE_LAUNCH_MS); - }); - - child.once('close', (code, signal) => { - if (settled) { - return; - } - - if (code === 0) { + browserName: string +): Promise { + return new Promise(resolve => { + let settled = false; + let stableTimer: NodeJS.Timeout | undefined; + const settle = (result: BrowserLaunchAttempt) => { + if (settled) { + return; + } settled = true; if (stableTimer) { clearTimeout(stableTimer); } + resolve(result); + }; + + let child; + try { + child = spawn(launchCommand.command, [...launchCommand.prefixArgs, ...browserArgs], { + detached: true, + stdio: 'ignore', + windowsHide: false + }); + } catch (error: unknown) { + settle({ success: false, failure: error instanceof Error ? error.message : String(error) }); return; } - continueWithFailure(t('browser_exited_immediately', { - browser: browserName, - command: launchCommand.command, - reason: formatBrowserExitReason(code, signal) - })); + child.once('error', (error: NodeJS.ErrnoException) => { + settle({ success: false, failure: error.code === 'ENOENT' ? undefined : error.message }); + }); + + child.once('spawn', () => { + stableTimer = setTimeout(() => { + child.unref(); + settle({ success: true }); + }, BROWSER_STABLE_LAUNCH_MS); + }); + + child.once('close', (code, signal) => { + if (code === 0) { + settle({ success: true }); + return; + } + settle({ + success: false, + failure: t('browser_exited_immediately', { + browser: browserName, + command: launchCommand.command, + reason: formatBrowserExitReason(code, signal) + }) + }); + }); }); } diff --git a/gateway-vscode/src/extension/browserProcessList.ts b/gateway-vscode/src/extension/browserProcessList.ts new file mode 100644 index 0000000..276b163 --- /dev/null +++ b/gateway-vscode/src/extension/browserProcessList.ts @@ -0,0 +1,229 @@ +import { execFile } from 'child_process'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import type { BrowserFamily } from './isolatedBrowserProfiles'; + +const PROCESS_LIST_MAX_BUFFER = 64 * 1024 * 1024; + +export interface BrowserProcessInfo { + pid: number; + commandLine: string; + executableName?: string; + argv?: string[]; +} + +export async function listAllProcesses(platform: NodeJS.Platform): Promise { + if (platform === 'win32') { + return listWindowsProcesses(); + } + if (platform === 'linux') { + return listLinuxProcesses(); + } + return listPsProcesses(); +} + +export function getBrowserFamily( + processInfo: BrowserProcessInfo, + platform: NodeJS.Platform +): BrowserFamily | null { + return processInfo.executableName + ? getBrowserFamilyForExecutableName(processInfo.executableName, platform) + : null; +} + +export function getBrowserFamilyForExecutableName( + executableName: string, + platform: NodeJS.Platform +): BrowserFamily | null { + const platformPath = platform === 'win32' ? path.win32 : path.posix; + const basename = platformPath.basename(executableName).toLowerCase(); + if (getBrowserProcessNames('edge', platform).some(name => basename === name.toLowerCase())) { + return 'edge'; + } + if (getBrowserProcessNames('chrome', platform).some(name => basename === name.toLowerCase())) { + return 'chrome'; + } + return null; +} + +export function getBrowserProcessNames(browserFamily: BrowserFamily, platform: NodeJS.Platform): string[] { + if (platform === 'win32') { + return browserFamily === 'edge' + ? ['msedge.exe'] + : ['chrome.exe', 'chrome-for-testing.exe', 'google-chrome-for-testing.exe', 'chromium.exe']; + } + if (platform === 'darwin') { + return browserFamily === 'edge' + ? ['Microsoft Edge', 'Microsoft Edge Helper'] + : [ + 'Google Chrome', + 'Google Chrome Helper', + 'Google Chrome for Testing', + 'Google Chrome for Testing Helper', + 'Chromium', + 'Chromium Helper' + ]; + } + return browserFamily === 'edge' + ? ['msedge', 'microsoft-edge', 'microsoft-edge-stable'] + : [ + 'chrome', + 'chrome-for-testing', + 'google-chrome-for-testing', + 'google-chrome', + 'google-chrome-stable', + 'chromium', + 'chromium-browser' + ]; +} + +export async function isBrowserProcessRunningFallback( + browserFamily: BrowserFamily, + platform: NodeJS.Platform +): Promise { + if (platform === 'win32') { + const results = await Promise.all(getBrowserProcessNames(browserFamily, platform).map(async imageName => { + try { + const output = await executeFileText('tasklist', [ + '/FI', + `IMAGENAME eq ${imageName}`, + '/FO', + 'CSV', + '/NH' + ]); + return output.toLowerCase().includes(`"${imageName.toLowerCase()}"`); + } catch { + return false; + } + })); + return results.some(Boolean); + } + + const results = await Promise.all(getBrowserProcessNames(browserFamily, platform).map(async processName => { + try { + await executeFileText('pgrep', ['-x', processName]); + return true; + } catch { + return false; + } + })); + return results.some(Boolean); +} + +export async function stopBrowserProcess(processId: number, platform: NodeJS.Platform): Promise { + if (platform === 'win32') { + await executeFileText('taskkill', ['/PID', String(processId), '/T']).catch(() => undefined); + return; + } + try { + process.kill(processId, 'SIGTERM'); + } catch (error: unknown) { + if (!hasErrorCode(error, 'ESRCH')) { + throw error; + } + } +} + +export function executeFileText(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile(command, args, { encoding: 'utf8', maxBuffer: PROCESS_LIST_MAX_BUFFER }, (error, stdout) => { + if (error) { + reject(new Error(error.message)); + return; + } + resolve(stdout); + }); + }); +} + +async function listWindowsProcesses(): Promise { + const command = [ + '$ErrorActionPreference = "Stop";', + '$processes = @(Get-CimInstance Win32_Process |', + 'Where-Object { $_.CommandLine } |', + 'Select-Object Name, ProcessId, CommandLine);', + 'ConvertTo-Json -InputObject $processes -Compress' + ].join(' '); + const output = await executeFileText('powershell.exe', ['-NoProfile', '-Command', command]); + const parsed: unknown = JSON.parse(output || '[]'); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.flatMap(value => { + if (!isRecord(value) || + typeof value.ProcessId !== 'number' || + typeof value.CommandLine !== 'string') { + return []; + } + return [{ + pid: value.ProcessId, + commandLine: value.CommandLine, + executableName: typeof value.Name === 'string' ? value.Name : undefined + }]; + }); +} + +async function listLinuxProcesses(): Promise { + const entries = await fs.readdir('/proc', { withFileTypes: true }); + const processes = await Promise.all(entries + .filter(entry => entry.isDirectory() && /^\d+$/.test(entry.name)) + .map(entry => readLinuxProcess(Number(entry.name)))); + return processes.filter((processInfo): processInfo is BrowserProcessInfo => Boolean(processInfo)); +} + +async function readLinuxProcess(pid: number): Promise { + try { + const commandLineBuffer = await fs.readFile(`/proc/${pid}/cmdline`); + const argv = commandLineBuffer.toString('utf8').split('\0').filter(Boolean); + if (argv.length === 0) { + return null; + } + return { + pid, + argv, + commandLine: argv.join(' '), + executableName: path.posix.basename(argv[0]) + }; + } catch { + return null; + } +} + +async function listPsProcesses(): Promise { + const [commandOutput, executableOutput] = await Promise.all([ + executeFileText('ps', ['-ww', '-axo', 'pid=,command=']), + executeFileText('ps', ['-axo', 'pid=,comm=']) + ]); + const executableNames = parsePsRows(executableOutput); + return commandOutput + .split(/\r?\n/) + .map(line => line.trim()) + .flatMap(line => { + const match = /^(\d+)\s+(.+)$/.exec(line); + return match ? [{ + pid: Number(match[1]), + commandLine: match[2], + executableName: executableNames.get(Number(match[1])) + }] : []; + }); +} + +function parsePsRows(output: string): Map { + const rows = new Map(); + for (const line of output.split(/\r?\n/)) { + const match = /^\s*(\d+)\s+(.+)$/.exec(line); + if (match) { + rows.set(Number(match[1]), match[2].trim()); + } + } + return rows; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code; +} diff --git a/gateway-vscode/src/extension/configurationWatcher.ts b/gateway-vscode/src/extension/configurationWatcher.ts index 233e816..8790266 100644 --- a/gateway-vscode/src/extension/configurationWatcher.ts +++ b/gateway-vscode/src/extension/configurationWatcher.ts @@ -32,6 +32,8 @@ export function registerGatewayConfigurationWatcher(options: RegisterGatewayConf function isGatewayServerConfigurationChange(event: vscode.ConfigurationChangeEvent): boolean { return event.affectsConfiguration('webcodeGateway.port') || + event.affectsConfiguration('webcodeGateway.idleTimeoutMinutes') || + event.affectsConfiguration('webcodeGateway.aiSites') || event.affectsConfiguration('webcodeGateway.servers') || event.affectsConfiguration('webcodeGateway.skillDirectories') || event.affectsConfiguration('webcodeGateway.commandShell.path') || diff --git a/gateway-vscode/src/extension/connectCommand.ts b/gateway-vscode/src/extension/connectCommand.ts index 52e01a0..c1add95 100644 --- a/gateway-vscode/src/extension/connectCommand.ts +++ b/gateway-vscode/src/extension/connectCommand.ts @@ -12,19 +12,21 @@ import { } from './isolatedProfileCleanupCommand'; import type { GatewayServiceController } from './serviceController'; import type { AISiteConfig, CustomActionItem, ResolvedAiSiteConfig } from './types'; +import type { BrowserExtensionManager } from './browserExtensionManager'; interface RegisterGatewayConnectCommandOptions { context: vscode.ExtensionContext; outputChannel: vscode.OutputChannel; serviceController: GatewayServiceController; + browserExtensionManager: BrowserExtensionManager; } interface OnlineMenuContext { extensionContext: vscode.ExtensionContext; currentPort: number; - currentToken: string; outputChannel: vscode.OutputChannel; serviceController: GatewayServiceController; + browserExtensionManager: BrowserExtensionManager; } const OPEN_PROFILE_FOLDER_BUTTON: vscode.QuickInputButton = { @@ -38,7 +40,12 @@ export const GATEWAY_RESTART_COMMAND = 'gateway-vscode.restart'; export function registerGatewayConnectCommand(options: RegisterGatewayConnectCommandOptions): void { options.context.subscriptions.push( vscode.commands.registerCommand(GATEWAY_CONNECT_COMMAND, async () => { - await handleGatewayConnectCommand(options.context, options.outputChannel, options.serviceController); + await handleGatewayConnectCommand( + options.context, + options.outputChannel, + options.serviceController, + options.browserExtensionManager + ); }), vscode.commands.registerCommand(GATEWAY_RESTART_COMMAND, async () => { await options.serviceController.restart(); @@ -49,7 +56,8 @@ export function registerGatewayConnectCommand(options: RegisterGatewayConnectCom async function handleGatewayConnectCommand( extensionContext: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, - serviceController: GatewayServiceController + serviceController: GatewayServiceController, + browserExtensionManager: BrowserExtensionManager ): Promise { const state = serviceController.getState(); @@ -61,12 +69,12 @@ async function handleGatewayConnectCommand( // 2. Case: Offline -> Show Start Option if (!state.isRunning) { - await showOfflineMenu(extensionContext, outputChannel, serviceController); + await showOfflineMenu(extensionContext, outputChannel, serviceController, browserExtensionManager); return; } // 3. Case: Online -> Show Full Menu - if (!state.currentPort || !state.currentToken) { + if (!state.currentPort) { // Should not happen if isRunning is true, but safe guard serviceController.markOffline(); return; @@ -75,16 +83,17 @@ async function handleGatewayConnectCommand( await showOnlineMenu({ extensionContext, currentPort: state.currentPort, - currentToken: state.currentToken, outputChannel, - serviceController + serviceController, + browserExtensionManager }); } async function showOfflineMenu( extensionContext: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, - serviceController: GatewayServiceController + serviceController: GatewayServiceController, + browserExtensionManager: BrowserExtensionManager ): Promise { const cleanupItems = await buildIsolatedProfileCleanupItems(extensionContext); const items: CustomActionItem[] = [ @@ -107,17 +116,17 @@ async function showOfflineMenu( if (selection.action === 'start') { await serviceController.start(); const newState = serviceController.getState(); - if (newState.currentPort && newState.currentToken && newState.isRunning) { + if (newState.currentPort && newState.isRunning) { await showOnlineMenu({ extensionContext, currentPort: newState.currentPort, - currentToken: newState.currentToken, outputChannel, - serviceController + serviceController, + browserExtensionManager }); } } else if (selection.action === 'openIsolatedEdgeProfile') { - launchIsolatedEdgeProfile(extensionContext); + await launchIsolatedEdgeProfile(extensionContext, browserExtensionManager); } else if (selection.action === 'resetIsolatedProfiles') { await vscode.commands.executeCommand(RESET_ISOLATED_BROWSER_PROFILES_COMMAND); } else if (selection.action === 'cleanLegacyIsolatedProfiles') { @@ -248,13 +257,13 @@ async function handleOnlineSelection( // 3. 自定义启动 if (selection.action === 'custom') { - await launchCustomBridge(aiSites, context.extensionContext, context.currentPort, context.currentToken); + await launchCustomBridge(aiSites, context); return; } // 3.5 直接打开默认 Edge 独立 profile,便于登录或管理浏览器插件。 if (selection.action === 'openIsolatedEdgeProfile') { - launchIsolatedEdgeProfile(context.extensionContext); + await launchIsolatedEdgeProfile(context.extensionContext, context.browserExtensionManager); return; } @@ -270,22 +279,21 @@ async function handleOnlineSelection( // 4. 默认启动 (智能匹配配置) if (selection.target) { - launchBridge({ + const siteId = selection.siteId ?? ''; + await launchBridge({ context: context.extensionContext, - siteId: selection.siteId ?? '', - targetUrl: selection.target, + siteId, browserMode: 'auto', currentPort: context.currentPort, - currentToken: context.currentToken + browserExtensionManager: context.browserExtensionManager, + issueBridgeCode: () => context.serviceController.issueBridgeCode(siteId, selection.target ?? '') }); } } async function launchCustomBridge( aiSites: ResolvedAiSiteConfig[], - extensionContext: vscode.ExtensionContext, - currentPort: number, - currentToken: string + context: OnlineMenuContext ): Promise { // Custom Launch 现在使用所有配置的 AI 站点,无论 showQuickLaunch 是否为 true const aiOptionsForCustomLaunch: CustomActionItem[] = aiSites.map(site => ({ @@ -326,13 +334,16 @@ async function launchCustomBridge( return; } - launchBridge({ - context: extensionContext, + await launchBridge({ + context: context.extensionContext, siteId: aiSelection.siteId ?? "", - targetUrl: aiSelection.target ?? "", browserMode: browserSelection.value ?? "", - currentPort, - currentToken + currentPort: context.currentPort, + browserExtensionManager: context.browserExtensionManager, + issueBridgeCode: () => context.serviceController.issueBridgeCode( + aiSelection.siteId ?? "", + aiSelection.target ?? "" + ) }); } diff --git a/gateway-vscode/src/extension/processDetection.ts b/gateway-vscode/src/extension/processDetection.ts index 7d1570f..c9a0f62 100644 --- a/gateway-vscode/src/extension/processDetection.ts +++ b/gateway-vscode/src/extension/processDetection.ts @@ -1,127 +1,339 @@ -import { execFile } from 'child_process'; +import * as crypto from 'crypto'; +import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; +import { + getBrowserFamily, + getBrowserFamilyForExecutableName, + isBrowserProcessRunningFallback, + listAllProcesses, + stopBrowserProcess, + type BrowserProcessInfo +} from './browserProcessList'; import type { BrowserFamily } from './isolatedBrowserProfiles'; +export { getBrowserFamilyForExecutableName }; + +export interface BrowserBridgeProcess { + pid: number; + browserFamily: BrowserFamily; +} + +const PROFILE_MARKER_SWITCH = '--webcode-profile-id'; +const BRIDGE_MARKER_SWITCH = '--webcode-bridge-id'; +const PROCESS_POLL_INTERVAL_MS = 100; + +export function getBrowserProfileMarkerArgument( + profileDir: string, + platform: NodeJS.Platform = os.platform() +): string { + return createPathMarkerArgument(PROFILE_MARKER_SWITCH, profileDir, platform); +} + +export function getBrowserBridgeMarkerArgument( + extensionPath: string, + platform: NodeJS.Platform = os.platform() +): string { + return createPathMarkerArgument(BRIDGE_MARKER_SWITCH, extensionPath, platform); +} + export async function isBrowserProcessRunning(browserFamily: BrowserFamily): Promise { const platform = os.platform(); - - if (platform === 'win32') { - const imageName = browserFamily === 'edge' ? 'msedge.exe' : 'chrome.exe'; - const output = await execFileText('tasklist', ['/FI', `IMAGENAME eq ${imageName}`, '/FO', 'CSV', '/NH']); - return output.toLowerCase().includes(`"${imageName.toLowerCase()}"`); + try { + const processes = await listAllProcesses(platform); + return processes.some(processInfo => getBrowserFamily(processInfo, platform) === browserFamily); + } catch { + return isBrowserProcessRunningFallback(browserFamily, platform); } - - const processNames = getBrowserProcessNames(browserFamily, platform); - const results = await Promise.all(processNames.map(name => isProcessNameRunning(name))); - return results.some(Boolean); } export async function isBrowserProfileInUse(browserFamily: BrowserFamily, profileDir: string): Promise { - const platform = os.platform(); try { - const commandLines = await listBrowserProcessCommandLines(browserFamily, platform); - return commandLines.some(commandLine => browserCommandLineUsesProfile(commandLine, profileDir, platform)); + if ((await getBrowserProfileProcessIds(browserFamily, profileDir)).length > 0) { + return true; + } + return await hasLiveProfileSingletonLock(profileDir); } catch { return isBrowserProcessRunning(browserFamily); } } +export async function getBrowserProfileProcessIds( + browserFamily: BrowserFamily, + profileDir: string +): Promise { + const platform = os.platform(); + const markerArgument = getBrowserProfileMarkerArgument(profileDir, platform); + const processes = await listAllProcesses(platform); + return uniqueProcessIds(processes.filter(processInfo => processMatchesProfile( + processInfo, + browserFamily, + profileDir, + markerArgument, + platform + ))); +} + +export async function getBrowserBridgeProcesses(extensionPath: string): Promise { + const platform = os.platform(); + const markerArgument = getBrowserBridgeMarkerArgument(extensionPath, platform); + const matches = (await listAllProcesses(platform)).filter(processInfo => processMatchesBridge( + processInfo, + extensionPath, + markerArgument, + platform + )); + const seen = new Set(); + return matches.flatMap(processInfo => { + if (seen.has(processInfo.pid)) { + return []; + } + seen.add(processInfo.pid); + return [{ + pid: processInfo.pid, + browserFamily: getBrowserFamily(processInfo, platform) ?? 'chrome' + }]; + }); +} + +export async function waitForBrowserProfileBridgeProcess( + browserFamily: BrowserFamily, + profileDir: string, + extensionPath: string, + timeoutMs = 10_000 +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await hasBrowserProfileBridgeProcess(browserFamily, profileDir, extensionPath)) { + return true; + } + await delay(PROCESS_POLL_INTERVAL_MS); + } + return hasBrowserProfileBridgeProcess(browserFamily, profileDir, extensionPath); +} + +export async function stopBrowserProfileProcesses( + browserFamily: BrowserFamily, + profileDir: string, + timeoutMs = 5_000 +): Promise { + const processIds = await getBrowserProfileProcessIds(browserFamily, profileDir); + return stopAndWaitForProcesses( + processIds, + () => getBrowserProfileProcessIds(browserFamily, profileDir), + timeoutMs + ); +} + +export async function stopBrowserBridgeProcesses( + extensionPath: string, + timeoutMs = 5_000 +): Promise { + const processIds = (await getBrowserBridgeProcesses(extensionPath)).map(processInfo => processInfo.pid); + return stopAndWaitForProcesses( + processIds, + async () => (await getBrowserBridgeProcesses(extensionPath)).map(processInfo => processInfo.pid), + timeoutMs + ); +} + +export function browserArgumentsUseProfile( + argv: readonly string[], + profileDir: string, + platform: NodeJS.Platform = os.platform() +): boolean { + const markerArgument = getBrowserProfileMarkerArgument(profileDir, platform); + if (argv.includes(markerArgument)) { + return true; + } + const profileArg = readSwitchValueFromArguments(argv, '--user-data-dir'); + return Boolean(profileArg && + normalizeProcessPath(profileArg, platform) === normalizeProcessPath(profileDir, platform)); +} + export function browserCommandLineUsesProfile( commandLine: string, profileDir: string, platform: NodeJS.Platform = os.platform() ): boolean { - const profileArg = readUserDataDirArgument(commandLine); - if (!profileArg) { - return false; + if (platform !== 'win32' && commandLineHasFlattenedSwitchValue( + commandLine, + '--user-data-dir', + normalizeBrowserArgumentPath(profileDir, platform) + )) { + return true; } + return browserArgumentsUseProfile(tokenizeCommandLine(commandLine), profileDir, platform); +} - return normalizeProcessPath(profileArg, platform) === normalizeProcessPath(profileDir, platform); +export function commandLineHasExactArgument(commandLine: string, argument: string): boolean { + const escapedArgument = escapeRegExp(argument); + return new RegExp(`(?:^|\\s)${escapedArgument}(?=$|\\s)`).test(commandLine); } -function getBrowserProcessNames(browserFamily: BrowserFamily, platform: NodeJS.Platform): string[] { - if (platform === 'darwin') { - return browserFamily === 'edge' ? ['Microsoft Edge'] : ['Google Chrome', 'Google Chrome Helper']; +async function stopAndWaitForProcesses( + processIds: number[], + getRemainingProcessIds: () => Promise, + timeoutMs: number +): Promise { + if (processIds.length === 0) { + return true; } - return browserFamily === 'edge' - ? ['msedge', 'microsoft-edge', 'microsoft-edge-stable'] - : ['chrome', 'google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']; + await Promise.all(processIds.map(processId => stopBrowserProcess(processId, os.platform()))); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if ((await getRemainingProcessIds()).length === 0) { + return true; + } + await delay(PROCESS_POLL_INTERVAL_MS); + } + return (await getRemainingProcessIds()).length === 0; } -async function isProcessNameRunning(processName: string): Promise { - try { - await execFileText('pgrep', ['-x', processName]); - return true; - } catch { - return isProcessNameRunningWithPs(processName); - } +function createPathMarkerArgument( + switchName: string, + filePath: string, + platform: NodeJS.Platform +): string { + const normalizedPath = normalizeProcessPath(filePath, platform); + const digest = crypto.createHash('sha256').update(normalizedPath).digest('hex'); + return `${switchName}=${digest}`; } -async function isProcessNameRunningWithPs(processName: string): Promise { - try { - const output = await execFileText('ps', ['-A', '-o', 'comm=']); - return output - .split(/\r?\n/) - .map(command => path.basename(command.trim())) - .some(command => command === processName); - } catch { - return false; - } +async function hasBrowserProfileBridgeProcess( + browserFamily: BrowserFamily, + profileDir: string, + extensionPath: string +): Promise { + const platform = os.platform(); + const profileMarker = getBrowserProfileMarkerArgument(profileDir, platform); + const bridgeMarker = getBrowserBridgeMarkerArgument(extensionPath, platform); + return (await listAllProcesses(platform)).some(processInfo => + processMatchesProfile(processInfo, browserFamily, profileDir, profileMarker, platform) && + processMatchesBridge(processInfo, extensionPath, bridgeMarker, platform) + ); } -async function listBrowserProcessCommandLines( +function processMatchesProfile( + processInfo: BrowserProcessInfo, browserFamily: BrowserFamily, + profileDir: string, + markerArgument: string, + platform: NodeJS.Platform +): boolean { + return processHasExactArgument(processInfo, markerArgument, platform) || + getBrowserFamily(processInfo, platform) === browserFamily && + processUsesProfile(processInfo, profileDir, platform); +} + +function processMatchesBridge( + processInfo: BrowserProcessInfo, + extensionPath: string, + markerArgument: string, + platform: NodeJS.Platform +): boolean { + return processHasExactArgument(processInfo, markerArgument, platform) || + processLoadsExtension(processInfo, extensionPath, platform); +} + +function processUsesProfile( + processInfo: BrowserProcessInfo, + profileDir: string, platform: NodeJS.Platform -): Promise { +): boolean { + if (processInfo.argv) { + return browserArgumentsUseProfile(processInfo.argv, profileDir, platform); + } if (platform === 'win32') { - return listWindowsBrowserProcessCommandLines(browserFamily); + return browserCommandLineUsesProfile(processInfo.commandLine, profileDir, platform); } - - return listPosixBrowserProcessCommandLines(browserFamily, platform); + return commandLineHasFlattenedSwitchValue( + processInfo.commandLine, + '--user-data-dir', + normalizeBrowserArgumentPath(profileDir, platform) + ); } -async function listWindowsBrowserProcessCommandLines(browserFamily: BrowserFamily): Promise { - const imageName = browserFamily === 'edge' ? 'msedge.exe' : 'chrome.exe'; - const command = [ - '$ErrorActionPreference = "Stop";', - `Get-CimInstance Win32_Process -Filter "Name='${imageName}'" |`, - 'ForEach-Object { $_.CommandLine }' - ].join(' '); - const output = await execFileText('powershell.exe', ['-NoProfile', '-Command', command]); - return output.split(/\r?\n/).filter(line => line.trim().length > 0); +function processLoadsExtension( + processInfo: BrowserProcessInfo, + extensionPath: string, + platform: NodeJS.Platform +): boolean { + const expectedPath = normalizeProcessPath(extensionPath, platform); + if (processInfo.argv) { + const loadedPath = readSwitchValueFromArguments(processInfo.argv, '--load-extension'); + return Boolean(loadedPath && normalizeProcessPath(loadedPath, platform) === expectedPath); + } + if (platform === 'win32') { + const loadedPath = readSwitchValueFromArguments(tokenizeCommandLine(processInfo.commandLine), '--load-extension'); + return Boolean(loadedPath && normalizeProcessPath(loadedPath, platform) === expectedPath); + } + return commandLineHasFlattenedSwitchValue( + processInfo.commandLine, + '--load-extension', + normalizeBrowserArgumentPath(extensionPath, platform) + ); } -async function listPosixBrowserProcessCommandLines( - browserFamily: BrowserFamily, +function processHasExactArgument( + processInfo: BrowserProcessInfo, + argument: string, platform: NodeJS.Platform -): Promise { - const output = await execFileText('ps', [platform === 'darwin' ? '-axo' : '-eo', 'args=']); - const processNames = getBrowserProcessNames(browserFamily, platform).map(name => name.toLowerCase()); - return output - .split(/\r?\n/) - .map(line => line.trim()) - .filter(line => processNames.some(name => line.toLowerCase().includes(name))); -} - -function readUserDataDirArgument(commandLine: string): string | null { - const tokens = tokenizeCommandLine(commandLine); - for (let index = 0; index < tokens.length; index += 1) { - const token = tokens[index]; - if (token === '--user-data-dir') { - return tokens[index + 1] ?? null; - } +): boolean { + if (processInfo.argv) { + return processInfo.argv.includes(argument); + } + if (platform === 'win32') { + return tokenizeCommandLine(processInfo.commandLine).includes(argument); + } + return commandLineHasExactArgument(processInfo.commandLine, argument); +} - if (token.startsWith('--user-data-dir=')) { - return token.slice('--user-data-dir='.length); - } +function commandLineHasFlattenedSwitchValue( + commandLine: string, + switchName: string, + expectedValue: string +): boolean { + const prefix = `${switchName}=`; + const exactArgument = `${prefix}${expectedValue}`; + if (commandLineHasExactArgument(commandLine, exactArgument) || + commandLineHasExactArgument(commandLine, `${prefix}"${expectedValue}"`) || + commandLineHasExactArgument(commandLine, `${prefix}'${expectedValue}'`)) { + return true; } + const escaped = escapeRegExp(exactArgument); + return new RegExp(`(?:^|\\s)${escaped}(?=$|\\s+--)`).test(commandLine); +} + +function readSwitchValueFromArguments(argv: readonly string[], switchName: string): string | null { + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === switchName) { + return argv[index + 1] ?? null; + } + if (argument.startsWith(`${switchName}=`)) { + return argument.slice(switchName.length + 1); + } + } return null; } +async function hasLiveProfileSingletonLock(profileDir: string): Promise { + if (os.platform() === 'win32') { + return false; + } + try { + const lockTarget = await fs.readlink(path.join(profileDir, 'SingletonLock')); + const match = /-(\d+)$/.exec(lockTarget); + return Boolean(match && isProcessAlive(Number(match[1]))); + } catch { + return false; + } +} + function tokenizeCommandLine(commandLine: string): string[] { const tokens: string[] = []; let current = ''; @@ -144,21 +356,27 @@ function tokenizeCommandLine(commandLine: string): string[] { if (char === '"' || char === "'") { quote = char; } else if (/\s/.test(char)) { - pushToken(tokens, current); - current = ''; + if (current) { + tokens.push(current); + current = ''; + } } else { current += char; } } - - pushToken(tokens, current); + if (current) { + tokens.push(current); + } return tokens; } -function pushToken(tokens: string[], token: string): void { - if (token) { - tokens.push(token); - } +function uniqueProcessIds(processes: BrowserProcessInfo[]): number[] { + return [...new Set(processes.map(processInfo => processInfo.pid))]; +} + +function normalizeBrowserArgumentPath(filePath: string, platform: NodeJS.Platform): string { + const platformPath = platform === 'win32' ? path.win32 : path.posix; + return platformPath.resolve(filePath).replace(/\\/g, '/').replace(/\/+$/g, ''); } function normalizeProcessPath(filePath: string, platform: NodeJS.Platform): string { @@ -171,15 +389,23 @@ function isCaseInsensitivePlatform(platform: NodeJS.Platform): boolean { return platform === 'win32' || platform === 'darwin'; } -function execFileText(command: string, args: string[]): Promise { - return new Promise((resolve, reject) => { - execFile(command, args, (error, stdout) => { - if (error) { - reject(new Error(error.message)); - return; - } +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error: unknown) { + return hasErrorCode(error, 'EPERM'); + } +} - resolve(stdout); - }); - }); +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code; +} + +function delay(milliseconds: number): Promise { + return new Promise(resolve => setTimeout(resolve, milliseconds)); } diff --git a/gateway-vscode/src/extension/serviceController.ts b/gateway-vscode/src/extension/serviceController.ts index 636585f..e8ff76a 100644 --- a/gateway-vscode/src/extension/serviceController.ts +++ b/gateway-vscode/src/extension/serviceController.ts @@ -8,10 +8,10 @@ import { getErrorMessage } from './errorUtils'; import { updateGatewayStatusBar } from './statusBar'; import type { AISiteConfig, ResolvedAiSiteConfig } from './types'; import { resolveCommandAllowedRoots } from './commandAllowedRoots'; +import { resolveIdleTimeoutMs } from '../gateway/idleTimeout'; export interface GatewayServiceSnapshot { currentPort: number | null; - currentToken: string | null; isStarting: boolean; isRunning: boolean; } @@ -20,7 +20,8 @@ export interface GatewayServiceController { start(): Promise; stop(): Promise; restart(): Promise; - markAutoStopped(): void; + issueBridgeCode(siteId: string, targetUrl: string): string; + markAutoStopped(idleTimeoutMs: number): void; markOffline(): void; getState(): GatewayServiceSnapshot; } @@ -34,11 +35,10 @@ interface CreateGatewayServiceControllerOptions { export function createGatewayServiceController(options: CreateGatewayServiceControllerOptions): GatewayServiceController { let currentPort: number | null = null; - let currentToken: string | null = null; let isStarting = false; let isRunning = false; - - const getState = () => ({ currentPort, currentToken, isStarting, isRunning }); + const getState = () => ({ currentPort, isStarting, isRunning }); + const issueBridgeCode = createBridgeCodeIssuer(options.manager, getState); const start = async () => { if (!hasWorkspaceFolder()) { @@ -46,7 +46,6 @@ export function createGatewayServiceController(options: CreateGatewayServiceCont await options.manager.stop(); } currentPort = null; - currentToken = null; isStarting = false; isRunning = false; updateGatewayStatusBar(options.statusBarItem, false); @@ -60,6 +59,7 @@ export function createGatewayServiceController(options: CreateGatewayServiceCont const config = vscode.workspace.getConfiguration('webcodeGateway'); const portConfig = config.get('port') ?? 34567; + const idleTimeoutMs = resolveIdleTimeoutMs(config.get('idleTimeoutMinutes')); const commandConfig = getCommandExecutionConfig(config, options.outputChannel); const customServers = filterCustomServers( config.get>('servers') ?? {}, @@ -75,6 +75,7 @@ export function createGatewayServiceController(options: CreateGatewayServiceCont try { const result = await options.manager.start({ port: portConfig, + idleTimeoutMs, preferredPort: lastUsedPort, mcpServers: customServers, allowedOrigins, @@ -84,8 +85,6 @@ export function createGatewayServiceController(options: CreateGatewayServiceCont }); currentPort = result.port; - currentToken = result.token; - if (currentPort !== lastUsedPort) { await options.context.workspaceState.update('mcp.lastPort', currentPort); } @@ -97,16 +96,20 @@ export function createGatewayServiceController(options: CreateGatewayServiceCont void vscode.window.showErrorMessage(t('start_failed', { message: getErrorMessage(error) })); isStarting = false; isRunning = false; + currentPort = null; updateGatewayStatusBar(options.statusBarItem, false); } }; - const stop = async () => { - await options.manager.stop(); + const markOffline = () => { isRunning = false; currentPort = null; - currentToken = null; updateGatewayStatusBar(options.statusBarItem, false); + }; + + const stop = async () => { + await options.manager.stop(); + markOffline(); void vscode.window.showInformationMessage(t('server_stopped')); }; @@ -117,28 +120,37 @@ export function createGatewayServiceController(options: CreateGatewayServiceCont void vscode.window.showInformationMessage(t('server_restarted')); }; - const markAutoStopped = () => { - isRunning = false; - updateGatewayStatusBar(options.statusBarItem, false); - void vscode.window.showInformationMessage(t('auto_stop_message')); + const markAutoStopped = (idleTimeoutMs: number) => { + markOffline(); + const minutes = Math.round(idleTimeoutMs / (60 * 1000)); + void vscode.window.showInformationMessage(t('auto_stop_message', { minutes })); options.outputChannel.appendLine("💤 Auto-shutdown triggered due to inactivity."); }; - const markOffline = () => { - isRunning = false; - updateGatewayStatusBar(options.statusBarItem, false); - }; - return { start, stop, restart, + issueBridgeCode, markAutoStopped, markOffline, getState }; } +function createBridgeCodeIssuer( + manager: GatewayManager, + getState: () => GatewayServiceSnapshot +): (siteId: string, targetUrl: string) => string { + return (siteId, targetUrl) => { + const state = getState(); + if (!state.isRunning || !state.currentPort) { + throw new Error('Gateway server is not running.'); + } + return manager.issueBridgeCode(siteId, targetUrl); + }; +} + function getCommandShellPath(config: vscode.WorkspaceConfiguration): string | undefined { const configuredCommandShellPath = config.get('commandShell.path')?.trim(); return configuredCommandShellPath === '' ? undefined : configuredCommandShellPath; diff --git a/gateway-vscode/src/gateway.ts b/gateway-vscode/src/gateway.ts index bf8336a..fc9014f 100644 --- a/gateway-vscode/src/gateway.ts +++ b/gateway-vscode/src/gateway.ts @@ -1,5 +1,4 @@ import express from 'express'; -import * as crypto from 'crypto'; import type { Server as HttpServer } from 'http'; import * as vscode from 'vscode'; @@ -13,11 +12,18 @@ import { type ToolDefinition, type ToolExecutionContext } from './tools'; -import { registerBridgeRoute } from './gateway/bridgeRoute'; +import { + registerBridgeRoute, + resolveAllowedBridgeTarget, + resolveBridgeSite +} from './gateway/bridgeRoute'; +import { BridgeSessionManager } from './gateway/bridgeSession'; import { registerConfigRoutes } from './gateway/initRoutes'; +import { DEFAULT_IDLE_TIMEOUT_MINUTES, formatIdleTimeoutMinutes } from './gateway/idleTimeout'; import { createAuthMiddleware, createCorsMiddleware, + createGatewayActivityMiddleware, createRequestLoggerMiddleware } from './gateway/middleware'; import { connectToConfiguredServers } from './gateway/serverConnector'; @@ -46,23 +52,24 @@ export class GatewayManager { private outputChannel: vscode.OutputChannel; private extensionPath: string; private context: vscode.ExtensionContext; - private authToken: string = ''; private watchdogTimer: NodeJS.Timeout | null = null; - private readonly WATCHDOG_TIMEOUT = 30 * 60 * 1000; // 30 minutes - private onAutoStop: (() => void) | null = null; + private idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MINUTES * 60 * 1000; + private onAutoStop: ((idleTimeoutMs: number) => void) | null = null; private skillManager: SkillManager; private terminalSessionManager: TerminalSessionManager; private skillDirectories: string[] = []; + private currentAiSites: NonNullable = []; private commandShellPath: string | undefined; private commandAllowedRoots: string[] = []; private readonly commandApprovalManager = new CommandApprovalManager(); + private readonly bridgeSessionManager = new BridgeSessionManager(); private readonly traceSink: GatewayRuntimeTraceSink | undefined; constructor( outputChannel: vscode.OutputChannel, extensionPath: string, context: vscode.ExtensionContext, - onAutoStop?: () => void, + onAutoStop?: (idleTimeoutMs: number) => void, traceSink?: GatewayRuntimeTraceSink ) { this.outputChannel = outputChannel; @@ -72,8 +79,6 @@ export class GatewayManager { this.traceSink = traceSink; this.skillManager = new SkillManager(outputChannel, extensionPath, () => vscode.workspace.workspaceFolders); this.terminalSessionManager = new TerminalSessionManager(outputChannel); - // [Persistence] Generate token once per VS Code session - this.authToken = crypto.randomUUID(); } private _generateGroupedTools() { @@ -102,10 +107,10 @@ export class GatewayManager { private resetWatchdog() { if (this.watchdogTimer) {clearTimeout(this.watchdogTimer);} this.watchdogTimer = setTimeout(() => { - this.log('💤 No activity for 30 minutes. Shutting down...'); + this.log(`💤 No activity for ${formatIdleTimeoutMinutes(this.idleTimeoutMs)} minutes. Shutting down...`); void this.stop(); - if (this.onAutoStop) {this.onAutoStop();} - }, this.WATCHDOG_TIMEOUT); + if (this.onAutoStop) {this.onAutoStop(this.idleTimeoutMs);} + }, this.idleTimeoutMs); } private log(message: string) { @@ -158,26 +163,46 @@ export class GatewayManager { }); } + issueBridgeCode(siteId: string, targetUrl: string): string { + if (!this.server) { + throw new Error('Gateway server is not running.'); + } + + const site = resolveBridgeSite(siteId, this.currentAiSites); + const resolvedTarget = site ? resolveAllowedBridgeTarget(targetUrl, site) : null; + if (!site || !resolvedTarget) { + throw new Error('Bridge target does not match a configured AI site.'); + } + + return this.bridgeSessionManager.issueBridgeCode({ + siteId: site.id, + targetUrl: resolvedTarget + }); + } + private registerRoutes(config: GatewayConfig): void { if (!this.app) {return;} this.app.use(createCorsMiddleware(config, this.log.bind(this))); - this.app.use(createRequestLoggerMiddleware( - () => this.resetWatchdog(), - this.log.bind(this), - { skipWatchdogPaths: ['/v1/status'] } - )); - this.app.use(createAuthMiddleware(() => this.authToken, this.log.bind(this))); - - registerConfigRoutes(this.app, config, this.log.bind(this)); + this.app.use(createRequestLoggerMiddleware(this.log.bind(this))); registerBridgeRoute(this.app, { - getPort: () => this.getServerPort(), + activateSession: () => this.bridgeSessionManager.activateSession(), + consumeBridgeCode: code => this.bridgeSessionManager.consumeBridgeCode(code), + getBridgeLaunch: code => this.bridgeSessionManager.getBridgeLaunch(code), getAiSites: () => config.aiSites ?? [], - getAuthToken: () => this.authToken, getExtensionVersion: () => this.getExtensionVersion(), + getIdleTimeoutMs: () => this.idleTimeoutMs, getWorkspaceRoot: () => this.getPrimaryWorkspaceRoot(), - log: this.log.bind(this) + log: this.log.bind(this), + recordActivity: () => this.resetWatchdog() }); + this.app.use(createAuthMiddleware( + token => this.bridgeSessionManager.isSessionTokenValid(token), + this.log.bind(this) + )); + this.app.use(createGatewayActivityMiddleware(() => this.resetWatchdog())); + + registerConfigRoutes(this.app, config, this.log.bind(this)); const toolCallOptions = { commandApprovalManager: this.commandApprovalManager, @@ -195,14 +220,6 @@ export class GatewayManager { this.app.post('/v1/tools/call', createToolCallHandler(toolCallOptions)); } - private getServerPort(): number { - const address = this.server?.address(); - if (!address || typeof address === 'string') { - throw new Error('Gateway server is not listening on a TCP port.'); - } - return address.port; - } - private getExtensionVersion(): string { const version = (this.context.extension.packageJSON as { version?: unknown }).version; return typeof version === 'string' && version.trim() ? version : 'unknown'; @@ -216,7 +233,10 @@ export class GatewayManager { const commandShellPath = config.commandShellPath?.trim(); this.commandShellPath = commandShellPath === '' ? undefined : commandShellPath; this.commandAllowedRoots = config.commandAllowedRoots ?? []; + this.currentAiSites = config.aiSites ?? []; + this.idleTimeoutMs = config.idleTimeoutMs; this.commandApprovalManager.clear(); + this.bridgeSessionManager.clear(); this.trace({ event: 'gateway_starting', status: 'started', @@ -224,10 +244,6 @@ export class GatewayManager { }); await this.connectToServers(config.mcpServers); - // 1. 使用持久化 Token (仅首次生成) - if (!this.authToken) {this.authToken = crypto.randomUUID();} - // this.log(`🔐 Security Token: ${this.authToken}`); // Reduce noise on restart - // Start Watchdog this.resetWatchdog(); @@ -249,14 +265,14 @@ export class GatewayManager { if (!this.app) {return;} this.server = this.app.listen(currentPort, '127.0.0.1', () => { - this.log(`🌐 Gateway running on http://127.0.0.1:${currentPort} (Token: ${this.authToken.slice(0, 8)}...)`); + this.log(`🌐 Gateway running on http://127.0.0.1:${currentPort}`); this.trace({ event: 'gateway_started', status: 'success', details: { port: currentPort } }); vscode.window.setStatusBarMessage(`MCP Gateway: On (${currentPort})`, 5000); - resolve({ port: currentPort, token: this.authToken }); + resolve({ port: currentPort }); }); this.server.on('error', (e: NodeJS.ErrnoException) => { @@ -291,7 +307,6 @@ export class GatewayManager { if (this.server) { this.server.close(); this.server = null; - // [Persistence] Do NOT clear authToken here this.log('🛑 Gateway server stopped.'); this.trace({ event: 'gateway_stopped', status: 'success' }); } @@ -302,5 +317,7 @@ export class GatewayManager { }); this.connectedClients = []; this.commandApprovalManager.clear(); + this.bridgeSessionManager.clear(); + this.currentAiSites = []; } } diff --git a/gateway-vscode/src/gateway/bridgeRoute.ts b/gateway-vscode/src/gateway/bridgeRoute.ts index dc727c8..9b3f655 100644 --- a/gateway-vscode/src/gateway/bridgeRoute.ts +++ b/gateway-vscode/src/gateway/bridgeRoute.ts @@ -1,17 +1,24 @@ import * as crypto from 'crypto'; import type express from 'express'; -import { BRANDING } from '@webcode/shared'; +import { BRANDING, BRIDGE_PROTOCOL_VERSION } from '@webcode/shared'; import { findAiSiteById, isTargetAllowedForSite, type ResolvedAiSiteConfig } from '../platforms'; +import type { PendingBridgeLaunch } from './bridgeSession'; import type { GatewayLogger } from './types'; +const BRIDGE_EXTENSION_ID = 'kghhldphcmpiimophipabdhldfipgiio'; +const BRIDGE_EXTENSION_ORIGIN = `chrome-extension://${BRIDGE_EXTENSION_ID}`; + type BridgeRouteOptions = { - getPort: () => number; + activateSession: () => string; + consumeBridgeCode: (code: string) => PendingBridgeLaunch | null; + getBridgeLaunch: (code: string) => PendingBridgeLaunch | null; getAiSites: () => readonly ResolvedAiSiteConfig[]; - getAuthToken: () => string; getExtensionVersion: () => string; + getIdleTimeoutMs: () => number; getWorkspaceRoot: () => string | null; log: GatewayLogger; + recordActivity: () => void; }; function createWorkspaceId(workspaceRoot: string | null): string { @@ -24,68 +31,111 @@ function createWorkspaceId(workspaceRoot: string | null): string { export function registerBridgeRoute(app: express.Express, options: BridgeRouteOptions): void { app.get('/bridge', (req, res) => { - const aiSites = options.getAiSites(); - const site = resolveBridgeSite(getSingleQueryValue(req.query.siteId), aiSites); - if (!site) { - options.log(`⛔ Rejected bridge site id: ${getSingleQueryValue(req.query.siteId) ?? ''}`); - res.status(400).send(renderInvalidBridgePage()); - return; - } - - const bridgeToken = getSingleQueryValue(req.query.bridgeToken); - if (!bridgeToken || bridgeToken !== options.getAuthToken()) { - options.log(`⛔ Rejected bridge token for ${site.id}.`); - res.status(400).send(renderInvalidBridgePage()); - return; - } - - const rawTarget = getSingleQueryValue(req.query.target) ?? site.address; - const target = resolveAllowedBridgeTarget(rawTarget, site); - if (!target) { - options.log(`⛔ Rejected bridge target for ${site.id}: ${rawTarget}`); + setBridgeSecurityHeaders(res); + const bridgeCode = getSingleQueryValue(req.query.bridgeCode); + const launch = bridgeCode ? options.getBridgeLaunch(bridgeCode) : null; + const resolvedLaunch = launch ? resolvePendingBridgeLaunch(launch, options.getAiSites()) : null; + if (!bridgeCode || !resolvedLaunch) { + options.log('⛔ Rejected missing, expired, or invalid bridge code.'); res.status(400).send(renderInvalidBridgePage()); return; } - const port = options.getPort(); const releaseUrl = `${BRANDING.repositoryUrl}/releases`; - const storeUrl = 'https://chromewebstore.google.com/detail/webcode-bridge/kghhldphcmpiimophipabdhldfipgiio'; + const storeUrl = `https://chromewebstore.google.com/detail/webcode-bridge/${BRIDGE_EXTENSION_ID}`; const vscodeExtensionVersion = options.getExtensionVersion(); const workspaceId = createWorkspaceId(options.getWorkspaceRoot()); - options.log(`🌉 Bridge handshake requested for ${site.id} in workspace [${workspaceId}].`); + options.log(`🌉 Bridge page requested for ${resolvedLaunch.site.id} in workspace [${workspaceId}].`); res.send(renderBridgePage({ - port, - token: bridgeToken, - siteId: site.id, - target, vscodeExtensionVersion, - workspaceId, releaseUrl, storeUrl })); }); + + app.post('/v1/bridge/redeem', (req, res) => { + setBridgeSecurityHeaders(res); + const origin = req.get('origin'); + if (!isAllowedBridgeRedemptionOrigin(origin)) { + options.log(`⛔ Rejected bridge redemption from origin: ${origin ?? ''}`); + res.status(403).json({ + success: false, + error: 'Bridge redemption is only available to the WebCode browser extension.' + }); + return; + } + + const redemptionRequest = getBridgeRedemptionFromBody(req.body); + if (!redemptionRequest) { + options.log('⛔ Rejected malformed bridge redemption request.'); + res.status(400).json({ + success: false, + error: 'A bridge code, browser extension version, and bridge protocol version are required.' + }); + return; + } + if (redemptionRequest.bridgeProtocolVersion !== BRIDGE_PROTOCOL_VERSION) { + options.log('⛔ Rejected bridge redemption using an incompatible bridge protocol.'); + res.status(409).json({ + success: false, + error: 'VS Code and browser bridge protocol versions do not match.' + }); + return; + } + if (redemptionRequest.browserExtensionVersion !== options.getExtensionVersion()) { + options.log('⛔ Rejected bridge redemption from an incompatible browser extension.'); + res.status(409).json({ + success: false, + error: 'VS Code and browser extension versions do not match.' + }); + return; + } + + const bridgeCode = redemptionRequest.bridgeCode; + const launch = bridgeCode ? options.consumeBridgeCode(bridgeCode) : null; + const resolvedLaunch = launch ? resolvePendingBridgeLaunch(launch, options.getAiSites()) : null; + if (!resolvedLaunch) { + options.log('⛔ Rejected expired, used, or invalid bridge code redemption.'); + res.status(410).json({ + success: false, + error: 'Bridge code expired or already used. Launch the site again from VS Code.' + }); + return; + } + + const token = options.activateSession(); + const workspaceId = createWorkspaceId(options.getWorkspaceRoot()); + options.recordActivity(); + options.log(`🔐 Bridge code redeemed for ${resolvedLaunch.site.id} in workspace [${workspaceId}].`); + + res.json({ + success: true, + token, + siteId: resolvedLaunch.site.id, + targetOrigin: new URL(resolvedLaunch.targetUrl).origin, + targetUrl: resolvedLaunch.targetUrl, + vscodeExtensionVersion: options.getExtensionVersion(), + bridgeProtocolVersion: BRIDGE_PROTOCOL_VERSION, + workspaceId, + idleTimeoutMs: options.getIdleTimeoutMs() + }); + }); + + app.get('/favicon.ico', (_req, res) => { + res.status(204).end(); + }); } type BridgePageOptions = { - port: number; - token: string; - siteId: string; - target: string; vscodeExtensionVersion: string; - workspaceId: string; releaseUrl: string; storeUrl: string; }; function renderBridgePage({ - port, - token, - siteId, - target, vscodeExtensionVersion, - workspaceId, releaseUrl, storeUrl }: BridgePageOptions): string { @@ -96,7 +146,7 @@ function renderBridgePage({ ${renderMainCard()} - ${renderBridgeData({ port, token, siteId, target, vscodeExtensionVersion, workspaceId })} + ${renderBridgeData(vscodeExtensionVersion)} ${renderInstallGuide({ releaseUrl, storeUrl })} @@ -139,6 +189,44 @@ export function resolveBridgeSite( return aiSites[0] ?? null; } +function resolvePendingBridgeLaunch( + launch: PendingBridgeLaunch, + aiSites: readonly ResolvedAiSiteConfig[] +): { site: ResolvedAiSiteConfig; targetUrl: string } | null { + const site = resolveBridgeSite(launch.siteId, aiSites); + if (!site) { + return null; + } + + const targetUrl = resolveAllowedBridgeTarget(launch.targetUrl, site); + return targetUrl ? { site, targetUrl } : null; +} + +export function isAllowedBridgeRedemptionOrigin(origin: string | undefined): boolean { + return origin === BRIDGE_EXTENSION_ORIGIN; +} + +function getBridgeRedemptionFromBody( + body: unknown +): { bridgeCode: string; browserExtensionVersion: string; bridgeProtocolVersion: number } | null { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return null; + } + + const record = body as Record; + const bridgeCode = typeof record.bridgeCode === 'string' ? record.bridgeCode.trim() : ''; + const browserExtensionVersion = typeof record.browserExtensionVersion === 'string' + ? record.browserExtensionVersion.trim() + : ''; + const bridgeProtocolVersion = record.bridgeProtocolVersion; + return bridgeCode && + browserExtensionVersion && + typeof bridgeProtocolVersion === 'number' && + Number.isInteger(bridgeProtocolVersion) + ? { bridgeCode, browserExtensionVersion, bridgeProtocolVersion } + : null; +} + function getSingleQueryValue(value: unknown): string | null { if (typeof value === 'string') { return value; @@ -151,8 +239,27 @@ function getSingleQueryValue(value: unknown): string | null { return null; } -function renderBridgeData(options: Pick): string { - return ``; +function setBridgeSecurityHeaders(res: express.Response): void { + res.set({ + 'Cache-Control': 'no-store, max-age=0', + Pragma: 'no-cache', + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff' + }); +} + +function renderBridgeData(vscodeExtensionVersion: string): string { + const legacyVersionMismatch = `bridge-protocol-${BRIDGE_PROTOCOL_VERSION}:${vscodeExtensionVersion}`; + return ``; } function renderBridgeHead(): string { diff --git a/gateway-vscode/src/gateway/bridgeSession.ts b/gateway-vscode/src/gateway/bridgeSession.ts new file mode 100644 index 0000000..6f17841 --- /dev/null +++ b/gateway-vscode/src/gateway/bridgeSession.ts @@ -0,0 +1,106 @@ +import * as crypto from 'crypto'; + +export const DEFAULT_BRIDGE_CODE_TTL_MS = 60 * 1000; + +const MAX_PENDING_BRIDGE_CODES = 64; + +export interface PendingBridgeLaunch { + siteId: string; + targetUrl: string; +} + +type PendingBridgeCode = PendingBridgeLaunch & { + expiresAt: number; +}; + +type RandomSecretFactory = () => string; + +function createRandomSecret(): string { + return crypto.randomBytes(32).toString('base64url'); +} + +/** + * Owns the short-lived launch codes and the browser session token shared by the + * current gateway lifecycle. Launch codes are safe to place in a URL because they + * expire quickly and can be consumed only once. The API session token is returned + * only by the redemption response and is revoked when the gateway stops or restarts. + */ +export class BridgeSessionManager { + private readonly pendingCodes = new Map(); + private activeSessionToken: string | null = null; + + constructor( + private readonly bridgeCodeTtlMs = DEFAULT_BRIDGE_CODE_TTL_MS, + private readonly now: () => number = Date.now, + private readonly createSecret: RandomSecretFactory = createRandomSecret + ) { } + + issueBridgeCode(launch: PendingBridgeLaunch): string { + this.cleanupExpiredCodes(); + this.enforcePendingCodeLimit(); + + let code = this.createSecret(); + while (this.pendingCodes.has(code)) { + code = this.createSecret(); + } + + this.pendingCodes.set(code, { + ...launch, + expiresAt: this.now() + this.bridgeCodeTtlMs + }); + return code; + } + + getBridgeLaunch(code: string): PendingBridgeLaunch | null { + this.cleanupExpiredCodes(); + const pending = this.pendingCodes.get(code); + return pending ? { siteId: pending.siteId, targetUrl: pending.targetUrl } : null; + } + + consumeBridgeCode(code: string): PendingBridgeLaunch | null { + this.cleanupExpiredCodes(); + const pending = this.pendingCodes.get(code); + if (!pending) { + return null; + } + + this.pendingCodes.delete(code); + return { + siteId: pending.siteId, + targetUrl: pending.targetUrl + }; + } + + activateSession(): string { + this.activeSessionToken ??= this.createSecret(); + return this.activeSessionToken; + } + + isSessionTokenValid(token: string | undefined): boolean { + return Boolean(token && this.activeSessionToken && token === this.activeSessionToken); + } + + clear(): void { + this.pendingCodes.clear(); + this.activeSessionToken = null; + } + + private cleanupExpiredCodes(): void { + const now = this.now(); + for (const [code, pending] of this.pendingCodes) { + if (pending.expiresAt <= now) { + this.pendingCodes.delete(code); + } + } + } + + private enforcePendingCodeLimit(): void { + while (this.pendingCodes.size >= MAX_PENDING_BRIDGE_CODES) { + const oldestCode = this.pendingCodes.keys().next().value; + if (!oldestCode) { + return; + } + this.pendingCodes.delete(oldestCode); + } + } +} diff --git a/gateway-vscode/src/gateway/idleTimeout.ts b/gateway-vscode/src/gateway/idleTimeout.ts new file mode 100644 index 0000000..a9d6a49 --- /dev/null +++ b/gateway-vscode/src/gateway/idleTimeout.ts @@ -0,0 +1,24 @@ +export const DEFAULT_IDLE_TIMEOUT_MINUTES = 30; +export const MIN_IDLE_TIMEOUT_MINUTES = 5; +export const MAX_IDLE_TIMEOUT_MINUTES = 240; + +const MILLISECONDS_PER_MINUTE = 60 * 1000; + +export function resolveIdleTimeoutMinutes(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return DEFAULT_IDLE_TIMEOUT_MINUTES; + } + + return Math.min( + MAX_IDLE_TIMEOUT_MINUTES, + Math.max(MIN_IDLE_TIMEOUT_MINUTES, Math.trunc(value)) + ); +} + +export function resolveIdleTimeoutMs(value: unknown): number { + return resolveIdleTimeoutMinutes(value) * MILLISECONDS_PER_MINUTE; +} + +export function formatIdleTimeoutMinutes(idleTimeoutMs: number): string { + return String(Math.round(idleTimeoutMs / MILLISECONDS_PER_MINUTE)); +} diff --git a/gateway-vscode/src/gateway/initRoutes.ts b/gateway-vscode/src/gateway/initRoutes.ts index 5a8ba12..e991b34 100644 --- a/gateway-vscode/src/gateway/initRoutes.ts +++ b/gateway-vscode/src/gateway/initRoutes.ts @@ -10,7 +10,7 @@ export function registerConfigRoutes( log: GatewayLogger ): void { app.get('/v1/status', (_req, res) => { - res.json({ ok: true }); + res.json({ ok: true, idleTimeoutMs: config.idleTimeoutMs }); }); app.get('/v1/init', (req, res) => { diff --git a/gateway-vscode/src/gateway/middleware.ts b/gateway-vscode/src/gateway/middleware.ts index 6ee9f34..c02be1a 100644 --- a/gateway-vscode/src/gateway/middleware.ts +++ b/gateway-vscode/src/gateway/middleware.ts @@ -7,38 +7,56 @@ import type { GatewayConfig, GatewayLogger } from './types'; export function createCorsMiddleware(config: GatewayConfig, log: GatewayLogger): express.RequestHandler { return cors({ origin: (origin, callback) => { - if (!origin) {return callback(null, true);} - - if (origin.startsWith('chrome-extension://')) {return callback(null, true);} - - if (config.allowedOrigins.includes(origin)) { + if (isAllowedCorsOrigin(origin, config.allowedOrigins)) { return callback(null, true); } - - if (origin.startsWith('http://127.0.0.1') || origin.startsWith('http://localhost')) { - return callback(null, true); - } - log(`⛔ Blocked CORS request from: ${origin}`); callback(new Error('Not allowed by CORS')); } }); } +export function isAllowedCorsOrigin(origin: string | undefined, allowedOrigins: readonly string[]): boolean { + if (!origin) { + return true; + } + if (allowedOrigins.includes(origin)) { + return true; + } + + let parsedOrigin: URL; + try { + parsedOrigin = new URL(origin); + } catch { + return false; + } + + return isChromeExtensionOrigin(parsedOrigin) || isLoopbackHttpOrigin(parsedOrigin, origin); +} + +function isChromeExtensionOrigin(origin: URL): boolean { + return origin.protocol === 'chrome-extension:' && + Boolean(origin.hostname) && + !origin.username && + !origin.password && + !origin.search && + !origin.hash && + (origin.pathname === '' || origin.pathname === '/'); +} + +function isLoopbackHttpOrigin(parsedOrigin: URL, rawOrigin: string): boolean { + return parsedOrigin.protocol === 'http:' && + (parsedOrigin.hostname === '127.0.0.1' || parsedOrigin.hostname === 'localhost') && + parsedOrigin.origin === rawOrigin; +} + export function createRequestLoggerMiddleware( - resetWatchdog: () => void, - log: GatewayLogger, - options: { skipWatchdogPaths?: readonly string[] } = {} + log: GatewayLogger ): express.RequestHandler { - const skipWatchdogPaths = new Set(options.skipWatchdogPaths ?? []); - return (req, res, next) => { - if (!skipWatchdogPaths.has(req.path)) { - resetWatchdog(); - } const start = Date.now(); if (req.method !== 'OPTIONS') { - log(`🔔 [${req.method}] ${req.url}`); + log(`🔔 [${req.method}] ${req.path}`); } res.on('finish', () => { const duration = Date.now() - start; @@ -51,19 +69,41 @@ export function createRequestLoggerMiddleware( }; } +const GATEWAY_ACTIVITY_ROUTES = new Set([ + 'GET /v1/init', + 'GET /v1/config', + 'POST /v1/config', + 'POST /v1/tools/preflight', + 'POST /v1/tools/approve', + 'POST /v1/tools/call' +]); + +export function isGatewayActivityRequest(method: string, requestPath: string): boolean { + return GATEWAY_ACTIVITY_ROUTES.has(`${method.toUpperCase()} ${requestPath}`); +} + +export function createGatewayActivityMiddleware(resetWatchdog: () => void): express.RequestHandler { + return (req, _res, next) => { + if (isGatewayActivityRequest(req.method, req.path)) { + resetWatchdog(); + } + next(); + }; +} + export function createAuthMiddleware( - getAuthToken: () => string, + isTokenValid: (token: string | undefined) => boolean, log: GatewayLogger ): express.RequestHandler { return (req, res, next) => { - if (req.path === '/bridge' || req.path === '/favicon.ico' || req.method === 'OPTIONS') { + if (req.method === 'OPTIONS') { return next(); } const rawClientToken = req.headers[PROTOCOL.authHeaderLowerName]; const clientToken = Array.isArray(rawClientToken) ? rawClientToken[0] : rawClientToken; - if (!clientToken || clientToken !== getAuthToken()) { - log(`⛔ Unauthorized access attempt. Token: ${clientToken ?? ''}`); + if (!isTokenValid(clientToken)) { + log(`⛔ Unauthorized access attempt (${clientToken ? 'invalid token' : 'missing token'}).`); return res.status(403).json({ isError: true, content: [{ type: 'text', text: "⛔ Forbidden: Invalid Security Token. Please launch from VS Code." }] diff --git a/gateway-vscode/src/gateway/types.ts b/gateway-vscode/src/gateway/types.ts index 9fd4a16..611704c 100644 --- a/gateway-vscode/src/gateway/types.ts +++ b/gateway-vscode/src/gateway/types.ts @@ -15,6 +15,7 @@ export interface ServerConfig { export interface GatewayConfig { port: number; + idleTimeoutMs: number; preferredPort?: number; mcpServers: Record; allowedOrigins: string[]; @@ -26,7 +27,6 @@ export interface GatewayConfig { export interface StartResult { port: number; - token: string; } export type ConnectedClient = { diff --git a/gateway-vscode/src/i18n.ts b/gateway-vscode/src/i18n.ts index 636946e..4d19193 100644 --- a/gateway-vscode/src/i18n.ts +++ b/gateway-vscode/src/i18n.ts @@ -8,7 +8,7 @@ const locale: Locale = vscode.env.language.toLowerCase().startsWith('zh') ? 'zh' const messages = { en: { output_channel_name: 'MCP Gateway', - auto_stop_message: `${BRANDING.serverName} stopped due to inactivity (30m).`, + auto_stop_message: `${BRANDING.serverName} stopped after {minutes} minutes without activity.`, start_failed: 'Failed to start MCP Gateway: {message}', start_requires_workspace: `Open a folder or workspace in VS Code before starting ${BRANDING.productName}.`, server_stopped: `${BRANDING.serverName} Stopped`, @@ -16,7 +16,7 @@ const messages = { offline_start_label: `$(play) Turn On ${BRANDING.productName}`, offline_start_desc: 'Start the local MCP server', open_isolated_edge_profile_label: '$(browser) Open Edge Isolated Profile', - open_isolated_edge_profile_desc: 'Launch the dedicated Edge profile for sign-in and browser extensions', + open_isolated_edge_profile_desc: 'Launch the dedicated Edge profile for sign-in and the built-in bridge', reset_isolated_profiles_label: '$(trash) Reset Current Isolated Profiles', reset_isolated_profiles_desc: 'Delete current isolated browser sign-in data and cache', clean_legacy_isolated_profiles_label: '$(trash) Clean Legacy Isolated Profiles', @@ -63,6 +63,12 @@ const messages = { open_browser_failed: 'Failed to open browser: {message}', browser_exited_immediately: '{browser} exited immediately with {reason}: {command}', browser_extension_missing: 'Built-in browser extension is missing. Run the browser build first or reinstall the VS Code extension.', + browser_bridge_preparing: 'Preparing the WebCode browser bridge...', + browser_bridge_prepare_failed: 'Failed to prepare the browser bridge at {path}: {message}', + browser_bridge_restart_required: 'The WebCode browser bridge was updated. Restart the isolated {browsers} process and continue?', + browser_bridge_restart_button: 'Restart Isolated Browser and Continue', + browser_bridge_restart_failed: 'WebCode could not close isolated {browsers}. Close its isolated browser windows manually, then try again.', + browser_bridge_newer_installed: 'Browser bridge {version} belongs to a newer WebCode release. Update or reload the VS Code extension before reconnecting.', browser_not_found: '{browser} was not found. Install it or choose another browser mode.', isolated_chrome_configured_path_missing: 'Configured Chrome for Testing path does not exist: {path}. Update webcodeGateway.isolatedChrome.executablePath or clear it.', isolated_chrome_requires_cft: 'Chrome isolated mode requires Chrome for Testing or Chromium because Google Chrome no longer supports automatic unpacked extension loading. Install Chrome for Testing, set webcodeGateway.isolatedChrome.executablePath, or use Edge isolated mode.', @@ -86,7 +92,7 @@ const messages = { }, zh: { output_channel_name: 'MCP Gateway', - auto_stop_message: `${BRANDING.productName} 服务因 30 分钟无活动已停止。`, + auto_stop_message: `${BRANDING.productName} 服务因 {minutes} 分钟无活动已停止。`, start_failed: '启动 MCP Gateway 失败:{message}', start_requires_workspace: `请先在 VS Code 中打开一个具体文件夹或工作区,再启动 ${BRANDING.productName}。`, server_stopped: `${BRANDING.productName} 服务已停止`, @@ -94,7 +100,7 @@ const messages = { offline_start_label: `$(play) 启动 ${BRANDING.productName}`, offline_start_desc: '启动本地 MCP 服务', open_isolated_edge_profile_label: '$(browser) 打开 Edge 独立 profile', - open_isolated_edge_profile_desc: '直接启动专用 Edge profile,方便登录 AI 网站或安装插件', + open_isolated_edge_profile_desc: '直接启动专用 Edge profile,方便登录 AI 网站或管理内置桥接插件', reset_isolated_profiles_label: '$(trash) 重置当前独立 profile', reset_isolated_profiles_desc: '删除当前独立浏览器的登录态和缓存', clean_legacy_isolated_profiles_label: '$(trash) 清理旧版独立 profile', @@ -141,6 +147,12 @@ const messages = { open_browser_failed: '打开浏览器失败:{message}', browser_exited_immediately: '{browser} 启动后立即退出,原因:{reason},命令:{command}', browser_extension_missing: '内置浏览器插件目录不存在。请先构建浏览器插件,或重新安装 VS Code 插件。', + browser_bridge_preparing: '正在准备 WebCode 浏览器桥接...', + browser_bridge_prepare_failed: '准备浏览器桥接失败:{path}:{message}', + browser_bridge_restart_required: 'WebCode 浏览器桥接已更新。是否重启 WebCode 隔离 {browsers} 并继续?', + browser_bridge_restart_button: '重启隔离浏览器并继续', + browser_bridge_restart_failed: 'WebCode 无法关闭隔离 {browsers}。请手动关闭对应的隔离浏览器窗口,然后重试。', + browser_bridge_newer_installed: '浏览器桥接 {version} 属于更新版本的 WebCode。请先更新或重新加载 VS Code 插件,再重新连接。', browser_not_found: '未找到 {browser}。请安装该浏览器,或选择其他浏览器模式。', isolated_chrome_configured_path_missing: '配置的 Chrome for Testing 路径不存在:{path}。请更新 webcodeGateway.isolatedChrome.executablePath,或清空该配置。', isolated_chrome_requires_cft: 'Chrome 独立模式需要 Chrome for Testing 或 Chromium,因为 Google Chrome 已不再支持自动加载未打包扩展。请安装 Chrome for Testing,设置 webcodeGateway.isolatedChrome.executablePath,或改用 Edge 独立保活模式。', diff --git a/gateway-vscode/src/unit-test/bridgeRouteSecurity.test.ts b/gateway-vscode/src/unit-test/bridgeRouteSecurity.test.ts new file mode 100644 index 0000000..bf290c7 --- /dev/null +++ b/gateway-vscode/src/unit-test/bridgeRouteSecurity.test.ts @@ -0,0 +1,202 @@ +import * as assert from 'assert'; +import express from 'express'; +import type { Server as HttpServer } from 'http'; +import type { AddressInfo } from 'net'; +import { BRIDGE_PROTOCOL_VERSION } from '@webcode/shared'; + +import { isAllowedBridgeRedemptionOrigin, registerBridgeRoute } from '../gateway/bridgeRoute'; +import { BridgeSessionManager } from '../gateway/bridgeSession'; +import type { ResolvedAiSiteConfig } from '../platforms'; + +const CHROME_WEB_STORE_BRIDGE_ORIGIN = 'chrome-extension://kghhldphcmpiimophipabdhldfipgiio'; + +const TEST_SITE: ResolvedAiSiteConfig = { + id: 'chatgpt', + name: 'ChatGPT', + address: 'https://chatgpt.com/', + showQuickLaunch: true, + browser: 'isolated-edge', + selectors: { + messageBlocks: '.message', + codeBlocks: 'pre code', + inputArea: 'textarea', + sendButton: 'button.send', + stopButton: 'button.stop' + } +}; + +suite('Bridge route security', () => { + test('allows only the shared browser bridge origin to redeem launch codes', () => { + assert.strictEqual(isAllowedBridgeRedemptionOrigin(CHROME_WEB_STORE_BRIDGE_ORIGIN), true); + assert.strictEqual(isAllowedBridgeRedemptionOrigin('chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), false); + assert.strictEqual(isAllowedBridgeRedemptionOrigin(`${CHROME_WEB_STORE_BRIDGE_ORIGIN}/`), false); + assert.strictEqual(isAllowedBridgeRedemptionOrigin(undefined), false); + }); + + test('rejects unauthorized redemption origins without consuming the launch code', async () => { + const sessions = new BridgeSessionManager(); + const server = await startBridgeServer(sessions, () => undefined); + + try { + const bridgeCode = sessions.issueBridgeCode({ + siteId: TEST_SITE.id, + targetUrl: TEST_SITE.address + }); + + const missingOriginResponse = await redeemBridgeCode(server.baseUrl, bridgeCode, '1.0.1', BRIDGE_PROTOCOL_VERSION, null); + assert.strictEqual(missingOriginResponse.status, 403); + assert.ok(sessions.getBridgeLaunch(bridgeCode)); + + const foreignExtensionResponse = await redeemBridgeCode( + server.baseUrl, + bridgeCode, + '1.0.1', + BRIDGE_PROTOCOL_VERSION, + 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + ); + assert.strictEqual(foreignExtensionResponse.status, 403); + assert.ok(sessions.getBridgeLaunch(bridgeCode)); + + const bridgeResponse = await redeemBridgeCode( + server.baseUrl, + bridgeCode, + '1.0.1', + BRIDGE_PROTOCOL_VERSION, + CHROME_WEB_STORE_BRIDGE_ORIGIN + ); + assert.strictEqual(bridgeResponse.status, 200); + } finally { + await closeServer(server.httpServer); + } + }); + + test('redeems a launch code once without placing the API token in the page', async () => { + const sessions = new BridgeSessionManager(); + let activityCount = 0; + const server = await startBridgeServer(sessions, () => { + activityCount += 1; + }); + + try { + const bridgeCode = sessions.issueBridgeCode({ + siteId: TEST_SITE.id, + targetUrl: TEST_SITE.address + }); + const bridgeResponse = await fetch(`${server.baseUrl}/bridge?bridgeCode=${bridgeCode}`); + const bridgeHtml = await bridgeResponse.text(); + + assert.strictEqual(bridgeResponse.status, 200); + assert.strictEqual(bridgeResponse.headers.get('referrer-policy'), 'no-referrer'); + assert.match(bridgeResponse.headers.get('cache-control') ?? '', /no-store/); + assert.doesNotMatch(bridgeHtml, new RegExp(bridgeCode)); + assert.match(bridgeHtml, /bridge-upgrade-required/); + assert.match(bridgeHtml, new RegExp(`"bridgeProtocolVersion":${BRIDGE_PROTOCOL_VERSION}`)); + assert.strictEqual(activityCount, 0); + + const protocolMismatchResponse = await redeemBridgeCode( + server.baseUrl, + bridgeCode, + '1.0.1', + BRIDGE_PROTOCOL_VERSION - 1, + CHROME_WEB_STORE_BRIDGE_ORIGIN + ); + assert.strictEqual(protocolMismatchResponse.status, 409); + assert.ok(sessions.getBridgeLaunch(bridgeCode)); + + const mismatchResponse = await redeemBridgeCode( + server.baseUrl, + bridgeCode, + '0.0.0', + BRIDGE_PROTOCOL_VERSION, + CHROME_WEB_STORE_BRIDGE_ORIGIN + ); + assert.strictEqual(mismatchResponse.status, 409); + assert.ok(sessions.getBridgeLaunch(bridgeCode)); + + const redeemResponse = await redeemBridgeCode( + server.baseUrl, + bridgeCode, + '1.0.1', + BRIDGE_PROTOCOL_VERSION, + CHROME_WEB_STORE_BRIDGE_ORIGIN + ); + const redemption = await redeemResponse.json() as Record; + const sessionToken = redemption.token; + + assert.strictEqual(redeemResponse.status, 200); + assert.strictEqual(redemption.success, true); + assert.strictEqual(redemption.siteId, TEST_SITE.id); + assert.strictEqual(redemption.targetUrl, TEST_SITE.address); + assert.strictEqual(redemption.idleTimeoutMs, 30 * 60 * 1000); + assert.strictEqual(redemption.bridgeProtocolVersion, BRIDGE_PROTOCOL_VERSION); + assert.strictEqual(typeof sessionToken, 'string'); + assert.notStrictEqual(sessionToken, bridgeCode); + assert.strictEqual(sessions.isSessionTokenValid(sessionToken as string), true); + assert.strictEqual(activityCount, 1); + + const reusedResponse = await redeemBridgeCode( + server.baseUrl, + bridgeCode, + '1.0.1', + BRIDGE_PROTOCOL_VERSION, + CHROME_WEB_STORE_BRIDGE_ORIGIN + ); + assert.strictEqual(reusedResponse.status, 410); + assert.strictEqual(activityCount, 1); + } finally { + await closeServer(server.httpServer); + } + }); +}); + +async function startBridgeServer( + sessions: BridgeSessionManager, + recordActivity: () => void +): Promise<{ baseUrl: string; httpServer: HttpServer }> { + const app = express(); + app.use(express.json()); + registerBridgeRoute(app, { + activateSession: () => sessions.activateSession(), + consumeBridgeCode: code => sessions.consumeBridgeCode(code), + getBridgeLaunch: code => sessions.getBridgeLaunch(code), + getAiSites: () => [TEST_SITE], + getExtensionVersion: () => '1.0.1', + getIdleTimeoutMs: () => 30 * 60 * 1000, + getWorkspaceRoot: () => null, + log: () => undefined, + recordActivity + }); + + const httpServer = await new Promise((resolve) => { + const listeningServer = app.listen(0, '127.0.0.1', () => resolve(listeningServer)); + }); + const address = httpServer.address() as AddressInfo; + return { + baseUrl: `http://127.0.0.1:${address.port}`, + httpServer + }; +} + +function redeemBridgeCode( + baseUrl: string, + bridgeCode: string, + browserExtensionVersion: string, + bridgeProtocolVersion = BRIDGE_PROTOCOL_VERSION, + origin: string | null = CHROME_WEB_STORE_BRIDGE_ORIGIN +): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + if (origin) { + headers.Origin = origin; + } + return fetch(`${baseUrl}/v1/bridge/redeem`, { + method: 'POST', + headers, + body: JSON.stringify({ bridgeCode, browserExtensionVersion, bridgeProtocolVersion }) + }); +} + +function closeServer(server: HttpServer): Promise { + return new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + }); +} diff --git a/gateway-vscode/src/unit-test/bridgeSession.test.ts b/gateway-vscode/src/unit-test/bridgeSession.test.ts new file mode 100644 index 0000000..321259d --- /dev/null +++ b/gateway-vscode/src/unit-test/bridgeSession.test.ts @@ -0,0 +1,39 @@ +import * as assert from 'assert'; + +import { BridgeSessionManager } from '../gateway/bridgeSession'; + +suite('Bridge session security', () => { + test('issues short-lived bridge codes that can be consumed only once', () => { + let now = 1000; + const secrets = ['bridge-code']; + const manager = new BridgeSessionManager(100, () => now, () => secrets.shift() ?? 'fallback'); + const launch = { siteId: 'chatgpt', targetUrl: 'https://chatgpt.com/' }; + + const code = manager.issueBridgeCode(launch); + assert.strictEqual(code, 'bridge-code'); + assert.deepStrictEqual(manager.getBridgeLaunch(code), launch); + assert.deepStrictEqual(manager.consumeBridgeCode(code), launch); + assert.strictEqual(manager.consumeBridgeCode(code), null); + + const expiringCode = manager.issueBridgeCode(launch); + now += 101; + assert.strictEqual(manager.getBridgeLaunch(expiringCode), null); + assert.strictEqual(manager.consumeBridgeCode(expiringCode), null); + }); + + test('shares one API session token for the gateway lifecycle', () => { + const secrets = ['session-one', 'session-two']; + const manager = new BridgeSessionManager(100, Date.now, () => secrets.shift() ?? 'fallback'); + + const firstToken = manager.activateSession(); + const secondToken = manager.activateSession(); + + assert.strictEqual(firstToken, 'session-one'); + assert.strictEqual(secondToken, firstToken); + assert.strictEqual(manager.isSessionTokenValid(firstToken), true); + assert.strictEqual(manager.isSessionTokenValid('session-two'), false); + + manager.clear(); + assert.strictEqual(manager.isSessionTokenValid(firstToken), false); + }); +}); diff --git a/gateway-vscode/src/unit-test/browserExtensionInstall.test.ts b/gateway-vscode/src/unit-test/browserExtensionInstall.test.ts new file mode 100644 index 0000000..99c82c6 --- /dev/null +++ b/gateway-vscode/src/unit-test/browserExtensionInstall.test.ts @@ -0,0 +1,349 @@ +import * as assert from 'assert'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { BRIDGE_PROTOCOL_VERSION } from '@webcode/shared'; + +import { + activatePreparedBrowserExtension, + BROWSER_EXTENSION_ACTIVE_DIR_NAME, + BROWSER_EXTENSION_BUILD_FILE, + calculateBrowserExtensionBuildHash, + prepareBrowserExtensionInstall, + readAndValidateBrowserExtensionBuild, + resolveBrowserExtensionRoot, + resolveDefaultBrowserExtensionRoot, + withBrowserExtensionInstallLock, + type BrowserExtensionBuild +} from '../extension/browserExtensionInstall'; + +suite('Browser extension installation', () => { + test('resolves a version-independent app data root', () => { + assert.strictEqual( + resolveDefaultBrowserExtensionRoot( + 'win32', + { LOCALAPPDATA: path.win32.join('C:\\', 'Users', 'me', 'AppData', 'Local') }, + path.win32.join('C:\\', 'Users', 'me') + ), + path.win32.join('C:\\', 'Users', 'me', 'AppData', 'Local', 'webcode', 'browser-extensions') + ); + assert.strictEqual( + resolveDefaultBrowserExtensionRoot('darwin', {}, '/Users/me'), + path.posix.join('/Users/me', 'Library', 'Application Support', 'webcode', 'browser-extensions') + ); + assert.strictEqual( + resolveDefaultBrowserExtensionRoot('linux', {}, '/home/me'), + path.posix.join('/home/me', '.local', 'share', 'webcode', 'browser-extensions') + ); + }); + + test('keeps development installations in separate storage on every supported platform', () => { + for (const platform of ['win32', 'darwin', 'linux'] as const) { + const platformPath = platform === 'win32' ? path.win32 : path.posix; + const homeDir = platform === 'win32' ? 'C:\\Users\\me' : '/home/me'; + const developmentStorageRoot = platformPath.join(homeDir, 'vscode-storage'); + const options = { platform, homeDir, env: {} }; + const productionRoot = resolveBrowserExtensionRoot(options); + const developmentRoot = resolveBrowserExtensionRoot({ ...options, developmentStorageRoot }); + + assert.strictEqual(productionRoot, resolveDefaultBrowserExtensionRoot(platform, {}, homeDir)); + assert.strictEqual( + developmentRoot, + platformPath.join(developmentStorageRoot, 'browser-extensions-development') + ); + assert.notStrictEqual(developmentRoot, productionRoot); + } + }); + + test('honors explicit install roots in production and development and rejects relative overrides', () => { + const overrideRoot = path.resolve('custom-browser-extensions'); + for (const developmentStorageRoot of [undefined, path.resolve('development-storage')]) { + assert.strictEqual(resolveBrowserExtensionRoot({ + developmentStorageRoot, + env: { WEBCODE_BROWSER_EXTENSION_ROOT: ` ${overrideRoot} ` } + }), overrideRoot); + assert.throws(() => resolveBrowserExtensionRoot({ + developmentStorageRoot, + env: { WEBCODE_BROWSER_EXTENSION_ROOT: './relative-root' } + }), /must be absolute/); + } + }); + + test('a later development build at the same version cannot block the production installation', async () => { + await withTempInstall(async ({ rootDir, sourceRoot }) => { + const options = { env: {}, homeDir: rootDir }; + const productionRoot = resolveBrowserExtensionRoot(options); + const developmentRoot = resolveBrowserExtensionRoot({ + ...options, + developmentStorageRoot: path.join(rootDir, 'vscode-storage') + }); + const production = await createExtensionBuild(sourceRoot, 'release', '1.0.1', '2026-09-01T00:00:00.000Z'); + const development = await createExtensionBuild(sourceRoot, 'development', '1.0.1', '2026-09-02T00:00:00.000Z'); + + await prepareBrowserExtensionInstall({ sourceDir: production.path, rootDir: productionRoot }); + const stagedDevelopment = await prepareBrowserExtensionInstall({ sourceDir: development.path, rootDir: developmentRoot }); + assert.strictEqual(stagedDevelopment.status, 'staged'); + const stagedProduction = await prepareBrowserExtensionInstall({ sourceDir: production.path, rootDir: productionRoot }); + assert.strictEqual(stagedProduction.status, 'staged'); + + const activatedDevelopment = await activatePreparedBrowserExtension({ rootDir: developmentRoot }, development.build); + assert.strictEqual(activatedDevelopment.status, 'ready'); + const activatedProduction = await activatePreparedBrowserExtension({ rootDir: productionRoot }, production.build); + assert.strictEqual(activatedProduction.status, 'ready'); + + const reinstalled = await prepareBrowserExtensionInstall({ sourceDir: production.path, rootDir: productionRoot }); + assert.strictEqual(reinstalled.status, 'ready'); + assert.deepStrictEqual( + await readAndValidateBrowserExtensionBuild(path.join(productionRoot, BROWSER_EXTENSION_ACTIVE_DIR_NAME)), + production.build + ); + assert.deepStrictEqual( + await readAndValidateBrowserExtensionBuild(path.join(developmentRoot, BROWSER_EXTENSION_ACTIVE_DIR_NAME)), + development.build + ); + }); + }); + + test('stages complete builds and switches the stable bridge path only after activation', async () => { + await withTempInstall(async ({ rootDir, sourceRoot }) => { + const sourceV1 = await createExtensionBuild(sourceRoot, 'v1', '1.0.1', '2026-09-01T00:00:00.000Z'); + const preparedV1 = await prepareBrowserExtensionInstall({ sourceDir: sourceV1.path, rootDir }); + + assert.strictEqual(preparedV1.status, 'staged'); + assert.strictEqual(await pathExists(path.join(rootDir, BROWSER_EXTENSION_ACTIVE_DIR_NAME)), false); + if (preparedV1.status !== 'staged') { + return; + } + const activatedV1 = await activatePreparedBrowserExtension({ rootDir }, preparedV1.build); + assert.strictEqual(activatedV1.status, 'ready'); + assert.strictEqual( + await fs.readFile(path.join(rootDir, BROWSER_EXTENSION_ACTIVE_DIR_NAME, 'worker.js'), 'utf8'), + 'v1' + ); + + const sourceV2 = await createExtensionBuild(sourceRoot, 'v2', '1.0.2', '2026-09-02T00:00:00.000Z'); + const preparedV2 = await prepareBrowserExtensionInstall({ sourceDir: sourceV2.path, rootDir }); + + assert.strictEqual(preparedV2.status, 'staged'); + assert.strictEqual( + await fs.readFile(path.join(rootDir, BROWSER_EXTENSION_ACTIVE_DIR_NAME, 'worker.js'), 'utf8'), + 'v1' + ); + if (preparedV2.status !== 'staged') { + return; + } + const activatedV2 = await activatePreparedBrowserExtension({ rootDir }, preparedV2.build); + assert.strictEqual(activatedV2.status, 'ready'); + assert.strictEqual( + await fs.readFile(path.join(rootDir, BROWSER_EXTENSION_ACTIVE_DIR_NAME, 'worker.js'), 'utf8'), + 'v2' + ); + assert.strictEqual(await fs.readFile(path.join(rootDir, 'previous', 'worker.js'), 'utf8'), 'v1'); + }); + }); + + test('reuses an installed build and serializes concurrent preparation', async () => { + await withTempInstall(async ({ rootDir, sourceRoot }) => { + const source = await createExtensionBuild(sourceRoot, 'same', '1.0.1', '2026-09-01T00:00:00.000Z'); + const preparedResults = await Promise.all([ + prepareBrowserExtensionInstall({ sourceDir: source.path, rootDir }), + prepareBrowserExtensionInstall({ sourceDir: source.path, rootDir }) + ]); + const staged = preparedResults[0].status === 'staged' ? preparedResults[0] : preparedResults[1]; + assert.strictEqual(staged.status, 'staged'); + if (staged.status !== 'staged') { + return; + } + + await activatePreparedBrowserExtension({ rootDir }, staged.build); + const reused = await prepareBrowserExtensionInstall({ sourceDir: source.path, rootDir }); + assert.strictEqual(reused.status, 'ready'); + assert.deepStrictEqual( + await readAndValidateBrowserExtensionBuild(path.join(rootDir, BROWSER_EXTENSION_ACTIVE_DIR_NAME)), + source.build + ); + }); + }); + + test('does not let an older VSIX overwrite a newer installed bridge', async () => { + await withTempInstall(async ({ rootDir, sourceRoot }) => { + const newer = await createExtensionBuild(sourceRoot, 'newer', '1.1.0', '2026-09-02T00:00:00.000Z'); + const prepared = await prepareBrowserExtensionInstall({ sourceDir: newer.path, rootDir }); + assert.strictEqual(prepared.status, 'staged'); + if (prepared.status !== 'staged') { + return; + } + await activatePreparedBrowserExtension({ rootDir }, prepared.build); + + const older = await createExtensionBuild(sourceRoot, 'older', '1.0.9', '2026-09-03T00:00:00.000Z'); + const downgrade = await prepareBrowserExtensionInstall({ sourceDir: older.path, rootDir }); + + assert.strictEqual(downgrade.status, 'newer-installed'); + assert.strictEqual( + await fs.readFile(path.join(rootDir, BROWSER_EXTENSION_ACTIVE_DIR_NAME, 'worker.js'), 'utf8'), + 'newer' + ); + }); + }); + + test('rejects a build whose files changed after its descriptor was written', async () => { + await withTempInstall(async ({ rootDir, sourceRoot }) => { + const source = await createExtensionBuild(sourceRoot, 'original', '1.0.1', '2026-09-01T00:00:00.000Z'); + await fs.writeFile(path.join(source.path, 'worker.js'), 'tampered', 'utf8'); + + await assert.rejects( + prepareBrowserExtensionInstall({ sourceDir: source.path, rootDir }), + /do not match/ + ); + assert.strictEqual(await pathExists(path.join(rootDir, BROWSER_EXTENSION_ACTIVE_DIR_NAME)), false); + }); + }); + + test('reclaims one stale lock generation without overlapping contenders', async () => { + await withTempInstall(({ rootDir }) => verifyStaleLockReclamation(rootDir)); + }); + + test('keeps stale empty lock reclamation atomic on POSIX rename semantics', async () => { + await withTempInstall(({ rootDir }) => verifyEmptyStaleLockReclamation(rootDir)); + }); + + test('keeps a launch-sized critical section serialized with bridge mutations', async () => { + await withTempInstall(({ rootDir }) => verifyLaunchLockSerialization(rootDir)); + }); +}); + +async function verifyStaleLockReclamation(rootDir: string): Promise { + const lockPath = path.join(rootDir, '.install-lock'); + await fs.mkdir(lockPath, { recursive: true }); + await fs.writeFile(path.join(lockPath, 'owner.json'), JSON.stringify({ + pid: 2_147_483_647, + token: 'abandoned-owner' + }), 'utf8'); + + let activeCallbacks = 0; + let maximumActiveCallbacks = 0; + const contend = () => withBrowserExtensionInstallLock({ rootDir }, async () => { + activeCallbacks += 1; + maximumActiveCallbacks = Math.max(maximumActiveCallbacks, activeCallbacks); + await delay(30); + activeCallbacks -= 1; + }); + + await Promise.all([contend(), contend()]); + + assert.strictEqual(maximumActiveCallbacks, 1); + const entries = await fs.readdir(rootDir); + assert.strictEqual(entries.filter(entry => entry.startsWith('.install-lock-reclaimed-')).length, 1); + assert.strictEqual(await pathExists(lockPath), false); +} + +async function verifyLaunchLockSerialization(rootDir: string): Promise { + const events: string[] = []; + let releaseFirst!: () => void; + const firstMayFinish = new Promise(resolve => { + releaseFirst = resolve; + }); + let reportFirstStarted!: () => void; + const firstStarted = new Promise(resolve => { + reportFirstStarted = resolve; + }); + + const first = withBrowserExtensionInstallLock({ rootDir }, async () => { + events.push('launch-start'); + reportFirstStarted(); + await firstMayFinish; + events.push('launch-end'); + }); + await firstStarted; + const second = withBrowserExtensionInstallLock({ rootDir }, () => { + events.push('activation'); + return Promise.resolve(); + }); + + await delay(30); + assert.deepStrictEqual(events, ['launch-start']); + releaseFirst(); + await Promise.all([first, second]); + assert.deepStrictEqual(events, ['launch-start', 'launch-end', 'activation']); +} + +async function verifyEmptyStaleLockReclamation(rootDir: string): Promise { + const lockPath = path.join(rootDir, '.install-lock'); + await fs.mkdir(lockPath, { recursive: true }); + const staleTime = new Date(Date.now() - 3 * 60_000); + await fs.utimes(lockPath, staleTime, staleTime); + + await Promise.all([ + withBrowserExtensionInstallLock({ rootDir }, () => delay(30)), + withBrowserExtensionInstallLock({ rootDir }, () => delay(30)) + ]); + + const entries = await fs.readdir(rootDir); + const reclaimed = entries.filter(entry => entry.startsWith('.install-lock-reclaimed-')); + assert.strictEqual(reclaimed.length, 1); + assert.strictEqual( + await fs.readFile(path.join(rootDir, reclaimed[0], '.reclaim-guard'), 'utf8') !== '', + true + ); + assert.strictEqual(await pathExists(lockPath), false); +} + +async function withTempInstall( + callback: (paths: { rootDir: string; sourceRoot: string }) => Promise +): Promise { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'webcode-browser-extension-')); + try { + await callback({ + rootDir: path.join(tempRoot, 'installed'), + sourceRoot: path.join(tempRoot, 'sources') + }); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } +} + +async function createExtensionBuild( + sourceRoot: string, + workerContents: string, + extensionVersion: string, + builtAt: string +): Promise<{ path: string; build: BrowserExtensionBuild }> { + const extensionPath = path.join(sourceRoot, `${extensionVersion}-${workerContents}`); + await fs.mkdir(extensionPath, { recursive: true }); + await fs.writeFile(path.join(extensionPath, 'manifest.json'), JSON.stringify({ + manifest_version: 3, + name: 'Test bridge', + version: extensionVersion + }), 'utf8'); + await fs.writeFile(path.join(extensionPath, 'worker.js'), workerContents, 'utf8'); + + const build: BrowserExtensionBuild = { + schemaVersion: 1, + extensionVersion, + bridgeProtocolVersion: BRIDGE_PROTOCOL_VERSION, + buildHash: await calculateBrowserExtensionBuildHash(extensionPath), + builtAt + }; + await fs.writeFile( + path.join(extensionPath, BROWSER_EXTENSION_BUILD_FILE), + `${JSON.stringify(build, null, 2)}\n`, + 'utf8' + ); + return { path: extensionPath, build }; +} + +async function pathExists(filePath: string): Promise { + try { + await fs.stat(filePath); + return true; + } catch (error: unknown) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { + return false; + } + throw error; + } +} + +function delay(milliseconds: number): Promise { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} diff --git a/gateway-vscode/src/unit-test/browserExtensionLock.test.ts b/gateway-vscode/src/unit-test/browserExtensionLock.test.ts new file mode 100644 index 0000000..d85bada --- /dev/null +++ b/gateway-vscode/src/unit-test/browserExtensionLock.test.ts @@ -0,0 +1,155 @@ +import * as assert from 'assert'; +import { AsyncLocalStorage } from 'async_hooks'; +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { mock } from 'node:test'; + +import { withBrowserExtensionFileLock } from '../extension/browserExtensionLock'; + +suite('Browser extension lock reclamation', () => { + test('does not combine an old directory stat with a missing owner to reclaim a new live lock', async () => { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'webcode-lock-race-')); + const lockPath = path.join(rootDir, '.install-lock'); + const finish = createSignal(); + const contenders: Promise[] = []; + let activeCallbacks = 0; + let maximumActiveCallbacks = 0; + let race: ReturnType | undefined; + + try { + await fs.mkdir(lockPath); + await fs.writeFile(path.join(lockPath, 'owner.json'), JSON.stringify({ + pid: 2_147_483_647, + token: 'abandoned-owner' + })); + const staleTime = new Date(Date.now() - 3 * 60_000); + await fs.utimes(lockPath, staleTime, staleTime); + const controlledRace = controlSnapshotRace(lockPath); + race = controlledRace; + + const holdLock = async () => { + activeCallbacks += 1; + maximumActiveCallbacks = Math.max(maximumActiveCallbacks, activeCallbacks); + if (controlledRace.actor.getStore() === 'winner') { + controlledRace.winnerEntered.resolve(); + } else { + controlledRace.delayedObserved.resolve(); + } + await finish.promise; + activeCallbacks -= 1; + }; + const contend = (actor: 'delayed' | 'winner') => controlledRace.actor.run(actor, () => + withBrowserExtensionFileLock(rootDir, 3_000, holdLock) + ); + + contenders.push(contend('delayed')); + await waitForSignal(controlledRace.statCaptured.promise); + contenders.push(contend('winner')); + await waitForSignal(controlledRace.delayedObserved.promise); + assert.strictEqual(maximumActiveCallbacks, 1, 'A delayed reclaimer must not enter while the winner owns the lock.'); + } finally { + finish.resolve(); + race?.releaseSignals(); + const results = await Promise.allSettled(contenders); + race?.restore(); + await fs.rm(rootDir, { recursive: true, force: true }); + for (const result of results) { + if (result.status === 'rejected') { + throw result.reason; + } + } + } + }).timeout(10_000); +}); + +function controlSnapshotRace(lockPath: string) { + const actor = new AsyncLocalStorage<'delayed' | 'winner'>(); + const statCaptured = createSignal(); + const oldDirectoryMoved = createSignal(); + const ownerMissing = createSignal(); + const winnerEntered = createSignal(); + const delayedObserved = createSignal(); + const original = { stat: fs.stat, readFile: fs.readFile, rename: fs.rename, mkdir: fs.mkdir }; + let captured = false; + let observedMissingOwner = false; + + const statMock = mock.method(fs, 'stat', async (...args: Parameters) => { + const stats = await original.stat(...args); + if (args[0] === lockPath && actor.getStore() === 'delayed' && !captured) { + captured = true; + statCaptured.resolve(); + await oldDirectoryMoved.promise; + } + return stats; + }); + const readMock = mock.method(fs, 'readFile', async (...args: Parameters) => { + try { + return await original.readFile(...args); + } catch (error: unknown) { + if (args[0] === path.join(lockPath, 'owner.json') && actor.getStore() === 'delayed') { + assert.ok(typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'); + observedMissingOwner = true; + ownerMissing.resolve(); + await winnerEntered.promise; + } + throw error; + } + }); + const renameMock = mock.method(fs, 'rename', async (...args: Parameters) => { + await original.rename(...args); + if (args[0] === lockPath && actor.getStore() === 'winner') { + oldDirectoryMoved.resolve(); + await ownerMissing.promise; + } + }); + const mkdirMock = mock.method(fs, 'mkdir', async (...args: Parameters) => { + try { + return await original.mkdir(...args); + } catch (error: unknown) { + // A correct reclaimer retries mkdir while the winner is still in its callback. + // The buggy implementation instead moves that live directory and enters too. + if (args[0] === lockPath && actor.getStore() === 'delayed' && observedMissingOwner) { + delayedObserved.resolve(); + } + throw error; + } + }); + + return { + actor, statCaptured, winnerEntered, delayedObserved, + releaseSignals() { + statCaptured.resolve(); + oldDirectoryMoved.resolve(); + ownerMissing.resolve(); + winnerEntered.resolve(); + delayedObserved.resolve(); + }, + restore() { + statMock.mock.restore(); + readMock.mock.restore(); + renameMock.mock.restore(); + mkdirMock.mock.restore(); + } + }; +} + +function createSignal(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise(fulfill => { resolve = fulfill; }); + return { promise, resolve }; +} + +async function waitForSignal(signal: Promise): Promise { + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + signal, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('Timed out controlling the lock reclamation race.')), 3_000); + }) + ]); + } finally { + clearTimeout(timer); + } +} diff --git a/gateway-vscode/src/unit-test/browserExtensionPackaging.test.ts b/gateway-vscode/src/unit-test/browserExtensionPackaging.test.ts new file mode 100644 index 0000000..aff60e4 --- /dev/null +++ b/gateway-vscode/src/unit-test/browserExtensionPackaging.test.ts @@ -0,0 +1,156 @@ +import * as assert from 'assert'; +import { spawnSync } from 'child_process'; +import { createHash } from 'crypto'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { isAllowedBridgeRedemptionOrigin } from '../gateway/bridgeRoute'; + +const repoRoot = path.resolve(__dirname, '../../..'); +const manifestPath = path.join(repoRoot, 'bridge-browser', 'manifest.json'); +const copyScriptPath = path.join(repoRoot, 'gateway-vscode', 'scripts', 'copy-browser-extension.mjs'); +const installModulePath = require.resolve('../extension/browserExtensionInstall'); + +interface PackagingFixture { + sourceDir: string; + targetDir: string; + scriptPath: string; +} + +suite('Browser extension packaging', () => { + test('pins unpacked builds to the Chrome Web Store identity accepted by the gateway', async () => { + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as { key?: string }; + assert.ok(manifest.key, 'The browser source manifest must include the public identity key.'); + const extensionId = createHash('sha256') + .update(Buffer.from(manifest.key, 'base64')) + .digest('hex') + .slice(0, 32) + .replace(/[0-9a-f]/g, value => String.fromCharCode(97 + parseInt(value, 16))); + + assert.strictEqual(extensionId, 'kghhldphcmpiimophipabdhldfipgiio'); + assert.strictEqual(isAllowedBridgeRedemptionOrigin(`chrome-extension://${extensionId}`), true); + }); + + test('preserves the standalone browser manifest when copying it into the VSIX', async () => { + await withPackagingFixture(async ({ sourceDir, targetDir, scriptPath }) => { + const originalManifest = await fs.readFile(path.join(sourceDir, 'manifest.json'), 'utf8'); + const result = spawnSync(process.execPath, [scriptPath], { encoding: 'utf8', timeout: 10_000 }); + + assert.ifError(result.error); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(await fs.readFile(path.join(targetDir, 'manifest.json'), 'utf8'), originalManifest); + }); + }); + + for (const [buildLocale, validationLocale] of [['en-US', 'th-TH'], ['th-TH', 'en-US']]) { + test(`validates a ${buildLocale} package under ${validationLocale} with canonical path ordering`, async () => { + await withPackagingFixture(async ({ sourceDir, targetDir, scriptPath }) => { + const expectedHash = await writeHashFixture(sourceDir); + // Locale environment variables do not change Node's default locale on Windows. + // Override only the default locale of localeCompare in each isolated child process. + const preloadPath = `${scriptPath}.locale.cjs`; + await fs.writeFile(preloadPath, ` + const compare = String.prototype.localeCompare; + String.prototype.localeCompare = function (right, locales, options) { + return compare.call(this, right, locales ?? process.env.WEBCODE_TEST_LOCALE, options); + }; + `); + const packaged = spawnSync(process.execPath, ['--require', preloadPath, scriptPath], { + env: { ...process.env, WEBCODE_TEST_LOCALE: buildLocale }, + encoding: 'utf8', + timeout: 10_000, + windowsHide: true + }); + assert.ifError(packaged.error); + assert.strictEqual(packaged.status, 0, packaged.stderr); + + const validated = spawnSync(process.execPath, ['--require', preloadPath, '-e', ` + const { readAndValidateBrowserExtensionBuild } = require(process.argv[1]); + readAndValidateBrowserExtensionBuild(process.argv[2]).then(build => { + console.log(build.buildHash); + }).catch(error => { + console.error(error); + process.exitCode = 1; + }); + `, installModulePath, targetDir], { + env: { ...process.env, WEBCODE_TEST_LOCALE: validationLocale }, + encoding: 'utf8', + timeout: 10_000, + windowsHide: true + }); + assert.ifError(validated.error); + assert.strictEqual(validated.status, 0, validated.stderr); + assert.strictEqual(validated.stdout.trim(), expectedHash); + }); + }); + } + + for (const key of [undefined, 'unexpected-public-key']) { + test(`rejects a prebuilt manifest with ${key ? 'a different key' : 'no key'} before replacing the bundle`, async () => { + await withPackagingFixture(async ({ sourceDir, targetDir, scriptPath }) => { + const builtManifestPath = path.join(sourceDir, 'manifest.json'); + const manifest = JSON.parse(await fs.readFile(builtManifestPath, 'utf8')) as Record; + await fs.writeFile(builtManifestPath, JSON.stringify({ ...manifest, key })); + await fs.mkdir(targetDir); + const existingFilePath = path.join(targetDir, 'existing.txt'); + await fs.writeFile(existingFilePath, 'existing bundle'); + + const result = spawnSync(process.execPath, [scriptPath], { encoding: 'utf8', timeout: 10_000 }); + + assert.ifError(result.error); + assert.notStrictEqual(result.status, 0); + assert.match(result.stderr, /unexpected identity key/); + assert.strictEqual(await fs.readFile(existingFilePath, 'utf8'), 'existing bundle'); + }); + }); + } +}); + +async function writeHashFixture(sourceDir: string): Promise { + // Deliberately fixed UTF-16 order, including a directory/file pair whose order + // would differ if Windows separators were sorted before normalization. + const orderedFiles = [ + ['Z-worker.js', 'uppercase'], + ['_locales/en/messages.json', '{"name":{"message":"Test bridge"}}'], + ['assets/worker.js', 'nested'], + ['assets0.js', 'adjacent'], + ['manifest.json', await fs.readFile(path.join(sourceDir, 'manifest.json'), 'utf8')], + ['worker.js', 'lowercase'], + ['é-worker.js', 'accented'], + ['ไทย.js', 'thai'] + ]; + const hash = createHash('sha256'); + for (const [relativePath, contents] of orderedFiles) { + const filePath = path.join(sourceDir, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents, 'utf8'); + hash.update(relativePath, 'utf8').update('\0').update(contents, 'utf8').update('\0'); + } + return hash.digest('hex'); +} + +async function withPackagingFixture(run: (fixture: PackagingFixture) => Promise): Promise { + const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'webcode-browser-packaging-')); + const sourceDir = path.join(fixtureRoot, 'bridge-browser', 'dist'); + const targetDir = path.join(fixtureRoot, 'gateway-vscode', 'browser-extension'); + const scriptPath = path.join(fixtureRoot, 'gateway-vscode', 'scripts', 'copy-browser-extension.mjs'); + const protocolPath = path.join(fixtureRoot, 'shared', 'src', 'bridgeProtocol.json'); + + try { + await Promise.all([ + fs.mkdir(sourceDir, { recursive: true }), + fs.mkdir(path.dirname(scriptPath), { recursive: true }), + fs.mkdir(path.dirname(protocolPath), { recursive: true }) + ]); + await Promise.all([ + fs.copyFile(manifestPath, path.join(sourceDir, 'manifest.json')), + fs.copyFile(manifestPath, path.join(fixtureRoot, 'bridge-browser', 'manifest.json')), + fs.copyFile(copyScriptPath, scriptPath), + fs.copyFile(path.join(repoRoot, 'shared', 'src', 'bridgeProtocol.json'), protocolPath) + ]); + await run({ sourceDir, targetDir, scriptPath }); + } finally { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } +} diff --git a/gateway-vscode/src/unit-test/gatewaySecurity.test.ts b/gateway-vscode/src/unit-test/gatewaySecurity.test.ts new file mode 100644 index 0000000..d1a8cba --- /dev/null +++ b/gateway-vscode/src/unit-test/gatewaySecurity.test.ts @@ -0,0 +1,51 @@ +import * as assert from 'assert'; + +import { buildBridgeUrl } from '../extension/bridgeUrl'; +import { + DEFAULT_IDLE_TIMEOUT_MINUTES, + MAX_IDLE_TIMEOUT_MINUTES, + MIN_IDLE_TIMEOUT_MINUTES, + resolveIdleTimeoutMinutes +} from '../gateway/idleTimeout'; +import { isAllowedCorsOrigin, isGatewayActivityRequest } from '../gateway/middleware'; + +suite('Gateway security helpers', () => { + test('places only the one-time bridge code in launch URLs', () => { + const url = new URL(buildBridgeUrl(34567, 'one-time-code')); + + assert.strictEqual(url.origin, 'http://127.0.0.1:34567'); + assert.strictEqual(url.pathname, '/bridge'); + assert.deepStrictEqual(Array.from(url.searchParams.keys()), ['bridgeCode']); + assert.strictEqual(url.searchParams.get('bridgeCode'), 'one-time-code'); + }); + + test('matches loopback CORS origins by parsed hostname instead of prefix', () => { + const configuredOrigins = ['https://chatgpt.com']; + + assert.strictEqual(isAllowedCorsOrigin(undefined, configuredOrigins), true); + assert.strictEqual(isAllowedCorsOrigin('https://chatgpt.com', configuredOrigins), true); + assert.strictEqual(isAllowedCorsOrigin('http://127.0.0.1:34567', configuredOrigins), true); + assert.strictEqual(isAllowedCorsOrigin('http://localhost:34567', configuredOrigins), true); + assert.strictEqual(isAllowedCorsOrigin('chrome-extension://abcdefghijklmnopabcdefghijklmnop', configuredOrigins), true); + assert.strictEqual(isAllowedCorsOrigin('http://localhost.attacker.example:34567', configuredOrigins), false); + assert.strictEqual(isAllowedCorsOrigin('http://127.0.0.1.attacker.example:34567', configuredOrigins), false); + assert.strictEqual(isAllowedCorsOrigin('https://localhost:34567', configuredOrigins), false); + }); + + test('counts only known authenticated API routes as gateway activity', () => { + assert.strictEqual(isGatewayActivityRequest('POST', '/v1/tools/call'), true); + assert.strictEqual(isGatewayActivityRequest('GET', '/v1/init'), true); + assert.strictEqual(isGatewayActivityRequest('GET', '/v1/status'), false); + assert.strictEqual(isGatewayActivityRequest('OPTIONS', '/v1/tools/call'), false); + assert.strictEqual(isGatewayActivityRequest('GET', '/unknown'), false); + }); + + test('keeps the idle timeout default at 30 minutes and constrains overrides', () => { + assert.strictEqual(resolveIdleTimeoutMinutes(undefined), DEFAULT_IDLE_TIMEOUT_MINUTES); + assert.strictEqual(resolveIdleTimeoutMinutes(30), 30); + assert.strictEqual(resolveIdleTimeoutMinutes(2), MIN_IDLE_TIMEOUT_MINUTES); + assert.strictEqual(resolveIdleTimeoutMinutes(999), MAX_IDLE_TIMEOUT_MINUTES); + assert.strictEqual(resolveIdleTimeoutMinutes(60.9), 60); + assert.strictEqual(resolveIdleTimeoutMinutes(Number.NaN), DEFAULT_IDLE_TIMEOUT_MINUTES); + }); +}); diff --git a/gateway-vscode/src/unit-test/processDetection.test.ts b/gateway-vscode/src/unit-test/processDetection.test.ts index 31f0725..3475a9c 100644 --- a/gateway-vscode/src/unit-test/processDetection.test.ts +++ b/gateway-vscode/src/unit-test/processDetection.test.ts @@ -1,7 +1,17 @@ import * as assert from 'assert'; import * as path from 'path'; -import { browserCommandLineUsesProfile } from '../extension/processDetection'; +import { + executeFileText, + getBrowserFamilyForExecutableName +} from '../extension/browserProcessList'; +import { + browserArgumentsUseProfile, + browserCommandLineUsesProfile, + commandLineHasExactArgument, + getBrowserBridgeMarkerArgument, + getBrowserProfileMarkerArgument +} from '../extension/processDetection'; suite('Browser process detection', () => { test('matches user data dir passed with equals syntax', () => { @@ -85,4 +95,71 @@ suite('Browser process detection', () => { true ); }); + + test('preserves POSIX argv values containing spaces', () => { + const profileDir = '/Users/me/Library/Application Support/webcode/edge'; + + assert.strictEqual( + browserArgumentsUseProfile( + ['/Applications/Microsoft Edge', `--user-data-dir=${profileDir}`, '--no-first-run'], + profileDir, + 'darwin' + ), + true + ); + }); + + test('recognizes an encoded profile marker in flattened macOS ps output', () => { + const profileDir = '/Users/me/Library/Application Support/webcode/edge'; + const marker = getBrowserProfileMarkerArgument(profileDir, 'darwin'); + const commandLine = [ + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + `--user-data-dir=${profileDir}`, + marker, + '--no-first-run' + ].join(' '); + + assert.strictEqual(marker.includes(' '), false); + assert.strictEqual(commandLineHasExactArgument(commandLine, marker), true); + assert.strictEqual(browserCommandLineUsesProfile(commandLine, profileDir, 'darwin'), true); + assert.strictEqual(commandLineHasExactArgument(commandLine, `${marker}0`), false); + }); + + test('matches a legacy unquoted macOS profile path flattened by ps', () => { + const profileDir = '/Users/me/Library/Application Support/webcode/edge'; + const commandLine = [ + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + `--user-data-dir=${profileDir}`, + '--load-extension=/Users/me/Library/Application Support/webcode/browser-extensions/bridge' + ].join(' '); + + assert.strictEqual(browserCommandLineUsesProfile(commandLine, profileDir, 'darwin'), true); + }); + + test('creates one stable marker for every process loading the shared bridge', () => { + const extensionPath = '/Users/me/Library/Application Support/webcode/browser-extensions/bridge'; + + assert.strictEqual( + getBrowserBridgeMarkerArgument(extensionPath, 'darwin'), + getBrowserBridgeMarkerArgument(`${extensionPath}/`, 'darwin') + ); + assert.strictEqual(getBrowserBridgeMarkerArgument(extensionPath, 'darwin').includes(' '), false); + }); + + test('recognizes every supported Windows Chromium executable', () => { + assert.strictEqual(getBrowserFamilyForExecutableName('chrome.exe', 'win32'), 'chrome'); + assert.strictEqual(getBrowserFamilyForExecutableName('chrome-for-testing.exe', 'win32'), 'chrome'); + assert.strictEqual(getBrowserFamilyForExecutableName('chromium.exe', 'win32'), 'chrome'); + assert.strictEqual(getBrowserFamilyForExecutableName('msedge.exe', 'win32'), 'edge'); + }); + + test('captures process command output larger than the execFile default buffer', async () => { + const outputSize = 2 * 1024 * 1024; + const output = await executeFileText(process.execPath, [ + '-e', + `process.stdout.write('x'.repeat(${outputSize}))` + ]); + + assert.strictEqual(output.length, outputSize); + }); }); diff --git a/gateway-vscode/vsc-extension-quickstart.md b/gateway-vscode/vsc-extension-quickstart.md index fee2791..682db00 100644 --- a/gateway-vscode/vsc-extension-quickstart.md +++ b/gateway-vscode/vsc-extension-quickstart.md @@ -37,7 +37,10 @@ * Put unit tests in `src/unit-test/**/*.test.ts`. * Put VS Code Extension Host tests in `src/extension-test/**/*.test.ts`. * Run `pnpm run test:extension` from the repository root to execute Extension Host tests. -* Extension Host tests use VS Code 1.106.1 by default. Set `VSCODE_TEST_VERSION` to test another version. +* Extension Host tests use a detected local VS Code installation by default. Set + `WEBCODE_EVAL_VSCODE_PATH` to select another executable. +* Set `WEBCODE_EVAL_VSCODE_PATH=download` to download an isolated test runtime. Its version defaults + to 1.106.1 and can be changed with `VSCODE_TEST_VERSION`. * The Extension Host command fails when no compiled test files are found. ## Go further diff --git a/shared/src/bridgeProtocol.json b/shared/src/bridgeProtocol.json new file mode 100644 index 0000000..85deee8 --- /dev/null +++ b/shared/src/bridgeProtocol.json @@ -0,0 +1,3 @@ +{ + "version": 2 +} diff --git a/shared/src/index.ts b/shared/src/index.ts index 47187b8..89c3543 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,6 +1,7 @@ // 通用通信协议定义 import brandConfig from './branding.json'; +import bridgeProtocolConfig from './bridgeProtocol.json'; export * from './chatgptStream'; export * from './deepseekStream'; @@ -44,6 +45,8 @@ export const PROTOCOL = { networkCaptureReadyMessage: `${brandConfig.slug}:network-capture-ready`, } as const; +export const BRIDGE_PROTOCOL_VERSION = bridgeProtocolConfig.version; + export const PLATFORM_PROMPT_KEY_PREFIX = 'platform_prompt_'; export type PromptLanguage = 'zh' | 'en'; @@ -121,6 +124,7 @@ export interface Session { autoApproveTools: boolean; workspaceId: string; lastGatewayActivityAt?: number; + gatewayIdleTimeoutMs?: number; siteId?: string; targetOrigin?: string; targetUrl?: string;