mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-22 03:10:43 +08:00
feat(browser): add managed instance agent capabilities
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { access, readFile, stat } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
|
||||
@@ -11,6 +12,7 @@ const TOTAL_PACKAGE_BUDGET = Math.floor(1.25 * MIB);
|
||||
const BRIDGE_BACKGROUND_BUDGET = 204 * 1024;
|
||||
const BRIDGE_BACKGROUND_GZIP_BUDGET = 60 * 1024;
|
||||
const ENTERPRISE_BACKGROUND_GZIP_BUDGET = 61 * 1024;
|
||||
const CHROMIUM_EXTENSION_ID = 'mcnaombmlombekhbonfndagbcfhmoail';
|
||||
// Recorder, callable registry and Pipeline runtime are installed only for an
|
||||
// explicitly selected document. Keep their budget separate from the always-on
|
||||
// Service Worker so moving work out of startup code remains measurable.
|
||||
@@ -103,6 +105,12 @@ for (const target of targets) {
|
||||
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'));
|
||||
|
||||
if (!isFirefox) {
|
||||
assert(typeof manifest.key === 'string', `${target.name} 缺少固定扩展公钥`);
|
||||
const extensionId = createHash('sha256').update(Buffer.from(manifest.key, 'base64')).digest('hex').slice(0, 32).replace(/[0-9a-f]/g, (digit) => String.fromCharCode(97 + Number.parseInt(digit, 16)));
|
||||
assert(extensionId === CHROMIUM_EXTENSION_ID, `${target.name} 扩展 ID 漂移:${extensionId}`);
|
||||
}
|
||||
|
||||
if (contentBytes > target.contentBudget) sizeAdvisories.push(`content script ${contentBytes}B > ${target.contentBudget}B reference`);
|
||||
if (backgroundBytes > target.backgroundBudget) sizeAdvisories.push(`background ${backgroundBytes}B > ${target.backgroundBudget}B reference`);
|
||||
if (backgroundGzipBytes > target.backgroundGzipBudget) sizeAdvisories.push(`background gzip ${backgroundGzipBytes}B > ${target.backgroundGzipBudget}B reference`);
|
||||
|
||||
+57
-11
@@ -1,11 +1,11 @@
|
||||
import { browser, type Browser } from 'wxt/browser';
|
||||
import {
|
||||
clearNetworkRequests, exportNetworkRequest, listNetworkRequests, networkCaptureStatus,
|
||||
rebindNetworkCapturesForGrant, startNetworkCapture, stopNetworkCapture,
|
||||
rebindNetworkCapturesForGrant, startNetworkCapture, stopNetworkCapture, stopNetworkCapturesForGrant,
|
||||
} from '@/features/network-capture/service';
|
||||
import { capturedRequestEnginePayload } from '@/features/network-capture/workflows';
|
||||
import { initializeBrowserRecordingService } from '@/features/browser-recording/service';
|
||||
import { initializeDeepCaptureService } from '@/features/deep-capture/service';
|
||||
import { initializeBrowserRecordingService, stopBrowserRecordingsForGrant } from '@/features/browser-recording/service';
|
||||
import { initializeDeepCaptureService, stopDeepCapturesForGrant } from '@/features/deep-capture/service';
|
||||
import { initializeBrowserTransformService } from '@/features/browser-transform/service';
|
||||
import { initializeFloatingPanelLifecycle } from '@/features/floating-panel/lifecycle';
|
||||
import type { ExtensionRequest, ExtensionResponse } from '@/types/messages';
|
||||
@@ -36,6 +36,9 @@ import {
|
||||
import {
|
||||
applyPolicyToBridge, applyPolicyToState, assertGrantPolicy, getEnterprisePolicy,
|
||||
} from '@/platform/policy/managed';
|
||||
import {
|
||||
browserInstanceAccess, PAIRED_BROWSER_INSTANCE_ACCESS_ID,
|
||||
} from '@/features/grants/capability-context';
|
||||
import { createDiagnosticsBundle } from '@/features/diagnostics/export';
|
||||
import { getRuntimeMetrics, recordServiceWorkerStart, resetRuntimeMetrics } from '@/features/diagnostics/metrics';
|
||||
import {
|
||||
@@ -68,6 +71,16 @@ function originOf(url: string): string {
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
async function syncManagedInstanceBadge(managedInstance?: { badge: string }): Promise<void> {
|
||||
const badge = managedInstance?.badge || '';
|
||||
await browser.action.setBadgeText({ text: badge });
|
||||
if (badge) {
|
||||
const color = badge === 'A' ? '#F26215' : badge === 'B' ? '#2563EB' : badge === 'C' ? '#16A34A' : '#7C3AED';
|
||||
await browser.action.setBadgeBackgroundColor({ color });
|
||||
}
|
||||
await browser.action.setTitle({ title: badge ? `Yakit Browser Agent · 实例 ${badge}` : 'Yakit Browser Agent' });
|
||||
}
|
||||
|
||||
async function createGrantTargets(inputs: Array<{ tabId: number; frameId: number }>): Promise<BridgeGrantTarget[]> {
|
||||
const unique = [...new Map(inputs.map((target) => [`${target.tabId}:${target.frameId}`, target])).values()];
|
||||
const tabIds = [...new Set(unique.map((target) => target.tabId))];
|
||||
@@ -106,6 +119,12 @@ const domainHandlers: readonly BackgroundRequestHandler[] = [
|
||||
handleTransformRequest,
|
||||
];
|
||||
|
||||
const stopPairedBrowserTasks = () => Promise.all([
|
||||
stopNetworkCapturesForGrant(PAIRED_BROWSER_INSTANCE_ACCESS_ID),
|
||||
stopBrowserRecordingsForGrant(PAIRED_BROWSER_INSTANCE_ACCESS_ID),
|
||||
stopDeepCapturesForGrant(PAIRED_BROWSER_INSTANCE_ACCESS_ID),
|
||||
]);
|
||||
|
||||
async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.MessageSender): Promise<ExtensionResponse> {
|
||||
const domainResponse = await dispatchBackgroundHandlers(request, sender, domainHandlers);
|
||||
if (domainResponse !== undefined) return domainResponse;
|
||||
@@ -290,7 +309,10 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
|
||||
};
|
||||
});
|
||||
const handoff = state.handoff!;
|
||||
await setAgentRuntimeState(input.outcome === 'completed' ? 'running' : 'paused', state.activeGrant);
|
||||
await setAgentRuntimeState(
|
||||
input.outcome === 'completed' ? 'running' : 'paused',
|
||||
await browserInstanceAccess('browser.tabs.read'),
|
||||
);
|
||||
await browser.action.setBadgeText({ text: '', tabId: handoff.target.tabId });
|
||||
engineBridge.emitEvent('browser.handoff.changed', handoff);
|
||||
void appendAuditEvent({
|
||||
@@ -388,14 +410,15 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
|
||||
}
|
||||
case 'agent.runtime.get': return ok(await getAgentRuntime());
|
||||
case 'agent.pause': {
|
||||
const grant = await requireActiveGrant();
|
||||
const grant = await browserInstanceAccess('browser.tabs.read');
|
||||
engineBridge.cancelActiveRequests();
|
||||
await stopPairedBrowserTasks();
|
||||
const runtime = await setAgentRuntimeState('paused', grant);
|
||||
void appendAuditEvent({ category: 'grant', action: 'agent.pause', outcome: 'success', taskId: grant.taskId });
|
||||
return ok(runtime);
|
||||
}
|
||||
case 'agent.resume': {
|
||||
const grant = await requireActiveGrant();
|
||||
const grant = await browserInstanceAccess('browser.tabs.read');
|
||||
const runtime = await setAgentRuntimeState('running', grant);
|
||||
void appendAuditEvent({ category: 'grant', action: 'agent.resume', outcome: 'success', taskId: grant.taskId });
|
||||
return ok(runtime);
|
||||
@@ -408,10 +431,29 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
|
||||
case 'bridge.config.save': {
|
||||
const config = applyPolicyToBridge(request.payload, (await getEnterprisePolicy()).policy);
|
||||
const state = await updateState((current) => ({ ...current, bridge: config }));
|
||||
await syncManagedInstanceBadge(state.bridge.managedInstance);
|
||||
if (config.autoConnect && config.pairedEngine) await engineBridge.connect(config);
|
||||
else engineBridge.disconnect();
|
||||
return ok(state);
|
||||
}
|
||||
case 'bridge.managed-instance.bind': {
|
||||
const senderURL = sender.url ? new URL(sender.url) : undefined;
|
||||
const bootstrapURL = new URL(browser.runtime.getURL('/ytray-bootstrap.html'));
|
||||
if (senderURL?.origin !== bootstrapURL.origin || senderURL.pathname !== bootstrapURL.pathname) {
|
||||
throw new ExtensionError('forbidden', '浏览器实例身份只能由受管启动页设置');
|
||||
}
|
||||
const state = await updateState((current) => ({
|
||||
...current,
|
||||
bridge: { ...current.bridge, managedInstance: request.payload },
|
||||
}));
|
||||
await syncManagedInstanceBadge(state.bridge.managedInstance);
|
||||
if (state.bridge.autoConnect && state.bridge.pairedEngine) {
|
||||
engineBridge.disconnect();
|
||||
await stopPairedBrowserTasks();
|
||||
await engineBridge.connect(state.bridge);
|
||||
}
|
||||
return ok(engineBridge.getStatus());
|
||||
}
|
||||
case 'bridge.pair': {
|
||||
const status = await engineBridge.startPairing();
|
||||
void appendAuditEvent({ category: 'bridge', action: 'bridge.pair', outcome: 'success' });
|
||||
@@ -421,6 +463,7 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
|
||||
case 'bridge.pair.status': return ok(engineBridge.getPairingStatus());
|
||||
case 'bridge.unpair': {
|
||||
await engineBridge.unpair();
|
||||
await stopPairedBrowserTasks();
|
||||
void appendAuditEvent({ category: 'bridge', action: 'bridge.unpair', outcome: 'success' });
|
||||
return ok(await getState());
|
||||
}
|
||||
@@ -431,6 +474,7 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
|
||||
}
|
||||
case 'bridge.disconnect': {
|
||||
engineBridge.disconnect();
|
||||
await stopPairedBrowserTasks();
|
||||
void appendAuditEvent({ category: 'bridge', action: 'bridge.disconnect', outcome: 'success' });
|
||||
return ok(engineBridge.getStatus());
|
||||
}
|
||||
@@ -443,10 +487,11 @@ let backgroundStarted = false;
|
||||
|
||||
async function restoreBackgroundState(): Promise<void> {
|
||||
const storedState = await restoreGrantLifecycle();
|
||||
const state = applyPolicyToState(storedState, (await getEnterprisePolicy()).policy);
|
||||
const policy = (await getEnterprisePolicy()).policy;
|
||||
const state = applyPolicyToState(storedState, policy);
|
||||
if (JSON.stringify(state.bridge) !== JSON.stringify(storedState.bridge)
|
||||
|| JSON.stringify(state.floatingPanel) !== JSON.stringify(storedState.floatingPanel)) {
|
||||
await updateState(() => state);
|
||||
await updateState((current) => applyPolicyToState(current, policy));
|
||||
}
|
||||
try {
|
||||
await reconcileUserAgentRuntime();
|
||||
@@ -460,8 +505,10 @@ async function restoreBackgroundState(): Promise<void> {
|
||||
summary: (error instanceof Error ? error.message : String(error)).slice(0, 512),
|
||||
});
|
||||
}
|
||||
if (state.bridge.autoConnect && state.bridge.pairedEngine) {
|
||||
await engineBridge.connect(state.bridge).catch(console.error);
|
||||
const currentState = await getState();
|
||||
await syncManagedInstanceBadge(currentState.bridge.managedInstance);
|
||||
if (currentState.bridge.autoConnect && currentState.bridge.pairedEngine) {
|
||||
await engineBridge.connect(currentState.bridge).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,7 +517,6 @@ export function runBackground(): void {
|
||||
backgroundStarted = true;
|
||||
|
||||
configureGrantLifecycleHooks({
|
||||
cancelActiveRequests: () => engineBridge.cancelActiveRequests(),
|
||||
emitHandoffChanged: (handoff) => engineBridge.emitEvent('browser.handoff.changed', handoff),
|
||||
});
|
||||
registerGrantLifecycleListeners();
|
||||
|
||||
@@ -19,8 +19,6 @@ import { ProxyProfilesView } from '@/features/proxy/ui/ProxyProfilesView';
|
||||
import { RuleSourcesView } from '@/features/proxy/ui/RuleSourcesView';
|
||||
import { RecordingWorkspace } from '@/features/browser-recording/RecordingWorkspace';
|
||||
import { AuthorizationTestingWorkspace } from '@/features/authorization-testing/ui/AuthorizationTestingWorkspace';
|
||||
import { gatewayShareActive, gatewayShareGrantInput } from '@/features/grants/gateway-share';
|
||||
import { CAPABILITY_LABELS, CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES, isControlScopeSet } from '@/protocol/capabilities';
|
||||
import { AGENT_RUNTIME_STORAGE_KEY, AUDIT_STORAGE_KEY, isStateStorageChange } from '@/protocol/storage';
|
||||
import type {
|
||||
ActiveTabInfo, AgentRuntime, AuditEvent, BridgePairingStatus, BridgeStatus, BrowserCookie, BrowserRequestAnalysisBundle, CookieInput, CookieTransferFormat, EnterprisePolicyStatus, ExtensionState, HumanHandoff,
|
||||
@@ -227,7 +225,7 @@ function App() {
|
||||
<span className="topbar-tab__favicon">{tab?.favIconUrl ? <img src={tab.favIconUrl} alt="" /> : <Radio size={13} />}</span>
|
||||
<select className="target-tab-select" aria-label="目标标签页" value={tab?.id || ''} onChange={(event) => void selectTab(Number(event.target.value))}><option value="" disabled>选择目标标签页</option>{tabs.map((item) => <option value={item.id} key={item.id}>{item.title}</option>)}</select>
|
||||
</div>}
|
||||
<div className="topbar-actions"><span className={`permission-state ${state.activeGrant ? 'enabled' : ''}`}><ShieldCheck size={14} />{state.activeGrant ? `${isControlScopeSet(state.activeGrant.scopes) ? '控制' : '只读'}会话` : '未共享'}</span><Button size="icon" variant="ghost" title="刷新状态" onClick={() => void load()}><RefreshCw size={17} /></Button></div>
|
||||
<div className="topbar-actions"><span className={`permission-state ${bridge.state === 'connected' ? 'enabled' : ''}`}><ShieldCheck size={14} />{bridge.state === 'connected' ? '实例已连接' : '实例离线'}</span><Button size="icon" variant="ghost" title="刷新状态" onClick={() => void load()}><RefreshCw size={17} /></Button></div>
|
||||
</header>
|
||||
|
||||
{handoff && <HandoffBanner handoff={handoff} setState={setState} run={run} busy={busy} />}
|
||||
@@ -240,7 +238,7 @@ function App() {
|
||||
{section === 'sources' && <RuleSourcesView state={state} setState={setState} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'cookies' && <CookieEditor key={tab?.id || 0} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'user-agent' && <UserAgents state={state} setState={setState} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'network' && <NetworkActivity key={tab?.id || 0} state={state} setState={setState} tab={tab} bridge={bridge} run={run} busy={busy} />}
|
||||
{section === 'network' && <NetworkActivity key={tab?.id || 0} tab={tab} bridge={bridge} run={run} busy={busy} />}
|
||||
{section === 'context' && <ContextTool key={tab?.id || 0} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'engine' && <EngineSettings state={state} setState={setState} bridge={bridge} setBridge={setBridge} tabs={tabs} run={run} busy={busy} />}
|
||||
{section === 'activity' && <ActivityLog run={run} busy={busy} />}
|
||||
@@ -313,7 +311,7 @@ function ActivityLog({ run, busy }: { run: (task: () => Promise<void>, success?:
|
||||
return <div className="section-view activity-view">
|
||||
<div className="page-heading"><div><h1>Agent 操作时间线</h1><p>实时动作保存在浏览器 session;长期审计只保存脱敏摘要,不记录参数、页面内容、Cookie 或执行结果。</p></div><div className="activity-heading-actions"><span className={`agent-runtime-state ${runtime.state}`}><Activity size={15} />{runtimeLabel}</span><Button variant="ghost" disabled={busy} onClick={() => void downloadDiagnostics()}><Download size={15} />导出诊断</Button></div></div>
|
||||
<section className="agent-runtime-band">
|
||||
<div className="agent-runtime-summary"><div><span>当前任务</span><strong>{runtime.taskId || '未共享'}</strong><small>{runtime.grantId ? `Grant ${runtime.grantId.slice(0, 8)}` : '没有活动授权'}</small></div><div><span>最近更新</span><strong>{new Date(runtime.updatedAt).toLocaleTimeString()}</strong><small>{runtime.actions.length} 条 session 动作</small></div><div className="agent-runtime-controls">{runtime.state === 'running' || runtime.state === 'waiting_for_human' ? <Button disabled={busy} onClick={() => void run(async () => setRuntime(await request('agent.pause')), 'Agent 已暂停')}><Square size={15} />暂停</Button> : runtime.state === 'paused' ? <Button variant="primary" disabled={busy} onClick={() => void run(async () => setRuntime(await request('agent.resume')), 'Agent 已恢复')}><Play size={15} />恢复</Button> : null}{runtime.grantId && !['revoked', 'expired'].includes(runtime.state) && <Button variant="danger" disabled={busy} onClick={() => void run(async () => { await request('grant.revoke'); setRuntime(await request('agent.runtime.get')); }, '共享会话已撤销')}><X size={15} />撤销</Button>}<Button variant="ghost" disabled={busy || runtime.actions.length === 0} onClick={() => void run(async () => setRuntime(await request('agent.actions.clear')), 'Session 时间线已清空')}><Trash2 size={15} />清空</Button></div></div>
|
||||
<div className="agent-runtime-summary"><div><span>浏览器实例</span><strong>{runtime.taskId ? '已接入 Agent' : '等待调用'}</strong><small>{runtime.grantId ? '配对级访问' : '尚无能力调用'}</small></div><div><span>最近更新</span><strong>{new Date(runtime.updatedAt).toLocaleTimeString()}</strong><small>{runtime.actions.length} 条 session 动作</small></div><div className="agent-runtime-controls">{runtime.state === 'running' || runtime.state === 'waiting_for_human' ? <Button disabled={busy} onClick={() => void run(async () => setRuntime(await request('agent.pause')), 'Agent 已暂停')}><Square size={15} />暂停</Button> : runtime.state === 'paused' ? <Button variant="primary" disabled={busy} onClick={() => void run(async () => setRuntime(await request('agent.resume')), 'Agent 已恢复')}><Play size={15} />恢复</Button> : null}<Button variant="ghost" disabled={busy || runtime.actions.length === 0} onClick={() => void run(async () => setRuntime(await request('agent.actions.clear')), 'Session 时间线已清空')}><Trash2 size={15} />清空</Button></div></div>
|
||||
{runtime.actions.length === 0 ? <div className="agent-actions-empty">当前 session 尚无 Agent 能力调用。</div> : <div className="agent-action-list" role="list">{[...runtime.actions].reverse().slice(0, 50).map((action) => <div key={action.id} className="agent-action-row" role="listitem"><span className={`action-state ${action.state}`} /> <time>{new Date(action.startedAt).toLocaleTimeString()}</time><code title={action.method}>{action.method}</code><span>{action.targetTabId ? `Tab ${action.targetTabId}` : '扩展本机'}</span><strong className={action.state}>{action.state}</strong><span>{action.durationMs === undefined ? '进行中' : `${action.durationMs} ms`}</span></div>)}</div>}
|
||||
</section>
|
||||
<div className="activity-subheading"><div><h2>持久化脱敏审计</h2><p>最近 500 条授权、Bridge、接管与能力结果。</p></div><Button variant="ghost" disabled={busy || events.length === 0} onClick={() => void run(async () => { await request('audit.clear'); setEvents([]); }, '操作记录已清空')}><Trash2 size={15} />清空审计</Button></div>
|
||||
@@ -362,12 +360,12 @@ function Overview({ state, bridge, tab, navigate, run, busy }: { state: Extensio
|
||||
<div className="page-heading"><div><h1>运行概览</h1><p>{tab?.title || '选择一个 HTTP(S) 标签页,建立浏览器现场。'}</p></div><span className={`large-status ${bridge.state}`}><Radio size={16} />{bridge.state === 'connected' ? `Yak ${bridge.engineVersion || '引擎'} 在线` : 'Yak 引擎离线'}</span></div>
|
||||
<div className="task-command-bar">
|
||||
<div className="task-site-identity"><KeyRound size={18} /><span><strong>{loginContext?.authentication.status === 'authenticated' ? '检测到登录环境' : loginContext?.authentication.status === 'unauthenticated' ? '未检测到登录态' : '登录环境待采集'}</strong><small>{site ? `${site.protocol.replace(':', '').toUpperCase()} · ${site.origin}` : '当前页面不可访问'}</small></span></div>
|
||||
<div className="task-quick-actions"><Button disabled={busy || !tab} onClick={() => void captureLoginEnvironment()}><Braces size={15} />采集登录环境</Button><Button disabled={busy || !tab || network?.active} onClick={() => void startCapture()}><Activity size={15} />{network?.active ? '正在捕获' : '抓取请求'}</Button><Button variant="primary" onClick={() => navigate('engine')}><Bot size={15} />共享给 Agent</Button></div>
|
||||
<div className="task-quick-actions"><Button disabled={busy || !tab} onClick={() => void captureLoginEnvironment()}><Braces size={15} />采集登录环境</Button><Button disabled={busy || !tab || network?.active} onClick={() => void startCapture()}><Activity size={15} />{network?.active ? '正在捕获' : '抓取请求'}</Button><Button variant="primary" onClick={() => navigate('engine')}><Bot size={15} />管理 Agent 连接</Button></div>
|
||||
</div>
|
||||
<div className="task-status-grid">
|
||||
<section><span>浏览器现场</span><strong>{loginContext ? `${loginContext.document?.forms.length || 0} 表单 / ${loginContext.document?.interactive.length || 0} 节点` : '尚未采集'}</strong><small>{loginContext?.authentication.evidence[0] || 'Cookie、Storage 与认证信号仅在用户点击后读取'}</small><button onClick={() => navigate('context')}>打开上下文<ChevronRight size={15} /></button></section>
|
||||
<section><span>代理与流量</span><strong>{activeProxy}</strong><small>{network?.active ? `${network.count} 条请求,${network.droppedCount} 条丢弃` : `${state.proxyRules.filter((rule) => rule.enabled).length} 条手动规则 · ${state.proxyRuleSources.filter((source) => source.enabled).length} 个订阅源`}</small><button onClick={() => navigate(network?.active ? 'network' : 'rules')}>查看流量策略<ChevronRight size={15} /></button></section>
|
||||
<section><span>Agent 会话</span><strong>{state.activeGrant ? `${isControlScopeSet(state.activeGrant.scopes) ? '控制' : '只读'} · ${runtime.state}` : '未共享'}</strong><small>{state.activeGrant ? `${state.activeGrant.targets.length} 个 frame · ${new Date(state.activeGrant.expiresAt).toLocaleTimeString()} 到期` : '创建 task-bound grant 后才允许远程读取'}</small><button onClick={() => navigate('activity')}>查看动作时间线<ChevronRight size={15} /></button></section>
|
||||
<section><span>Agent 连接</span><strong>{bridge.state === 'connected' ? `实例在线 · ${runtime.state}` : '实例离线'}</strong><small>{bridge.state === 'connected' ? '当前浏览器内的 HTTP(S) 页面可直接被引用' : '配对并连接 Yakit 后即可使用,无需逐页授权'}</small><button onClick={() => navigate('activity')}>查看动作时间线<ChevronRight size={15} /></button></section>
|
||||
<section className={state.handoff?.state === 'waiting_for_user' ? 'needs-attention' : ''}><span>需要用户处理</span><strong>{state.handoff?.state === 'waiting_for_user' ? HANDOFF_REASON_LABELS[state.handoff.reason] : runtime.state === 'paused' ? 'Agent 已暂停' : '没有待办步骤'}</strong><small>{state.handoff?.state === 'waiting_for_user' ? state.handoff.message : latestAction ? `最近 ${latestAction.method} · ${latestAction.state}` : '二维码、MFA 与 CAPTCHA 会在这里出现'}</small><button onClick={() => navigate('activity')}>会话控制<ChevronRight size={15} /></button></section>
|
||||
</div>
|
||||
<div className="task-workflow-list">
|
||||
@@ -518,15 +516,11 @@ function networkLabel(record: NetworkRequestRecord): { host: string; path: strin
|
||||
}
|
||||
|
||||
function NetworkActivity({
|
||||
state,
|
||||
setState,
|
||||
tab,
|
||||
bridge,
|
||||
run,
|
||||
busy,
|
||||
}: {
|
||||
state: ExtensionState;
|
||||
setState: (state: ExtensionState) => void;
|
||||
tab?: ActiveTabInfo;
|
||||
bridge: BridgeStatus;
|
||||
run: (task: () => Promise<void>, success?: string) => Promise<void>;
|
||||
@@ -543,11 +537,11 @@ function NetworkActivity({
|
||||
const [captureHeaders, setCaptureHeaders] = useState(false);
|
||||
const [captureBody, setCaptureBody] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const transformShared = gatewayShareActive(state.activeGrant, tab);
|
||||
const transformShared = bridge.state === 'connected';
|
||||
|
||||
const shareTransform = async () => {
|
||||
if (!tab) throw new Error('请先选择需要共享的页面');
|
||||
setState(await request('grant.create', gatewayShareGrantInput(state, tab)));
|
||||
if (!tab) throw new Error('请先选择需要使用的页面');
|
||||
if (bridge.state !== 'connected') await request('bridge.connect');
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -666,8 +660,6 @@ function NetworkActivity({
|
||||
busy={busy}
|
||||
run={run}
|
||||
gatewayShared={transformShared}
|
||||
gatewayShareExpiresAt={transformShared ? state.activeGrant?.expiresAt : undefined}
|
||||
gatewayBridgeConnected={bridge.state === 'connected'}
|
||||
onShareGateway={shareTransform}
|
||||
/>
|
||||
</div>;
|
||||
@@ -799,15 +791,7 @@ function EngineSettings({ state, setState, bridge, setBridge, tabs, run, busy }:
|
||||
const [draft, setDraft] = useState(state.bridge);
|
||||
const [pairing, setPairing] = useState<BridgePairingStatus>({ state: 'idle', message: state.bridge.pairedEngine ? '当前浏览器已配对' : '尚未配对' });
|
||||
const [panelDraft, setPanelDraft] = useState(state.floatingPanel);
|
||||
const [framesByTab, setFramesByTab] = useState<Record<number, PageFrameSummary[]>>({});
|
||||
const [selectedTargets, setSelectedTargets] = useState<string[]>(state.activeGrant?.targets.map((target) => `${target.tabId}:${target.frameId}`) || []);
|
||||
const [grantLevel, setGrantLevel] = useState<'read' | 'control'>(state.activeGrant && isControlScopeSet(state.activeGrant.scopes) ? 'control' : 'read');
|
||||
const [allowProgramEval, setAllowProgramEval] = useState(Boolean(state.activeGrant?.scopes.includes('browser.page.eval.program')));
|
||||
const [policy, setPolicy] = useState<EnterprisePolicyStatus>({ managed: false, policy: {}, warnings: [] });
|
||||
const [durationMinutes, setDurationMinutes] = useState(30);
|
||||
const selectedGrantScopes = grantLevel === 'control'
|
||||
? [...CONTROL_CAPABILITY_SCOPES, ...(allowProgramEval ? ['browser.page.eval.program' as const] : [])]
|
||||
: READ_CAPABILITY_SCOPES;
|
||||
useEffect(() => {
|
||||
void request('policy.status').then(setPolicy).catch(() => undefined);
|
||||
void request('bridge.pair.status').then(setPairing).catch(() => undefined);
|
||||
@@ -818,23 +802,7 @@ function EngineSettings({ state, setState, bridge, setBridge, tabs, run, busy }:
|
||||
browser.runtime.onMessage.addListener(listener);
|
||||
return () => browser.runtime.onMessage.removeListener(listener);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void Promise.all(tabs.map(async (item) => [item.id, await request('frame.list', { tabId: item.id }).catch(() => [])] as const))
|
||||
.then((inventories) => {
|
||||
if (active) setFramesByTab(Object.fromEntries(inventories));
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [tabs]);
|
||||
useEffect(() => setDraft(state.bridge), [state.bridge]);
|
||||
const toggleTarget = (key: string, checked: boolean) => setSelectedTargets((current) => checked
|
||||
? [...new Set([...current, key])]
|
||||
: current.filter((item) => item !== key));
|
||||
const toggleTab = (tabId: number, checked: boolean) => {
|
||||
const mainKey = `${tabId}:0`;
|
||||
if (checked) toggleTarget(mainKey, true);
|
||||
else setSelectedTargets((current) => current.filter((key) => !key.startsWith(`${tabId}:`)));
|
||||
};
|
||||
const save = () => run(async () => {
|
||||
if (draft.transport === 'native') {
|
||||
// Permission requests must be the first browser call made from the click gesture.
|
||||
@@ -879,8 +847,9 @@ function EngineSettings({ state, setState, bridge, setBridge, tabs, run, busy }:
|
||||
<label className="toggle-row"><span><strong>全屏自动收起</strong><small>进入全屏、演示或视频场景时关闭展开内容</small></span><Switch checked={panelDraft.autoCollapseFullscreen} onCheckedChange={(autoCollapseFullscreen) => setPanelDraft({ ...panelDraft, autoCollapseFullscreen })} /></label>
|
||||
<div className="editor-actions"><Button disabled={busy} onClick={() => void savePanel()}><Save size={16} />保存面板策略</Button></div>
|
||||
</section>
|
||||
<div className="grant-editor"><h2>浏览器共享会话</h2><p>只把明确勾选的 frame 和能力授权给当前 Agent;子 frame、刷新和跨来源导航不会静默继承授权。</p><div className="tab-picker">{tabs.map((tabItem) => { const frames = framesByTab[tabItem.id] || []; const mainSelected = selectedTargets.includes(`${tabItem.id}:0`); return <div className="tab-picker-group" key={tabItem.id}><label><input type="checkbox" checked={mainSelected} onChange={(event) => toggleTab(tabItem.id, event.target.checked)} /><span><strong>{tabItem.title}</strong><small>{tabItem.url}</small></span></label>{mainSelected && frames.filter((frame) => !frame.isTop).map((frame) => <label className="frame-target" key={frame.frameId}><input type="checkbox" disabled={!frame.accessible || !frame.origin} checked={selectedTargets.includes(`${tabItem.id}:${frame.frameId}`)} onChange={(event) => toggleTarget(`${tabItem.id}:${frame.frameId}`, event.target.checked)} /><span><strong>{frame.title || frame.name || `Frame ${frame.frameId}`}</strong><small>#{frame.frameId} · {frame.sameOrigin ? '同源' : '跨源'} · {frame.origin || frame.url}</small></span></label>)}</div>; })}</div><div className="grant-options"><Field label="权限预设"><select value={grantLevel} onChange={(event) => setGrantLevel(event.target.value as 'read' | 'control')}><option value="read">只读:页面、Storage、Cookie</option><option value="control">控制:页面操作、网络敏感字段、深度捕获、代理</option></select></Field><Field label="有效期"><select value={durationMinutes} onChange={(event) => setDurationMinutes(Number(event.target.value))}><option value="15">15 分钟</option><option value="30">30 分钟</option><option value="60">1 小时</option><option value="240">4 小时</option></select></Field></div>{grantLevel === 'control' && <label className="toggle-row grant-risk-toggle"><span><strong>允许程序 Eval</strong><small>独立高风险 scope,可执行多条语句并产生页面副作用</small></span><Switch disabled={policy.policy.allowProgramEval === false} checked={allowProgramEval && policy.policy.allowProgramEval !== false} onCheckedChange={setAllowProgramEval} /></label>}<div className="grant-scope-list">{selectedGrantScopes.filter((scope) => policy.policy.allowProgramEval !== false || scope !== 'browser.page.eval.program').map((scope) => <span key={scope}>{CAPABILITY_LABELS[scope]}</span>)}</div><div className="editor-actions"><button className="primary-button" disabled={busy || selectedTargets.length === 0} onClick={() => void run(async () => setState(await request('grant.create', { targets: selectedTargets.map((key) => { const [tabId, frameId] = key.split(':').map(Number); return { tabId, frameId }; }), scopes: selectedGrantScopes.filter((scope) => policy.policy.allowProgramEval !== false || scope !== 'browser.page.eval.program'), durationMinutes })), '共享会话已创建')}><ShieldCheck size={16} />创建会话</button>{state.activeGrant && <button className="danger-button" onClick={() => void run(async () => setState(await request('grant.revoke')), '共享会话已撤销')}><X size={16} />立即撤销</button>}</div>{state.activeGrant && <div className="grant-status"><strong>{isControlScopeSet(state.activeGrant.scopes) ? '控制会话' : '只读会话'}</strong><span>{state.activeGrant.targets.length} 个 frame · {state.activeGrant.scopes.length} 项能力 · {new Date(state.activeGrant.expiresAt).toLocaleString()} 到期</span></div>}</div></div>
|
||||
<div className="protocol-panel"><h2>Bridge 方法</h2><div><code>browser.tabs / frames</code><span>列出授权标签页与完整 frame inventory</span></div><div><code>browser.context</code><span>生成结构化快照、存储 inventory、认证信号与上下文 diff</span></div><div><code>browser.node.*</code><span>检查或操作快照内的文档绑定节点引用</span></div><div><code>browser.cookies</code><span>读取指定标签页的浏览器 Cookie</span></div><div><code>browser.network.*</code><span>控制有界网络捕获、读取请求时间线并导出重放包</span></div><div><code>browser.takeover</code><span>将页面切到前台,交给用户扫码或二次验证</span></div><div><code>browser.invoke</code><span>以控制权限调用页面已有全局函数</span></div><div><code>browser.eval</code><span>以控制权限在页面主世界执行代码,支持 Promise 和超时</span></div><div><code>proxy.list / switch</code><span>读取并切换扩展代理配置</span></div></div>
|
||||
<div className="grant-editor"><h2>浏览器实例访问</h2><p>配对成功后,Yakit 可直接引用此浏览器中的全部 HTTP(S) 页面;刷新、跳转和新标签页会自动跟随,不再逐页授权。</p><div className="grant-status"><strong>{bridge.state === 'connected' ? '实例已连接' : state.bridge.pairedEngine ? '实例已配对,当前离线' : '实例尚未配对'}</strong><span>{tabs.length} 个可访问页面 · 浏览器内部页始终排除 · 无痕窗口沿用浏览器自己的独立访问开关</span></div><div className="grant-scope-list"><span>人工:逐次确认 · 协同 AI:按风险判断 · YOLO:自动执行</span><span>{policy.policy.allowProgramEval === false ? '程序 Eval 已被企业策略禁用' : '程序 Eval 在 YOLO 下无需手动批准,仍受浏览器与企业策略限制'}</span>{policy.policy.grantAllowedOrigins?.length ? <span>企业来源白名单:{policy.policy.grantAllowedOrigins.length} 项</span> : null}</div></div>
|
||||
</div>
|
||||
<div className="protocol-panel"><h2>Bridge 方法</h2><div><code>browser.tabs / tab.open / frames</code><span>列出当前实例的 HTTP(S) 标签页、打开网页并读取完整 frame inventory</span></div><div><code>browser.context</code><span>生成结构化快照、存储 inventory、认证信号与上下文 diff</span></div><div><code>browser.node.*</code><span>检查或操作快照内的文档绑定节点引用</span></div><div><code>browser.cookies</code><span>读取指定标签页的浏览器 Cookie</span></div><div><code>browser.network.*</code><span>控制有界网络捕获、读取请求时间线并导出重放包</span></div><div><code>browser.takeover</code><span>将页面切到前台,交给用户扫码或二次验证</span></div><div><code>browser.invoke</code><span>调用页面已有全局函数</span></div><div><code>browser.eval</code><span>在页面主世界执行代码,支持 Promise 和超时</span></div><div><code>proxy.list / switch</code><span>读取并切换扩展代理配置</span></div></div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>正在准备浏览器实例</title>
|
||||
</head>
|
||||
<body>
|
||||
<p id="status">正在同步浏览器实例身份…</p>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import { request } from '@/platform/messaging/runtime';
|
||||
|
||||
const status = document.getElementById('status');
|
||||
const fail = (message: string) => {
|
||||
if (status) status.textContent = message;
|
||||
};
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const query = new URLSearchParams(location.search);
|
||||
const manager = query.get('manager');
|
||||
const instanceId = query.get('instanceId') || '';
|
||||
const badge = query.get('badge') || '';
|
||||
const target = query.get('target') || 'chrome://newtab/';
|
||||
if (!['ytray', 'yakit'].includes(manager || '')
|
||||
|| !/^[A-Za-z0-9-]{1,160}$/.test(instanceId)
|
||||
|| !/^[A-Z]{1,2}$/.test(badge)) {
|
||||
throw new Error('浏览器实例身份参数无效');
|
||||
}
|
||||
const protocol = new URL(target).protocol;
|
||||
if (!['http:', 'https:', 'chrome:'].includes(protocol)
|
||||
&& target !== 'data:text/html,<title>YTray</title>') {
|
||||
throw new Error('浏览器实例目标地址无效');
|
||||
}
|
||||
|
||||
await request('bridge.managed-instance.bind', {
|
||||
manager: manager as 'ytray' | 'yakit', instanceId, badge,
|
||||
});
|
||||
|
||||
const current = await browser.tabs.getCurrent();
|
||||
if (!current?.id) {
|
||||
location.replace(target);
|
||||
return;
|
||||
}
|
||||
if (query.get('restore') === '1') {
|
||||
await new Promise((resolve) => globalThis.setTimeout(resolve, 400));
|
||||
const tabs = await browser.tabs.query({ currentWindow: true });
|
||||
if (tabs.some((tab) => tab.id !== current.id)) {
|
||||
await browser.tabs.remove(current.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await browser.tabs.update(current.id, { url: target });
|
||||
}
|
||||
|
||||
void bootstrap().catch((error) => fail(error instanceof Error ? error.message : String(error)));
|
||||
@@ -29,8 +29,6 @@ interface RecordingWorkspaceProps {
|
||||
busy: boolean;
|
||||
run: RunTask;
|
||||
gatewayShared: boolean;
|
||||
gatewayShareExpiresAt?: number;
|
||||
gatewayBridgeConnected: boolean;
|
||||
onShareGateway: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -183,8 +181,6 @@ export function RecordingWorkspace({
|
||||
busy,
|
||||
run,
|
||||
gatewayShared,
|
||||
gatewayShareExpiresAt,
|
||||
gatewayBridgeConnected,
|
||||
onShareGateway,
|
||||
}: RecordingWorkspaceProps) {
|
||||
const [workspaceMode, setWorkspaceMode] = useState<'gateway' | 'recording' | 'deep'>('recording');
|
||||
@@ -634,8 +630,6 @@ export function RecordingWorkspace({
|
||||
busy={busy}
|
||||
run={run}
|
||||
gatewayShared={gatewayShared}
|
||||
gatewayShareExpiresAt={gatewayShareExpiresAt}
|
||||
gatewayBridgeConnected={gatewayBridgeConnected}
|
||||
onShareGateway={onShareGateway}
|
||||
onOpenCapture={() => { setRecoveryProfileId(''); setWorkspaceMode(DEEP_CAPTURE_AVAILABLE ? 'deep' : 'recording'); }}
|
||||
onOpenRecovery={DEEP_CAPTURE_AVAILABLE ? openRecovery : () => setWorkspaceMode('recording')}
|
||||
|
||||
@@ -45,8 +45,6 @@ interface BrowserTransformWorkspaceProps {
|
||||
busy: boolean;
|
||||
run: RunTask;
|
||||
gatewayShared: boolean;
|
||||
gatewayShareExpiresAt?: number;
|
||||
gatewayBridgeConnected: boolean;
|
||||
onShareGateway: () => Promise<void>;
|
||||
onOpenCapture: () => void;
|
||||
onOpenRecovery: (profileId: string) => void;
|
||||
@@ -247,8 +245,6 @@ export function BrowserTransformWorkspace({
|
||||
busy,
|
||||
run,
|
||||
gatewayShared,
|
||||
gatewayShareExpiresAt,
|
||||
gatewayBridgeConnected,
|
||||
onShareGateway,
|
||||
onOpenCapture,
|
||||
onOpenRecovery,
|
||||
@@ -926,11 +922,9 @@ export function BrowserTransformWorkspace({
|
||||
replayPersistenceLabel={replayPersistenceLabel(replayPersistence)}
|
||||
replayPersistenceTitle={replayPersistenceTitle}
|
||||
gatewayShared={gatewayShared}
|
||||
gatewayShareExpiresAt={gatewayShareExpiresAt}
|
||||
gatewayBridgeConnected={gatewayBridgeConnected}
|
||||
onShareGateway={() => run(
|
||||
onShareGateway,
|
||||
gatewayShared ? '共享会话已刷新' : '当前页面已共享给 Yakit',
|
||||
gatewayShared ? '浏览器实例已连接' : '正在连接 Yakit',
|
||||
)}
|
||||
onClear={clearReplay}
|
||||
canExecute={Boolean(draft?.id && !dirty && !busy && !replayLoading && bindingReady)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlertTriangle, CheckCircle2, FlaskConical, Play, Share2, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import { AlertTriangle, CheckCircle2, FlaskConical, Play, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type {
|
||||
ActiveTabInfo,
|
||||
@@ -28,8 +28,6 @@ export function TransformReplayPanel({
|
||||
replayPersistenceLabel,
|
||||
replayPersistenceTitle,
|
||||
gatewayShared,
|
||||
gatewayShareExpiresAt,
|
||||
gatewayBridgeConnected,
|
||||
onShareGateway,
|
||||
onClear,
|
||||
canExecute,
|
||||
@@ -56,8 +54,6 @@ export function TransformReplayPanel({
|
||||
replayPersistenceLabel: string;
|
||||
replayPersistenceTitle: string;
|
||||
gatewayShared: boolean;
|
||||
gatewayShareExpiresAt?: number;
|
||||
gatewayBridgeConnected: boolean;
|
||||
onShareGateway: () => Promise<void>;
|
||||
onClear: () => Promise<void>;
|
||||
canExecute: boolean;
|
||||
@@ -87,21 +83,19 @@ export function TransformReplayPanel({
|
||||
</div>
|
||||
</header>
|
||||
{draft?.id && <section className={`transform-gateway-share ${gatewayShared ? 'is-active' : ''}`}>
|
||||
<span className="transform-gateway-share__mark">{gatewayShared ? <ShieldCheck size={15} /> : <Share2 size={15} />}</span>
|
||||
<span className="transform-gateway-share__mark"><ShieldCheck size={15} /></span>
|
||||
<div>
|
||||
<strong>{gatewayShared ? '当前页面已共享给 Yakit' : '在 Yakit 中使用这个网关'}</strong>
|
||||
<small>{gatewayShared && gatewayShareExpiresAt
|
||||
? `控制会话 · ${new Date(gatewayShareExpiresAt).toLocaleTimeString()} 到期`
|
||||
: gatewayBridgeConnected
|
||||
? '创建 30 分钟控制会话,并保留已共享页面'
|
||||
: '可先创建会话;引擎重连后即可使用'}</small>
|
||||
<strong>{gatewayShared ? '当前浏览器实例已接入 Yakit' : '连接 Yakit 后使用这个网关'}</strong>
|
||||
<small>{gatewayShared
|
||||
? '页面刷新、跳转后仍可使用,无需续接授权'
|
||||
: '连接后由 Agent 操作审核策略统一控制'}</small>
|
||||
</div>
|
||||
<Button
|
||||
{!gatewayShared && <Button
|
||||
size="sm"
|
||||
variant={gatewayShared ? 'ghost' : 'primary'}
|
||||
variant="primary"
|
||||
disabled={busy || !tab}
|
||||
onClick={() => void onShareGateway()}
|
||||
>{gatewayShared ? '刷新' : '一键共享'}</Button>
|
||||
>连接</Button>}
|
||||
</section>}
|
||||
<label><span>请求</span><div><input disabled={replayLoading} aria-label="回放 HTTP 方法" value={method} onChange={(event) => onMethodChange(event.target.value)} /><input disabled={replayLoading} aria-label="回放请求 URL" value={url} onChange={(event) => onUrlChange(event.target.value)} placeholder="https://example.test/api" /></div></label>
|
||||
<label><span>Headers · JSON</span><textarea disabled={replayLoading} rows={4} value={headers} onChange={(event) => onHeadersChange(event.target.value)} /></label>
|
||||
|
||||
@@ -34,6 +34,29 @@ describe('Bridge v3 identity transcript', () => {
|
||||
})).resolves.toBe('113961');
|
||||
});
|
||||
|
||||
it('binds a managed browser identity into the signed transcript', () => {
|
||||
const envelope: BridgeEnvelope = {
|
||||
type: 'auth', installationId: 'install-1', client: 'client-1', version: '1.0.0',
|
||||
capabilities: [],
|
||||
managedInstance: { manager: 'ytray', instanceId: 'instance-1', badge: 'B' },
|
||||
};
|
||||
expect(clientAuthPayload({
|
||||
origin: 'chrome-extension://abc', engineIdentityId: 'identity-1', engineInstanceId: 'engine-1',
|
||||
challenge: 'nonce-1', envelope,
|
||||
})).toMatch(/\nytray\ninstance-1\nB$/);
|
||||
});
|
||||
|
||||
it('binds a managed browser identity into the pairing code', async () => {
|
||||
const input = {
|
||||
engineIdentityId: 'engine-id', requestId: 'request-1', origin: 'chrome-extension://abc', installationId: 'install-1',
|
||||
clientNonce: 'client-nonce-value', serverNonce: 'server-nonce-value',
|
||||
publicKey: { kty: 'EC' as const, crv: 'P-256' as const, x: 'x-coordinate', y: 'y-coordinate' },
|
||||
};
|
||||
await expect(pairingVerificationCode({
|
||||
...input, managedInstance: { manager: 'ytray', instanceId: 'instance-1', badge: 'B' },
|
||||
})).resolves.toBe('005427');
|
||||
});
|
||||
|
||||
it('signs and verifies ECDSA P-256 payloads', async () => {
|
||||
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
|
||||
const publicJWK = await crypto.subtle.exportKey('jwk', pair.publicKey);
|
||||
|
||||
@@ -134,7 +134,7 @@ export function clientAuthPayload(input: {
|
||||
challenge: string;
|
||||
envelope: BridgeEnvelope;
|
||||
}): string {
|
||||
return [
|
||||
const fields = [
|
||||
'yak-browser-bridge-v3', 'client-auth', input.origin, input.engineIdentityId, input.engineInstanceId,
|
||||
input.challenge, input.envelope.installationId || '', input.envelope.client || '', input.envelope.version || '',
|
||||
[...(input.envelope.capabilities || [])].sort().join(','),
|
||||
@@ -142,7 +142,15 @@ export function clientAuthPayload(input: {
|
||||
input.envelope.capabilityCatalog?.hash || '',
|
||||
input.envelope.taskId || '', input.envelope.grantId || '',
|
||||
input.envelope.resumeSessionId || '',
|
||||
].join('\n');
|
||||
];
|
||||
if (input.envelope.managedInstance) {
|
||||
fields.push(
|
||||
input.envelope.managedInstance.manager,
|
||||
input.envelope.managedInstance.instanceId,
|
||||
input.envelope.managedInstance.badge,
|
||||
);
|
||||
}
|
||||
return fields.join('\n');
|
||||
}
|
||||
|
||||
export async function pairingVerificationCode(input: {
|
||||
@@ -153,11 +161,16 @@ export async function pairingVerificationCode(input: {
|
||||
clientNonce: string;
|
||||
serverNonce: string;
|
||||
publicKey: BridgePublicKey;
|
||||
managedInstance?: BridgeEnvelope['managedInstance'];
|
||||
}): Promise<string> {
|
||||
const payload = [
|
||||
const fields = [
|
||||
'yak-browser-pairing-v1', input.engineIdentityId, input.requestId, input.origin, input.installationId,
|
||||
input.clientNonce, input.serverNonce, input.publicKey.kty, input.publicKey.crv, input.publicKey.x, input.publicKey.y,
|
||||
].join('\n');
|
||||
];
|
||||
if (input.managedInstance) {
|
||||
fields.push(input.managedInstance.manager, input.managedInstance.instanceId, input.managedInstance.badge);
|
||||
}
|
||||
const payload = fields.join('\n');
|
||||
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload)));
|
||||
let value = 0n;
|
||||
for (const byte of hash.subarray(0, 8)) value = (value << 8n) | BigInt(byte);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@/protocol/bridge';
|
||||
import { getBridgeRuntimeSession, getState, setBridgeRuntimeSession, updateState } from '@/platform/storage/state';
|
||||
import { routeCapability } from '@/features/grants/service';
|
||||
import { currentActiveGrant } from '@/features/grants/lifecycle';
|
||||
import { browserInstanceAccess } from '@/features/grants/capability-context';
|
||||
import { errorCode, ExtensionError, isDeniedErrorCode } from '@/shared/errors';
|
||||
import { appendAuditEvent } from '@/features/diagnostics/audit';
|
||||
import { beginAgentAction, finishAgentAction } from '@/features/agent-runtime/service';
|
||||
@@ -301,6 +301,7 @@ export class EngineBridge {
|
||||
capabilities: [...BRIDGE_CAPABILITIES],
|
||||
capabilityCatalog,
|
||||
installationId: config.installationId,
|
||||
managedInstance: state.bridge.managedInstance,
|
||||
taskId: state.activeGrant?.taskId,
|
||||
grantId: state.activeGrant?.id,
|
||||
resumeSessionId: previousSession?.sessionId,
|
||||
@@ -522,18 +523,13 @@ export class EngineBridge {
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const grant = await currentActiveGrant();
|
||||
taskId = grant?.taskId;
|
||||
targetTabId ??= grant?.targets[0]?.tabId;
|
||||
if (grant) {
|
||||
const grantTarget = grant.targets.find((target) => target.tabId === targetTabId);
|
||||
const grant = await browserInstanceAccess('browser.tabs.read');
|
||||
taskId = grant.taskId;
|
||||
actionId = (await beginAgentAction(grant, {
|
||||
requestId: message.id,
|
||||
method: message.method,
|
||||
targetTabId,
|
||||
isolationContextId: grantTarget?.isolationContextId,
|
||||
})).id;
|
||||
}
|
||||
const operation = routeCapability(message.method, message.params, (method, params) => this.requestEngine(method, params));
|
||||
const result = await Promise.race([operation, cancelled]);
|
||||
const durationMs = performance.now() - startedAt;
|
||||
@@ -802,6 +798,7 @@ export class EngineBridge {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'pair_request', protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
||||
installationId: config.installationId,
|
||||
managedInstance: config.managedInstance,
|
||||
client: 'yakit-browser-extension', version: browser.runtime.getManifest().version,
|
||||
nonce: clientNonce, publicKey: identity.publicKey,
|
||||
} satisfies BridgePairingEnvelope));
|
||||
@@ -872,6 +869,7 @@ export class EngineBridge {
|
||||
engineIdentityId: message.engineIdentityId!, requestId: message.requestId!,
|
||||
origin: browser.runtime.getURL('').replace(/\/$/, ''), installationId: context.config.installationId,
|
||||
clientNonce: context.clientNonce, serverNonce: message.serverNonce!, publicKey: context.publicKey,
|
||||
managedInstance: context.config.managedInstance,
|
||||
});
|
||||
if (code !== message.code) {
|
||||
this.failPairing(new Error('Yak 配对验证码校验失败'));
|
||||
|
||||
@@ -5,10 +5,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { browser } from 'wxt/browser';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { HANDOFF_REASON_LABELS, waitingHandoff } from '@/features/handoff/presentation';
|
||||
import { READ_CAPABILITY_SCOPES, isControlScopeSet } from '@/protocol/capabilities';
|
||||
import { AGENT_RUNTIME_STORAGE_KEY, isStateStorageChange } from '@/protocol/storage';
|
||||
import type { ActiveTabInfo, AgentRuntime, BridgeStatus, ExtensionState, PageContext } from '@/types/models';
|
||||
import { errorMessage, request } from '@/platform/messaging/runtime';
|
||||
@@ -31,9 +29,6 @@ export function FloatingPanel({ initialState, initialTab, initialBridge, hostCha
|
||||
const [runtime, setRuntime] = useState<AgentRuntime>({ state: 'idle', updatedAt: Date.now(), actions: [] });
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const grantActive = Boolean(
|
||||
state.activeGrant && state.activeGrant.expiresAt > Date.now() && tab && state.activeGrant.targets.some((target) => target.tabId === tab.id),
|
||||
);
|
||||
const pendingHandoff = waitingHandoff(state.handoff);
|
||||
const handoff = pendingHandoff?.target.tabId === tab?.id ? pendingHandoff : undefined;
|
||||
|
||||
@@ -177,9 +172,9 @@ export function FloatingPanel({ initialState, initialTab, initialBridge, hostCha
|
||||
<Button variant="ghost" disabled={busy} onClick={() => void run(async () => setState(await request('handoff.resolve', { id: handoff.id, outcome: 'cancelled' })))}><X size={14} />取消</Button>
|
||||
</div>
|
||||
</div> : <>
|
||||
{grantActive && <div className={`floating-agent-task ${runtime.state}`}><span><strong>{runtime.state === 'paused' ? 'Agent 已暂停' : runtime.state === 'running' ? 'Agent 正在操作' : '共享会话活动'}</strong><small title={state.activeGrant?.taskId}>{state.activeGrant?.taskId} · {state.activeGrant && isControlScopeSet(state.activeGrant.scopes) ? '控制权限' : '只读权限'}</small></span>{runtime.state === 'paused' ? <Button size="icon" variant="ghost" title="恢复 Agent" onClick={() => void run(async () => setRuntime(await request('agent.resume')))}><Play size={14} /></Button> : <Button size="icon" variant="ghost" title="暂停 Agent" onClick={() => void run(async () => setRuntime(await request('agent.pause')))}><Pause size={14} /></Button>}</div>}
|
||||
<label className="floating-share-row"><span><strong>共享当前主 frame</strong><small>30 分钟只读授权,可随时撤销</small></span><Switch checked={grantActive} disabled={!tab || busy} onCheckedChange={(checked) => void run(async () => setState(checked ? await request('grant.create', { targets: [{ tabId: tab!.id, frameId: 0 }], scopes: READ_CAPABILITY_SCOPES, durationMinutes: 30 }) : await request('grant.revoke')))} /></label>
|
||||
<Button variant="secondary" onClick={() => openWorkspace('engine')}>管理控制授权<Settings size={14} /></Button>
|
||||
{bridge.state === 'connected' && <div className={`floating-agent-task ${runtime.state}`}><span><strong>{runtime.state === 'paused' ? 'Agent 已暂停' : runtime.state === 'running' ? 'Agent 正在操作' : '浏览器实例已接入'}</strong><small>当前浏览器的 HTTP(S) 页面均可引用</small></span>{runtime.state === 'paused' ? <Button size="icon" variant="ghost" title="恢复 Agent" onClick={() => void run(async () => setRuntime(await request('agent.resume')))}><Play size={14} /></Button> : <Button size="icon" variant="ghost" title="暂停 Agent" onClick={() => void run(async () => setRuntime(await request('agent.pause')))}><Pause size={14} /></Button>}</div>}
|
||||
<div className="floating-share-row"><span><strong>实例级页面访问</strong><small>刷新、跳转和新标签页自动跟随,无需逐页授权</small></span><ShieldCheck size={16} /></div>
|
||||
<Button variant="secondary" onClick={() => openWorkspace('engine')}>管理 Agent 连接<Settings size={14} /></Button>
|
||||
<Button variant="ghost" disabled={!tab?.url} onClick={() => void hideCurrentSite()}><EyeOff size={14} />在此站点隐藏</Button>
|
||||
</>}
|
||||
</TabsContent>
|
||||
|
||||
@@ -2,10 +2,12 @@ import { browser } from 'wxt/browser';
|
||||
import type { BridgeGrant, BrowserTarget, CapabilityScope } from '@/types/models';
|
||||
import { getFrameInventory } from '@/features/page-context/frames';
|
||||
import { getTab, resolveDocumentTarget } from '@/platform/browser/targets';
|
||||
import { assertBrowserAccessPolicy, getEnterprisePolicy } from '@/platform/policy/managed';
|
||||
import { CONTROL_CAPABILITY_SCOPES } from '@/protocol/capabilities';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
import { requireActiveGrant } from './lifecycle';
|
||||
|
||||
export type CapabilityEngineRequest = <T>(method: string, params: unknown) => Promise<T>;
|
||||
export const PAIRED_BROWSER_INSTANCE_ACCESS_ID = 'paired-browser-instance';
|
||||
|
||||
export interface CapabilityRouteContext {
|
||||
method: string;
|
||||
@@ -20,8 +22,23 @@ export interface CapabilityDomainHandler {
|
||||
handle(context: CapabilityRouteContext): Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function activeGrant(required: CapabilityScope): Promise<BridgeGrant> {
|
||||
const grant = await requireActiveGrant();
|
||||
export async function browserInstanceAccess(required: CapabilityScope): Promise<BridgeGrant> {
|
||||
const policy = (await getEnterprisePolicy()).policy;
|
||||
assertBrowserAccessPolicy(policy, {
|
||||
programEval: required === 'browser.page.eval.program',
|
||||
});
|
||||
const scopes: CapabilityScope[] = [
|
||||
...CONTROL_CAPABILITY_SCOPES,
|
||||
...(policy.allowProgramEval === false ? [] : ['browser.page.eval.program' as const]),
|
||||
];
|
||||
const grant: BridgeGrant = {
|
||||
id: PAIRED_BROWSER_INSTANCE_ACCESS_ID,
|
||||
taskId: PAIRED_BROWSER_INSTANCE_ACCESS_ID,
|
||||
targets: [],
|
||||
scopes: [...scopes],
|
||||
createdAt: 0,
|
||||
expiresAt: Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
requireScope(grant, required);
|
||||
return grant;
|
||||
}
|
||||
@@ -36,22 +53,15 @@ function originOf(url: string): string {
|
||||
}
|
||||
|
||||
export async function allowedTarget(
|
||||
grant: BridgeGrant,
|
||||
_grant: BridgeGrant,
|
||||
input: { tabId?: unknown; frameId?: unknown; documentId?: unknown },
|
||||
resolveInPage = true,
|
||||
): Promise<BrowserTarget> {
|
||||
const requested = typeof input.tabId === 'number' ? input.tabId : grant.targets[0]?.tabId;
|
||||
const requestedFrameId = typeof input.frameId === 'number' ? input.frameId : 0;
|
||||
const target = grant.targets.find((item) => (
|
||||
item.tabId === requested && item.frameId === requestedFrameId
|
||||
));
|
||||
if (!target) throw new ExtensionError('target_denied', '目标标签页不在本次共享会话中');
|
||||
const currentTab = await getTab(target.tabId);
|
||||
if (!currentTab.isolationContextId
|
||||
|| currentTab.isolationContextId !== target.isolationContextId
|
||||
|| currentTab.cookieStoreId !== target.cookieStoreId) {
|
||||
throw new ExtensionError('isolation_stale', '目标标签页的身份隔离上下文已经变化,请重新共享页面');
|
||||
}
|
||||
const currentTab = await getTab(typeof input.tabId === 'number' ? input.tabId : undefined);
|
||||
const target: BrowserTarget = {
|
||||
tabId: currentTab.id,
|
||||
frameId: typeof input.frameId === 'number' ? input.frameId : 0,
|
||||
};
|
||||
const currentFrame = await browser.webNavigation.getFrame({
|
||||
tabId: target.tabId,
|
||||
frameId: target.frameId,
|
||||
@@ -62,27 +72,20 @@ export async function allowedTarget(
|
||||
currentOrigin = (await getFrameInventory(target.tabId))
|
||||
.find((frame) => frame.frameId === target.frameId)?.origin || '';
|
||||
}
|
||||
if (currentOrigin !== target.origin) {
|
||||
throw new ExtensionError('origin_changed', '目标 frame 已经跨来源导航,请重新授权');
|
||||
if (!currentOrigin) {
|
||||
throw new ExtensionError('target_unavailable', '目标 frame 不是可访问的 HTTP(S) 页面');
|
||||
}
|
||||
if (target.documentId && currentFrame.documentId
|
||||
&& target.documentId !== currentFrame.documentId) {
|
||||
throw new ExtensionError('stale_document', '目标 frame 已经刷新或导航,请重新授权');
|
||||
assertBrowserAccessPolicy((await getEnterprisePolicy()).policy, { origin: currentOrigin });
|
||||
if (typeof input.documentId === 'string' && currentFrame.documentId
|
||||
&& input.documentId !== currentFrame.documentId) {
|
||||
throw new ExtensionError('stale_document', '请求的页面文档已经刷新或导航,请重新获取页面上下文');
|
||||
}
|
||||
if (typeof input.documentId === 'string' && target.documentId
|
||||
&& input.documentId !== target.documentId) {
|
||||
throw new ExtensionError('stale_document', '请求的页面文档已经失效,请重新授权');
|
||||
}
|
||||
if (!resolveInPage) return target;
|
||||
const resolved = await resolveDocumentTarget(target);
|
||||
if (target.documentId && resolved.documentId && target.documentId !== resolved.documentId) {
|
||||
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新授权');
|
||||
}
|
||||
return resolved;
|
||||
const currentTarget = { ...target, documentId: currentFrame.documentId };
|
||||
return resolveInPage ? resolveDocumentTarget(currentTarget) : currentTarget;
|
||||
}
|
||||
|
||||
export function requireScope(grant: BridgeGrant, scope: CapabilityScope): void {
|
||||
if (!grant.scopes.includes(scope)) {
|
||||
throw new ExtensionError('permission_denied', `共享会话未授权能力: ${scope}`);
|
||||
throw new ExtensionError('permission_denied', `浏览器实例不允许能力: ${scope}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ function exactMethods(id: CapabilityDomainId, methods: readonly string[]): Capab
|
||||
|
||||
export const NAVIGATION_CAPABILITY_DOMAIN = exactMethods('navigation-isolation', [
|
||||
'browser.tabs',
|
||||
'browser.tab.open',
|
||||
'browser.thumbnail',
|
||||
'browser.frames',
|
||||
'browser.instance.close',
|
||||
'browser.isolation.inspect',
|
||||
'browser.isolation.proof',
|
||||
'browser.isolation.incognito.open',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { HandoffReason } from '@/types/models';
|
||||
import type { CapabilityDomainHandler } from '../capability-context';
|
||||
import { allowedTarget } from '../capability-context';
|
||||
import { activateTab } from '@/platform/browser/targets';
|
||||
import { getTab } from '@/platform/browser/targets';
|
||||
import { getState, updateState } from '@/platform/storage/state';
|
||||
import { setAgentRuntimeState } from '@/features/agent-runtime/service';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
@@ -16,15 +17,23 @@ export const handoffCapabilityHandler: CapabilityDomainHandler = {
|
||||
return handoff?.taskId === grant.taskId ? handoff : { state: 'idle' };
|
||||
}
|
||||
const resolvedTarget = await allowedTarget(grant, input);
|
||||
const grantTarget = grant.targets.find((target) => (
|
||||
target.tabId === resolvedTarget.tabId && target.frameId === resolvedTarget.frameId
|
||||
));
|
||||
if (!grantTarget) throw new Error('目标标签页不在本次共享会话中');
|
||||
const [tab, frame] = await Promise.all([
|
||||
getTab(resolvedTarget.tabId),
|
||||
browser.webNavigation.getFrame(resolvedTarget),
|
||||
]);
|
||||
if (!frame?.url || !/^https?:/i.test(frame.url)) {
|
||||
throw new ExtensionError('target_unavailable', '目标 frame 不是可接管的 HTTP(S) 页面');
|
||||
}
|
||||
const grantTarget = {
|
||||
...resolvedTarget,
|
||||
isolationContextId: tab.isolationContextId || `browser-profile:tab-${tab.id}`,
|
||||
cookieStoreId: tab.cookieStoreId,
|
||||
origin: new URL(frame.url).origin,
|
||||
grantedUrl: frame.url,
|
||||
title: tab.title,
|
||||
};
|
||||
const now = Date.now();
|
||||
const state = await updateState((current) => {
|
||||
if (current.activeGrant?.id !== grant.id || current.activeGrant.expiresAt <= Date.now()) {
|
||||
throw new ExtensionError('grant_expired', '浏览器共享会话已经变化,请重新发起请求');
|
||||
}
|
||||
if (current.handoff?.state === 'waiting_for_user') {
|
||||
throw new ExtensionError('handoff_in_progress', '已有人工接管请求正在等待处理');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import type { CapabilityDomainHandler } from '../capability-context';
|
||||
import { allowedTarget, requireScope } from '../capability-context';
|
||||
import { getFrameInventory } from '@/features/page-context/frames';
|
||||
import { getTab } from '@/platform/browser/targets';
|
||||
import { activateTab, getTab, scheduleBrowserInstanceClose } from '@/platform/browser/targets';
|
||||
import {
|
||||
createBrowserIsolationProof,
|
||||
deleteFirefoxContainerIdentity,
|
||||
@@ -11,70 +12,88 @@ import {
|
||||
openIncognitoIdentity,
|
||||
} from '@/features/authorization-testing/isolation';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
import { assertBrowserAccessPolicy, getEnterprisePolicy } from '@/platform/policy/managed';
|
||||
import { NAVIGATION_CAPABILITY_DOMAIN } from '../capability-domains';
|
||||
|
||||
export const navigationCapabilityHandler: CapabilityDomainHandler = {
|
||||
...NAVIGATION_CAPABILITY_DOMAIN,
|
||||
async handle({ method, input, grant }) {
|
||||
if (method === 'browser.tabs') {
|
||||
const tabIds = [...new Set(grant.targets.map((target) => target.tabId))];
|
||||
const tabs = await Promise.all(tabIds.map(async (tabId) => {
|
||||
const targets = grant.targets.filter((target) => target.tabId === tabId);
|
||||
for (const target of targets) {
|
||||
try {
|
||||
await allowedTarget(grant, {
|
||||
tabId,
|
||||
frameId: target.frameId,
|
||||
documentId: target.documentId,
|
||||
});
|
||||
return getTab(tabId);
|
||||
} catch {
|
||||
// A tab remains visible while at least one explicitly granted frame is current.
|
||||
const { tabs } = await inspectBrowserIsolation();
|
||||
const allowedOrigins = (await getEnterprisePolicy()).policy.grantAllowedOrigins;
|
||||
return tabs.filter((tab) => !allowedOrigins?.length || allowedOrigins.includes(new URL(tab.url).origin))
|
||||
.sort((left, right) => Number(Boolean(right.active)) - Number(Boolean(left.active))
|
||||
|| (right.lastAccessed || 0) - (left.lastAccessed || 0));
|
||||
}
|
||||
if (method === 'browser.tab.open') {
|
||||
const url = String(input.url || '');
|
||||
assertBrowserAccessPolicy((await getEnterprisePolicy()).policy, { origin: new URL(url).origin });
|
||||
const tab = await browser.tabs.create({ url, active: true });
|
||||
if (!tab.id) throw new ExtensionError('target_unavailable', '浏览器没有返回新标签页 ID');
|
||||
await activateTab(tab.id);
|
||||
return { opened: true, id: tab.id, windowId: tab.windowId, active: true, url };
|
||||
}
|
||||
return undefined;
|
||||
}));
|
||||
return tabs.filter(Boolean);
|
||||
if (method === 'browser.thumbnail') {
|
||||
const tab = await getTab(typeof input.tabId === 'number' ? input.tabId : undefined);
|
||||
await allowedTarget(grant, { tabId: tab.id }, false);
|
||||
if (!tab.active) {
|
||||
throw new ExtensionError('target_not_active', '只能预览浏览器窗口当前可见的标签页');
|
||||
}
|
||||
return {
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: tab.url,
|
||||
capturedAt: Date.now(),
|
||||
dataUrl: await browser.tabs.captureVisibleTab(tab.windowId, { format: 'jpeg', quality: 55 }),
|
||||
};
|
||||
}
|
||||
if (method === 'browser.frames') {
|
||||
const tabId = typeof input.tabId === 'number' ? input.tabId : grant.targets[0]?.tabId;
|
||||
if (!tabId || !grant.targets.some((target) => target.tabId === tabId)) {
|
||||
throw new ExtensionError('target_denied', '目标标签页不在本次共享会话中');
|
||||
}
|
||||
return getFrameInventory(tabId);
|
||||
const tabId = (await getTab(typeof input.tabId === 'number' ? input.tabId : undefined)).id;
|
||||
await allowedTarget(grant, { tabId }, false);
|
||||
const frames = await getFrameInventory(tabId);
|
||||
const allowedOrigins = (await getEnterprisePolicy()).policy.grantAllowedOrigins;
|
||||
return frames.filter((frame) => !allowedOrigins?.length
|
||||
|| Boolean(frame.origin && allowedOrigins.includes(frame.origin)));
|
||||
}
|
||||
if (method === 'browser.instance.close') return scheduleBrowserInstanceClose();
|
||||
if (method === 'browser.isolation.inspect') {
|
||||
const grantedTabIds = [...new Set(grant.targets.map((target) => target.tabId))];
|
||||
const requestedTabIds = Array.isArray(input.tabIds)
|
||||
? input.tabIds.map(Number)
|
||||
: grantedTabIds;
|
||||
if (requestedTabIds.some((tabId) => !grantedTabIds.includes(tabId))) {
|
||||
throw new ExtensionError(
|
||||
'target_denied',
|
||||
'身份隔离检查只能读取本次共享会话中的标签页',
|
||||
);
|
||||
}
|
||||
return inspectBrowserIsolation(requestedTabIds);
|
||||
: undefined;
|
||||
const inspection = await inspectBrowserIsolation(requestedTabIds);
|
||||
const allowedOrigins = (await getEnterprisePolicy()).policy.grantAllowedOrigins;
|
||||
if (!allowedOrigins?.length) return inspection;
|
||||
const tabs = inspection.tabs.filter((tab) => allowedOrigins.includes(new URL(tab.url).origin));
|
||||
const tabIds = new Set(tabs.map((tab) => tab.id));
|
||||
return {
|
||||
...inspection,
|
||||
tabs,
|
||||
contexts: inspection.contexts
|
||||
.map((context) => ({ ...context, tabIds: context.tabIds.filter((tabId) => tabIds.has(tabId)) }))
|
||||
.filter((context) => context.tabIds.length > 0),
|
||||
};
|
||||
}
|
||||
if (method === 'browser.isolation.proof') {
|
||||
requireScope(grant, 'browser.cookies.read');
|
||||
requireScope(grant, 'browser.storage.read');
|
||||
const leftTabId = Number(input.leftTabId);
|
||||
const rightTabId = Number(input.rightTabId);
|
||||
if (![leftTabId, rightTabId].every((tabId) => (
|
||||
grant.targets.some((target) => target.tabId === tabId)
|
||||
))) {
|
||||
throw new ExtensionError(
|
||||
'target_denied',
|
||||
'隔离证明的两个身份都必须在本次共享会话中',
|
||||
);
|
||||
}
|
||||
await Promise.all([
|
||||
allowedTarget(grant, { tabId: leftTabId }, false),
|
||||
allowedTarget(grant, { tabId: rightTabId }, false),
|
||||
]);
|
||||
return createBrowserIsolationProof(leftTabId, rightTabId);
|
||||
}
|
||||
if (method === 'browser.isolation.incognito.open') {
|
||||
assertBrowserAccessPolicy((await getEnterprisePolicy()).policy, {
|
||||
origin: new URL(String(input.url || '')).origin,
|
||||
});
|
||||
return openIncognitoIdentity(String(input.url || ''));
|
||||
}
|
||||
if (method === 'browser.isolation.container.open') {
|
||||
assertBrowserAccessPolicy((await getEnterprisePolicy()).policy, {
|
||||
origin: new URL(String(input.url || '')).origin,
|
||||
});
|
||||
return openFirefoxContainerIdentity({
|
||||
url: String(input.url || ''),
|
||||
name: typeof input.name === 'string' ? input.name : undefined,
|
||||
|
||||
@@ -48,12 +48,7 @@ export const pageCapabilityHandler: CapabilityDomainHandler = {
|
||||
tabId: target.tabId,
|
||||
frameId: target.frameId,
|
||||
});
|
||||
const grantTarget = grant.targets.find((item) => (
|
||||
item.tabId === target.tabId && item.frameId === target.frameId
|
||||
));
|
||||
const url = frame?.url && /^https?:/i.test(frame.url)
|
||||
? frame.url
|
||||
: `${grantTarget?.origin || ''}/`;
|
||||
const url = frame?.url || '';
|
||||
if (!/^https?:/i.test(url)) {
|
||||
throw new ExtensionError(
|
||||
'target_unavailable',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
BrowserTransformPacket,
|
||||
BrowserTransformProfileInput,
|
||||
} from '@/types/models';
|
||||
import { browser } from 'wxt/browser';
|
||||
import type { CapabilityDomainHandler } from '../capability-context';
|
||||
import { allowedTarget, requireScope } from '../capability-context';
|
||||
import {
|
||||
@@ -124,11 +125,9 @@ export const transformCapabilityHandler: CapabilityDomainHandler = {
|
||||
if (method === 'browser.transform.profile.save') {
|
||||
const profileInput = input as unknown as BrowserTransformProfileInput;
|
||||
const target = await allowedTarget(grant, profileInput.target);
|
||||
const grantedTarget = grant.targets.find((item) => (
|
||||
item.tabId === target.tabId && item.frameId === target.frameId
|
||||
));
|
||||
if (!grantedTarget || profileInput.origin !== grantedTarget.origin) {
|
||||
throw new ExtensionError('target_denied', '转换配置来源不在本次共享会话中');
|
||||
const frame = await browser.webNavigation.getFrame(target);
|
||||
if (!frame?.url || profileInput.origin !== new URL(frame.url).origin) {
|
||||
throw new ExtensionError('target_denied', '转换配置来源与当前页面不一致');
|
||||
}
|
||||
return saveBrowserTransformProfile({ ...profileInput, target });
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ const fixture = vi.hoisted(() => ({
|
||||
stopNetwork: vi.fn(async (_grantId: string) => undefined),
|
||||
stopRecording: vi.fn(async (_grantId: string) => undefined),
|
||||
stopDeepCapture: vi.fn(async (_grantId: string) => undefined),
|
||||
startRuntime: vi.fn(async (_grant: BridgeGrant) => ({ state: 'running' })),
|
||||
endRuntime: vi.fn(async (_state: 'revoked' | 'expired', _grant: BridgeGrant) => ({ state: 'revoked' })),
|
||||
appendAudit: vi.fn(async () => undefined),
|
||||
clearBadge: vi.fn(async () => undefined),
|
||||
}));
|
||||
@@ -50,10 +48,6 @@ vi.mock('@/features/browser-recording/service', () => ({
|
||||
vi.mock('@/features/deep-capture/service', () => ({
|
||||
stopDeepCapturesForGrant: fixture.stopDeepCapture,
|
||||
}));
|
||||
vi.mock('@/features/agent-runtime/service', () => ({
|
||||
startAgentRuntime: fixture.startRuntime,
|
||||
endAgentRuntimeForGrant: fixture.endRuntime,
|
||||
}));
|
||||
vi.mock('@/features/diagnostics/audit', () => ({
|
||||
appendAuditEvent: fixture.appendAudit,
|
||||
}));
|
||||
@@ -124,19 +118,15 @@ describe('grant lifecycle manager', () => {
|
||||
|
||||
it('consumes an expired stored grant and releases all grant-owned resources', async () => {
|
||||
const expired = grant('expired-restore', NOW - 1);
|
||||
const cancelActiveRequests = vi.fn();
|
||||
await setState({ ...structuredClone(DEFAULT_STATE), activeGrant: expired });
|
||||
configureGrantLifecycleHooks({ cancelActiveRequests });
|
||||
|
||||
const state = await restoreGrantLifecycle();
|
||||
|
||||
expect(state.activeGrant).toBeUndefined();
|
||||
expect((await getState()).activeGrant).toBeUndefined();
|
||||
expect(cancelActiveRequests).toHaveBeenCalledOnce();
|
||||
expect(fixture.stopNetwork).toHaveBeenCalledWith(expired.id);
|
||||
expect(fixture.stopRecording).toHaveBeenCalledWith(expired.id);
|
||||
expect(fixture.stopDeepCapture).toHaveBeenCalledWith(expired.id);
|
||||
expect(fixture.endRuntime).toHaveBeenCalledWith('expired', expired);
|
||||
expect(fixture.alarms.has(ACTIVE_GRANT_EXPIRY_ALARM)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -152,7 +142,6 @@ describe('grant lifecycle manager', () => {
|
||||
expect(fixture.stopNetwork.mock.calls.map(([id]) => id)).toEqual([old.id, first.id]);
|
||||
expect(fixture.stopRecording.mock.calls.map(([id]) => id)).toEqual([old.id, first.id]);
|
||||
expect(fixture.stopDeepCapture.mock.calls.map(([id]) => id)).toEqual([old.id, first.id]);
|
||||
expect(fixture.startRuntime.mock.calls.map(([item]) => item.id)).toEqual([first.id, second.id]);
|
||||
expect(fixture.alarms.get(ACTIVE_GRANT_EXPIRY_ALARM)).toEqual({ when: second.expiresAt });
|
||||
});
|
||||
|
||||
@@ -168,15 +157,16 @@ describe('grant lifecycle manager', () => {
|
||||
expect(fixture.stopNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.stopRecording).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.stopDeepCapture).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.endRuntime).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels a waiting handoff and publishes the resolved state on replacement', async () => {
|
||||
const waiting = handoff('handoff-waiting');
|
||||
const previous = grant('handoff-old');
|
||||
previous.taskId = waiting.taskId;
|
||||
const emitHandoffChanged = vi.fn();
|
||||
await setState({
|
||||
...structuredClone(DEFAULT_STATE),
|
||||
activeGrant: grant('handoff-old'),
|
||||
activeGrant: previous,
|
||||
handoff: waiting,
|
||||
});
|
||||
configureGrantLifecycleHooks({ emitHandoffChanged });
|
||||
@@ -188,6 +178,21 @@ describe('grant lifecycle manager', () => {
|
||||
expect(emitHandoffChanged).toHaveBeenCalledWith(state.handoff);
|
||||
});
|
||||
|
||||
it('does not cancel a paired-instance handoff when an authorization-test grant ends', async () => {
|
||||
const waiting = handoff('paired-handoff');
|
||||
waiting.taskId = 'paired-browser-instance';
|
||||
await setState({
|
||||
...structuredClone(DEFAULT_STATE),
|
||||
activeGrant: grant('authorization-test'),
|
||||
handoff: waiting,
|
||||
});
|
||||
|
||||
const { state } = await revokeActiveGrant();
|
||||
|
||||
expect(state.handoff).toEqual(waiting);
|
||||
expect(fixture.clearBadge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an update that reaches the queue after expiry and performs cleanup first', async () => {
|
||||
const expired = grant('expired-update', NOW - 1);
|
||||
await setState({ ...structuredClone(DEFAULT_STATE), activeGrant: expired });
|
||||
@@ -218,7 +223,6 @@ describe('grant lifecycle manager', () => {
|
||||
|
||||
expect((await getState()).activeGrant).toBeUndefined();
|
||||
expect(fixture.stopNetwork).toHaveBeenCalledWith(active.id);
|
||||
expect(fixture.endRuntime).toHaveBeenCalledWith('expired', active);
|
||||
});
|
||||
|
||||
it('reschedules an early alarm without revoking a still-live grant', async () => {
|
||||
@@ -247,20 +251,14 @@ describe('grant lifecycle manager', () => {
|
||||
expect((await getState()).activeGrant?.id).toBe(old.id);
|
||||
expect(fixture.alarms.get(ACTIVE_GRANT_EXPIRY_ALARM)).toEqual({ when: old.expiresAt });
|
||||
expect(fixture.stopNetwork).not.toHaveBeenCalled();
|
||||
expect(fixture.startRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed when Agent Runtime activation fails after the grant commit', async () => {
|
||||
const active = grant('runtime-failure');
|
||||
fixture.startRuntime.mockRejectedValueOnce(new Error('session storage unavailable'));
|
||||
it('does not couple an authorization-test grant to Agent runtime state', async () => {
|
||||
const active = grant('authorization-only');
|
||||
|
||||
await expect(replaceActiveGrant(active)).rejects.toMatchObject({ code: 'grant_activation_failed' });
|
||||
|
||||
expect((await getState()).activeGrant).toBeUndefined();
|
||||
expect(fixture.stopNetwork).toHaveBeenCalledWith(active.id);
|
||||
expect(fixture.stopRecording).toHaveBeenCalledWith(active.id);
|
||||
expect(fixture.stopDeepCapture).toHaveBeenCalledWith(active.id);
|
||||
expect(fixture.alarms.has(ACTIVE_GRANT_EXPIRY_ALARM)).toBe(false);
|
||||
await expect(replaceActiveGrant(active)).resolves.toMatchObject({
|
||||
state: { activeGrant: { id: active.id } },
|
||||
});
|
||||
});
|
||||
|
||||
it('clears authorization state even when one resource cleanup reports a failure', async () => {
|
||||
|
||||
@@ -2,9 +2,6 @@ import { browser } from 'wxt/browser';
|
||||
import { stopNetworkCapturesForGrant } from '@/features/network-capture/service';
|
||||
import { stopBrowserRecordingsForGrant } from '@/features/browser-recording/service';
|
||||
import { stopDeepCapturesForGrant } from '@/features/deep-capture/service';
|
||||
import {
|
||||
endAgentRuntimeForGrant, startAgentRuntime,
|
||||
} from '@/features/agent-runtime/service';
|
||||
import { appendAuditEvent } from '@/features/diagnostics/audit';
|
||||
import { getState, updateState } from '@/platform/storage/state';
|
||||
import type {
|
||||
@@ -14,10 +11,9 @@ import { ExtensionError } from '@/shared/errors';
|
||||
|
||||
export const ACTIVE_GRANT_EXPIRY_ALARM = 'yakit.active-grant.expiry';
|
||||
|
||||
type GrantEndReason = 'revoked' | 'expired' | 'replaced' | 'scheduler_failure' | 'activation_failure';
|
||||
type GrantEndReason = 'revoked' | 'expired' | 'replaced' | 'scheduler_failure';
|
||||
|
||||
interface GrantLifecycleHooks {
|
||||
cancelActiveRequests?: () => void;
|
||||
emitHandoffChanged?: (handoff: HumanHandoff) => void;
|
||||
}
|
||||
|
||||
@@ -80,23 +76,6 @@ function cancelledHandoff(current: HumanHandoff | undefined, now: number): Human
|
||||
: current;
|
||||
}
|
||||
|
||||
function cancelActiveRequestsBestEffort(grant: BridgeGrant): void {
|
||||
try {
|
||||
hooks.cancelActiveRequests?.();
|
||||
} catch (error) {
|
||||
console.error('Grant request cancellation failed', error);
|
||||
void appendAuditEvent({
|
||||
category: 'grant',
|
||||
action: 'grant.requests.cancel',
|
||||
outcome: 'error',
|
||||
taskId: grant.taskId,
|
||||
targetTabId: grant.targets[0]?.tabId,
|
||||
errorCode: 'grant_request_cancel_failed',
|
||||
summary: (error instanceof Error ? error.message : String(error)).slice(0, 512),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function publishCancelledHandoff(
|
||||
previous: HumanHandoff | undefined,
|
||||
current: HumanHandoff | undefined,
|
||||
@@ -124,12 +103,10 @@ async function publishCancelledHandoff(
|
||||
function cleanupGrantResources(grant: BridgeGrant, reason: GrantEndReason): Promise<void> {
|
||||
const existing = cleanupTasks.get(grant.id);
|
||||
if (existing) return existing;
|
||||
const runtimeState = reason === 'expired' ? 'expired' as const : 'revoked' as const;
|
||||
const task = Promise.allSettled([
|
||||
stopNetworkCapturesForGrant(grant.id),
|
||||
stopBrowserRecordingsForGrant(grant.id),
|
||||
stopDeepCapturesForGrant(grant.id),
|
||||
endAgentRuntimeForGrant(runtimeState, grant),
|
||||
]).then((results) => {
|
||||
const failures = results.filter((result) => result.status === 'rejected');
|
||||
if (failures.length === 0) return;
|
||||
@@ -159,11 +136,11 @@ async function endActiveGrantInQueue(
|
||||
if (!grant || (expectedGrantId && grant.id !== expectedGrantId)) return current;
|
||||
if (reason === 'expired' && grant.expiresAt > now) return current;
|
||||
previousGrant = grant;
|
||||
previousHandoff = current.handoff;
|
||||
previousHandoff = current.handoff?.taskId === grant.taskId ? current.handoff : undefined;
|
||||
return {
|
||||
...current,
|
||||
activeGrant: undefined,
|
||||
handoff: cancelledHandoff(current.handoff, now),
|
||||
handoff: cancelledHandoff(previousHandoff, now) || current.handoff,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -173,7 +150,6 @@ async function endActiveGrantInQueue(
|
||||
return { state };
|
||||
}
|
||||
|
||||
cancelActiveRequestsBestEffort(previousGrant);
|
||||
await clearExpiryAlarmBestEffort(previousGrant);
|
||||
await cleanupGrantResources(previousGrant, reason);
|
||||
await publishCancelledHandoff(previousHandoff, state.handoff, reason);
|
||||
@@ -187,7 +163,7 @@ async function endActiveGrantInQueue(
|
||||
? '已由新共享会话替换'
|
||||
: reason === 'scheduler_failure'
|
||||
? '无法建立可靠的到期调度,已安全撤销'
|
||||
: reason === 'activation_failure' ? 'Agent Runtime 初始化失败,已安全撤销' : undefined,
|
||||
: undefined,
|
||||
});
|
||||
return { state, previousGrant, previousHandoff };
|
||||
}
|
||||
@@ -258,11 +234,14 @@ export function replaceActiveGrant(grant: BridgeGrant): Promise<GrantTransition>
|
||||
try {
|
||||
state = await updateState((current) => {
|
||||
previousGrant = current.activeGrant;
|
||||
previousHandoff = current.handoff;
|
||||
previousHandoff = current.activeGrant
|
||||
&& current.handoff?.taskId === current.activeGrant.taskId
|
||||
? current.handoff
|
||||
: undefined;
|
||||
return {
|
||||
...current,
|
||||
activeGrant: grant,
|
||||
handoff: cancelledHandoff(current.handoff, now),
|
||||
handoff: cancelledHandoff(previousHandoff, now) || current.handoff,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -271,18 +250,8 @@ export function replaceActiveGrant(grant: BridgeGrant): Promise<GrantTransition>
|
||||
}
|
||||
|
||||
if (previousGrant && previousGrant.id !== grant.id) {
|
||||
cancelActiveRequestsBestEffort(previousGrant);
|
||||
await cleanupGrantResources(previousGrant, 'replaced');
|
||||
}
|
||||
try {
|
||||
await startAgentRuntime(grant);
|
||||
} catch (error) {
|
||||
await endActiveGrantInQueue('activation_failure', grant.id);
|
||||
throw new ExtensionError(
|
||||
'grant_activation_failed',
|
||||
`无法初始化浏览器共享会话: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
await publishCancelledHandoff(previousHandoff, state.handoff, 'replaced');
|
||||
return { state, previousGrant, previousHandoff };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { BridgeGrant } from '@/types/models';
|
||||
|
||||
const fixture = vi.hoisted(() => ({
|
||||
access: vi.fn(async (): Promise<BridgeGrant> => ({
|
||||
id: 'paired-browser-instance',
|
||||
taskId: 'paired-browser-instance',
|
||||
targets: [],
|
||||
scopes: ['browser.dom.read'],
|
||||
createdAt: 0,
|
||||
expiresAt: Number.MAX_SAFE_INTEGER,
|
||||
})),
|
||||
dispatch: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
|
||||
vi.mock('wxt/browser', () => ({
|
||||
browser: { runtime: { getManifest: () => ({ version: '1.0.0' }) } },
|
||||
}));
|
||||
vi.mock('./capability-context', () => ({
|
||||
browserInstanceAccess: fixture.access,
|
||||
}));
|
||||
vi.mock('./capability-router', () => ({
|
||||
dispatchCapability: fixture.dispatch,
|
||||
}));
|
||||
|
||||
import { routeCapability } from './service';
|
||||
|
||||
describe('paired browser capability routing', () => {
|
||||
it('routes page access through the paired instance without an active page grant', async () => {
|
||||
await expect(routeCapability('browser.context', { includeDom: true })).resolves.toEqual({ ok: true });
|
||||
expect(fixture.access).toHaveBeenCalledWith('browser.dom.read');
|
||||
expect(fixture.dispatch).toHaveBeenCalledWith(expect.objectContaining({
|
||||
method: 'browser.context',
|
||||
input: { includeDom: true },
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@/protocol/capabilities';
|
||||
import { parseCapabilityParams } from '@/protocol/bridge';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
import { activeGrant, type CapabilityEngineRequest } from './capability-context';
|
||||
import { browserInstanceAccess, type CapabilityEngineRequest } from './capability-context';
|
||||
import { dispatchCapability } from './capability-router';
|
||||
|
||||
export { CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
|
||||
@@ -17,9 +17,18 @@ export async function routeCapability(
|
||||
requestEngine?: CapabilityEngineRequest,
|
||||
): Promise<unknown> {
|
||||
if (method === 'system.ping') {
|
||||
const userAgent = globalThis.navigator?.userAgent || '';
|
||||
const browserName = /Firefox\//i.test(userAgent)
|
||||
? 'Firefox'
|
||||
: /Edg\//i.test(userAgent)
|
||||
? 'Edge'
|
||||
: /Chrom(?:e|ium)\//i.test(userAgent)
|
||||
? 'Chrome'
|
||||
: undefined;
|
||||
return {
|
||||
now: Date.now(),
|
||||
extensionVersion: browser.runtime.getManifest().version,
|
||||
browserName,
|
||||
};
|
||||
}
|
||||
if (import.meta.env.FIREFOX
|
||||
@@ -35,6 +44,6 @@ export async function routeCapability(
|
||||
? 'browser.page.eval.program'
|
||||
: capabilityBaseScope(method);
|
||||
if (!required) throw new Error(`不支持的 Bridge 方法: ${method}`);
|
||||
const grant = await activeGrant(required);
|
||||
const grant = await browserInstanceAccess(required);
|
||||
return dispatchCapability({ method, input, grant, requestEngine });
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface IsolationCookieStore {
|
||||
export interface IsolationTabDescriptor {
|
||||
id: number;
|
||||
windowId: number;
|
||||
active?: boolean;
|
||||
title: string;
|
||||
url: string;
|
||||
incognito: boolean;
|
||||
@@ -173,6 +174,7 @@ export function activeTabInfo(
|
||||
return {
|
||||
id: tab.id,
|
||||
windowId: tab.windowId,
|
||||
active: Boolean(tab.active),
|
||||
title: tab.title || '未命名页面',
|
||||
url: tab.url,
|
||||
incognito: tab.incognito,
|
||||
@@ -189,6 +191,7 @@ export function browserTabDescriptor(tab: Browser.tabs.Tab): IsolationTabDescrip
|
||||
return {
|
||||
id: tab.id,
|
||||
windowId: tab.windowId,
|
||||
active: tab.active,
|
||||
title: tab.title || '未命名页面',
|
||||
url: tab.url,
|
||||
incognito: tab.incognito,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fixture = vi.hoisted(() => ({
|
||||
getTab: vi.fn(),
|
||||
updateTab: vi.fn(),
|
||||
getWindow: vi.fn(),
|
||||
getAllWindows: vi.fn(),
|
||||
updateWindow: vi.fn(),
|
||||
removeWindow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('wxt/browser', () => ({
|
||||
browser: {
|
||||
tabs: { get: fixture.getTab, update: fixture.updateTab },
|
||||
windows: {
|
||||
get: fixture.getWindow,
|
||||
getAll: fixture.getAllWindows,
|
||||
update: fixture.updateWindow,
|
||||
remove: fixture.removeWindow,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { activateTab, scheduleBrowserInstanceClose } from './targets';
|
||||
|
||||
describe('browser window actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('restores a minimized window before bringing it to the front', async () => {
|
||||
fixture.getTab.mockResolvedValue({ id: 7, windowId: 3 });
|
||||
fixture.getWindow.mockResolvedValue({ id: 3, state: 'minimized' });
|
||||
|
||||
await activateTab(7);
|
||||
|
||||
expect(fixture.updateTab).toHaveBeenCalledWith(7, { active: true });
|
||||
expect(fixture.updateWindow).toHaveBeenNthCalledWith(1, 3, { state: 'normal' });
|
||||
expect(fixture.updateWindow).toHaveBeenNthCalledWith(2, 3, { focused: true });
|
||||
});
|
||||
|
||||
it('acknowledges the request before closing every window in the instance', async () => {
|
||||
vi.useFakeTimers();
|
||||
fixture.getAllWindows.mockResolvedValue([{ id: 3 }, { id: 4 }]);
|
||||
fixture.removeWindow.mockResolvedValue(undefined);
|
||||
|
||||
await expect(scheduleBrowserInstanceClose()).resolves.toEqual({ closing: true, windowCount: 2 });
|
||||
expect(fixture.removeWindow).not.toHaveBeenCalled();
|
||||
await vi.runAllTimersAsync();
|
||||
expect(fixture.removeWindow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,7 @@ export async function resolveDocumentTarget(input: BrowserTarget | number): Prom
|
||||
}
|
||||
if (!probe) throw new ExtensionError('target_unavailable', '无法定位目标页面文档');
|
||||
if (requested.documentId && probe.documentId && requested.documentId !== probe.documentId) {
|
||||
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新授权');
|
||||
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新获取页面上下文');
|
||||
}
|
||||
return { tabId: requested.tabId, frameId: probe.frameId, documentId: probe.documentId || requested.documentId };
|
||||
}
|
||||
@@ -64,6 +64,19 @@ export const getActiveTab = () => getTab();
|
||||
|
||||
export async function activateTab(tabId?: number): Promise<void> {
|
||||
const tab = tabId ? await browser.tabs.get(tabId) : await browser.tabs.get((await getActiveTab()).id);
|
||||
await browser.windows.update(tab.windowId, { focused: true });
|
||||
await browser.tabs.update(tab.id, { active: true });
|
||||
const window = await browser.windows.get(tab.windowId);
|
||||
if (window.state === 'minimized') await browser.windows.update(tab.windowId, { state: 'normal' });
|
||||
await browser.windows.update(tab.windowId, { focused: true });
|
||||
}
|
||||
|
||||
export async function scheduleBrowserInstanceClose(): Promise<{ closing: boolean; windowCount: number }> {
|
||||
const windowIds = (await browser.windows.getAll())
|
||||
.map((window) => window.id)
|
||||
.filter((id): id is number => typeof id === 'number');
|
||||
if (!windowIds.length) return { closing: false, windowCount: 0 };
|
||||
globalThis.setTimeout(() => {
|
||||
void Promise.all(windowIds.map((id) => browser.windows.remove(id).catch(() => undefined)));
|
||||
}, 250);
|
||||
return { closing: true, windowCount: windowIds.length };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { vi, describe, expect, it } from 'vitest';
|
||||
vi.mock('wxt/browser', () => ({ browser: { storage: {} } }));
|
||||
|
||||
import type { BridgeConfig } from '@/types/models';
|
||||
import { applyPolicyToBridge, assertGrantPolicy } from './managed';
|
||||
import { applyPolicyToBridge, assertBrowserAccessPolicy, assertGrantPolicy } from './managed';
|
||||
|
||||
const bridge: BridgeConfig = {
|
||||
transport: 'websocket', endpoint: 'ws://127.0.0.1:64333/extension', nativeHost: 'default.host',
|
||||
@@ -21,4 +21,15 @@ describe('managed policy enforcement', () => {
|
||||
expect(() => assertGrantPolicy({ allowProgramEval: false }, { durationMinutes: 5, origins: [], programEval: true })).toThrow('禁止');
|
||||
expect(() => assertGrantPolicy({ grantAllowedOrigins: ['https://a.test'] }, { durationMinutes: 5, origins: ['https://b.test'], programEval: false })).toThrow('不允许');
|
||||
});
|
||||
|
||||
it('keeps enterprise restrictions on paired instance access', () => {
|
||||
expect(() => assertBrowserAccessPolicy(
|
||||
{ grantAllowedOrigins: ['https://a.test'] },
|
||||
{ origin: 'https://a.test' },
|
||||
)).not.toThrow();
|
||||
expect(() => assertBrowserAccessPolicy(
|
||||
{ grantAllowedOrigins: ['https://a.test'] },
|
||||
{ origin: 'https://b.test' },
|
||||
)).toThrow('不允许');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,12 +79,20 @@ export function assertGrantPolicy(
|
||||
policy: EnterprisePolicy,
|
||||
input: { durationMinutes: number; origins: string[]; programEval: boolean },
|
||||
): number {
|
||||
if (input.programEval) assertBrowserAccessPolicy(policy, { programEval: true });
|
||||
for (const origin of input.origins) assertBrowserAccessPolicy(policy, { origin });
|
||||
return Math.min(input.durationMinutes, policy.maxGrantMinutes || input.durationMinutes);
|
||||
}
|
||||
|
||||
export function assertBrowserAccessPolicy(
|
||||
policy: EnterprisePolicy,
|
||||
input: { origin?: string; programEval?: boolean },
|
||||
): void {
|
||||
if (input.programEval && policy.allowProgramEval === false) {
|
||||
throw new ExtensionError('policy_denied', '企业策略禁止 browser.page.eval.program');
|
||||
}
|
||||
if (policy.grantAllowedOrigins?.length) {
|
||||
const denied = input.origins.find((origin) => !policy.grantAllowedOrigins!.includes(origin));
|
||||
if (denied) throw new ExtensionError('policy_denied', `企业策略不允许授权 origin: ${denied}`);
|
||||
if (input.origin && policy.grantAllowedOrigins?.length
|
||||
&& !policy.grantAllowedOrigins.includes(input.origin)) {
|
||||
throw new ExtensionError('policy_denied', `企业策略不允许访问 origin: ${input.origin}`);
|
||||
}
|
||||
return Math.min(input.durationMinutes, policy.maxGrantMinutes || input.durationMinutes);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,24 @@ describe('split state storage', () => {
|
||||
expect(state.floatingPanel.side).toBe('left');
|
||||
});
|
||||
|
||||
it('keeps only validated manager-owned browser instance identity', async () => {
|
||||
await setState({
|
||||
...structuredClone(DEFAULT_STATE),
|
||||
bridge: {
|
||||
...structuredClone(DEFAULT_STATE.bridge),
|
||||
managedInstance: { manager: 'ytray', instanceId: 'instance-1', badge: 'C' },
|
||||
},
|
||||
});
|
||||
expect((await getState()).bridge.managedInstance).toEqual({
|
||||
manager: 'ytray', instanceId: 'instance-1', badge: 'C',
|
||||
});
|
||||
|
||||
stores.local[BRIDGE_SETTINGS_STORAGE_KEY] = {
|
||||
bridge: { ...structuredClone(DEFAULT_STATE.bridge), managedInstance: { manager: 'web', instanceId: '../bad', badge: '3' } },
|
||||
};
|
||||
expect((await getState()).bridge.managedInstance).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops a session grant that is not bound to an isolation context', async () => {
|
||||
const now = Date.now();
|
||||
stores.session[ACTIVE_SESSION_STORAGE_KEY] = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import type {
|
||||
BridgeGrant, BridgeGrantTarget, BridgeRuntimeSession, CapabilityScope, ExtensionState,
|
||||
BridgeConfig, BridgeGrant, BridgeGrantTarget, BridgeRuntimeSession, CapabilityScope, ExtensionState,
|
||||
ProxyConditionType, ProxyProfile,
|
||||
} from '@/types/models';
|
||||
import {
|
||||
@@ -15,7 +15,7 @@ interface StorageArea {
|
||||
}
|
||||
|
||||
let mutationQueue: Promise<void> = Promise.resolve();
|
||||
const sessionStorage = (browser.storage as unknown as { session?: StorageArea }).session;
|
||||
const sessionStorage = (browser.storage as unknown as { session?: StorageArea } | undefined)?.session;
|
||||
|
||||
export const DEFAULT_STATE: ExtensionState = {
|
||||
version: 7,
|
||||
@@ -96,6 +96,19 @@ function normalizeActiveGrant(input: unknown): BridgeGrant | undefined {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeManagedInstance(input: unknown): BridgeConfig['managedInstance'] {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) return undefined;
|
||||
const value = input as Partial<NonNullable<BridgeConfig['managedInstance']>>;
|
||||
if (
|
||||
!['ytray', 'yakit'].includes(value.manager || '')
|
||||
|| typeof value.instanceId !== 'string'
|
||||
|| !/^[A-Za-z0-9-]{1,160}$/.test(value.instanceId)
|
||||
|| typeof value.badge !== 'string'
|
||||
|| !/^[A-Z]{1,2}$/.test(value.badge)
|
||||
) return undefined;
|
||||
return value as NonNullable<BridgeConfig['managedInstance']>;
|
||||
}
|
||||
|
||||
function normalizeState(value: Partial<ExtensionState>): ExtensionState {
|
||||
const profileMap = new Map(defaultProfiles().map((profile) => [profile.id, profile]));
|
||||
const storedProfiles = Array.isArray(value.proxyProfiles) ? value.proxyProfiles.slice(0, 500) : [];
|
||||
@@ -175,7 +188,11 @@ function normalizeState(value: Partial<ExtensionState>): ExtensionState {
|
||||
: 'direct',
|
||||
customUserAgentProfiles: userAgentState.customUserAgentProfiles,
|
||||
userAgentAssignments: userAgentState.userAgentAssignments,
|
||||
bridge: { ...DEFAULT_STATE.bridge, ...value.bridge },
|
||||
bridge: {
|
||||
...DEFAULT_STATE.bridge,
|
||||
...value.bridge,
|
||||
managedInstance: normalizeManagedInstance(value.bridge?.managedInstance),
|
||||
},
|
||||
floatingPanel: {
|
||||
...DEFAULT_STATE.floatingPanel, ...value.floatingPanel,
|
||||
siteOrigins: [...new Set(value.floatingPanel?.siteOrigins || [])].slice(0, 500),
|
||||
|
||||
@@ -57,6 +57,12 @@ describe('Bridge v3 protocol', () => {
|
||||
expect(() => parseBridgeEnvelope('x'.repeat(BRIDGE_MAX_MESSAGE_BYTES + 1))).toThrow('16 MiB');
|
||||
});
|
||||
|
||||
it('opens only HTTP(S) pages in the attached browser instance', () => {
|
||||
expect(parseCapabilityParams('browser.tab.open', { url: 'https://www.baidu.com/' }))
|
||||
.toEqual({ url: 'https://www.baidu.com/' });
|
||||
expect(() => parseCapabilityParams('browser.tab.open', { url: 'chrome://settings' })).toThrow('HTTP(S)');
|
||||
});
|
||||
|
||||
it('accepts exact Worker boundary handles for remote deep capture', () => {
|
||||
expect(parseCapabilityParams('browser.deep_capture.start', {
|
||||
matcher: {
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface BridgePairingEnvelope {
|
||||
protocolVersion?: number;
|
||||
requestId?: string;
|
||||
installationId?: string;
|
||||
managedInstance?: BridgeEnvelope['managedInstance'];
|
||||
client?: string;
|
||||
version?: string;
|
||||
nonce?: string;
|
||||
@@ -99,6 +100,8 @@ const authorizationResourceValue = v.strictObject({
|
||||
export const capabilityParams = {
|
||||
'system.ping': v.optional(v.strictObject({})),
|
||||
'browser.tabs': v.optional(v.strictObject({})),
|
||||
'browser.tab.open': v.strictObject({ url: httpUrl }),
|
||||
'browser.thumbnail': v.optional(v.strictObject({ tabId: optionalTabId })),
|
||||
'browser.frames': v.optional(v.strictObject({ tabId: optionalTabId })),
|
||||
'browser.isolation.inspect': v.optional(v.strictObject({
|
||||
tabIds: v.optional(v.pipe(v.array(tabId), v.minLength(1), v.maxLength(256))),
|
||||
@@ -183,6 +186,7 @@ export const capabilityParams = {
|
||||
}), v.check((input) => input.action !== 'setValue' || typeof input.value === 'string', 'setValue 操作必须提供 value')),
|
||||
'browser.cookies': v.optional(v.strictObject(targetFields)),
|
||||
'browser.takeover': v.optional(v.strictObject(targetFields)),
|
||||
'browser.instance.close': v.optional(v.strictObject({})),
|
||||
'browser.handoff.request': v.strictObject({
|
||||
...targetFields,
|
||||
reason: v.picklist(['qr_code', 'mfa', 'captcha', 'device_confirmation', 'other']),
|
||||
@@ -354,6 +358,7 @@ export function parseBridgeEnvelope(raw: unknown): BridgeEnvelope {
|
||||
const allowedKeys = new Set([
|
||||
'id', 'type', 'method', 'params', 'result', 'error', 'client', 'version', 'protocolVersion',
|
||||
'capabilities', 'capabilityCatalog', 'sessionId', 'taskId', 'grantId', 'installationId',
|
||||
'managedInstance',
|
||||
'engineInstanceId', 'engineIdentityId', 'challenge', 'signature', 'publicKey', 'connectionId',
|
||||
'resumeSessionId', 'resumed', 'sequence', 'timestamp', 'replyTimestamp', 'transferId', 'index',
|
||||
'total', 'data', 'originalBytes',
|
||||
@@ -361,6 +366,16 @@ export function parseBridgeEnvelope(raw: unknown): BridgeEnvelope {
|
||||
const unexpected = Object.keys(message).find((key) => !allowedKeys.has(key));
|
||||
if (unexpected) throw new Error(`Bridge 消息包含未声明字段 $.${unexpected}`);
|
||||
if (typeof message.type !== 'string') throw new Error('Bridge 消息缺少 type');
|
||||
if (message.managedInstance !== undefined) {
|
||||
const managed = message.managedInstance as Record<string, unknown>;
|
||||
if (!managed || typeof managed !== 'object' || Array.isArray(managed)
|
||||
|| !['ytray', 'yakit'].includes(String(managed.manager || ''))
|
||||
|| typeof managed.instanceId !== 'string' || !/^[A-Za-z0-9-]{1,160}$/.test(managed.instanceId)
|
||||
|| typeof managed.badge !== 'string' || !/^[A-Z]{1,2}$/.test(managed.badge)
|
||||
|| Object.keys(managed).some((key) => !['manager', 'instanceId', 'badge'].includes(key))) {
|
||||
throw new Error('Bridge 浏览器实例身份无效');
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === 'challenge') {
|
||||
if (message.protocolVersion !== BRIDGE_PROTOCOL_VERSION) throw new Error(`Bridge 协议版本不兼容: ${String(message.protocolVersion)}`);
|
||||
|
||||
@@ -33,11 +33,19 @@ const CAPABILITY_METADATA = {
|
||||
scopes: [], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.tabs': {
|
||||
domain: 'page', access: 'read', summary: '列出当前 grant 明确共享的标签页',
|
||||
domain: 'page', access: 'read', summary: '列出当前浏览器实例中的全部 HTTP(S) 标签页;配对实例无需逐页授权',
|
||||
scopes: ['browser.tabs.read'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.tab.open': {
|
||||
domain: 'page', access: 'write', summary: '在当前浏览器实例中新建并前台打开 HTTP(S) 页面',
|
||||
scopes: ['browser.tabs.write'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.thumbnail': {
|
||||
domain: 'page', access: 'read', summary: '读取当前可见标签页的低清预览图,供 Yakit 实例列表展示',
|
||||
scopes: ['browser.tabs.read'], targetMode: 'tab', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.isolation.inspect': {
|
||||
domain: 'isolation', access: 'read', summary: '读取共享标签页的 Cookie Store 与身份隔离上下文',
|
||||
domain: 'isolation', access: 'read', summary: '读取浏览器实例内标签页的 Cookie Store 与身份隔离上下文',
|
||||
scopes: ['browser.isolation.read'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.isolation.proof': {
|
||||
@@ -67,7 +75,7 @@ const CAPABILITY_METADATA = {
|
||||
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.authorization.context.get': {
|
||||
domain: 'authorization', access: 'sensitive-read', summary: '实时复核并读取当前共享会话中的短时认证上下文句柄',
|
||||
domain: 'authorization', access: 'sensitive-read', summary: '实时复核并读取当前浏览器实例中的短时认证上下文句柄',
|
||||
scopes: ['browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
|
||||
targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
@@ -127,7 +135,7 @@ const CAPABILITY_METADATA = {
|
||||
targetMode: 'none', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||
},
|
||||
'browser.frames': {
|
||||
domain: 'page', access: 'read', summary: '列出共享标签页中的 Frame',
|
||||
domain: 'page', access: 'read', summary: '列出浏览器实例指定标签页中的 Frame',
|
||||
scopes: ['browser.tabs.read'], targetMode: 'tab', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.context': {
|
||||
@@ -155,6 +163,10 @@ const CAPABILITY_METADATA = {
|
||||
domain: 'page', access: 'write', summary: '将目标标签页切换到前台',
|
||||
scopes: ['browser.tab.activate'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.instance.close': {
|
||||
domain: 'page', access: 'dangerous', summary: '关闭当前浏览器实例的全部窗口',
|
||||
scopes: ['browser.instance.close'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.handoff.request': {
|
||||
domain: 'handoff', access: 'write', summary: '请求用户完成扫码、MFA、验证码或设备确认',
|
||||
scopes: ['browser.human.takeover'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
@@ -174,7 +186,7 @@ const CAPABILITY_METADATA = {
|
||||
scopes: ['browser.network.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.network.list': {
|
||||
domain: 'network', access: 'read', summary: '列出已捕获请求,敏感字段继续受 grant 约束',
|
||||
domain: 'network', access: 'read', summary: '列出已捕获请求,敏感字段仍由 Agent 操作审核策略保护',
|
||||
scopes: ['browser.network.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.network.clear': {
|
||||
@@ -385,9 +397,11 @@ export const READ_CAPABILITY_SCOPES: CapabilityScope[] = [
|
||||
|
||||
export const CONTROL_CAPABILITY_SCOPES: CapabilityScope[] = [
|
||||
...READ_CAPABILITY_SCOPES,
|
||||
'browser.tabs.write',
|
||||
'browser.dom.write',
|
||||
'browser.isolation.manage',
|
||||
'browser.tab.activate',
|
||||
'browser.instance.close',
|
||||
...(!(import.meta.env.FIREFOX && import.meta.env.MODE === 'store')
|
||||
? ['browser.page.invoke' as const, 'browser.page.eval.expression' as const]
|
||||
: []),
|
||||
@@ -410,6 +424,7 @@ export const CONTROL_CAPABILITY_SCOPES: CapabilityScope[] = [
|
||||
|
||||
export const CAPABILITY_LABELS: Record<CapabilityScope, string> = {
|
||||
'browser.tabs.read': '标签页列表',
|
||||
'browser.tabs.write': '打开网页',
|
||||
'browser.isolation.read': '读取身份隔离状态',
|
||||
'browser.isolation.manage': '创建隔离身份页面',
|
||||
'browser.dom.read': '页面 DOM',
|
||||
@@ -417,6 +432,7 @@ export const CAPABILITY_LABELS: Record<CapabilityScope, string> = {
|
||||
'browser.storage.read': '页面 Storage',
|
||||
'browser.cookies.read': 'Cookie',
|
||||
'browser.tab.activate': '切到前台',
|
||||
'browser.instance.close': '关闭浏览器实例',
|
||||
'browser.page.invoke': '调用页面函数',
|
||||
'browser.page.eval.expression': '执行页面表达式',
|
||||
'browser.page.eval.program': '执行页面程序',
|
||||
|
||||
@@ -120,6 +120,17 @@ describe('extension request schemas', () => {
|
||||
})).toThrow('HTTP(S)');
|
||||
});
|
||||
|
||||
it('validates manager-owned browser instance binding', () => {
|
||||
expect(parseExtensionRequest({
|
||||
action: 'bridge.managed-instance.bind',
|
||||
payload: { manager: 'ytray', instanceId: '13367db6-232a-40d1-ad84-81ee5d97634f', badge: 'B' },
|
||||
}).action).toBe('bridge.managed-instance.bind');
|
||||
expect(() => parseExtensionRequest({
|
||||
action: 'bridge.managed-instance.bind',
|
||||
payload: { manager: 'web', instanceId: '../shared', badge: '3' },
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('validates recording bounds and recorded page callables', () => {
|
||||
expect(parseExtensionRequest({
|
||||
action: 'recording.start',
|
||||
|
||||
@@ -167,12 +167,19 @@ const userAgentProfileInput = v.strictObject({
|
||||
userAgent: userAgentValue,
|
||||
});
|
||||
|
||||
const managedInstance = v.strictObject({
|
||||
manager: v.picklist(['ytray', 'yakit']),
|
||||
instanceId: v.pipe(v.string(), v.regex(/^[A-Za-z0-9-]{1,160}$/)),
|
||||
badge: v.pipe(v.string(), v.regex(/^[A-Z]{1,2}$/)),
|
||||
});
|
||||
|
||||
const bridgeConfig = v.strictObject({
|
||||
transport: v.picklist(['native', 'websocket']),
|
||||
nativeHost: v.pipe(v.string(), v.trim(), v.maxLength(253)),
|
||||
endpoint: v.pipe(v.string(), v.trim(), v.maxLength(2_048)),
|
||||
autoConnect: v.boolean(),
|
||||
installationId: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)),
|
||||
managedInstance: v.optional(managedInstance),
|
||||
pairedEngine: v.optional(v.strictObject({
|
||||
engineIdentityId: id,
|
||||
deviceId: id,
|
||||
@@ -226,6 +233,7 @@ const contextOptions = {
|
||||
|
||||
const capabilityScopes: readonly CapabilityScope[] = [
|
||||
'browser.tabs.read',
|
||||
'browser.tabs.write',
|
||||
'browser.isolation.read',
|
||||
'browser.isolation.manage',
|
||||
'browser.dom.read',
|
||||
@@ -233,6 +241,7 @@ const capabilityScopes: readonly CapabilityScope[] = [
|
||||
'browser.storage.read',
|
||||
'browser.cookies.read',
|
||||
'browser.tab.activate',
|
||||
'browser.instance.close',
|
||||
'browser.page.invoke',
|
||||
'browser.page.eval.expression',
|
||||
'browser.page.eval.program',
|
||||
@@ -462,6 +471,7 @@ const payloadSchemas = {
|
||||
'metrics.get': noPayload,
|
||||
'metrics.reset': noPayload,
|
||||
'bridge.config.save': bridgeConfig,
|
||||
'bridge.managed-instance.bind': managedInstance,
|
||||
'bridge.pair': noPayload,
|
||||
'bridge.pair.cancel': noPayload,
|
||||
'bridge.pair.status': noPayload,
|
||||
|
||||
@@ -243,6 +243,10 @@ export interface ExtensionRequestMap {
|
||||
'metrics.get': { input: undefined; output: RuntimeMetrics };
|
||||
'metrics.reset': { input: undefined; output: RuntimeMetrics };
|
||||
'bridge.config.save': { input: BridgeConfig; output: ExtensionState };
|
||||
'bridge.managed-instance.bind': {
|
||||
input: NonNullable<BridgeConfig['managedInstance']>;
|
||||
output: BridgeStatus;
|
||||
};
|
||||
'bridge.pair': { input: undefined; output: BridgePairingStatus };
|
||||
'bridge.pair.cancel': { input: undefined; output: BridgePairingStatus };
|
||||
'bridge.pair.status': { input: undefined; output: BridgePairingStatus };
|
||||
@@ -333,6 +337,7 @@ export interface BridgeEnvelope {
|
||||
taskId?: string;
|
||||
grantId?: string;
|
||||
installationId?: string;
|
||||
managedInstance?: BridgeConfig['managedInstance'];
|
||||
engineInstanceId?: string;
|
||||
engineIdentityId?: string;
|
||||
challenge?: string;
|
||||
|
||||
+9
-1
@@ -196,6 +196,11 @@ export interface BridgeConfig {
|
||||
endpoint: string;
|
||||
autoConnect: boolean;
|
||||
installationId: string;
|
||||
managedInstance?: {
|
||||
manager: 'ytray' | 'yakit';
|
||||
instanceId: string;
|
||||
badge: string;
|
||||
};
|
||||
pairedEngine?: BridgePairedEngine;
|
||||
}
|
||||
|
||||
@@ -226,6 +231,7 @@ export interface BridgePairingStatus {
|
||||
|
||||
export type CapabilityScope =
|
||||
| 'browser.tabs.read'
|
||||
| 'browser.tabs.write'
|
||||
| 'browser.isolation.read'
|
||||
| 'browser.isolation.manage'
|
||||
| 'browser.dom.read'
|
||||
@@ -233,6 +239,7 @@ export type CapabilityScope =
|
||||
| 'browser.storage.read'
|
||||
| 'browser.cookies.read'
|
||||
| 'browser.tab.activate'
|
||||
| 'browser.instance.close'
|
||||
| 'browser.page.invoke'
|
||||
| 'browser.page.eval.expression'
|
||||
| 'browser.page.eval.program'
|
||||
@@ -406,7 +413,7 @@ export interface NetworkRequestRecord {
|
||||
}
|
||||
|
||||
export interface NetworkCaptureStatus {
|
||||
active: boolean;
|
||||
active?: boolean;
|
||||
target: BrowserTarget;
|
||||
startedAt?: number;
|
||||
count: number;
|
||||
@@ -1483,6 +1490,7 @@ export interface ExtensionState {
|
||||
export interface ActiveTabInfo {
|
||||
id: number;
|
||||
windowId: number;
|
||||
active?: boolean;
|
||||
title: string;
|
||||
url: string;
|
||||
incognito: boolean;
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import { defineConfig } from 'wxt';
|
||||
// package.json is the single source of truth for the version; release
|
||||
// packaging asserts the built manifest matches it.
|
||||
const { version } = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'));
|
||||
const CHROMIUM_EXTENSION_KEY = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1bj9d0jEOY87aT9nk4Ov7svZVnrFPD0dJsS39exzqMIJGMkGmqQ7J4TfFLlAV3Ckm9uszkMyw1oKKM/5ejd662B2uTcolHcSzmEVKLTGLvwUylWE6YJWcb3b5G88bzkcQepnNdz3gg3JvMhwPBNMk4qeSAHtX7u6S5zjoX4AyvQg5/qs29zViUTZoPcSEprJidaMilKwGxsJ5VpgtUXCE7JoKgadm/CK4iwJF5yCmKrkCi6xFwrt/qfrLAd6qXae7d5PDztxNyU+KSHX6FUHFfvJx9cmeIjIIJiZ35RHV78oT2beATSrU70uxg6in2JMy0z9SnpoV4euJ4Xyh6f/cwIDAQAB';
|
||||
|
||||
// See https://wxt.dev/api/config.html
|
||||
export default defineConfig({
|
||||
@@ -29,7 +30,7 @@ export default defineConfig({
|
||||
storage: {
|
||||
managed_schema: 'managed-storage-schema.json',
|
||||
},
|
||||
...(browser !== 'firefox' ? { incognito: 'spanning' as const } : {}),
|
||||
...(browser !== 'firefox' ? { key: CHROMIUM_EXTENSION_KEY, incognito: 'spanning' as const } : {}),
|
||||
permissions: [
|
||||
'proxy',
|
||||
'storage',
|
||||
|
||||
Reference in New Issue
Block a user