Update project structure and dependencies; add architecture documentation and improve build scripts. Introduce new versioning and permissions for enhanced functionality.

This commit is contained in:
go0p
2026-08-06 15:11:35 +08:00
committed by go0p
parent c8380de521
commit 0371a8b802
113 changed files with 15944 additions and 6556 deletions
+94
View File
@@ -0,0 +1,94 @@
import { access, readFile, stat } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { gzipSync } from 'node:zlib';
const root = resolve(import.meta.dirname, '..');
const MIB = 1024 * 1024;
// Extension Service Workers do not support runtime import(). Bridge v3 identity verification must stay in the startup bundle.
const BRIDGE_BACKGROUND_BUDGET = 144 * 1024;
const BRIDGE_BACKGROUND_GZIP_BUDGET = 44 * 1024;
const targets = [
{ name: 'store', dir: '.output/chrome-mv3-store', contentBudget: 12 * 1024, backgroundBudget: BRIDGE_BACKGROUND_BUDGET, backgroundGzipBudget: BRIDGE_BACKGROUND_GZIP_BUDGET, totalBudget: MIB, directEval: false, userScripts: true, execution: 'user-scripts' },
{ name: 'enterprise', dir: '.output/chrome-mv3-enterprise', contentBudget: 16 * 1024, backgroundBudget: BRIDGE_BACKGROUND_BUDGET, backgroundGzipBudget: BRIDGE_BACKGROUND_GZIP_BUDGET, totalBudget: MIB, directEval: true, userScripts: true, execution: 'user-scripts+injected-fallback' },
{ name: 'firefox', dir: '.output/firefox-mv2', contentBudget: 16 * 1024, backgroundBudget: BRIDGE_BACKGROUND_BUDGET, backgroundGzipBudget: BRIDGE_BACKGROUND_GZIP_BUDGET, totalBudget: MIB, directEval: true, userScripts: false, execution: 'injected-bridge' },
{ name: 'firefox-amo', dir: '.output/firefox-mv3-store', contentBudget: 12 * 1024, backgroundBudget: BRIDGE_BACKGROUND_BUDGET, backgroundGzipBudget: BRIDGE_BACKGROUND_GZIP_BUDGET, totalBudget: MIB, directEval: false, userScripts: false, execution: 'invoke-only' },
];
async function fileSize(path) {
return (await stat(path)).size;
}
async function exists(path) {
try {
await access(path);
return true;
} catch {
return false;
}
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
async function directorySize(path) {
const { readdir } = await import('node:fs/promises');
let total = 0;
for (const entry of await readdir(path, { withFileTypes: true })) {
const child = join(path, entry.name);
total += entry.isDirectory() ? await directorySize(child) : await fileSize(child);
}
return total;
}
const report = [];
for (const target of targets) {
const isFirefox = target.name.startsWith('firefox');
const output = resolve(root, target.dir);
assert(await exists(output), `${target.name} 产物不存在,请先运行对应构建命令`);
const manifest = JSON.parse(await readFile(join(output, 'manifest.json'), 'utf8'));
const contentBytes = await fileSize(join(output, 'content-scripts/agent.js'));
const backgroundSource = await readFile(join(output, 'background.js'));
const backgroundBytes = backgroundSource.byteLength;
const backgroundGzipBytes = gzipSync(backgroundSource).byteLength;
const observerBytes = await fileSize(join(output, 'page-observer-main-world.js'));
const totalBytes = await directorySize(output);
const directEvalExists = await exists(join(output, 'page-main-world.js'));
const resources = (manifest.web_accessible_resources || []).flatMap((entry) => typeof entry === 'string' ? [entry] : entry.resources || []);
const dynamicResourceGroup = (manifest.web_accessible_resources || []).find((entry) => typeof entry !== 'string' && entry.resources?.includes('floating.html'));
assert(contentBytes <= target.contentBudget, `${target.name} 常驻 content script ${contentBytes}B 超过预算 ${target.contentBudget}B`);
assert(backgroundBytes <= target.backgroundBudget, `${target.name} background ${backgroundBytes}B 超过 ${target.backgroundBudget / 1024}KiB 原始预算`);
assert(backgroundGzipBytes <= target.backgroundGzipBudget, `${target.name} background gzip ${backgroundGzipBytes}B 超过 ${target.backgroundGzipBudget / 1024}KiB 预算`);
assert(observerBytes <= 12 * 1024, `${target.name} MAIN-world observer ${observerBytes}B 超过 12KiB 预算`);
assert(totalBytes <= target.totalBudget, `${target.name} 总产物 ${totalBytes}B 超过 ${target.totalBudget / MIB}MiB 预算`);
assert(directEvalExists === target.directEval, `${target.name} page-main-world.js 存在状态不符合构建策略`);
assert(resources.includes('page-main-world.js') === target.directEval, `${target.name} page-main-world.js 暴露状态不符合构建策略`);
assert((manifest.permissions || []).includes('userScripts') === target.userScripts, `${target.name} userScripts 权限不符合构建策略`);
if (target.name === 'store' || target.name === 'firefox-amo') {
assert(!backgroundSource.toString().includes('(0,eval)'), `${target.name} background 不得包含间接 Eval 实现`);
}
assert((manifest.permissions || []).includes('webRequest'), `${target.name} 缺少网络捕获所需 webRequest 权限`);
assert((manifest.permissions || []).includes('webNavigation'), `${target.name} 缺少 frame/document 生命周期所需 webNavigation 权限`);
assert(!(manifest.permissions || []).includes('activeTab'), `${target.name} 不应申请未使用的 activeTab 权限`);
assert(!(manifest.permissions || []).includes('nativeMessaging') && (manifest.optional_permissions || []).includes('nativeMessaging'), `${target.name} Native Messaging 必须按需授权`);
assert((manifest.permissions || []).includes(isFirefox ? 'webRequestBlocking' : 'webRequestAuthProvider'), `${target.name} 缺少代理认证权限`);
assert(manifest.storage?.managed_schema === 'managed-storage-schema.json', `${target.name} 缺少企业 managed storage schema`);
assert(await exists(join(output, 'managed-storage-schema.json')), `${target.name} managed storage schema 未打包`);
if (!isFirefox) assert(!(manifest.permissions || []).includes('webRequestBlocking'), `${target.name} 不应申请阻断或修改网络请求的 webRequestBlocking 权限`);
assert(resources.includes('floating.html'), `${target.name} 没有公开按需浮动页`);
if (manifest.manifest_version === 3) assert(dynamicResourceGroup?.use_dynamic_url === true, `${target.name} 浮动页必须使用动态资源 URL`);
report.push({
target: target.name,
contentScriptKiB: Number((contentBytes / 1024).toFixed(2)),
backgroundKiB: Number((backgroundBytes / 1024).toFixed(2)),
backgroundGzipKiB: Number((backgroundGzipBytes / 1024).toFixed(2)),
observerKiB: Number((observerBytes / 1024).toFixed(2)),
totalKiB: Number((totalBytes / 1024).toFixed(2)),
execution: target.execution,
});
}
console.log(JSON.stringify(report, null, 2));
+81
View File
@@ -0,0 +1,81 @@
import { createInterface } from 'node:readline';
import { spawn } from 'node:child_process';
import { access, mkdir, realpath } from 'node:fs/promises';
import { constants } from 'node:fs';
import { resolve } from 'node:path';
const root = process.cwd();
const output = resolve(root, '.output/chrome-mv3-dev');
const manifest = resolve(output, 'manifest.json');
const profile = resolve(root, '.wxt/chrome-wsl-profile');
const chrome = process.env.CHROME_PATH || '/usr/bin/google-chrome';
await access(chrome, constants.X_OK).catch(() => {
throw new Error(`Chrome is not executable: ${chrome}. Set CHROME_PATH to override it.`);
});
await mkdir(profile, { recursive: true });
const resolvedChrome = await realpath(chrome);
const isBrandedChrome = resolvedChrome.startsWith('/opt/google/chrome/');
const wxt = spawn(process.execPath, [resolve(root, 'node_modules/wxt/bin/wxt.mjs')], {
cwd: root,
env: process.env,
stdio: ['inherit', 'pipe', 'pipe'],
});
const pipeLines = (stream, destination) => {
const reader = createInterface({ input: stream });
reader.on('line', (line) => {
if (!line.includes('Cannot open browser when using WSL')) destination.write(`${line}\n`);
});
};
pipeLines(wxt.stdout, process.stdout);
pipeLines(wxt.stderr, process.stderr);
const waitForManifest = async () => {
for (let attempt = 0; attempt < 200; attempt += 1) {
if (wxt.exitCode !== null) throw new Error(`WXT exited before producing ${manifest}`);
try {
await access(manifest, constants.R_OK);
return;
} catch {
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
}
}
throw new Error(`Timed out waiting for ${manifest}`);
};
let chromeProcess;
const shutdown = async (signal) => {
if (chromeProcess?.exitCode === null) chromeProcess.kill(signal);
if (wxt.exitCode === null) wxt.kill(signal);
};
process.once('SIGINT', () => void shutdown('SIGINT'));
process.once('SIGTERM', () => void shutdown('SIGTERM'));
try {
await waitForManifest();
const chromeArgs = [
`--user-data-dir=${profile}`,
'--disable-gpu',
'--no-first-run',
'--no-default-browser-check',
];
if (!isBrandedChrome || process.env.WXT_AUTO_LOAD_EXTENSION === '1') {
chromeArgs.push(`--disable-extensions-except=${output}`, `--load-extension=${output}`, 'about:blank');
} else {
chromeArgs.push('chrome://extensions');
}
chromeProcess = spawn(chrome, chromeArgs, { cwd: root, env: process.env, stdio: 'inherit' });
if (isBrandedChrome && process.env.WXT_AUTO_LOAD_EXTENSION !== '1') {
process.stdout.write(`\nOpened official Chrome with the persistent WXT profile.\nChrome 137+ ignores --load-extension in branded builds. On first run, enable Developer mode and load:\n${output}\nProfile: ${profile}\n`);
} else {
process.stdout.write(`\nOpened ${chrome} with the WXT development extension.\nProfile: ${profile}\n`);
}
} catch (error) {
await shutdown('SIGTERM');
throw error;
}
await new Promise((resolveExit) => wxt.once('exit', resolveExit));
if (chromeProcess?.exitCode === null) chromeProcess.kill('SIGTERM');
+44
View File
@@ -0,0 +1,44 @@
import { constants } from 'node:fs';
import { access, readdir } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
async function executable(path) {
if (!path) return false;
try {
await access(path, constants.X_OK);
return true;
} catch {
return false;
}
}
export async function resolveChromiumPath() {
for (const candidate of [process.env.CHROMIUM_PATH, process.env.CHROME_PATH]) {
if (await executable(candidate)) return candidate;
}
const cacheRoot = process.env.PLAYWRIGHT_BROWSERS_PATH || join(homedir(), '.cache', 'ms-playwright');
let entries = [];
try {
entries = (await readdir(cacheRoot, { withFileTypes: true }))
.filter((entry) => entry.isDirectory() && entry.name.startsWith('chromium-'))
.map((entry) => entry.name)
.sort((left, right) => Number(right.slice(9)) - Number(left.slice(9)));
} catch {
// The final error below lists the supported configuration options.
}
for (const entry of entries) {
for (const relative of ['chrome-linux64/chrome', 'chrome-linux/chrome']) {
const candidate = join(cacheRoot, entry, relative);
if (await executable(candidate)) return candidate;
}
}
for (const command of ['google-chrome-for-testing', 'chromium', 'chromium-browser']) {
const resolved = spawnSync('which', [command], { encoding: 'utf8' }).stdout.trim();
if (await executable(resolved)) return resolved;
}
throw new Error('Unpacked-capable Chromium not found. Set CHROMIUM_PATH or install Chromium/Playwright Chromium.');
}
+241
View File
@@ -0,0 +1,241 @@
import { spawnSync } from 'node:child_process';
import { randomBytes, webcrypto } from 'node:crypto';
import { cp, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { chromium } from 'playwright-core';
import { WebSocketServer } from 'ws';
import { resolveChromiumPath } from './resolve-chromium.mjs';
const root = resolve(import.meta.dirname, '..');
const yakRoot = resolve(process.env.YAK_REPO || root, process.env.YAK_REPO ? '.' : '../../go/yaklang');
const extensionPath = resolve(root, process.env.EXTENSION_PATH || '.output/chrome-mv3-store');
const executablePath = await resolveChromiumPath();
const temporary = await mkdtemp(join(tmpdir(), 'yakit-native-host-e2e-'));
const home = join(temporary, 'home');
const profile = join(temporary, 'profile');
const hostBinary = join(temporary, 'yakit-browser-agent-host');
const testExtensionPath = join(temporary, 'extension');
const hostName = 'com.yaklang.browser_agent';
const packagedManifest = JSON.parse(await readFile(join(extensionPath, 'manifest.json'), 'utf8'));
if (!packagedManifest.optional_permissions?.includes('nativeMessaging') || packagedManifest.permissions?.includes('nativeMessaging')) {
throw new Error('Native Messaging is not packaged as an optional permission');
}
// Chrome's optional-permission prompt is browser chrome and cannot be accepted by
// Playwright. Pre-grant it only in a disposable copy so the native transport itself
// can still be exercised through the real extension and browser APIs.
await cp(extensionPath, testExtensionPath, { recursive: true });
const testManifest = structuredClone(packagedManifest);
testManifest.permissions = [...new Set([...(testManifest.permissions || []), 'nativeMessaging'])];
testManifest.optional_permissions = (testManifest.optional_permissions || []).filter((value) => value !== 'nativeMessaging');
await writeFile(join(testExtensionPath, 'manifest.json'), JSON.stringify(testManifest, null, 2));
const build = spawnSync('go', ['build', '-o', hostBinary, './common/browser/nativehostcmd'], {
cwd: yakRoot, encoding: 'utf8', env: process.env,
});
if (build.status !== 0) throw new Error(`Native Host build failed:\n${build.stderr || build.stdout}`);
const bridgeHTTPServer = createServer();
const pairingServer = new WebSocketServer({ noServer: true });
const bridgeServer = new WebSocketServer({ noServer: true });
bridgeHTTPServer.on('upgrade', (request, socket, head) => {
const pathname = new URL(request.url || '/', 'http://127.0.0.1').pathname;
const target = pathname === '/pairing' ? pairingServer : pathname === '/extension' ? bridgeServer : undefined;
if (!target) return socket.destroy();
target.handleUpgrade(request, socket, head, (webSocket) => target.emit('connection', webSocket, request));
});
await new Promise((resolveListen) => bridgeHTTPServer.listen(0, '127.0.0.1', resolveListen));
const address = bridgeHTTPServer.address();
const endpoint = `ws://127.0.0.1:${address.port}/extension`;
const protocolVersion = 3;
const engineIdentityId = 'native-e2e-engine-identity';
const engineInstanceId = 'native-e2e-engine-instance';
const engineKeys = await webcrypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
const rawEngineJWK = await webcrypto.subtle.exportKey('jwk', engineKeys.publicKey);
const enginePublicKey = { kty: 'EC', crv: 'P-256', x: rawEngineJWK.x, y: rawEngineJWK.y };
let pairedClient;
let authenticatedConnections = 0;
let resolveHello;
const helloReceived = new Promise((resolveMessage) => { resolveHello = resolveMessage; });
const toBase64URL = (value) => Buffer.from(value).toString('base64url');
const engineChallengePayload = (challenge, timestamp) => ['yak-browser-bridge-v3', 'engine-challenge', engineIdentityId, engineInstanceId, challenge, String(timestamp)].join('\n');
const clientAuthPayload = (origin, challenge, auth) => [
'yak-browser-bridge-v3', 'client-auth', origin, engineIdentityId, engineInstanceId, challenge,
auth.installationId || '', auth.client || '', auth.version || '', [...(auth.capabilities || [])].sort().join(','),
auth.taskId || '', auth.grantId || '', auth.resumeSessionId || '',
].join('\n');
pairingServer.on('connection', (socket, request) => socket.once('message', async (raw) => {
const pairing = JSON.parse(raw.toString());
if (pairing.type !== 'pair_request' || pairing.protocolVersion !== protocolVersion) return socket.close(1008, 'invalid pairing request');
const requestId = 'native-e2e-pairing';
const serverNonce = toBase64URL(randomBytes(32));
const transcript = [
'yak-browser-pairing-v1', engineIdentityId, requestId, request.headers.origin, pairing.installationId,
pairing.nonce, serverNonce, pairing.publicKey.kty, pairing.publicKey.crv, pairing.publicKey.x, pairing.publicKey.y,
].join('\n');
const digest = Buffer.from(await webcrypto.subtle.digest('SHA-256', Buffer.from(transcript)));
const code = String(digest.readBigUInt64BE() % 1_000_000n).padStart(6, '0');
pairedClient = { installationId: pairing.installationId, publicKey: pairing.publicKey };
socket.send(JSON.stringify({
type: 'pair_pending', protocolVersion, requestId, serverNonce, engineIdentityId, code,
expiresAt: Date.now() + 60_000, publicKey: enginePublicKey,
}));
setTimeout(() => socket.send(JSON.stringify({
type: 'pair_approved', requestId, deviceId: 'native-e2e-device', engineIdentityId, publicKey: enginePublicKey,
})), 50);
}));
bridgeServer.on('connection', async (socket, request) => {
const challenge = toBase64URL(randomBytes(32));
const timestamp = Date.now();
socket.send(JSON.stringify({
type: 'challenge', protocolVersion, challenge, timestamp, engineIdentityId, engineInstanceId, publicKey: enginePublicKey,
signature: toBase64URL(await webcrypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' }, engineKeys.privateKey, Buffer.from(engineChallengePayload(challenge, timestamp)),
)),
}));
socket.once('message', (raw) => void (async () => {
const hello = JSON.parse(raw.toString());
if (!request.headers.origin?.startsWith('chrome-extension://') || hello.type !== 'auth' || hello.protocolVersion !== protocolVersion || !hello.installationId || hello.challenge !== challenge || !pairedClient) {
socket.close(1008, 'invalid native e2e handshake');
return;
}
const clientKey = await webcrypto.subtle.importKey('jwk', pairedClient.publicKey, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
const verified = await webcrypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' }, clientKey, Buffer.from(hello.signature, 'base64url'),
Buffer.from(clientAuthPayload(request.headers.origin, challenge, hello)),
);
if (!verified || pairedClient.installationId !== hello.installationId) return socket.close(1008, 'invalid native e2e signature');
socket.send(JSON.stringify({
type: 'hello_ack', protocolVersion, version: 'native-e2e-engine', capabilities: [],
sessionId: 'native-e2e-session', engineIdentityId, engineInstanceId,
connectionId: 'native-e2e-connection', resumed: false,
}));
socket.on('message', (payload) => {
const message = JSON.parse(payload.toString());
if (message.type === 'ping') socket.send(JSON.stringify({
type: 'pong', id: message.id, sequence: message.sequence, timestamp: message.timestamp, replyTimestamp: Date.now(),
}));
});
authenticatedConnections += 1;
if (authenticatedConnections >= 2) resolveHello({ hello, origin: request.headers.origin });
})());
});
await mkdir(join(home, '.config', 'yakit'), { recursive: true });
await writeFile(join(home, '.config', 'yakit', 'browser-agent-native-host.json'), JSON.stringify({ endpoint }));
let context;
try {
const launch = () => chromium.launchPersistentContext(profile, {
executablePath,
headless: true,
env: { ...process.env, HOME: home },
args: [`--disable-extensions-except=${testExtensionPath}`, `--load-extension=${testExtensionPath}`, '--no-first-run'],
});
context = await launch();
let worker = context.serviceWorkers()[0];
if (!worker) worker = await context.waitForEvent('serviceworker', { timeout: 15_000 });
const extensionId = new URL(worker.url()).host;
const manifest = {
name: hostName,
description: 'Yakit Browser Agent Native Host E2E',
path: hostBinary,
type: 'stdio',
allowed_origins: [`chrome-extension://${extensionId}/`],
};
const manifestDirectories = [
...['google-chrome', 'google-chrome-for-testing', 'chromium'].map((product) => join(home, '.config', product, 'NativeMessagingHosts')),
join(profile, 'NativeMessagingHosts'),
];
for (const directory of manifestDirectories) {
const path = join(directory, `${hostName}.json`);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(manifest));
}
// Chromium caches native-host registrations at process startup.
await context.close();
context = await launch();
worker = context.serviceWorkers()[0];
if (!worker) worker = await context.waitForEvent('serviceworker', { timeout: 15_000 });
const restartedExtensionId = new URL(worker.url()).host;
if (restartedExtensionId !== extensionId) throw new Error('Extension ID changed after Native Host registration');
const options = await context.newPage();
await options.goto(`chrome-extension://${extensionId}/options.html#engine`);
try {
await options.evaluate(async ({ bridgeEndpoint }) => {
const send = async (action, payload) => {
const response = await chrome.runtime.sendMessage({ action, payload });
if (!response?.ok) throw new Error(response?.error || action);
return response.data;
};
const state = await send('state.get');
await send('bridge.config.save', {
transport: 'websocket', nativeHost: 'com.yaklang.browser_agent', endpoint: bridgeEndpoint,
autoConnect: false, installationId: state.bridge.installationId,
});
await send('bridge.pair');
for (let attempt = 0; attempt < 50; attempt += 1) {
const [next, status] = await Promise.all([send('state.get'), send('bridge.status')]);
if (next.bridge.pairedEngine && status.state === 'connected') {
await send('bridge.disconnect');
await send('bridge.config.save', { ...next.bridge, transport: 'native', autoConnect: false });
await send('bridge.connect');
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error('Browser extension pairing did not complete');
}, { bridgeEndpoint: endpoint });
} catch (error) {
const diagnostics = await options.evaluate(async () => ({
body: document.body.innerText,
permissions: await chrome.permissions.getAll(),
bridge: await chrome.runtime.sendMessage({ action: 'bridge.status' }),
}));
throw new Error(`Native Host pairing and settings failed: ${JSON.stringify(diagnostics)}`, { cause: error });
}
let connection;
try {
connection = await Promise.race([
helloReceived,
new Promise((_, reject) => setTimeout(() => reject(new Error('Native Host did not reach Yak Bridge')), 15_000)),
]);
} catch (error) {
const diagnostics = await options.evaluate(async () => ({
body: document.body.innerText,
permissions: await chrome.permissions.getAll(),
bridge: await chrome.runtime.sendMessage({ action: 'bridge.status' }),
}));
throw new Error(`Native Host transport failed: ${JSON.stringify(diagnostics)}`, { cause: error });
}
const status = await options.evaluate(async () => {
const response = await chrome.runtime.sendMessage({ action: 'bridge.status' });
if (!response?.ok) throw new Error(response?.error || 'bridge.status');
return response.data;
});
if (status.state !== 'connected' || status.engineInstanceId !== 'native-e2e-engine-instance' || status.connectionId !== 'native-e2e-connection') {
throw new Error(`Native Host identity did not reach the extension: ${JSON.stringify(status)}`);
}
console.log(JSON.stringify({
extensionId,
endpoint,
permissionFixture: 'pre-granted only in temporary E2E copy; production package remains optional',
connection,
status,
}, null, 2));
} finally {
await context?.close();
for (const client of bridgeServer.clients) client.terminate();
for (const client of pairingServer.clients) client.terminate();
bridgeServer.close();
pairingServer.close();
bridgeHTTPServer.close();
await rm(temporary, { recursive: true, force: true });
}
File diff suppressed because it is too large Load Diff