Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
1 change: 1 addition & 0 deletions bridge-browser/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
84 changes: 84 additions & 0 deletions bridge-browser/src/background/bridge_redemption.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, unknown>): 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;
}
120 changes: 90 additions & 30 deletions bridge-browser/src/background/connection.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,36 @@
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';
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<HandshakeResponse> {
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<HandshakeResponse> {
const params = getHandshakeParams(request);

if (!tabId) {return { success: false, error: "No Tab ID" };}
Expand All @@ -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 {
Expand All @@ -60,6 +90,7 @@ interface BindSessionOptions {
siteId: string;
targetOrigin: string;
targetUrl: string;
idleTimeoutMs: number;
}

export async function bindSession(tabId: number, options: BindSessionOptions) {
Expand All @@ -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
Expand All @@ -98,51 +130,79 @@ 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;
}

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<BridgeRedemptionResult> {
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<string | null> {
const all = await chrome.storage.local.get(null) as Record<string, unknown>;
for (const [key, val] of Object.entries(all)) {
Expand Down
18 changes: 13 additions & 5 deletions bridge-browser/src/background/gateway.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,7 +27,7 @@ export type ToolPreflightResponse = {
};

export async function executeTool(
request: MessageRequest,
request: ExecuteToolMessageRequest,
tabId: number | null | undefined,
senderUrl?: string
) {
Expand Down Expand Up @@ -72,7 +78,7 @@ export async function executeTool(
}

export async function preflightTool(
request: MessageRequest,
request: PreflightToolMessageRequest,
tabId: number | null | undefined,
senderUrl?: string
): Promise<ToolPreflightResponse> {
Expand Down Expand Up @@ -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 }> {
Expand Down Expand Up @@ -215,7 +221,9 @@ function isCommandRisk(value: unknown): value is NonNullable<ToolPreflightRespon
&& value.reasons.every((reason) => 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;
}

Expand Down
Loading
Loading