feat(browser): add managed instance agent capabilities

This commit is contained in:
go0p
2026-09-03 13:31:59 +08:00
parent 8af9bb777e
commit e00746d834
37 changed files with 608 additions and 281 deletions
+8
View File
@@ -1,4 +1,5 @@
import { access, readFile, stat } from 'node:fs/promises'; import { access, readFile, stat } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { gzipSync } from 'node:zlib'; 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_BUDGET = 204 * 1024;
const BRIDGE_BACKGROUND_GZIP_BUDGET = 60 * 1024; const BRIDGE_BACKGROUND_GZIP_BUDGET = 60 * 1024;
const ENTERPRISE_BACKGROUND_GZIP_BUDGET = 61 * 1024; const ENTERPRISE_BACKGROUND_GZIP_BUDGET = 61 * 1024;
const CHROMIUM_EXTENSION_ID = 'mcnaombmlombekhbonfndagbcfhmoail';
// Recorder, callable registry and Pipeline runtime are installed only for an // Recorder, callable registry and Pipeline runtime are installed only for an
// explicitly selected document. Keep their budget separate from the always-on // explicitly selected document. Keep their budget separate from the always-on
// Service Worker so moving work out of startup code remains measurable. // 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 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')); 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 (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 (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`); if (backgroundGzipBytes > target.backgroundGzipBudget) sizeAdvisories.push(`background gzip ${backgroundGzipBytes}B > ${target.backgroundGzipBudget}B reference`);
+57 -11
View File
@@ -1,11 +1,11 @@
import { browser, type Browser } from 'wxt/browser'; import { browser, type Browser } from 'wxt/browser';
import { import {
clearNetworkRequests, exportNetworkRequest, listNetworkRequests, networkCaptureStatus, clearNetworkRequests, exportNetworkRequest, listNetworkRequests, networkCaptureStatus,
rebindNetworkCapturesForGrant, startNetworkCapture, stopNetworkCapture, rebindNetworkCapturesForGrant, startNetworkCapture, stopNetworkCapture, stopNetworkCapturesForGrant,
} from '@/features/network-capture/service'; } from '@/features/network-capture/service';
import { capturedRequestEnginePayload } from '@/features/network-capture/workflows'; import { capturedRequestEnginePayload } from '@/features/network-capture/workflows';
import { initializeBrowserRecordingService } from '@/features/browser-recording/service'; import { initializeBrowserRecordingService, stopBrowserRecordingsForGrant } from '@/features/browser-recording/service';
import { initializeDeepCaptureService } from '@/features/deep-capture/service'; import { initializeDeepCaptureService, stopDeepCapturesForGrant } from '@/features/deep-capture/service';
import { initializeBrowserTransformService } from '@/features/browser-transform/service'; import { initializeBrowserTransformService } from '@/features/browser-transform/service';
import { initializeFloatingPanelLifecycle } from '@/features/floating-panel/lifecycle'; import { initializeFloatingPanelLifecycle } from '@/features/floating-panel/lifecycle';
import type { ExtensionRequest, ExtensionResponse } from '@/types/messages'; import type { ExtensionRequest, ExtensionResponse } from '@/types/messages';
@@ -36,6 +36,9 @@ import {
import { import {
applyPolicyToBridge, applyPolicyToState, assertGrantPolicy, getEnterprisePolicy, applyPolicyToBridge, applyPolicyToState, assertGrantPolicy, getEnterprisePolicy,
} from '@/platform/policy/managed'; } from '@/platform/policy/managed';
import {
browserInstanceAccess, PAIRED_BROWSER_INSTANCE_ACCESS_ID,
} from '@/features/grants/capability-context';
import { createDiagnosticsBundle } from '@/features/diagnostics/export'; import { createDiagnosticsBundle } from '@/features/diagnostics/export';
import { getRuntimeMetrics, recordServiceWorkerStart, resetRuntimeMetrics } from '@/features/diagnostics/metrics'; import { getRuntimeMetrics, recordServiceWorkerStart, resetRuntimeMetrics } from '@/features/diagnostics/metrics';
import { import {
@@ -68,6 +71,16 @@ function originOf(url: string): string {
return parsed.origin; 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[]> { 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 unique = [...new Map(inputs.map((target) => [`${target.tabId}:${target.frameId}`, target])).values()];
const tabIds = [...new Set(unique.map((target) => target.tabId))]; const tabIds = [...new Set(unique.map((target) => target.tabId))];
@@ -106,6 +119,12 @@ const domainHandlers: readonly BackgroundRequestHandler[] = [
handleTransformRequest, 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> { async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.MessageSender): Promise<ExtensionResponse> {
const domainResponse = await dispatchBackgroundHandlers(request, sender, domainHandlers); const domainResponse = await dispatchBackgroundHandlers(request, sender, domainHandlers);
if (domainResponse !== undefined) return domainResponse; if (domainResponse !== undefined) return domainResponse;
@@ -290,7 +309,10 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
}; };
}); });
const handoff = state.handoff!; 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 }); await browser.action.setBadgeText({ text: '', tabId: handoff.target.tabId });
engineBridge.emitEvent('browser.handoff.changed', handoff); engineBridge.emitEvent('browser.handoff.changed', handoff);
void appendAuditEvent({ void appendAuditEvent({
@@ -388,14 +410,15 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
} }
case 'agent.runtime.get': return ok(await getAgentRuntime()); case 'agent.runtime.get': return ok(await getAgentRuntime());
case 'agent.pause': { case 'agent.pause': {
const grant = await requireActiveGrant(); const grant = await browserInstanceAccess('browser.tabs.read');
engineBridge.cancelActiveRequests(); engineBridge.cancelActiveRequests();
await stopPairedBrowserTasks();
const runtime = await setAgentRuntimeState('paused', grant); const runtime = await setAgentRuntimeState('paused', grant);
void appendAuditEvent({ category: 'grant', action: 'agent.pause', outcome: 'success', taskId: grant.taskId }); void appendAuditEvent({ category: 'grant', action: 'agent.pause', outcome: 'success', taskId: grant.taskId });
return ok(runtime); return ok(runtime);
} }
case 'agent.resume': { case 'agent.resume': {
const grant = await requireActiveGrant(); const grant = await browserInstanceAccess('browser.tabs.read');
const runtime = await setAgentRuntimeState('running', grant); const runtime = await setAgentRuntimeState('running', grant);
void appendAuditEvent({ category: 'grant', action: 'agent.resume', outcome: 'success', taskId: grant.taskId }); void appendAuditEvent({ category: 'grant', action: 'agent.resume', outcome: 'success', taskId: grant.taskId });
return ok(runtime); return ok(runtime);
@@ -408,10 +431,29 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
case 'bridge.config.save': { case 'bridge.config.save': {
const config = applyPolicyToBridge(request.payload, (await getEnterprisePolicy()).policy); const config = applyPolicyToBridge(request.payload, (await getEnterprisePolicy()).policy);
const state = await updateState((current) => ({ ...current, bridge: config })); const state = await updateState((current) => ({ ...current, bridge: config }));
await syncManagedInstanceBadge(state.bridge.managedInstance);
if (config.autoConnect && config.pairedEngine) await engineBridge.connect(config); if (config.autoConnect && config.pairedEngine) await engineBridge.connect(config);
else engineBridge.disconnect(); else engineBridge.disconnect();
return ok(state); 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': { case 'bridge.pair': {
const status = await engineBridge.startPairing(); const status = await engineBridge.startPairing();
void appendAuditEvent({ category: 'bridge', action: 'bridge.pair', outcome: 'success' }); 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.pair.status': return ok(engineBridge.getPairingStatus());
case 'bridge.unpair': { case 'bridge.unpair': {
await engineBridge.unpair(); await engineBridge.unpair();
await stopPairedBrowserTasks();
void appendAuditEvent({ category: 'bridge', action: 'bridge.unpair', outcome: 'success' }); void appendAuditEvent({ category: 'bridge', action: 'bridge.unpair', outcome: 'success' });
return ok(await getState()); return ok(await getState());
} }
@@ -431,6 +474,7 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
} }
case 'bridge.disconnect': { case 'bridge.disconnect': {
engineBridge.disconnect(); engineBridge.disconnect();
await stopPairedBrowserTasks();
void appendAuditEvent({ category: 'bridge', action: 'bridge.disconnect', outcome: 'success' }); void appendAuditEvent({ category: 'bridge', action: 'bridge.disconnect', outcome: 'success' });
return ok(engineBridge.getStatus()); return ok(engineBridge.getStatus());
} }
@@ -443,10 +487,11 @@ let backgroundStarted = false;
async function restoreBackgroundState(): Promise<void> { async function restoreBackgroundState(): Promise<void> {
const storedState = await restoreGrantLifecycle(); 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) if (JSON.stringify(state.bridge) !== JSON.stringify(storedState.bridge)
|| JSON.stringify(state.floatingPanel) !== JSON.stringify(storedState.floatingPanel)) { || JSON.stringify(state.floatingPanel) !== JSON.stringify(storedState.floatingPanel)) {
await updateState(() => state); await updateState((current) => applyPolicyToState(current, policy));
} }
try { try {
await reconcileUserAgentRuntime(); await reconcileUserAgentRuntime();
@@ -460,8 +505,10 @@ async function restoreBackgroundState(): Promise<void> {
summary: (error instanceof Error ? error.message : String(error)).slice(0, 512), summary: (error instanceof Error ? error.message : String(error)).slice(0, 512),
}); });
} }
if (state.bridge.autoConnect && state.bridge.pairedEngine) { const currentState = await getState();
await engineBridge.connect(state.bridge).catch(console.error); 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; backgroundStarted = true;
configureGrantLifecycleHooks({ configureGrantLifecycleHooks({
cancelActiveRequests: () => engineBridge.cancelActiveRequests(),
emitHandoffChanged: (handoff) => engineBridge.emitEvent('browser.handoff.changed', handoff), emitHandoffChanged: (handoff) => engineBridge.emitEvent('browser.handoff.changed', handoff),
}); });
registerGrantLifecycleListeners(); registerGrantLifecycleListeners();
+11 -42
View File
@@ -19,8 +19,6 @@ import { ProxyProfilesView } from '@/features/proxy/ui/ProxyProfilesView';
import { RuleSourcesView } from '@/features/proxy/ui/RuleSourcesView'; import { RuleSourcesView } from '@/features/proxy/ui/RuleSourcesView';
import { RecordingWorkspace } from '@/features/browser-recording/RecordingWorkspace'; import { RecordingWorkspace } from '@/features/browser-recording/RecordingWorkspace';
import { AuthorizationTestingWorkspace } from '@/features/authorization-testing/ui/AuthorizationTestingWorkspace'; 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 { AGENT_RUNTIME_STORAGE_KEY, AUDIT_STORAGE_KEY, isStateStorageChange } from '@/protocol/storage';
import type { import type {
ActiveTabInfo, AgentRuntime, AuditEvent, BridgePairingStatus, BridgeStatus, BrowserCookie, BrowserRequestAnalysisBundle, CookieInput, CookieTransferFormat, EnterprisePolicyStatus, ExtensionState, HumanHandoff, 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> <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> <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>}
<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> </header>
{handoff && <HandoffBanner handoff={handoff} setState={setState} run={run} busy={busy} />} {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 === '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 === '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 === '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 === '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 === 'engine' && <EngineSettings state={state} setState={setState} bridge={bridge} setBridge={setBridge} tabs={tabs} run={run} busy={busy} />}
{section === 'activity' && <ActivityLog 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"> return <div className="section-view activity-view">
<div className="page-heading"><div><h1>Agent 线</h1><p> sessionCookie </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> <div className="page-heading"><div><h1>Agent 线</h1><p> sessionCookie </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"> <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>} {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> </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> <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="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-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-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>
<div className="task-status-grid"> <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>{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></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> <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>
<div className="task-workflow-list"> <div className="task-workflow-list">
@@ -518,15 +516,11 @@ function networkLabel(record: NetworkRequestRecord): { host: string; path: strin
} }
function NetworkActivity({ function NetworkActivity({
state,
setState,
tab, tab,
bridge, bridge,
run, run,
busy, busy,
}: { }: {
state: ExtensionState;
setState: (state: ExtensionState) => void;
tab?: ActiveTabInfo; tab?: ActiveTabInfo;
bridge: BridgeStatus; bridge: BridgeStatus;
run: (task: () => Promise<void>, success?: string) => Promise<void>; run: (task: () => Promise<void>, success?: string) => Promise<void>;
@@ -543,11 +537,11 @@ function NetworkActivity({
const [captureHeaders, setCaptureHeaders] = useState(false); const [captureHeaders, setCaptureHeaders] = useState(false);
const [captureBody, setCaptureBody] = useState(false); const [captureBody, setCaptureBody] = useState(false);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const transformShared = gatewayShareActive(state.activeGrant, tab); const transformShared = bridge.state === 'connected';
const shareTransform = async () => { const shareTransform = async () => {
if (!tab) throw new Error('请先选择需要共享的页面'); if (!tab) throw new Error('请先选择需要使用的页面');
setState(await request('grant.create', gatewayShareGrantInput(state, tab))); if (bridge.state !== 'connected') await request('bridge.connect');
}; };
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -666,8 +660,6 @@ function NetworkActivity({
busy={busy} busy={busy}
run={run} run={run}
gatewayShared={transformShared} gatewayShared={transformShared}
gatewayShareExpiresAt={transformShared ? state.activeGrant?.expiresAt : undefined}
gatewayBridgeConnected={bridge.state === 'connected'}
onShareGateway={shareTransform} onShareGateway={shareTransform}
/> />
</div>; </div>;
@@ -799,15 +791,7 @@ function EngineSettings({ state, setState, bridge, setBridge, tabs, run, busy }:
const [draft, setDraft] = useState(state.bridge); const [draft, setDraft] = useState(state.bridge);
const [pairing, setPairing] = useState<BridgePairingStatus>({ state: 'idle', message: state.bridge.pairedEngine ? '当前浏览器已配对' : '尚未配对' }); const [pairing, setPairing] = useState<BridgePairingStatus>({ state: 'idle', message: state.bridge.pairedEngine ? '当前浏览器已配对' : '尚未配对' });
const [panelDraft, setPanelDraft] = useState(state.floatingPanel); 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 [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(() => { useEffect(() => {
void request('policy.status').then(setPolicy).catch(() => undefined); void request('policy.status').then(setPolicy).catch(() => undefined);
void request('bridge.pair.status').then(setPairing).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); browser.runtime.onMessage.addListener(listener);
return () => browser.runtime.onMessage.removeListener(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]); 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 () => { const save = () => run(async () => {
if (draft.transport === 'native') { if (draft.transport === 'native') {
// Permission requests must be the first browser call made from the click gesture. // 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> <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> <div className="editor-actions"><Button disabled={busy} onClick={() => void savePanel()}><Save size={16} /></Button></div>
</section> </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">StorageCookie</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="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 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>
<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>
</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>
+46
View File
@@ -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; busy: boolean;
run: RunTask; run: RunTask;
gatewayShared: boolean; gatewayShared: boolean;
gatewayShareExpiresAt?: number;
gatewayBridgeConnected: boolean;
onShareGateway: () => Promise<void>; onShareGateway: () => Promise<void>;
} }
@@ -183,8 +181,6 @@ export function RecordingWorkspace({
busy, busy,
run, run,
gatewayShared, gatewayShared,
gatewayShareExpiresAt,
gatewayBridgeConnected,
onShareGateway, onShareGateway,
}: RecordingWorkspaceProps) { }: RecordingWorkspaceProps) {
const [workspaceMode, setWorkspaceMode] = useState<'gateway' | 'recording' | 'deep'>('recording'); const [workspaceMode, setWorkspaceMode] = useState<'gateway' | 'recording' | 'deep'>('recording');
@@ -634,8 +630,6 @@ export function RecordingWorkspace({
busy={busy} busy={busy}
run={run} run={run}
gatewayShared={gatewayShared} gatewayShared={gatewayShared}
gatewayShareExpiresAt={gatewayShareExpiresAt}
gatewayBridgeConnected={gatewayBridgeConnected}
onShareGateway={onShareGateway} onShareGateway={onShareGateway}
onOpenCapture={() => { setRecoveryProfileId(''); setWorkspaceMode(DEEP_CAPTURE_AVAILABLE ? 'deep' : 'recording'); }} onOpenCapture={() => { setRecoveryProfileId(''); setWorkspaceMode(DEEP_CAPTURE_AVAILABLE ? 'deep' : 'recording'); }}
onOpenRecovery={DEEP_CAPTURE_AVAILABLE ? openRecovery : () => setWorkspaceMode('recording')} onOpenRecovery={DEEP_CAPTURE_AVAILABLE ? openRecovery : () => setWorkspaceMode('recording')}
@@ -45,8 +45,6 @@ interface BrowserTransformWorkspaceProps {
busy: boolean; busy: boolean;
run: RunTask; run: RunTask;
gatewayShared: boolean; gatewayShared: boolean;
gatewayShareExpiresAt?: number;
gatewayBridgeConnected: boolean;
onShareGateway: () => Promise<void>; onShareGateway: () => Promise<void>;
onOpenCapture: () => void; onOpenCapture: () => void;
onOpenRecovery: (profileId: string) => void; onOpenRecovery: (profileId: string) => void;
@@ -247,8 +245,6 @@ export function BrowserTransformWorkspace({
busy, busy,
run, run,
gatewayShared, gatewayShared,
gatewayShareExpiresAt,
gatewayBridgeConnected,
onShareGateway, onShareGateway,
onOpenCapture, onOpenCapture,
onOpenRecovery, onOpenRecovery,
@@ -926,11 +922,9 @@ export function BrowserTransformWorkspace({
replayPersistenceLabel={replayPersistenceLabel(replayPersistence)} replayPersistenceLabel={replayPersistenceLabel(replayPersistence)}
replayPersistenceTitle={replayPersistenceTitle} replayPersistenceTitle={replayPersistenceTitle}
gatewayShared={gatewayShared} gatewayShared={gatewayShared}
gatewayShareExpiresAt={gatewayShareExpiresAt}
gatewayBridgeConnected={gatewayBridgeConnected}
onShareGateway={() => run( onShareGateway={() => run(
onShareGateway, onShareGateway,
gatewayShared ? '共享会话已刷新' : '当前页面已共享给 Yakit', gatewayShared ? '浏览器实例已连接' : '正在连接 Yakit',
)} )}
onClear={clearReplay} onClear={clearReplay}
canExecute={Boolean(draft?.id && !dirty && !busy && !replayLoading && bindingReady)} 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 { Button } from '@/components/ui/button';
import type { import type {
ActiveTabInfo, ActiveTabInfo,
@@ -28,8 +28,6 @@ export function TransformReplayPanel({
replayPersistenceLabel, replayPersistenceLabel,
replayPersistenceTitle, replayPersistenceTitle,
gatewayShared, gatewayShared,
gatewayShareExpiresAt,
gatewayBridgeConnected,
onShareGateway, onShareGateway,
onClear, onClear,
canExecute, canExecute,
@@ -56,8 +54,6 @@ export function TransformReplayPanel({
replayPersistenceLabel: string; replayPersistenceLabel: string;
replayPersistenceTitle: string; replayPersistenceTitle: string;
gatewayShared: boolean; gatewayShared: boolean;
gatewayShareExpiresAt?: number;
gatewayBridgeConnected: boolean;
onShareGateway: () => Promise<void>; onShareGateway: () => Promise<void>;
onClear: () => Promise<void>; onClear: () => Promise<void>;
canExecute: boolean; canExecute: boolean;
@@ -87,21 +83,19 @@ export function TransformReplayPanel({
</div> </div>
</header> </header>
{draft?.id && <section className={`transform-gateway-share ${gatewayShared ? 'is-active' : ''}`}> {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> <div>
<strong>{gatewayShared ? '当前页面已共享给 Yakit' : ' Yakit 使用这个网关'}</strong> <strong>{gatewayShared ? '当前浏览器实例已接入 Yakit' : '连接 Yakit 使用这个网关'}</strong>
<small>{gatewayShared && gatewayShareExpiresAt <small>{gatewayShared
? `控制会话 · ${new Date(gatewayShareExpiresAt).toLocaleTimeString()} 到期` ? '页面刷新、跳转后仍可使用,无需续接授权'
: gatewayBridgeConnected : '连接后由 Agent 操作审核策略统一控制'}</small>
? '创建 30 分钟控制会话,并保留已共享页面'
: '可先创建会话;引擎重连后即可使用'}</small>
</div> </div>
<Button {!gatewayShared && <Button
size="sm" size="sm"
variant={gatewayShared ? 'ghost' : 'primary'} variant="primary"
disabled={busy || !tab} disabled={busy || !tab}
onClick={() => void onShareGateway()} onClick={() => void onShareGateway()}
>{gatewayShared ? '刷新' : '一键共享'}</Button> ></Button>}
</section>} </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></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> <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'); })).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 () => { it('signs and verifies ECDSA P-256 payloads', async () => {
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
const publicJWK = await crypto.subtle.exportKey('jwk', pair.publicKey); const publicJWK = await crypto.subtle.exportKey('jwk', pair.publicKey);
+17 -4
View File
@@ -134,7 +134,7 @@ export function clientAuthPayload(input: {
challenge: string; challenge: string;
envelope: BridgeEnvelope; envelope: BridgeEnvelope;
}): string { }): string {
return [ const fields = [
'yak-browser-bridge-v3', 'client-auth', input.origin, input.engineIdentityId, input.engineInstanceId, 'yak-browser-bridge-v3', 'client-auth', input.origin, input.engineIdentityId, input.engineInstanceId,
input.challenge, input.envelope.installationId || '', input.envelope.client || '', input.envelope.version || '', input.challenge, input.envelope.installationId || '', input.envelope.client || '', input.envelope.version || '',
[...(input.envelope.capabilities || [])].sort().join(','), [...(input.envelope.capabilities || [])].sort().join(','),
@@ -142,7 +142,15 @@ export function clientAuthPayload(input: {
input.envelope.capabilityCatalog?.hash || '', input.envelope.capabilityCatalog?.hash || '',
input.envelope.taskId || '', input.envelope.grantId || '', input.envelope.taskId || '', input.envelope.grantId || '',
input.envelope.resumeSessionId || '', 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: { export async function pairingVerificationCode(input: {
@@ -153,11 +161,16 @@ export async function pairingVerificationCode(input: {
clientNonce: string; clientNonce: string;
serverNonce: string; serverNonce: string;
publicKey: BridgePublicKey; publicKey: BridgePublicKey;
managedInstance?: BridgeEnvelope['managedInstance'];
}): Promise<string> { }): Promise<string> {
const payload = [ const fields = [
'yak-browser-pairing-v1', input.engineIdentityId, input.requestId, input.origin, input.installationId, '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, 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))); const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload)));
let value = 0n; let value = 0n;
for (const byte of hash.subarray(0, 8)) value = (value << 8n) | BigInt(byte); for (const byte of hash.subarray(0, 8)) value = (value << 8n) | BigInt(byte);
+11 -13
View File
@@ -9,7 +9,7 @@ import {
} from '@/protocol/bridge'; } from '@/protocol/bridge';
import { getBridgeRuntimeSession, getState, setBridgeRuntimeSession, updateState } from '@/platform/storage/state'; import { getBridgeRuntimeSession, getState, setBridgeRuntimeSession, updateState } from '@/platform/storage/state';
import { routeCapability } from '@/features/grants/service'; 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 { errorCode, ExtensionError, isDeniedErrorCode } from '@/shared/errors';
import { appendAuditEvent } from '@/features/diagnostics/audit'; import { appendAuditEvent } from '@/features/diagnostics/audit';
import { beginAgentAction, finishAgentAction } from '@/features/agent-runtime/service'; import { beginAgentAction, finishAgentAction } from '@/features/agent-runtime/service';
@@ -301,6 +301,7 @@ export class EngineBridge {
capabilities: [...BRIDGE_CAPABILITIES], capabilities: [...BRIDGE_CAPABILITIES],
capabilityCatalog, capabilityCatalog,
installationId: config.installationId, installationId: config.installationId,
managedInstance: state.bridge.managedInstance,
taskId: state.activeGrant?.taskId, taskId: state.activeGrant?.taskId,
grantId: state.activeGrant?.id, grantId: state.activeGrant?.id,
resumeSessionId: previousSession?.sessionId, resumeSessionId: previousSession?.sessionId,
@@ -522,18 +523,13 @@ export class EngineBridge {
: undefined; : undefined;
try { try {
const grant = await currentActiveGrant(); const grant = await browserInstanceAccess('browser.tabs.read');
taskId = grant?.taskId; taskId = grant.taskId;
targetTabId ??= grant?.targets[0]?.tabId; actionId = (await beginAgentAction(grant, {
if (grant) { requestId: message.id,
const grantTarget = grant.targets.find((target) => target.tabId === targetTabId); method: message.method,
actionId = (await beginAgentAction(grant, { targetTabId,
requestId: message.id, })).id;
method: message.method,
targetTabId,
isolationContextId: grantTarget?.isolationContextId,
})).id;
}
const operation = routeCapability(message.method, message.params, (method, params) => this.requestEngine(method, params)); const operation = routeCapability(message.method, message.params, (method, params) => this.requestEngine(method, params));
const result = await Promise.race([operation, cancelled]); const result = await Promise.race([operation, cancelled]);
const durationMs = performance.now() - startedAt; const durationMs = performance.now() - startedAt;
@@ -802,6 +798,7 @@ export class EngineBridge {
socket.send(JSON.stringify({ socket.send(JSON.stringify({
type: 'pair_request', protocolVersion: BRIDGE_PROTOCOL_VERSION, type: 'pair_request', protocolVersion: BRIDGE_PROTOCOL_VERSION,
installationId: config.installationId, installationId: config.installationId,
managedInstance: config.managedInstance,
client: 'yakit-browser-extension', version: browser.runtime.getManifest().version, client: 'yakit-browser-extension', version: browser.runtime.getManifest().version,
nonce: clientNonce, publicKey: identity.publicKey, nonce: clientNonce, publicKey: identity.publicKey,
} satisfies BridgePairingEnvelope)); } satisfies BridgePairingEnvelope));
@@ -872,6 +869,7 @@ export class EngineBridge {
engineIdentityId: message.engineIdentityId!, requestId: message.requestId!, engineIdentityId: message.engineIdentityId!, requestId: message.requestId!,
origin: browser.runtime.getURL('').replace(/\/$/, ''), installationId: context.config.installationId, origin: browser.runtime.getURL('').replace(/\/$/, ''), installationId: context.config.installationId,
clientNonce: context.clientNonce, serverNonce: message.serverNonce!, publicKey: context.publicKey, clientNonce: context.clientNonce, serverNonce: message.serverNonce!, publicKey: context.publicKey,
managedInstance: context.config.managedInstance,
}); });
if (code !== message.code) { if (code !== message.code) {
this.failPairing(new Error('Yak 配对验证码校验失败')); this.failPairing(new Error('Yak 配对验证码校验失败'));
@@ -5,10 +5,8 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { browser } from 'wxt/browser'; import { browser } from 'wxt/browser';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { HANDOFF_REASON_LABELS, waitingHandoff } from '@/features/handoff/presentation'; 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 { AGENT_RUNTIME_STORAGE_KEY, isStateStorageChange } from '@/protocol/storage';
import type { ActiveTabInfo, AgentRuntime, BridgeStatus, ExtensionState, PageContext } from '@/types/models'; import type { ActiveTabInfo, AgentRuntime, BridgeStatus, ExtensionState, PageContext } from '@/types/models';
import { errorMessage, request } from '@/platform/messaging/runtime'; 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 [runtime, setRuntime] = useState<AgentRuntime>({ state: 'idle', updatedAt: Date.now(), actions: [] });
const bodyRef = useRef<HTMLDivElement>(null); 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 pendingHandoff = waitingHandoff(state.handoff);
const handoff = pendingHandoff?.target.tabId === tab?.id ? pendingHandoff : undefined; 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> <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>
</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>} {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>}
<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> <div className="floating-share-row"><span><strong>访</strong><small></small></span><ShieldCheck size={16} /></div>
<Button variant="secondary" onClick={() => openWorkspace('engine')}><Settings size={14} /></Button> <Button variant="secondary" onClick={() => openWorkspace('engine')}> Agent <Settings size={14} /></Button>
<Button variant="ghost" disabled={!tab?.url} onClick={() => void hideCurrentSite()}><EyeOff size={14} /></Button> <Button variant="ghost" disabled={!tab?.url} onClick={() => void hideCurrentSite()}><EyeOff size={14} /></Button>
</>} </>}
</TabsContent> </TabsContent>
+35 -32
View File
@@ -2,10 +2,12 @@ import { browser } from 'wxt/browser';
import type { BridgeGrant, BrowserTarget, CapabilityScope } from '@/types/models'; import type { BridgeGrant, BrowserTarget, CapabilityScope } from '@/types/models';
import { getFrameInventory } from '@/features/page-context/frames'; import { getFrameInventory } from '@/features/page-context/frames';
import { getTab, resolveDocumentTarget } from '@/platform/browser/targets'; 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 { ExtensionError } from '@/shared/errors';
import { requireActiveGrant } from './lifecycle';
export type CapabilityEngineRequest = <T>(method: string, params: unknown) => Promise<T>; export type CapabilityEngineRequest = <T>(method: string, params: unknown) => Promise<T>;
export const PAIRED_BROWSER_INSTANCE_ACCESS_ID = 'paired-browser-instance';
export interface CapabilityRouteContext { export interface CapabilityRouteContext {
method: string; method: string;
@@ -20,8 +22,23 @@ export interface CapabilityDomainHandler {
handle(context: CapabilityRouteContext): Promise<unknown>; handle(context: CapabilityRouteContext): Promise<unknown>;
} }
export async function activeGrant(required: CapabilityScope): Promise<BridgeGrant> { export async function browserInstanceAccess(required: CapabilityScope): Promise<BridgeGrant> {
const grant = await requireActiveGrant(); 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); requireScope(grant, required);
return grant; return grant;
} }
@@ -36,22 +53,15 @@ function originOf(url: string): string {
} }
export async function allowedTarget( export async function allowedTarget(
grant: BridgeGrant, _grant: BridgeGrant,
input: { tabId?: unknown; frameId?: unknown; documentId?: unknown }, input: { tabId?: unknown; frameId?: unknown; documentId?: unknown },
resolveInPage = true, resolveInPage = true,
): Promise<BrowserTarget> { ): Promise<BrowserTarget> {
const requested = typeof input.tabId === 'number' ? input.tabId : grant.targets[0]?.tabId; const currentTab = await getTab(typeof input.tabId === 'number' ? input.tabId : undefined);
const requestedFrameId = typeof input.frameId === 'number' ? input.frameId : 0; const target: BrowserTarget = {
const target = grant.targets.find((item) => ( tabId: currentTab.id,
item.tabId === requested && item.frameId === requestedFrameId frameId: typeof input.frameId === 'number' ? input.frameId : 0,
)); };
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 currentFrame = await browser.webNavigation.getFrame({ const currentFrame = await browser.webNavigation.getFrame({
tabId: target.tabId, tabId: target.tabId,
frameId: target.frameId, frameId: target.frameId,
@@ -62,27 +72,20 @@ export async function allowedTarget(
currentOrigin = (await getFrameInventory(target.tabId)) currentOrigin = (await getFrameInventory(target.tabId))
.find((frame) => frame.frameId === target.frameId)?.origin || ''; .find((frame) => frame.frameId === target.frameId)?.origin || '';
} }
if (currentOrigin !== target.origin) { if (!currentOrigin) {
throw new ExtensionError('origin_changed', '目标 frame 已经跨来源导航,请重新授权'); throw new ExtensionError('target_unavailable', '目标 frame 不是可访问的 HTTP(S) 页面');
} }
if (target.documentId && currentFrame.documentId assertBrowserAccessPolicy((await getEnterprisePolicy()).policy, { origin: currentOrigin });
&& target.documentId !== currentFrame.documentId) { if (typeof input.documentId === 'string' && currentFrame.documentId
throw new ExtensionError('stale_document', '目标 frame 已经刷新或导航,请重新授权'); && input.documentId !== currentFrame.documentId) {
throw new ExtensionError('stale_document', '请求的页面文档已经刷新或导航,请重新获取页面上下文');
} }
if (typeof input.documentId === 'string' && target.documentId const currentTarget = { ...target, documentId: currentFrame.documentId };
&& input.documentId !== target.documentId) { return resolveInPage ? resolveDocumentTarget(currentTarget) : currentTarget;
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;
} }
export function requireScope(grant: BridgeGrant, scope: CapabilityScope): void { export function requireScope(grant: BridgeGrant, scope: CapabilityScope): void {
if (!grant.scopes.includes(scope)) { 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', [ export const NAVIGATION_CAPABILITY_DOMAIN = exactMethods('navigation-isolation', [
'browser.tabs', 'browser.tabs',
'browser.tab.open',
'browser.thumbnail',
'browser.frames', 'browser.frames',
'browser.instance.close',
'browser.isolation.inspect', 'browser.isolation.inspect',
'browser.isolation.proof', 'browser.isolation.proof',
'browser.isolation.incognito.open', 'browser.isolation.incognito.open',
@@ -3,6 +3,7 @@ import type { HandoffReason } from '@/types/models';
import type { CapabilityDomainHandler } from '../capability-context'; import type { CapabilityDomainHandler } from '../capability-context';
import { allowedTarget } from '../capability-context'; import { allowedTarget } from '../capability-context';
import { activateTab } from '@/platform/browser/targets'; import { activateTab } from '@/platform/browser/targets';
import { getTab } from '@/platform/browser/targets';
import { getState, updateState } from '@/platform/storage/state'; import { getState, updateState } from '@/platform/storage/state';
import { setAgentRuntimeState } from '@/features/agent-runtime/service'; import { setAgentRuntimeState } from '@/features/agent-runtime/service';
import { ExtensionError } from '@/shared/errors'; import { ExtensionError } from '@/shared/errors';
@@ -16,15 +17,23 @@ export const handoffCapabilityHandler: CapabilityDomainHandler = {
return handoff?.taskId === grant.taskId ? handoff : { state: 'idle' }; return handoff?.taskId === grant.taskId ? handoff : { state: 'idle' };
} }
const resolvedTarget = await allowedTarget(grant, input); const resolvedTarget = await allowedTarget(grant, input);
const grantTarget = grant.targets.find((target) => ( const [tab, frame] = await Promise.all([
target.tabId === resolvedTarget.tabId && target.frameId === resolvedTarget.frameId getTab(resolvedTarget.tabId),
)); browser.webNavigation.getFrame(resolvedTarget),
if (!grantTarget) throw new Error('目标标签页不在本次共享会话中'); ]);
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 now = Date.now();
const state = await updateState((current) => { 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') { if (current.handoff?.state === 'waiting_for_user') {
throw new ExtensionError('handoff_in_progress', '已有人工接管请求正在等待处理'); throw new ExtensionError('handoff_in_progress', '已有人工接管请求正在等待处理');
} }
@@ -1,7 +1,8 @@
import { browser } from 'wxt/browser';
import type { CapabilityDomainHandler } from '../capability-context'; import type { CapabilityDomainHandler } from '../capability-context';
import { allowedTarget, requireScope } from '../capability-context'; import { allowedTarget, requireScope } from '../capability-context';
import { getFrameInventory } from '@/features/page-context/frames'; import { getFrameInventory } from '@/features/page-context/frames';
import { getTab } from '@/platform/browser/targets'; import { activateTab, getTab, scheduleBrowserInstanceClose } from '@/platform/browser/targets';
import { import {
createBrowserIsolationProof, createBrowserIsolationProof,
deleteFirefoxContainerIdentity, deleteFirefoxContainerIdentity,
@@ -11,70 +12,88 @@ import {
openIncognitoIdentity, openIncognitoIdentity,
} from '@/features/authorization-testing/isolation'; } from '@/features/authorization-testing/isolation';
import { ExtensionError } from '@/shared/errors'; import { ExtensionError } from '@/shared/errors';
import { assertBrowserAccessPolicy, getEnterprisePolicy } from '@/platform/policy/managed';
import { NAVIGATION_CAPABILITY_DOMAIN } from '../capability-domains'; import { NAVIGATION_CAPABILITY_DOMAIN } from '../capability-domains';
export const navigationCapabilityHandler: CapabilityDomainHandler = { export const navigationCapabilityHandler: CapabilityDomainHandler = {
...NAVIGATION_CAPABILITY_DOMAIN, ...NAVIGATION_CAPABILITY_DOMAIN,
async handle({ method, input, grant }) { async handle({ method, input, grant }) {
if (method === 'browser.tabs') { if (method === 'browser.tabs') {
const tabIds = [...new Set(grant.targets.map((target) => target.tabId))]; const { tabs } = await inspectBrowserIsolation();
const tabs = await Promise.all(tabIds.map(async (tabId) => { const allowedOrigins = (await getEnterprisePolicy()).policy.grantAllowedOrigins;
const targets = grant.targets.filter((target) => target.tabId === tabId); return tabs.filter((tab) => !allowedOrigins?.length || allowedOrigins.includes(new URL(tab.url).origin))
for (const target of targets) { .sort((left, right) => Number(Boolean(right.active)) - Number(Boolean(left.active))
try { || (right.lastAccessed || 0) - (left.lastAccessed || 0));
await allowedTarget(grant, { }
tabId, if (method === 'browser.tab.open') {
frameId: target.frameId, const url = String(input.url || '');
documentId: target.documentId, assertBrowserAccessPolicy((await getEnterprisePolicy()).policy, { origin: new URL(url).origin });
}); const tab = await browser.tabs.create({ url, active: true });
return getTab(tabId); if (!tab.id) throw new ExtensionError('target_unavailable', '浏览器没有返回新标签页 ID');
} catch { await activateTab(tab.id);
// A tab remains visible while at least one explicitly granted frame is current. return { opened: true, id: tab.id, windowId: tab.windowId, active: true, url };
} }
} if (method === 'browser.thumbnail') {
return undefined; const tab = await getTab(typeof input.tabId === 'number' ? input.tabId : undefined);
})); await allowedTarget(grant, { tabId: tab.id }, false);
return tabs.filter(Boolean); 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') { if (method === 'browser.frames') {
const tabId = typeof input.tabId === 'number' ? input.tabId : grant.targets[0]?.tabId; const tabId = (await getTab(typeof input.tabId === 'number' ? input.tabId : undefined)).id;
if (!tabId || !grant.targets.some((target) => target.tabId === tabId)) { await allowedTarget(grant, { tabId }, false);
throw new ExtensionError('target_denied', '目标标签页不在本次共享会话中'); const frames = await getFrameInventory(tabId);
} const allowedOrigins = (await getEnterprisePolicy()).policy.grantAllowedOrigins;
return getFrameInventory(tabId); 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') { if (method === 'browser.isolation.inspect') {
const grantedTabIds = [...new Set(grant.targets.map((target) => target.tabId))];
const requestedTabIds = Array.isArray(input.tabIds) const requestedTabIds = Array.isArray(input.tabIds)
? input.tabIds.map(Number) ? input.tabIds.map(Number)
: grantedTabIds; : undefined;
if (requestedTabIds.some((tabId) => !grantedTabIds.includes(tabId))) { const inspection = await inspectBrowserIsolation(requestedTabIds);
throw new ExtensionError( const allowedOrigins = (await getEnterprisePolicy()).policy.grantAllowedOrigins;
'target_denied', 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 {
return inspectBrowserIsolation(requestedTabIds); ...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') { if (method === 'browser.isolation.proof') {
requireScope(grant, 'browser.cookies.read'); requireScope(grant, 'browser.cookies.read');
requireScope(grant, 'browser.storage.read'); requireScope(grant, 'browser.storage.read');
const leftTabId = Number(input.leftTabId); const leftTabId = Number(input.leftTabId);
const rightTabId = Number(input.rightTabId); const rightTabId = Number(input.rightTabId);
if (![leftTabId, rightTabId].every((tabId) => ( await Promise.all([
grant.targets.some((target) => target.tabId === tabId) allowedTarget(grant, { tabId: leftTabId }, false),
))) { allowedTarget(grant, { tabId: rightTabId }, false),
throw new ExtensionError( ]);
'target_denied',
'隔离证明的两个身份都必须在本次共享会话中',
);
}
return createBrowserIsolationProof(leftTabId, rightTabId); return createBrowserIsolationProof(leftTabId, rightTabId);
} }
if (method === 'browser.isolation.incognito.open') { if (method === 'browser.isolation.incognito.open') {
assertBrowserAccessPolicy((await getEnterprisePolicy()).policy, {
origin: new URL(String(input.url || '')).origin,
});
return openIncognitoIdentity(String(input.url || '')); return openIncognitoIdentity(String(input.url || ''));
} }
if (method === 'browser.isolation.container.open') { if (method === 'browser.isolation.container.open') {
assertBrowserAccessPolicy((await getEnterprisePolicy()).policy, {
origin: new URL(String(input.url || '')).origin,
});
return openFirefoxContainerIdentity({ return openFirefoxContainerIdentity({
url: String(input.url || ''), url: String(input.url || ''),
name: typeof input.name === 'string' ? input.name : undefined, name: typeof input.name === 'string' ? input.name : undefined,
@@ -48,12 +48,7 @@ export const pageCapabilityHandler: CapabilityDomainHandler = {
tabId: target.tabId, tabId: target.tabId,
frameId: target.frameId, frameId: target.frameId,
}); });
const grantTarget = grant.targets.find((item) => ( const url = frame?.url || '';
item.tabId === target.tabId && item.frameId === target.frameId
));
const url = frame?.url && /^https?:/i.test(frame.url)
? frame.url
: `${grantTarget?.origin || ''}/`;
if (!/^https?:/i.test(url)) { if (!/^https?:/i.test(url)) {
throw new ExtensionError( throw new ExtensionError(
'target_unavailable', 'target_unavailable',
@@ -3,6 +3,7 @@ import type {
BrowserTransformPacket, BrowserTransformPacket,
BrowserTransformProfileInput, BrowserTransformProfileInput,
} from '@/types/models'; } from '@/types/models';
import { browser } from 'wxt/browser';
import type { CapabilityDomainHandler } from '../capability-context'; import type { CapabilityDomainHandler } from '../capability-context';
import { allowedTarget, requireScope } from '../capability-context'; import { allowedTarget, requireScope } from '../capability-context';
import { import {
@@ -124,11 +125,9 @@ export const transformCapabilityHandler: CapabilityDomainHandler = {
if (method === 'browser.transform.profile.save') { if (method === 'browser.transform.profile.save') {
const profileInput = input as unknown as BrowserTransformProfileInput; const profileInput = input as unknown as BrowserTransformProfileInput;
const target = await allowedTarget(grant, profileInput.target); const target = await allowedTarget(grant, profileInput.target);
const grantedTarget = grant.targets.find((item) => ( const frame = await browser.webNavigation.getFrame(target);
item.tabId === target.tabId && item.frameId === target.frameId if (!frame?.url || profileInput.origin !== new URL(frame.url).origin) {
)); throw new ExtensionError('target_denied', '转换配置来源与当前页面不一致');
if (!grantedTarget || profileInput.origin !== grantedTarget.origin) {
throw new ExtensionError('target_denied', '转换配置来源不在本次共享会话中');
} }
return saveBrowserTransformProfile({ ...profileInput, target }); return saveBrowserTransformProfile({ ...profileInput, target });
} }
+23 -25
View File
@@ -11,8 +11,6 @@ const fixture = vi.hoisted(() => ({
stopNetwork: vi.fn(async (_grantId: string) => undefined), stopNetwork: vi.fn(async (_grantId: string) => undefined),
stopRecording: vi.fn(async (_grantId: string) => undefined), stopRecording: vi.fn(async (_grantId: string) => undefined),
stopDeepCapture: 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), appendAudit: vi.fn(async () => undefined),
clearBadge: 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', () => ({ vi.mock('@/features/deep-capture/service', () => ({
stopDeepCapturesForGrant: fixture.stopDeepCapture, stopDeepCapturesForGrant: fixture.stopDeepCapture,
})); }));
vi.mock('@/features/agent-runtime/service', () => ({
startAgentRuntime: fixture.startRuntime,
endAgentRuntimeForGrant: fixture.endRuntime,
}));
vi.mock('@/features/diagnostics/audit', () => ({ vi.mock('@/features/diagnostics/audit', () => ({
appendAuditEvent: fixture.appendAudit, appendAuditEvent: fixture.appendAudit,
})); }));
@@ -124,19 +118,15 @@ describe('grant lifecycle manager', () => {
it('consumes an expired stored grant and releases all grant-owned resources', async () => { it('consumes an expired stored grant and releases all grant-owned resources', async () => {
const expired = grant('expired-restore', NOW - 1); const expired = grant('expired-restore', NOW - 1);
const cancelActiveRequests = vi.fn();
await setState({ ...structuredClone(DEFAULT_STATE), activeGrant: expired }); await setState({ ...structuredClone(DEFAULT_STATE), activeGrant: expired });
configureGrantLifecycleHooks({ cancelActiveRequests });
const state = await restoreGrantLifecycle(); const state = await restoreGrantLifecycle();
expect(state.activeGrant).toBeUndefined(); expect(state.activeGrant).toBeUndefined();
expect((await getState()).activeGrant).toBeUndefined(); expect((await getState()).activeGrant).toBeUndefined();
expect(cancelActiveRequests).toHaveBeenCalledOnce();
expect(fixture.stopNetwork).toHaveBeenCalledWith(expired.id); expect(fixture.stopNetwork).toHaveBeenCalledWith(expired.id);
expect(fixture.stopRecording).toHaveBeenCalledWith(expired.id); expect(fixture.stopRecording).toHaveBeenCalledWith(expired.id);
expect(fixture.stopDeepCapture).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); 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.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.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.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 }); 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.stopNetwork).toHaveBeenCalledTimes(1);
expect(fixture.stopRecording).toHaveBeenCalledTimes(1); expect(fixture.stopRecording).toHaveBeenCalledTimes(1);
expect(fixture.stopDeepCapture).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 () => { it('cancels a waiting handoff and publishes the resolved state on replacement', async () => {
const waiting = handoff('handoff-waiting'); const waiting = handoff('handoff-waiting');
const previous = grant('handoff-old');
previous.taskId = waiting.taskId;
const emitHandoffChanged = vi.fn(); const emitHandoffChanged = vi.fn();
await setState({ await setState({
...structuredClone(DEFAULT_STATE), ...structuredClone(DEFAULT_STATE),
activeGrant: grant('handoff-old'), activeGrant: previous,
handoff: waiting, handoff: waiting,
}); });
configureGrantLifecycleHooks({ emitHandoffChanged }); configureGrantLifecycleHooks({ emitHandoffChanged });
@@ -188,6 +178,21 @@ describe('grant lifecycle manager', () => {
expect(emitHandoffChanged).toHaveBeenCalledWith(state.handoff); 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 () => { it('rejects an update that reaches the queue after expiry and performs cleanup first', async () => {
const expired = grant('expired-update', NOW - 1); const expired = grant('expired-update', NOW - 1);
await setState({ ...structuredClone(DEFAULT_STATE), activeGrant: expired }); await setState({ ...structuredClone(DEFAULT_STATE), activeGrant: expired });
@@ -218,7 +223,6 @@ describe('grant lifecycle manager', () => {
expect((await getState()).activeGrant).toBeUndefined(); expect((await getState()).activeGrant).toBeUndefined();
expect(fixture.stopNetwork).toHaveBeenCalledWith(active.id); expect(fixture.stopNetwork).toHaveBeenCalledWith(active.id);
expect(fixture.endRuntime).toHaveBeenCalledWith('expired', active);
}); });
it('reschedules an early alarm without revoking a still-live grant', async () => { 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((await getState()).activeGrant?.id).toBe(old.id);
expect(fixture.alarms.get(ACTIVE_GRANT_EXPIRY_ALARM)).toEqual({ when: old.expiresAt }); expect(fixture.alarms.get(ACTIVE_GRANT_EXPIRY_ALARM)).toEqual({ when: old.expiresAt });
expect(fixture.stopNetwork).not.toHaveBeenCalled(); expect(fixture.stopNetwork).not.toHaveBeenCalled();
expect(fixture.startRuntime).not.toHaveBeenCalled();
}); });
it('fails closed when Agent Runtime activation fails after the grant commit', async () => { it('does not couple an authorization-test grant to Agent runtime state', async () => {
const active = grant('runtime-failure'); const active = grant('authorization-only');
fixture.startRuntime.mockRejectedValueOnce(new Error('session storage unavailable'));
await expect(replaceActiveGrant(active)).rejects.toMatchObject({ code: 'grant_activation_failed' }); await expect(replaceActiveGrant(active)).resolves.toMatchObject({
state: { activeGrant: { id: active.id } },
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);
}); });
it('clears authorization state even when one resource cleanup reports a failure', async () => { it('clears authorization state even when one resource cleanup reports a failure', async () => {
+9 -40
View File
@@ -2,9 +2,6 @@ import { browser } from 'wxt/browser';
import { stopNetworkCapturesForGrant } from '@/features/network-capture/service'; import { stopNetworkCapturesForGrant } from '@/features/network-capture/service';
import { stopBrowserRecordingsForGrant } from '@/features/browser-recording/service'; import { stopBrowserRecordingsForGrant } from '@/features/browser-recording/service';
import { stopDeepCapturesForGrant } from '@/features/deep-capture/service'; import { stopDeepCapturesForGrant } from '@/features/deep-capture/service';
import {
endAgentRuntimeForGrant, startAgentRuntime,
} from '@/features/agent-runtime/service';
import { appendAuditEvent } from '@/features/diagnostics/audit'; import { appendAuditEvent } from '@/features/diagnostics/audit';
import { getState, updateState } from '@/platform/storage/state'; import { getState, updateState } from '@/platform/storage/state';
import type { import type {
@@ -14,10 +11,9 @@ import { ExtensionError } from '@/shared/errors';
export const ACTIVE_GRANT_EXPIRY_ALARM = 'yakit.active-grant.expiry'; 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 { interface GrantLifecycleHooks {
cancelActiveRequests?: () => void;
emitHandoffChanged?: (handoff: HumanHandoff) => void; emitHandoffChanged?: (handoff: HumanHandoff) => void;
} }
@@ -80,23 +76,6 @@ function cancelledHandoff(current: HumanHandoff | undefined, now: number): Human
: current; : 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( async function publishCancelledHandoff(
previous: HumanHandoff | undefined, previous: HumanHandoff | undefined,
current: HumanHandoff | undefined, current: HumanHandoff | undefined,
@@ -124,12 +103,10 @@ async function publishCancelledHandoff(
function cleanupGrantResources(grant: BridgeGrant, reason: GrantEndReason): Promise<void> { function cleanupGrantResources(grant: BridgeGrant, reason: GrantEndReason): Promise<void> {
const existing = cleanupTasks.get(grant.id); const existing = cleanupTasks.get(grant.id);
if (existing) return existing; if (existing) return existing;
const runtimeState = reason === 'expired' ? 'expired' as const : 'revoked' as const;
const task = Promise.allSettled([ const task = Promise.allSettled([
stopNetworkCapturesForGrant(grant.id), stopNetworkCapturesForGrant(grant.id),
stopBrowserRecordingsForGrant(grant.id), stopBrowserRecordingsForGrant(grant.id),
stopDeepCapturesForGrant(grant.id), stopDeepCapturesForGrant(grant.id),
endAgentRuntimeForGrant(runtimeState, grant),
]).then((results) => { ]).then((results) => {
const failures = results.filter((result) => result.status === 'rejected'); const failures = results.filter((result) => result.status === 'rejected');
if (failures.length === 0) return; if (failures.length === 0) return;
@@ -159,11 +136,11 @@ async function endActiveGrantInQueue(
if (!grant || (expectedGrantId && grant.id !== expectedGrantId)) return current; if (!grant || (expectedGrantId && grant.id !== expectedGrantId)) return current;
if (reason === 'expired' && grant.expiresAt > now) return current; if (reason === 'expired' && grant.expiresAt > now) return current;
previousGrant = grant; previousGrant = grant;
previousHandoff = current.handoff; previousHandoff = current.handoff?.taskId === grant.taskId ? current.handoff : undefined;
return { return {
...current, ...current,
activeGrant: undefined, activeGrant: undefined,
handoff: cancelledHandoff(current.handoff, now), handoff: cancelledHandoff(previousHandoff, now) || current.handoff,
}; };
}); });
@@ -173,7 +150,6 @@ async function endActiveGrantInQueue(
return { state }; return { state };
} }
cancelActiveRequestsBestEffort(previousGrant);
await clearExpiryAlarmBestEffort(previousGrant); await clearExpiryAlarmBestEffort(previousGrant);
await cleanupGrantResources(previousGrant, reason); await cleanupGrantResources(previousGrant, reason);
await publishCancelledHandoff(previousHandoff, state.handoff, reason); await publishCancelledHandoff(previousHandoff, state.handoff, reason);
@@ -187,7 +163,7 @@ async function endActiveGrantInQueue(
? '已由新共享会话替换' ? '已由新共享会话替换'
: reason === 'scheduler_failure' : reason === 'scheduler_failure'
? '无法建立可靠的到期调度,已安全撤销' ? '无法建立可靠的到期调度,已安全撤销'
: reason === 'activation_failure' ? 'Agent Runtime 初始化失败,已安全撤销' : undefined, : undefined,
}); });
return { state, previousGrant, previousHandoff }; return { state, previousGrant, previousHandoff };
} }
@@ -258,11 +234,14 @@ export function replaceActiveGrant(grant: BridgeGrant): Promise<GrantTransition>
try { try {
state = await updateState((current) => { state = await updateState((current) => {
previousGrant = current.activeGrant; previousGrant = current.activeGrant;
previousHandoff = current.handoff; previousHandoff = current.activeGrant
&& current.handoff?.taskId === current.activeGrant.taskId
? current.handoff
: undefined;
return { return {
...current, ...current,
activeGrant: grant, activeGrant: grant,
handoff: cancelledHandoff(current.handoff, now), handoff: cancelledHandoff(previousHandoff, now) || current.handoff,
}; };
}); });
} catch (error) { } catch (error) {
@@ -271,18 +250,8 @@ export function replaceActiveGrant(grant: BridgeGrant): Promise<GrantTransition>
} }
if (previousGrant && previousGrant.id !== grant.id) { if (previousGrant && previousGrant.id !== grant.id) {
cancelActiveRequestsBestEffort(previousGrant);
await cleanupGrantResources(previousGrant, 'replaced'); 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'); await publishCancelledHandoff(previousHandoff, state.handoff, 'replaced');
return { state, previousGrant, previousHandoff }; return { state, previousGrant, previousHandoff };
}); });
+37
View File
@@ -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 },
}));
});
});
+11 -2
View File
@@ -6,7 +6,7 @@ import {
} from '@/protocol/capabilities'; } from '@/protocol/capabilities';
import { parseCapabilityParams } from '@/protocol/bridge'; import { parseCapabilityParams } from '@/protocol/bridge';
import { ExtensionError } from '@/shared/errors'; import { ExtensionError } from '@/shared/errors';
import { activeGrant, type CapabilityEngineRequest } from './capability-context'; import { browserInstanceAccess, type CapabilityEngineRequest } from './capability-context';
import { dispatchCapability } from './capability-router'; import { dispatchCapability } from './capability-router';
export { CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES } from '@/protocol/capabilities'; export { CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
@@ -17,9 +17,18 @@ export async function routeCapability(
requestEngine?: CapabilityEngineRequest, requestEngine?: CapabilityEngineRequest,
): Promise<unknown> { ): Promise<unknown> {
if (method === 'system.ping') { 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 { return {
now: Date.now(), now: Date.now(),
extensionVersion: browser.runtime.getManifest().version, extensionVersion: browser.runtime.getManifest().version,
browserName,
}; };
} }
if (import.meta.env.FIREFOX if (import.meta.env.FIREFOX
@@ -35,6 +44,6 @@ export async function routeCapability(
? 'browser.page.eval.program' ? 'browser.page.eval.program'
: capabilityBaseScope(method); : capabilityBaseScope(method);
if (!required) throw new Error(`不支持的 Bridge 方法: ${method}`); if (!required) throw new Error(`不支持的 Bridge 方法: ${method}`);
const grant = await activeGrant(required); const grant = await browserInstanceAccess(required);
return dispatchCapability({ method, input, grant, requestEngine }); return dispatchCapability({ method, input, grant, requestEngine });
} }
+3
View File
@@ -14,6 +14,7 @@ export interface IsolationCookieStore {
export interface IsolationTabDescriptor { export interface IsolationTabDescriptor {
id: number; id: number;
windowId: number; windowId: number;
active?: boolean;
title: string; title: string;
url: string; url: string;
incognito: boolean; incognito: boolean;
@@ -173,6 +174,7 @@ export function activeTabInfo(
return { return {
id: tab.id, id: tab.id,
windowId: tab.windowId, windowId: tab.windowId,
active: Boolean(tab.active),
title: tab.title || '未命名页面', title: tab.title || '未命名页面',
url: tab.url, url: tab.url,
incognito: tab.incognito, incognito: tab.incognito,
@@ -189,6 +191,7 @@ export function browserTabDescriptor(tab: Browser.tabs.Tab): IsolationTabDescrip
return { return {
id: tab.id, id: tab.id,
windowId: tab.windowId, windowId: tab.windowId,
active: tab.active,
title: tab.title || '未命名页面', title: tab.title || '未命名页面',
url: tab.url, url: tab.url,
incognito: tab.incognito, incognito: tab.incognito,
+53
View File
@@ -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);
});
});
+15 -2
View File
@@ -41,7 +41,7 @@ export async function resolveDocumentTarget(input: BrowserTarget | number): Prom
} }
if (!probe) throw new ExtensionError('target_unavailable', '无法定位目标页面文档'); if (!probe) throw new ExtensionError('target_unavailable', '无法定位目标页面文档');
if (requested.documentId && probe.documentId && requested.documentId !== probe.documentId) { 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 }; 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> { export async function activateTab(tabId?: number): Promise<void> {
const tab = tabId ? await browser.tabs.get(tabId) : await browser.tabs.get((await getActiveTab()).id); 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 }); 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 };
} }
+12 -1
View File
@@ -3,7 +3,7 @@ import { vi, describe, expect, it } from 'vitest';
vi.mock('wxt/browser', () => ({ browser: { storage: {} } })); vi.mock('wxt/browser', () => ({ browser: { storage: {} } }));
import type { BridgeConfig } from '@/types/models'; import type { BridgeConfig } from '@/types/models';
import { applyPolicyToBridge, assertGrantPolicy } from './managed'; import { applyPolicyToBridge, assertBrowserAccessPolicy, assertGrantPolicy } from './managed';
const bridge: BridgeConfig = { const bridge: BridgeConfig = {
transport: 'websocket', endpoint: 'ws://127.0.0.1:64333/extension', nativeHost: 'default.host', 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({ allowProgramEval: false }, { durationMinutes: 5, origins: [], programEval: true })).toThrow('禁止');
expect(() => assertGrantPolicy({ grantAllowedOrigins: ['https://a.test'] }, { durationMinutes: 5, origins: ['https://b.test'], programEval: false })).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('不允许');
});
}); });
+12 -4
View File
@@ -79,12 +79,20 @@ export function assertGrantPolicy(
policy: EnterprisePolicy, policy: EnterprisePolicy,
input: { durationMinutes: number; origins: string[]; programEval: boolean }, input: { durationMinutes: number; origins: string[]; programEval: boolean },
): number { ): 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) { if (input.programEval && policy.allowProgramEval === false) {
throw new ExtensionError('policy_denied', '企业策略禁止 browser.page.eval.program'); throw new ExtensionError('policy_denied', '企业策略禁止 browser.page.eval.program');
} }
if (policy.grantAllowedOrigins?.length) { if (input.origin && policy.grantAllowedOrigins?.length
const denied = input.origins.find((origin) => !policy.grantAllowedOrigins!.includes(origin)); && !policy.grantAllowedOrigins.includes(input.origin)) {
if (denied) throw new ExtensionError('policy_denied', `企业策略不允许授权 origin: ${denied}`); throw new ExtensionError('policy_denied', `企业策略不允许访问 origin: ${input.origin}`);
} }
return Math.min(input.durationMinutes, policy.maxGrantMinutes || input.durationMinutes);
} }
+18
View File
@@ -67,6 +67,24 @@ describe('split state storage', () => {
expect(state.floatingPanel.side).toBe('left'); 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 () => { it('drops a session grant that is not bound to an isolation context', async () => {
const now = Date.now(); const now = Date.now();
stores.session[ACTIVE_SESSION_STORAGE_KEY] = { stores.session[ACTIVE_SESSION_STORAGE_KEY] = {
+20 -3
View File
@@ -1,6 +1,6 @@
import { browser } from 'wxt/browser'; import { browser } from 'wxt/browser';
import type { import type {
BridgeGrant, BridgeGrantTarget, BridgeRuntimeSession, CapabilityScope, ExtensionState, BridgeConfig, BridgeGrant, BridgeGrantTarget, BridgeRuntimeSession, CapabilityScope, ExtensionState,
ProxyConditionType, ProxyProfile, ProxyConditionType, ProxyProfile,
} from '@/types/models'; } from '@/types/models';
import { import {
@@ -15,7 +15,7 @@ interface StorageArea {
} }
let mutationQueue: Promise<void> = Promise.resolve(); 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 = { export const DEFAULT_STATE: ExtensionState = {
version: 7, 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 { function normalizeState(value: Partial<ExtensionState>): ExtensionState {
const profileMap = new Map(defaultProfiles().map((profile) => [profile.id, profile])); const profileMap = new Map(defaultProfiles().map((profile) => [profile.id, profile]));
const storedProfiles = Array.isArray(value.proxyProfiles) ? value.proxyProfiles.slice(0, 500) : []; const storedProfiles = Array.isArray(value.proxyProfiles) ? value.proxyProfiles.slice(0, 500) : [];
@@ -175,7 +188,11 @@ function normalizeState(value: Partial<ExtensionState>): ExtensionState {
: 'direct', : 'direct',
customUserAgentProfiles: userAgentState.customUserAgentProfiles, customUserAgentProfiles: userAgentState.customUserAgentProfiles,
userAgentAssignments: userAgentState.userAgentAssignments, userAgentAssignments: userAgentState.userAgentAssignments,
bridge: { ...DEFAULT_STATE.bridge, ...value.bridge }, bridge: {
...DEFAULT_STATE.bridge,
...value.bridge,
managedInstance: normalizeManagedInstance(value.bridge?.managedInstance),
},
floatingPanel: { floatingPanel: {
...DEFAULT_STATE.floatingPanel, ...value.floatingPanel, ...DEFAULT_STATE.floatingPanel, ...value.floatingPanel,
siteOrigins: [...new Set(value.floatingPanel?.siteOrigins || [])].slice(0, 500), siteOrigins: [...new Set(value.floatingPanel?.siteOrigins || [])].slice(0, 500),
+6
View File
@@ -57,6 +57,12 @@ describe('Bridge v3 protocol', () => {
expect(() => parseBridgeEnvelope('x'.repeat(BRIDGE_MAX_MESSAGE_BYTES + 1))).toThrow('16 MiB'); 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', () => { it('accepts exact Worker boundary handles for remote deep capture', () => {
expect(parseCapabilityParams('browser.deep_capture.start', { expect(parseCapabilityParams('browser.deep_capture.start', {
matcher: { matcher: {
+15
View File
@@ -19,6 +19,7 @@ export interface BridgePairingEnvelope {
protocolVersion?: number; protocolVersion?: number;
requestId?: string; requestId?: string;
installationId?: string; installationId?: string;
managedInstance?: BridgeEnvelope['managedInstance'];
client?: string; client?: string;
version?: string; version?: string;
nonce?: string; nonce?: string;
@@ -99,6 +100,8 @@ const authorizationResourceValue = v.strictObject({
export const capabilityParams = { export const capabilityParams = {
'system.ping': v.optional(v.strictObject({})), 'system.ping': v.optional(v.strictObject({})),
'browser.tabs': 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.frames': v.optional(v.strictObject({ tabId: optionalTabId })),
'browser.isolation.inspect': v.optional(v.strictObject({ 'browser.isolation.inspect': v.optional(v.strictObject({
tabIds: v.optional(v.pipe(v.array(tabId), v.minLength(1), v.maxLength(256))), 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')), }), v.check((input) => input.action !== 'setValue' || typeof input.value === 'string', 'setValue 操作必须提供 value')),
'browser.cookies': v.optional(v.strictObject(targetFields)), 'browser.cookies': v.optional(v.strictObject(targetFields)),
'browser.takeover': v.optional(v.strictObject(targetFields)), 'browser.takeover': v.optional(v.strictObject(targetFields)),
'browser.instance.close': v.optional(v.strictObject({})),
'browser.handoff.request': v.strictObject({ 'browser.handoff.request': v.strictObject({
...targetFields, ...targetFields,
reason: v.picklist(['qr_code', 'mfa', 'captcha', 'device_confirmation', 'other']), reason: v.picklist(['qr_code', 'mfa', 'captcha', 'device_confirmation', 'other']),
@@ -354,6 +358,7 @@ export function parseBridgeEnvelope(raw: unknown): BridgeEnvelope {
const allowedKeys = new Set([ const allowedKeys = new Set([
'id', 'type', 'method', 'params', 'result', 'error', 'client', 'version', 'protocolVersion', 'id', 'type', 'method', 'params', 'result', 'error', 'client', 'version', 'protocolVersion',
'capabilities', 'capabilityCatalog', 'sessionId', 'taskId', 'grantId', 'installationId', 'capabilities', 'capabilityCatalog', 'sessionId', 'taskId', 'grantId', 'installationId',
'managedInstance',
'engineInstanceId', 'engineIdentityId', 'challenge', 'signature', 'publicKey', 'connectionId', 'engineInstanceId', 'engineIdentityId', 'challenge', 'signature', 'publicKey', 'connectionId',
'resumeSessionId', 'resumed', 'sequence', 'timestamp', 'replyTimestamp', 'transferId', 'index', 'resumeSessionId', 'resumed', 'sequence', 'timestamp', 'replyTimestamp', 'transferId', 'index',
'total', 'data', 'originalBytes', 'total', 'data', 'originalBytes',
@@ -361,6 +366,16 @@ export function parseBridgeEnvelope(raw: unknown): BridgeEnvelope {
const unexpected = Object.keys(message).find((key) => !allowedKeys.has(key)); const unexpected = Object.keys(message).find((key) => !allowedKeys.has(key));
if (unexpected) throw new Error(`Bridge 消息包含未声明字段 $.${unexpected}`); if (unexpected) throw new Error(`Bridge 消息包含未声明字段 $.${unexpected}`);
if (typeof message.type !== 'string') throw new Error('Bridge 消息缺少 type'); 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.type === 'challenge') {
if (message.protocolVersion !== BRIDGE_PROTOCOL_VERSION) throw new Error(`Bridge 协议版本不兼容: ${String(message.protocolVersion)}`); if (message.protocolVersion !== BRIDGE_PROTOCOL_VERSION) throw new Error(`Bridge 协议版本不兼容: ${String(message.protocolVersion)}`);
+21 -5
View File
@@ -33,11 +33,19 @@ const CAPABILITY_METADATA = {
scopes: [], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS, scopes: [], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
}, },
'browser.tabs': { '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, 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': { '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, scopes: ['browser.isolation.read'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
}, },
'browser.isolation.proof': { 'browser.isolation.proof': {
@@ -67,7 +75,7 @@ const CAPABILITY_METADATA = {
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS, targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
}, },
'browser.authorization.context.get': { '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'], scopes: ['browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS, targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
}, },
@@ -127,7 +135,7 @@ const CAPABILITY_METADATA = {
targetMode: 'none', defaultTimeoutMs: REPLAY_TIMEOUT_MS, targetMode: 'none', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
}, },
'browser.frames': { 'browser.frames': {
domain: 'page', access: 'read', summary: '列出共享标签页中的 Frame', domain: 'page', access: 'read', summary: '列出浏览器实例指定标签页中的 Frame',
scopes: ['browser.tabs.read'], targetMode: 'tab', defaultTimeoutMs: READ_TIMEOUT_MS, scopes: ['browser.tabs.read'], targetMode: 'tab', defaultTimeoutMs: READ_TIMEOUT_MS,
}, },
'browser.context': { 'browser.context': {
@@ -155,6 +163,10 @@ const CAPABILITY_METADATA = {
domain: 'page', access: 'write', summary: '将目标标签页切换到前台', domain: 'page', access: 'write', summary: '将目标标签页切换到前台',
scopes: ['browser.tab.activate'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS, 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': { 'browser.handoff.request': {
domain: 'handoff', access: 'write', summary: '请求用户完成扫码、MFA、验证码或设备确认', domain: 'handoff', access: 'write', summary: '请求用户完成扫码、MFA、验证码或设备确认',
scopes: ['browser.human.takeover'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS, 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, scopes: ['browser.network.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
}, },
'browser.network.list': { '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, scopes: ['browser.network.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
}, },
'browser.network.clear': { 'browser.network.clear': {
@@ -385,9 +397,11 @@ export const READ_CAPABILITY_SCOPES: CapabilityScope[] = [
export const CONTROL_CAPABILITY_SCOPES: CapabilityScope[] = [ export const CONTROL_CAPABILITY_SCOPES: CapabilityScope[] = [
...READ_CAPABILITY_SCOPES, ...READ_CAPABILITY_SCOPES,
'browser.tabs.write',
'browser.dom.write', 'browser.dom.write',
'browser.isolation.manage', 'browser.isolation.manage',
'browser.tab.activate', 'browser.tab.activate',
'browser.instance.close',
...(!(import.meta.env.FIREFOX && import.meta.env.MODE === 'store') ...(!(import.meta.env.FIREFOX && import.meta.env.MODE === 'store')
? ['browser.page.invoke' as const, 'browser.page.eval.expression' as const] ? ['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> = { export const CAPABILITY_LABELS: Record<CapabilityScope, string> = {
'browser.tabs.read': '标签页列表', 'browser.tabs.read': '标签页列表',
'browser.tabs.write': '打开网页',
'browser.isolation.read': '读取身份隔离状态', 'browser.isolation.read': '读取身份隔离状态',
'browser.isolation.manage': '创建隔离身份页面', 'browser.isolation.manage': '创建隔离身份页面',
'browser.dom.read': '页面 DOM', 'browser.dom.read': '页面 DOM',
@@ -417,6 +432,7 @@ export const CAPABILITY_LABELS: Record<CapabilityScope, string> = {
'browser.storage.read': '页面 Storage', 'browser.storage.read': '页面 Storage',
'browser.cookies.read': 'Cookie', 'browser.cookies.read': 'Cookie',
'browser.tab.activate': '切到前台', 'browser.tab.activate': '切到前台',
'browser.instance.close': '关闭浏览器实例',
'browser.page.invoke': '调用页面函数', 'browser.page.invoke': '调用页面函数',
'browser.page.eval.expression': '执行页面表达式', 'browser.page.eval.expression': '执行页面表达式',
'browser.page.eval.program': '执行页面程序', 'browser.page.eval.program': '执行页面程序',
+11
View File
@@ -120,6 +120,17 @@ describe('extension request schemas', () => {
})).toThrow('HTTP(S)'); })).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', () => { it('validates recording bounds and recorded page callables', () => {
expect(parseExtensionRequest({ expect(parseExtensionRequest({
action: 'recording.start', action: 'recording.start',
+10
View File
@@ -167,12 +167,19 @@ const userAgentProfileInput = v.strictObject({
userAgent: userAgentValue, 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({ const bridgeConfig = v.strictObject({
transport: v.picklist(['native', 'websocket']), transport: v.picklist(['native', 'websocket']),
nativeHost: v.pipe(v.string(), v.trim(), v.maxLength(253)), nativeHost: v.pipe(v.string(), v.trim(), v.maxLength(253)),
endpoint: v.pipe(v.string(), v.trim(), v.maxLength(2_048)), endpoint: v.pipe(v.string(), v.trim(), v.maxLength(2_048)),
autoConnect: v.boolean(), autoConnect: v.boolean(),
installationId: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)), installationId: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)),
managedInstance: v.optional(managedInstance),
pairedEngine: v.optional(v.strictObject({ pairedEngine: v.optional(v.strictObject({
engineIdentityId: id, engineIdentityId: id,
deviceId: id, deviceId: id,
@@ -226,6 +233,7 @@ const contextOptions = {
const capabilityScopes: readonly CapabilityScope[] = [ const capabilityScopes: readonly CapabilityScope[] = [
'browser.tabs.read', 'browser.tabs.read',
'browser.tabs.write',
'browser.isolation.read', 'browser.isolation.read',
'browser.isolation.manage', 'browser.isolation.manage',
'browser.dom.read', 'browser.dom.read',
@@ -233,6 +241,7 @@ const capabilityScopes: readonly CapabilityScope[] = [
'browser.storage.read', 'browser.storage.read',
'browser.cookies.read', 'browser.cookies.read',
'browser.tab.activate', 'browser.tab.activate',
'browser.instance.close',
'browser.page.invoke', 'browser.page.invoke',
'browser.page.eval.expression', 'browser.page.eval.expression',
'browser.page.eval.program', 'browser.page.eval.program',
@@ -462,6 +471,7 @@ const payloadSchemas = {
'metrics.get': noPayload, 'metrics.get': noPayload,
'metrics.reset': noPayload, 'metrics.reset': noPayload,
'bridge.config.save': bridgeConfig, 'bridge.config.save': bridgeConfig,
'bridge.managed-instance.bind': managedInstance,
'bridge.pair': noPayload, 'bridge.pair': noPayload,
'bridge.pair.cancel': noPayload, 'bridge.pair.cancel': noPayload,
'bridge.pair.status': noPayload, 'bridge.pair.status': noPayload,
+5
View File
@@ -243,6 +243,10 @@ export interface ExtensionRequestMap {
'metrics.get': { input: undefined; output: RuntimeMetrics }; 'metrics.get': { input: undefined; output: RuntimeMetrics };
'metrics.reset': { input: undefined; output: RuntimeMetrics }; 'metrics.reset': { input: undefined; output: RuntimeMetrics };
'bridge.config.save': { input: BridgeConfig; output: ExtensionState }; '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': { input: undefined; output: BridgePairingStatus };
'bridge.pair.cancel': { input: undefined; output: BridgePairingStatus }; 'bridge.pair.cancel': { input: undefined; output: BridgePairingStatus };
'bridge.pair.status': { input: undefined; output: BridgePairingStatus }; 'bridge.pair.status': { input: undefined; output: BridgePairingStatus };
@@ -333,6 +337,7 @@ export interface BridgeEnvelope {
taskId?: string; taskId?: string;
grantId?: string; grantId?: string;
installationId?: string; installationId?: string;
managedInstance?: BridgeConfig['managedInstance'];
engineInstanceId?: string; engineInstanceId?: string;
engineIdentityId?: string; engineIdentityId?: string;
challenge?: string; challenge?: string;
+9 -1
View File
@@ -196,6 +196,11 @@ export interface BridgeConfig {
endpoint: string; endpoint: string;
autoConnect: boolean; autoConnect: boolean;
installationId: string; installationId: string;
managedInstance?: {
manager: 'ytray' | 'yakit';
instanceId: string;
badge: string;
};
pairedEngine?: BridgePairedEngine; pairedEngine?: BridgePairedEngine;
} }
@@ -226,6 +231,7 @@ export interface BridgePairingStatus {
export type CapabilityScope = export type CapabilityScope =
| 'browser.tabs.read' | 'browser.tabs.read'
| 'browser.tabs.write'
| 'browser.isolation.read' | 'browser.isolation.read'
| 'browser.isolation.manage' | 'browser.isolation.manage'
| 'browser.dom.read' | 'browser.dom.read'
@@ -233,6 +239,7 @@ export type CapabilityScope =
| 'browser.storage.read' | 'browser.storage.read'
| 'browser.cookies.read' | 'browser.cookies.read'
| 'browser.tab.activate' | 'browser.tab.activate'
| 'browser.instance.close'
| 'browser.page.invoke' | 'browser.page.invoke'
| 'browser.page.eval.expression' | 'browser.page.eval.expression'
| 'browser.page.eval.program' | 'browser.page.eval.program'
@@ -406,7 +413,7 @@ export interface NetworkRequestRecord {
} }
export interface NetworkCaptureStatus { export interface NetworkCaptureStatus {
active: boolean; active?: boolean;
target: BrowserTarget; target: BrowserTarget;
startedAt?: number; startedAt?: number;
count: number; count: number;
@@ -1483,6 +1490,7 @@ export interface ExtensionState {
export interface ActiveTabInfo { export interface ActiveTabInfo {
id: number; id: number;
windowId: number; windowId: number;
active?: boolean;
title: string; title: string;
url: string; url: string;
incognito: boolean; incognito: boolean;
+2 -1
View File
@@ -5,6 +5,7 @@ import { defineConfig } from 'wxt';
// package.json is the single source of truth for the version; release // package.json is the single source of truth for the version; release
// packaging asserts the built manifest matches it. // packaging asserts the built manifest matches it.
const { version } = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')); 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 // See https://wxt.dev/api/config.html
export default defineConfig({ export default defineConfig({
@@ -29,7 +30,7 @@ export default defineConfig({
storage: { storage: {
managed_schema: 'managed-storage-schema.json', managed_schema: 'managed-storage-schema.json',
}, },
...(browser !== 'firefox' ? { incognito: 'spanning' as const } : {}), ...(browser !== 'firefox' ? { key: CHROMIUM_EXTENSION_KEY, incognito: 'spanning' as const } : {}),
permissions: [ permissions: [
'proxy', 'proxy',
'storage', 'storage',