mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-26 13:11:53 +08:00
feat(browser): add managed instance agent capabilities
This commit is contained in:
@@ -29,8 +29,6 @@ interface RecordingWorkspaceProps {
|
||||
busy: boolean;
|
||||
run: RunTask;
|
||||
gatewayShared: boolean;
|
||||
gatewayShareExpiresAt?: number;
|
||||
gatewayBridgeConnected: boolean;
|
||||
onShareGateway: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -183,8 +181,6 @@ export function RecordingWorkspace({
|
||||
busy,
|
||||
run,
|
||||
gatewayShared,
|
||||
gatewayShareExpiresAt,
|
||||
gatewayBridgeConnected,
|
||||
onShareGateway,
|
||||
}: RecordingWorkspaceProps) {
|
||||
const [workspaceMode, setWorkspaceMode] = useState<'gateway' | 'recording' | 'deep'>('recording');
|
||||
@@ -634,8 +630,6 @@ export function RecordingWorkspace({
|
||||
busy={busy}
|
||||
run={run}
|
||||
gatewayShared={gatewayShared}
|
||||
gatewayShareExpiresAt={gatewayShareExpiresAt}
|
||||
gatewayBridgeConnected={gatewayBridgeConnected}
|
||||
onShareGateway={onShareGateway}
|
||||
onOpenCapture={() => { setRecoveryProfileId(''); setWorkspaceMode(DEEP_CAPTURE_AVAILABLE ? 'deep' : 'recording'); }}
|
||||
onOpenRecovery={DEEP_CAPTURE_AVAILABLE ? openRecovery : () => setWorkspaceMode('recording')}
|
||||
|
||||
@@ -45,8 +45,6 @@ interface BrowserTransformWorkspaceProps {
|
||||
busy: boolean;
|
||||
run: RunTask;
|
||||
gatewayShared: boolean;
|
||||
gatewayShareExpiresAt?: number;
|
||||
gatewayBridgeConnected: boolean;
|
||||
onShareGateway: () => Promise<void>;
|
||||
onOpenCapture: () => void;
|
||||
onOpenRecovery: (profileId: string) => void;
|
||||
@@ -247,8 +245,6 @@ export function BrowserTransformWorkspace({
|
||||
busy,
|
||||
run,
|
||||
gatewayShared,
|
||||
gatewayShareExpiresAt,
|
||||
gatewayBridgeConnected,
|
||||
onShareGateway,
|
||||
onOpenCapture,
|
||||
onOpenRecovery,
|
||||
@@ -926,11 +922,9 @@ export function BrowserTransformWorkspace({
|
||||
replayPersistenceLabel={replayPersistenceLabel(replayPersistence)}
|
||||
replayPersistenceTitle={replayPersistenceTitle}
|
||||
gatewayShared={gatewayShared}
|
||||
gatewayShareExpiresAt={gatewayShareExpiresAt}
|
||||
gatewayBridgeConnected={gatewayBridgeConnected}
|
||||
onShareGateway={() => run(
|
||||
onShareGateway,
|
||||
gatewayShared ? '共享会话已刷新' : '当前页面已共享给 Yakit',
|
||||
gatewayShared ? '浏览器实例已连接' : '正在连接 Yakit',
|
||||
)}
|
||||
onClear={clearReplay}
|
||||
canExecute={Boolean(draft?.id && !dirty && !busy && !replayLoading && bindingReady)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlertTriangle, CheckCircle2, FlaskConical, Play, Share2, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import { AlertTriangle, CheckCircle2, FlaskConical, Play, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type {
|
||||
ActiveTabInfo,
|
||||
@@ -28,8 +28,6 @@ export function TransformReplayPanel({
|
||||
replayPersistenceLabel,
|
||||
replayPersistenceTitle,
|
||||
gatewayShared,
|
||||
gatewayShareExpiresAt,
|
||||
gatewayBridgeConnected,
|
||||
onShareGateway,
|
||||
onClear,
|
||||
canExecute,
|
||||
@@ -56,8 +54,6 @@ export function TransformReplayPanel({
|
||||
replayPersistenceLabel: string;
|
||||
replayPersistenceTitle: string;
|
||||
gatewayShared: boolean;
|
||||
gatewayShareExpiresAt?: number;
|
||||
gatewayBridgeConnected: boolean;
|
||||
onShareGateway: () => Promise<void>;
|
||||
onClear: () => Promise<void>;
|
||||
canExecute: boolean;
|
||||
@@ -87,21 +83,19 @@ export function TransformReplayPanel({
|
||||
</div>
|
||||
</header>
|
||||
{draft?.id && <section className={`transform-gateway-share ${gatewayShared ? 'is-active' : ''}`}>
|
||||
<span className="transform-gateway-share__mark">{gatewayShared ? <ShieldCheck size={15} /> : <Share2 size={15} />}</span>
|
||||
<span className="transform-gateway-share__mark"><ShieldCheck size={15} /></span>
|
||||
<div>
|
||||
<strong>{gatewayShared ? '当前页面已共享给 Yakit' : '在 Yakit 中使用这个网关'}</strong>
|
||||
<small>{gatewayShared && gatewayShareExpiresAt
|
||||
? `控制会话 · ${new Date(gatewayShareExpiresAt).toLocaleTimeString()} 到期`
|
||||
: gatewayBridgeConnected
|
||||
? '创建 30 分钟控制会话,并保留已共享页面'
|
||||
: '可先创建会话;引擎重连后即可使用'}</small>
|
||||
<strong>{gatewayShared ? '当前浏览器实例已接入 Yakit' : '连接 Yakit 后使用这个网关'}</strong>
|
||||
<small>{gatewayShared
|
||||
? '页面刷新、跳转后仍可使用,无需续接授权'
|
||||
: '连接后由 Agent 操作审核策略统一控制'}</small>
|
||||
</div>
|
||||
<Button
|
||||
{!gatewayShared && <Button
|
||||
size="sm"
|
||||
variant={gatewayShared ? 'ghost' : 'primary'}
|
||||
variant="primary"
|
||||
disabled={busy || !tab}
|
||||
onClick={() => void onShareGateway()}
|
||||
>{gatewayShared ? '刷新' : '一键共享'}</Button>
|
||||
>连接</Button>}
|
||||
</section>}
|
||||
<label><span>请求</span><div><input disabled={replayLoading} aria-label="回放 HTTP 方法" value={method} onChange={(event) => onMethodChange(event.target.value)} /><input disabled={replayLoading} aria-label="回放请求 URL" value={url} onChange={(event) => onUrlChange(event.target.value)} placeholder="https://example.test/api" /></div></label>
|
||||
<label><span>Headers · JSON</span><textarea disabled={replayLoading} rows={4} value={headers} onChange={(event) => onHeadersChange(event.target.value)} /></label>
|
||||
|
||||
@@ -34,6 +34,29 @@ describe('Bridge v3 identity transcript', () => {
|
||||
})).resolves.toBe('113961');
|
||||
});
|
||||
|
||||
it('binds a managed browser identity into the signed transcript', () => {
|
||||
const envelope: BridgeEnvelope = {
|
||||
type: 'auth', installationId: 'install-1', client: 'client-1', version: '1.0.0',
|
||||
capabilities: [],
|
||||
managedInstance: { manager: 'ytray', instanceId: 'instance-1', badge: 'B' },
|
||||
};
|
||||
expect(clientAuthPayload({
|
||||
origin: 'chrome-extension://abc', engineIdentityId: 'identity-1', engineInstanceId: 'engine-1',
|
||||
challenge: 'nonce-1', envelope,
|
||||
})).toMatch(/\nytray\ninstance-1\nB$/);
|
||||
});
|
||||
|
||||
it('binds a managed browser identity into the pairing code', async () => {
|
||||
const input = {
|
||||
engineIdentityId: 'engine-id', requestId: 'request-1', origin: 'chrome-extension://abc', installationId: 'install-1',
|
||||
clientNonce: 'client-nonce-value', serverNonce: 'server-nonce-value',
|
||||
publicKey: { kty: 'EC' as const, crv: 'P-256' as const, x: 'x-coordinate', y: 'y-coordinate' },
|
||||
};
|
||||
await expect(pairingVerificationCode({
|
||||
...input, managedInstance: { manager: 'ytray', instanceId: 'instance-1', badge: 'B' },
|
||||
})).resolves.toBe('005427');
|
||||
});
|
||||
|
||||
it('signs and verifies ECDSA P-256 payloads', async () => {
|
||||
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
|
||||
const publicJWK = await crypto.subtle.exportKey('jwk', pair.publicKey);
|
||||
|
||||
@@ -134,7 +134,7 @@ export function clientAuthPayload(input: {
|
||||
challenge: string;
|
||||
envelope: BridgeEnvelope;
|
||||
}): string {
|
||||
return [
|
||||
const fields = [
|
||||
'yak-browser-bridge-v3', 'client-auth', input.origin, input.engineIdentityId, input.engineInstanceId,
|
||||
input.challenge, input.envelope.installationId || '', input.envelope.client || '', input.envelope.version || '',
|
||||
[...(input.envelope.capabilities || [])].sort().join(','),
|
||||
@@ -142,7 +142,15 @@ export function clientAuthPayload(input: {
|
||||
input.envelope.capabilityCatalog?.hash || '',
|
||||
input.envelope.taskId || '', input.envelope.grantId || '',
|
||||
input.envelope.resumeSessionId || '',
|
||||
].join('\n');
|
||||
];
|
||||
if (input.envelope.managedInstance) {
|
||||
fields.push(
|
||||
input.envelope.managedInstance.manager,
|
||||
input.envelope.managedInstance.instanceId,
|
||||
input.envelope.managedInstance.badge,
|
||||
);
|
||||
}
|
||||
return fields.join('\n');
|
||||
}
|
||||
|
||||
export async function pairingVerificationCode(input: {
|
||||
@@ -153,11 +161,16 @@ export async function pairingVerificationCode(input: {
|
||||
clientNonce: string;
|
||||
serverNonce: string;
|
||||
publicKey: BridgePublicKey;
|
||||
managedInstance?: BridgeEnvelope['managedInstance'];
|
||||
}): Promise<string> {
|
||||
const payload = [
|
||||
const fields = [
|
||||
'yak-browser-pairing-v1', input.engineIdentityId, input.requestId, input.origin, input.installationId,
|
||||
input.clientNonce, input.serverNonce, input.publicKey.kty, input.publicKey.crv, input.publicKey.x, input.publicKey.y,
|
||||
].join('\n');
|
||||
];
|
||||
if (input.managedInstance) {
|
||||
fields.push(input.managedInstance.manager, input.managedInstance.instanceId, input.managedInstance.badge);
|
||||
}
|
||||
const payload = fields.join('\n');
|
||||
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload)));
|
||||
let value = 0n;
|
||||
for (const byte of hash.subarray(0, 8)) value = (value << 8n) | BigInt(byte);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@/protocol/bridge';
|
||||
import { getBridgeRuntimeSession, getState, setBridgeRuntimeSession, updateState } from '@/platform/storage/state';
|
||||
import { routeCapability } from '@/features/grants/service';
|
||||
import { currentActiveGrant } from '@/features/grants/lifecycle';
|
||||
import { browserInstanceAccess } from '@/features/grants/capability-context';
|
||||
import { errorCode, ExtensionError, isDeniedErrorCode } from '@/shared/errors';
|
||||
import { appendAuditEvent } from '@/features/diagnostics/audit';
|
||||
import { beginAgentAction, finishAgentAction } from '@/features/agent-runtime/service';
|
||||
@@ -301,6 +301,7 @@ export class EngineBridge {
|
||||
capabilities: [...BRIDGE_CAPABILITIES],
|
||||
capabilityCatalog,
|
||||
installationId: config.installationId,
|
||||
managedInstance: state.bridge.managedInstance,
|
||||
taskId: state.activeGrant?.taskId,
|
||||
grantId: state.activeGrant?.id,
|
||||
resumeSessionId: previousSession?.sessionId,
|
||||
@@ -522,18 +523,13 @@ export class EngineBridge {
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const grant = await currentActiveGrant();
|
||||
taskId = grant?.taskId;
|
||||
targetTabId ??= grant?.targets[0]?.tabId;
|
||||
if (grant) {
|
||||
const grantTarget = grant.targets.find((target) => target.tabId === targetTabId);
|
||||
actionId = (await beginAgentAction(grant, {
|
||||
requestId: message.id,
|
||||
method: message.method,
|
||||
targetTabId,
|
||||
isolationContextId: grantTarget?.isolationContextId,
|
||||
})).id;
|
||||
}
|
||||
const grant = await browserInstanceAccess('browser.tabs.read');
|
||||
taskId = grant.taskId;
|
||||
actionId = (await beginAgentAction(grant, {
|
||||
requestId: message.id,
|
||||
method: message.method,
|
||||
targetTabId,
|
||||
})).id;
|
||||
const operation = routeCapability(message.method, message.params, (method, params) => this.requestEngine(method, params));
|
||||
const result = await Promise.race([operation, cancelled]);
|
||||
const durationMs = performance.now() - startedAt;
|
||||
@@ -802,6 +798,7 @@ export class EngineBridge {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'pair_request', protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
||||
installationId: config.installationId,
|
||||
managedInstance: config.managedInstance,
|
||||
client: 'yakit-browser-extension', version: browser.runtime.getManifest().version,
|
||||
nonce: clientNonce, publicKey: identity.publicKey,
|
||||
} satisfies BridgePairingEnvelope));
|
||||
@@ -872,6 +869,7 @@ export class EngineBridge {
|
||||
engineIdentityId: message.engineIdentityId!, requestId: message.requestId!,
|
||||
origin: browser.runtime.getURL('').replace(/\/$/, ''), installationId: context.config.installationId,
|
||||
clientNonce: context.clientNonce, serverNonce: message.serverNonce!, publicKey: context.publicKey,
|
||||
managedInstance: context.config.managedInstance,
|
||||
});
|
||||
if (code !== message.code) {
|
||||
this.failPairing(new Error('Yak 配对验证码校验失败'));
|
||||
|
||||
@@ -5,10 +5,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { browser } from 'wxt/browser';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { HANDOFF_REASON_LABELS, waitingHandoff } from '@/features/handoff/presentation';
|
||||
import { READ_CAPABILITY_SCOPES, isControlScopeSet } from '@/protocol/capabilities';
|
||||
import { AGENT_RUNTIME_STORAGE_KEY, isStateStorageChange } from '@/protocol/storage';
|
||||
import type { ActiveTabInfo, AgentRuntime, BridgeStatus, ExtensionState, PageContext } from '@/types/models';
|
||||
import { errorMessage, request } from '@/platform/messaging/runtime';
|
||||
@@ -31,9 +29,6 @@ export function FloatingPanel({ initialState, initialTab, initialBridge, hostCha
|
||||
const [runtime, setRuntime] = useState<AgentRuntime>({ state: 'idle', updatedAt: Date.now(), actions: [] });
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const grantActive = Boolean(
|
||||
state.activeGrant && state.activeGrant.expiresAt > Date.now() && tab && state.activeGrant.targets.some((target) => target.tabId === tab.id),
|
||||
);
|
||||
const pendingHandoff = waitingHandoff(state.handoff);
|
||||
const handoff = pendingHandoff?.target.tabId === tab?.id ? pendingHandoff : undefined;
|
||||
|
||||
@@ -177,9 +172,9 @@ export function FloatingPanel({ initialState, initialTab, initialBridge, hostCha
|
||||
<Button variant="ghost" disabled={busy} onClick={() => void run(async () => setState(await request('handoff.resolve', { id: handoff.id, outcome: 'cancelled' })))}><X size={14} />取消</Button>
|
||||
</div>
|
||||
</div> : <>
|
||||
{grantActive && <div className={`floating-agent-task ${runtime.state}`}><span><strong>{runtime.state === 'paused' ? 'Agent 已暂停' : runtime.state === 'running' ? 'Agent 正在操作' : '共享会话活动'}</strong><small title={state.activeGrant?.taskId}>{state.activeGrant?.taskId} · {state.activeGrant && isControlScopeSet(state.activeGrant.scopes) ? '控制权限' : '只读权限'}</small></span>{runtime.state === 'paused' ? <Button size="icon" variant="ghost" title="恢复 Agent" onClick={() => void run(async () => setRuntime(await request('agent.resume')))}><Play size={14} /></Button> : <Button size="icon" variant="ghost" title="暂停 Agent" onClick={() => void run(async () => setRuntime(await request('agent.pause')))}><Pause size={14} /></Button>}</div>}
|
||||
<label className="floating-share-row"><span><strong>共享当前主 frame</strong><small>30 分钟只读授权,可随时撤销</small></span><Switch checked={grantActive} disabled={!tab || busy} onCheckedChange={(checked) => void run(async () => setState(checked ? await request('grant.create', { targets: [{ tabId: tab!.id, frameId: 0 }], scopes: READ_CAPABILITY_SCOPES, durationMinutes: 30 }) : await request('grant.revoke')))} /></label>
|
||||
<Button variant="secondary" onClick={() => openWorkspace('engine')}>管理控制授权<Settings size={14} /></Button>
|
||||
{bridge.state === 'connected' && <div className={`floating-agent-task ${runtime.state}`}><span><strong>{runtime.state === 'paused' ? 'Agent 已暂停' : runtime.state === 'running' ? 'Agent 正在操作' : '浏览器实例已接入'}</strong><small>当前浏览器的 HTTP(S) 页面均可引用</small></span>{runtime.state === 'paused' ? <Button size="icon" variant="ghost" title="恢复 Agent" onClick={() => void run(async () => setRuntime(await request('agent.resume')))}><Play size={14} /></Button> : <Button size="icon" variant="ghost" title="暂停 Agent" onClick={() => void run(async () => setRuntime(await request('agent.pause')))}><Pause size={14} /></Button>}</div>}
|
||||
<div className="floating-share-row"><span><strong>实例级页面访问</strong><small>刷新、跳转和新标签页自动跟随,无需逐页授权</small></span><ShieldCheck size={16} /></div>
|
||||
<Button variant="secondary" onClick={() => openWorkspace('engine')}>管理 Agent 连接<Settings size={14} /></Button>
|
||||
<Button variant="ghost" disabled={!tab?.url} onClick={() => void hideCurrentSite()}><EyeOff size={14} />在此站点隐藏</Button>
|
||||
</>}
|
||||
</TabsContent>
|
||||
|
||||
@@ -2,10 +2,12 @@ import { browser } from 'wxt/browser';
|
||||
import type { BridgeGrant, BrowserTarget, CapabilityScope } from '@/types/models';
|
||||
import { getFrameInventory } from '@/features/page-context/frames';
|
||||
import { getTab, resolveDocumentTarget } from '@/platform/browser/targets';
|
||||
import { assertBrowserAccessPolicy, getEnterprisePolicy } from '@/platform/policy/managed';
|
||||
import { CONTROL_CAPABILITY_SCOPES } from '@/protocol/capabilities';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
import { requireActiveGrant } from './lifecycle';
|
||||
|
||||
export type CapabilityEngineRequest = <T>(method: string, params: unknown) => Promise<T>;
|
||||
export const PAIRED_BROWSER_INSTANCE_ACCESS_ID = 'paired-browser-instance';
|
||||
|
||||
export interface CapabilityRouteContext {
|
||||
method: string;
|
||||
@@ -20,8 +22,23 @@ export interface CapabilityDomainHandler {
|
||||
handle(context: CapabilityRouteContext): Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function activeGrant(required: CapabilityScope): Promise<BridgeGrant> {
|
||||
const grant = await requireActiveGrant();
|
||||
export async function browserInstanceAccess(required: CapabilityScope): Promise<BridgeGrant> {
|
||||
const policy = (await getEnterprisePolicy()).policy;
|
||||
assertBrowserAccessPolicy(policy, {
|
||||
programEval: required === 'browser.page.eval.program',
|
||||
});
|
||||
const scopes: CapabilityScope[] = [
|
||||
...CONTROL_CAPABILITY_SCOPES,
|
||||
...(policy.allowProgramEval === false ? [] : ['browser.page.eval.program' as const]),
|
||||
];
|
||||
const grant: BridgeGrant = {
|
||||
id: PAIRED_BROWSER_INSTANCE_ACCESS_ID,
|
||||
taskId: PAIRED_BROWSER_INSTANCE_ACCESS_ID,
|
||||
targets: [],
|
||||
scopes: [...scopes],
|
||||
createdAt: 0,
|
||||
expiresAt: Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
requireScope(grant, required);
|
||||
return grant;
|
||||
}
|
||||
@@ -36,22 +53,15 @@ function originOf(url: string): string {
|
||||
}
|
||||
|
||||
export async function allowedTarget(
|
||||
grant: BridgeGrant,
|
||||
_grant: BridgeGrant,
|
||||
input: { tabId?: unknown; frameId?: unknown; documentId?: unknown },
|
||||
resolveInPage = true,
|
||||
): Promise<BrowserTarget> {
|
||||
const requested = typeof input.tabId === 'number' ? input.tabId : grant.targets[0]?.tabId;
|
||||
const requestedFrameId = typeof input.frameId === 'number' ? input.frameId : 0;
|
||||
const target = grant.targets.find((item) => (
|
||||
item.tabId === requested && item.frameId === requestedFrameId
|
||||
));
|
||||
if (!target) throw new ExtensionError('target_denied', '目标标签页不在本次共享会话中');
|
||||
const currentTab = await getTab(target.tabId);
|
||||
if (!currentTab.isolationContextId
|
||||
|| currentTab.isolationContextId !== target.isolationContextId
|
||||
|| currentTab.cookieStoreId !== target.cookieStoreId) {
|
||||
throw new ExtensionError('isolation_stale', '目标标签页的身份隔离上下文已经变化,请重新共享页面');
|
||||
}
|
||||
const currentTab = await getTab(typeof input.tabId === 'number' ? input.tabId : undefined);
|
||||
const target: BrowserTarget = {
|
||||
tabId: currentTab.id,
|
||||
frameId: typeof input.frameId === 'number' ? input.frameId : 0,
|
||||
};
|
||||
const currentFrame = await browser.webNavigation.getFrame({
|
||||
tabId: target.tabId,
|
||||
frameId: target.frameId,
|
||||
@@ -62,27 +72,20 @@ export async function allowedTarget(
|
||||
currentOrigin = (await getFrameInventory(target.tabId))
|
||||
.find((frame) => frame.frameId === target.frameId)?.origin || '';
|
||||
}
|
||||
if (currentOrigin !== target.origin) {
|
||||
throw new ExtensionError('origin_changed', '目标 frame 已经跨来源导航,请重新授权');
|
||||
if (!currentOrigin) {
|
||||
throw new ExtensionError('target_unavailable', '目标 frame 不是可访问的 HTTP(S) 页面');
|
||||
}
|
||||
if (target.documentId && currentFrame.documentId
|
||||
&& target.documentId !== currentFrame.documentId) {
|
||||
throw new ExtensionError('stale_document', '目标 frame 已经刷新或导航,请重新授权');
|
||||
assertBrowserAccessPolicy((await getEnterprisePolicy()).policy, { origin: currentOrigin });
|
||||
if (typeof input.documentId === 'string' && currentFrame.documentId
|
||||
&& input.documentId !== currentFrame.documentId) {
|
||||
throw new ExtensionError('stale_document', '请求的页面文档已经刷新或导航,请重新获取页面上下文');
|
||||
}
|
||||
if (typeof input.documentId === 'string' && target.documentId
|
||||
&& input.documentId !== target.documentId) {
|
||||
throw new ExtensionError('stale_document', '请求的页面文档已经失效,请重新授权');
|
||||
}
|
||||
if (!resolveInPage) return target;
|
||||
const resolved = await resolveDocumentTarget(target);
|
||||
if (target.documentId && resolved.documentId && target.documentId !== resolved.documentId) {
|
||||
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新授权');
|
||||
}
|
||||
return resolved;
|
||||
const currentTarget = { ...target, documentId: currentFrame.documentId };
|
||||
return resolveInPage ? resolveDocumentTarget(currentTarget) : currentTarget;
|
||||
}
|
||||
|
||||
export function requireScope(grant: BridgeGrant, scope: CapabilityScope): void {
|
||||
if (!grant.scopes.includes(scope)) {
|
||||
throw new ExtensionError('permission_denied', `共享会话未授权能力: ${scope}`);
|
||||
throw new ExtensionError('permission_denied', `浏览器实例不允许能力: ${scope}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ function exactMethods(id: CapabilityDomainId, methods: readonly string[]): Capab
|
||||
|
||||
export const NAVIGATION_CAPABILITY_DOMAIN = exactMethods('navigation-isolation', [
|
||||
'browser.tabs',
|
||||
'browser.tab.open',
|
||||
'browser.thumbnail',
|
||||
'browser.frames',
|
||||
'browser.instance.close',
|
||||
'browser.isolation.inspect',
|
||||
'browser.isolation.proof',
|
||||
'browser.isolation.incognito.open',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { HandoffReason } from '@/types/models';
|
||||
import type { CapabilityDomainHandler } from '../capability-context';
|
||||
import { allowedTarget } from '../capability-context';
|
||||
import { activateTab } from '@/platform/browser/targets';
|
||||
import { getTab } from '@/platform/browser/targets';
|
||||
import { getState, updateState } from '@/platform/storage/state';
|
||||
import { setAgentRuntimeState } from '@/features/agent-runtime/service';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
@@ -16,15 +17,23 @@ export const handoffCapabilityHandler: CapabilityDomainHandler = {
|
||||
return handoff?.taskId === grant.taskId ? handoff : { state: 'idle' };
|
||||
}
|
||||
const resolvedTarget = await allowedTarget(grant, input);
|
||||
const grantTarget = grant.targets.find((target) => (
|
||||
target.tabId === resolvedTarget.tabId && target.frameId === resolvedTarget.frameId
|
||||
));
|
||||
if (!grantTarget) throw new Error('目标标签页不在本次共享会话中');
|
||||
const [tab, frame] = await Promise.all([
|
||||
getTab(resolvedTarget.tabId),
|
||||
browser.webNavigation.getFrame(resolvedTarget),
|
||||
]);
|
||||
if (!frame?.url || !/^https?:/i.test(frame.url)) {
|
||||
throw new ExtensionError('target_unavailable', '目标 frame 不是可接管的 HTTP(S) 页面');
|
||||
}
|
||||
const grantTarget = {
|
||||
...resolvedTarget,
|
||||
isolationContextId: tab.isolationContextId || `browser-profile:tab-${tab.id}`,
|
||||
cookieStoreId: tab.cookieStoreId,
|
||||
origin: new URL(frame.url).origin,
|
||||
grantedUrl: frame.url,
|
||||
title: tab.title,
|
||||
};
|
||||
const now = Date.now();
|
||||
const state = await updateState((current) => {
|
||||
if (current.activeGrant?.id !== grant.id || current.activeGrant.expiresAt <= Date.now()) {
|
||||
throw new ExtensionError('grant_expired', '浏览器共享会话已经变化,请重新发起请求');
|
||||
}
|
||||
if (current.handoff?.state === 'waiting_for_user') {
|
||||
throw new ExtensionError('handoff_in_progress', '已有人工接管请求正在等待处理');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import type { CapabilityDomainHandler } from '../capability-context';
|
||||
import { allowedTarget, requireScope } from '../capability-context';
|
||||
import { getFrameInventory } from '@/features/page-context/frames';
|
||||
import { getTab } from '@/platform/browser/targets';
|
||||
import { activateTab, getTab, scheduleBrowserInstanceClose } from '@/platform/browser/targets';
|
||||
import {
|
||||
createBrowserIsolationProof,
|
||||
deleteFirefoxContainerIdentity,
|
||||
@@ -11,70 +12,88 @@ import {
|
||||
openIncognitoIdentity,
|
||||
} from '@/features/authorization-testing/isolation';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
import { assertBrowserAccessPolicy, getEnterprisePolicy } from '@/platform/policy/managed';
|
||||
import { NAVIGATION_CAPABILITY_DOMAIN } from '../capability-domains';
|
||||
|
||||
export const navigationCapabilityHandler: CapabilityDomainHandler = {
|
||||
...NAVIGATION_CAPABILITY_DOMAIN,
|
||||
async handle({ method, input, grant }) {
|
||||
if (method === 'browser.tabs') {
|
||||
const tabIds = [...new Set(grant.targets.map((target) => target.tabId))];
|
||||
const tabs = await Promise.all(tabIds.map(async (tabId) => {
|
||||
const targets = grant.targets.filter((target) => target.tabId === tabId);
|
||||
for (const target of targets) {
|
||||
try {
|
||||
await allowedTarget(grant, {
|
||||
tabId,
|
||||
frameId: target.frameId,
|
||||
documentId: target.documentId,
|
||||
});
|
||||
return getTab(tabId);
|
||||
} catch {
|
||||
// A tab remains visible while at least one explicitly granted frame is current.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}));
|
||||
return tabs.filter(Boolean);
|
||||
const { tabs } = await inspectBrowserIsolation();
|
||||
const allowedOrigins = (await getEnterprisePolicy()).policy.grantAllowedOrigins;
|
||||
return tabs.filter((tab) => !allowedOrigins?.length || allowedOrigins.includes(new URL(tab.url).origin))
|
||||
.sort((left, right) => Number(Boolean(right.active)) - Number(Boolean(left.active))
|
||||
|| (right.lastAccessed || 0) - (left.lastAccessed || 0));
|
||||
}
|
||||
if (method === 'browser.tab.open') {
|
||||
const url = String(input.url || '');
|
||||
assertBrowserAccessPolicy((await getEnterprisePolicy()).policy, { origin: new URL(url).origin });
|
||||
const tab = await browser.tabs.create({ url, active: true });
|
||||
if (!tab.id) throw new ExtensionError('target_unavailable', '浏览器没有返回新标签页 ID');
|
||||
await activateTab(tab.id);
|
||||
return { opened: true, id: tab.id, windowId: tab.windowId, active: true, url };
|
||||
}
|
||||
if (method === 'browser.thumbnail') {
|
||||
const tab = await getTab(typeof input.tabId === 'number' ? input.tabId : undefined);
|
||||
await allowedTarget(grant, { tabId: tab.id }, false);
|
||||
if (!tab.active) {
|
||||
throw new ExtensionError('target_not_active', '只能预览浏览器窗口当前可见的标签页');
|
||||
}
|
||||
return {
|
||||
tabId: tab.id,
|
||||
title: tab.title,
|
||||
url: tab.url,
|
||||
capturedAt: Date.now(),
|
||||
dataUrl: await browser.tabs.captureVisibleTab(tab.windowId, { format: 'jpeg', quality: 55 }),
|
||||
};
|
||||
}
|
||||
if (method === 'browser.frames') {
|
||||
const tabId = typeof input.tabId === 'number' ? input.tabId : grant.targets[0]?.tabId;
|
||||
if (!tabId || !grant.targets.some((target) => target.tabId === tabId)) {
|
||||
throw new ExtensionError('target_denied', '目标标签页不在本次共享会话中');
|
||||
}
|
||||
return getFrameInventory(tabId);
|
||||
const tabId = (await getTab(typeof input.tabId === 'number' ? input.tabId : undefined)).id;
|
||||
await allowedTarget(grant, { tabId }, false);
|
||||
const frames = await getFrameInventory(tabId);
|
||||
const allowedOrigins = (await getEnterprisePolicy()).policy.grantAllowedOrigins;
|
||||
return frames.filter((frame) => !allowedOrigins?.length
|
||||
|| Boolean(frame.origin && allowedOrigins.includes(frame.origin)));
|
||||
}
|
||||
if (method === 'browser.instance.close') return scheduleBrowserInstanceClose();
|
||||
if (method === 'browser.isolation.inspect') {
|
||||
const grantedTabIds = [...new Set(grant.targets.map((target) => target.tabId))];
|
||||
const requestedTabIds = Array.isArray(input.tabIds)
|
||||
? input.tabIds.map(Number)
|
||||
: grantedTabIds;
|
||||
if (requestedTabIds.some((tabId) => !grantedTabIds.includes(tabId))) {
|
||||
throw new ExtensionError(
|
||||
'target_denied',
|
||||
'身份隔离检查只能读取本次共享会话中的标签页',
|
||||
);
|
||||
}
|
||||
return inspectBrowserIsolation(requestedTabIds);
|
||||
: undefined;
|
||||
const inspection = await inspectBrowserIsolation(requestedTabIds);
|
||||
const allowedOrigins = (await getEnterprisePolicy()).policy.grantAllowedOrigins;
|
||||
if (!allowedOrigins?.length) return inspection;
|
||||
const tabs = inspection.tabs.filter((tab) => allowedOrigins.includes(new URL(tab.url).origin));
|
||||
const tabIds = new Set(tabs.map((tab) => tab.id));
|
||||
return {
|
||||
...inspection,
|
||||
tabs,
|
||||
contexts: inspection.contexts
|
||||
.map((context) => ({ ...context, tabIds: context.tabIds.filter((tabId) => tabIds.has(tabId)) }))
|
||||
.filter((context) => context.tabIds.length > 0),
|
||||
};
|
||||
}
|
||||
if (method === 'browser.isolation.proof') {
|
||||
requireScope(grant, 'browser.cookies.read');
|
||||
requireScope(grant, 'browser.storage.read');
|
||||
const leftTabId = Number(input.leftTabId);
|
||||
const rightTabId = Number(input.rightTabId);
|
||||
if (![leftTabId, rightTabId].every((tabId) => (
|
||||
grant.targets.some((target) => target.tabId === tabId)
|
||||
))) {
|
||||
throw new ExtensionError(
|
||||
'target_denied',
|
||||
'隔离证明的两个身份都必须在本次共享会话中',
|
||||
);
|
||||
}
|
||||
await Promise.all([
|
||||
allowedTarget(grant, { tabId: leftTabId }, false),
|
||||
allowedTarget(grant, { tabId: rightTabId }, false),
|
||||
]);
|
||||
return createBrowserIsolationProof(leftTabId, rightTabId);
|
||||
}
|
||||
if (method === 'browser.isolation.incognito.open') {
|
||||
assertBrowserAccessPolicy((await getEnterprisePolicy()).policy, {
|
||||
origin: new URL(String(input.url || '')).origin,
|
||||
});
|
||||
return openIncognitoIdentity(String(input.url || ''));
|
||||
}
|
||||
if (method === 'browser.isolation.container.open') {
|
||||
assertBrowserAccessPolicy((await getEnterprisePolicy()).policy, {
|
||||
origin: new URL(String(input.url || '')).origin,
|
||||
});
|
||||
return openFirefoxContainerIdentity({
|
||||
url: String(input.url || ''),
|
||||
name: typeof input.name === 'string' ? input.name : undefined,
|
||||
|
||||
@@ -48,12 +48,7 @@ export const pageCapabilityHandler: CapabilityDomainHandler = {
|
||||
tabId: target.tabId,
|
||||
frameId: target.frameId,
|
||||
});
|
||||
const grantTarget = grant.targets.find((item) => (
|
||||
item.tabId === target.tabId && item.frameId === target.frameId
|
||||
));
|
||||
const url = frame?.url && /^https?:/i.test(frame.url)
|
||||
? frame.url
|
||||
: `${grantTarget?.origin || ''}/`;
|
||||
const url = frame?.url || '';
|
||||
if (!/^https?:/i.test(url)) {
|
||||
throw new ExtensionError(
|
||||
'target_unavailable',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
BrowserTransformPacket,
|
||||
BrowserTransformProfileInput,
|
||||
} from '@/types/models';
|
||||
import { browser } from 'wxt/browser';
|
||||
import type { CapabilityDomainHandler } from '../capability-context';
|
||||
import { allowedTarget, requireScope } from '../capability-context';
|
||||
import {
|
||||
@@ -124,11 +125,9 @@ export const transformCapabilityHandler: CapabilityDomainHandler = {
|
||||
if (method === 'browser.transform.profile.save') {
|
||||
const profileInput = input as unknown as BrowserTransformProfileInput;
|
||||
const target = await allowedTarget(grant, profileInput.target);
|
||||
const grantedTarget = grant.targets.find((item) => (
|
||||
item.tabId === target.tabId && item.frameId === target.frameId
|
||||
));
|
||||
if (!grantedTarget || profileInput.origin !== grantedTarget.origin) {
|
||||
throw new ExtensionError('target_denied', '转换配置来源不在本次共享会话中');
|
||||
const frame = await browser.webNavigation.getFrame(target);
|
||||
if (!frame?.url || profileInput.origin !== new URL(frame.url).origin) {
|
||||
throw new ExtensionError('target_denied', '转换配置来源与当前页面不一致');
|
||||
}
|
||||
return saveBrowserTransformProfile({ ...profileInput, target });
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ const fixture = vi.hoisted(() => ({
|
||||
stopNetwork: vi.fn(async (_grantId: string) => undefined),
|
||||
stopRecording: vi.fn(async (_grantId: string) => undefined),
|
||||
stopDeepCapture: vi.fn(async (_grantId: string) => undefined),
|
||||
startRuntime: vi.fn(async (_grant: BridgeGrant) => ({ state: 'running' })),
|
||||
endRuntime: vi.fn(async (_state: 'revoked' | 'expired', _grant: BridgeGrant) => ({ state: 'revoked' })),
|
||||
appendAudit: vi.fn(async () => undefined),
|
||||
clearBadge: vi.fn(async () => undefined),
|
||||
}));
|
||||
@@ -50,10 +48,6 @@ vi.mock('@/features/browser-recording/service', () => ({
|
||||
vi.mock('@/features/deep-capture/service', () => ({
|
||||
stopDeepCapturesForGrant: fixture.stopDeepCapture,
|
||||
}));
|
||||
vi.mock('@/features/agent-runtime/service', () => ({
|
||||
startAgentRuntime: fixture.startRuntime,
|
||||
endAgentRuntimeForGrant: fixture.endRuntime,
|
||||
}));
|
||||
vi.mock('@/features/diagnostics/audit', () => ({
|
||||
appendAuditEvent: fixture.appendAudit,
|
||||
}));
|
||||
@@ -124,19 +118,15 @@ describe('grant lifecycle manager', () => {
|
||||
|
||||
it('consumes an expired stored grant and releases all grant-owned resources', async () => {
|
||||
const expired = grant('expired-restore', NOW - 1);
|
||||
const cancelActiveRequests = vi.fn();
|
||||
await setState({ ...structuredClone(DEFAULT_STATE), activeGrant: expired });
|
||||
configureGrantLifecycleHooks({ cancelActiveRequests });
|
||||
|
||||
const state = await restoreGrantLifecycle();
|
||||
|
||||
expect(state.activeGrant).toBeUndefined();
|
||||
expect((await getState()).activeGrant).toBeUndefined();
|
||||
expect(cancelActiveRequests).toHaveBeenCalledOnce();
|
||||
expect(fixture.stopNetwork).toHaveBeenCalledWith(expired.id);
|
||||
expect(fixture.stopRecording).toHaveBeenCalledWith(expired.id);
|
||||
expect(fixture.stopDeepCapture).toHaveBeenCalledWith(expired.id);
|
||||
expect(fixture.endRuntime).toHaveBeenCalledWith('expired', expired);
|
||||
expect(fixture.alarms.has(ACTIVE_GRANT_EXPIRY_ALARM)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -152,7 +142,6 @@ describe('grant lifecycle manager', () => {
|
||||
expect(fixture.stopNetwork.mock.calls.map(([id]) => id)).toEqual([old.id, first.id]);
|
||||
expect(fixture.stopRecording.mock.calls.map(([id]) => id)).toEqual([old.id, first.id]);
|
||||
expect(fixture.stopDeepCapture.mock.calls.map(([id]) => id)).toEqual([old.id, first.id]);
|
||||
expect(fixture.startRuntime.mock.calls.map(([item]) => item.id)).toEqual([first.id, second.id]);
|
||||
expect(fixture.alarms.get(ACTIVE_GRANT_EXPIRY_ALARM)).toEqual({ when: second.expiresAt });
|
||||
});
|
||||
|
||||
@@ -168,15 +157,16 @@ describe('grant lifecycle manager', () => {
|
||||
expect(fixture.stopNetwork).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.stopRecording).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.stopDeepCapture).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.endRuntime).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels a waiting handoff and publishes the resolved state on replacement', async () => {
|
||||
const waiting = handoff('handoff-waiting');
|
||||
const previous = grant('handoff-old');
|
||||
previous.taskId = waiting.taskId;
|
||||
const emitHandoffChanged = vi.fn();
|
||||
await setState({
|
||||
...structuredClone(DEFAULT_STATE),
|
||||
activeGrant: grant('handoff-old'),
|
||||
activeGrant: previous,
|
||||
handoff: waiting,
|
||||
});
|
||||
configureGrantLifecycleHooks({ emitHandoffChanged });
|
||||
@@ -188,6 +178,21 @@ describe('grant lifecycle manager', () => {
|
||||
expect(emitHandoffChanged).toHaveBeenCalledWith(state.handoff);
|
||||
});
|
||||
|
||||
it('does not cancel a paired-instance handoff when an authorization-test grant ends', async () => {
|
||||
const waiting = handoff('paired-handoff');
|
||||
waiting.taskId = 'paired-browser-instance';
|
||||
await setState({
|
||||
...structuredClone(DEFAULT_STATE),
|
||||
activeGrant: grant('authorization-test'),
|
||||
handoff: waiting,
|
||||
});
|
||||
|
||||
const { state } = await revokeActiveGrant();
|
||||
|
||||
expect(state.handoff).toEqual(waiting);
|
||||
expect(fixture.clearBadge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an update that reaches the queue after expiry and performs cleanup first', async () => {
|
||||
const expired = grant('expired-update', NOW - 1);
|
||||
await setState({ ...structuredClone(DEFAULT_STATE), activeGrant: expired });
|
||||
@@ -218,7 +223,6 @@ describe('grant lifecycle manager', () => {
|
||||
|
||||
expect((await getState()).activeGrant).toBeUndefined();
|
||||
expect(fixture.stopNetwork).toHaveBeenCalledWith(active.id);
|
||||
expect(fixture.endRuntime).toHaveBeenCalledWith('expired', active);
|
||||
});
|
||||
|
||||
it('reschedules an early alarm without revoking a still-live grant', async () => {
|
||||
@@ -247,20 +251,14 @@ describe('grant lifecycle manager', () => {
|
||||
expect((await getState()).activeGrant?.id).toBe(old.id);
|
||||
expect(fixture.alarms.get(ACTIVE_GRANT_EXPIRY_ALARM)).toEqual({ when: old.expiresAt });
|
||||
expect(fixture.stopNetwork).not.toHaveBeenCalled();
|
||||
expect(fixture.startRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed when Agent Runtime activation fails after the grant commit', async () => {
|
||||
const active = grant('runtime-failure');
|
||||
fixture.startRuntime.mockRejectedValueOnce(new Error('session storage unavailable'));
|
||||
it('does not couple an authorization-test grant to Agent runtime state', async () => {
|
||||
const active = grant('authorization-only');
|
||||
|
||||
await expect(replaceActiveGrant(active)).rejects.toMatchObject({ code: 'grant_activation_failed' });
|
||||
|
||||
expect((await getState()).activeGrant).toBeUndefined();
|
||||
expect(fixture.stopNetwork).toHaveBeenCalledWith(active.id);
|
||||
expect(fixture.stopRecording).toHaveBeenCalledWith(active.id);
|
||||
expect(fixture.stopDeepCapture).toHaveBeenCalledWith(active.id);
|
||||
expect(fixture.alarms.has(ACTIVE_GRANT_EXPIRY_ALARM)).toBe(false);
|
||||
await expect(replaceActiveGrant(active)).resolves.toMatchObject({
|
||||
state: { activeGrant: { id: active.id } },
|
||||
});
|
||||
});
|
||||
|
||||
it('clears authorization state even when one resource cleanup reports a failure', async () => {
|
||||
|
||||
@@ -2,9 +2,6 @@ import { browser } from 'wxt/browser';
|
||||
import { stopNetworkCapturesForGrant } from '@/features/network-capture/service';
|
||||
import { stopBrowserRecordingsForGrant } from '@/features/browser-recording/service';
|
||||
import { stopDeepCapturesForGrant } from '@/features/deep-capture/service';
|
||||
import {
|
||||
endAgentRuntimeForGrant, startAgentRuntime,
|
||||
} from '@/features/agent-runtime/service';
|
||||
import { appendAuditEvent } from '@/features/diagnostics/audit';
|
||||
import { getState, updateState } from '@/platform/storage/state';
|
||||
import type {
|
||||
@@ -14,10 +11,9 @@ import { ExtensionError } from '@/shared/errors';
|
||||
|
||||
export const ACTIVE_GRANT_EXPIRY_ALARM = 'yakit.active-grant.expiry';
|
||||
|
||||
type GrantEndReason = 'revoked' | 'expired' | 'replaced' | 'scheduler_failure' | 'activation_failure';
|
||||
type GrantEndReason = 'revoked' | 'expired' | 'replaced' | 'scheduler_failure';
|
||||
|
||||
interface GrantLifecycleHooks {
|
||||
cancelActiveRequests?: () => void;
|
||||
emitHandoffChanged?: (handoff: HumanHandoff) => void;
|
||||
}
|
||||
|
||||
@@ -80,23 +76,6 @@ function cancelledHandoff(current: HumanHandoff | undefined, now: number): Human
|
||||
: current;
|
||||
}
|
||||
|
||||
function cancelActiveRequestsBestEffort(grant: BridgeGrant): void {
|
||||
try {
|
||||
hooks.cancelActiveRequests?.();
|
||||
} catch (error) {
|
||||
console.error('Grant request cancellation failed', error);
|
||||
void appendAuditEvent({
|
||||
category: 'grant',
|
||||
action: 'grant.requests.cancel',
|
||||
outcome: 'error',
|
||||
taskId: grant.taskId,
|
||||
targetTabId: grant.targets[0]?.tabId,
|
||||
errorCode: 'grant_request_cancel_failed',
|
||||
summary: (error instanceof Error ? error.message : String(error)).slice(0, 512),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function publishCancelledHandoff(
|
||||
previous: HumanHandoff | undefined,
|
||||
current: HumanHandoff | undefined,
|
||||
@@ -124,12 +103,10 @@ async function publishCancelledHandoff(
|
||||
function cleanupGrantResources(grant: BridgeGrant, reason: GrantEndReason): Promise<void> {
|
||||
const existing = cleanupTasks.get(grant.id);
|
||||
if (existing) return existing;
|
||||
const runtimeState = reason === 'expired' ? 'expired' as const : 'revoked' as const;
|
||||
const task = Promise.allSettled([
|
||||
stopNetworkCapturesForGrant(grant.id),
|
||||
stopBrowserRecordingsForGrant(grant.id),
|
||||
stopDeepCapturesForGrant(grant.id),
|
||||
endAgentRuntimeForGrant(runtimeState, grant),
|
||||
]).then((results) => {
|
||||
const failures = results.filter((result) => result.status === 'rejected');
|
||||
if (failures.length === 0) return;
|
||||
@@ -159,11 +136,11 @@ async function endActiveGrantInQueue(
|
||||
if (!grant || (expectedGrantId && grant.id !== expectedGrantId)) return current;
|
||||
if (reason === 'expired' && grant.expiresAt > now) return current;
|
||||
previousGrant = grant;
|
||||
previousHandoff = current.handoff;
|
||||
previousHandoff = current.handoff?.taskId === grant.taskId ? current.handoff : undefined;
|
||||
return {
|
||||
...current,
|
||||
activeGrant: undefined,
|
||||
handoff: cancelledHandoff(current.handoff, now),
|
||||
handoff: cancelledHandoff(previousHandoff, now) || current.handoff,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -173,7 +150,6 @@ async function endActiveGrantInQueue(
|
||||
return { state };
|
||||
}
|
||||
|
||||
cancelActiveRequestsBestEffort(previousGrant);
|
||||
await clearExpiryAlarmBestEffort(previousGrant);
|
||||
await cleanupGrantResources(previousGrant, reason);
|
||||
await publishCancelledHandoff(previousHandoff, state.handoff, reason);
|
||||
@@ -187,7 +163,7 @@ async function endActiveGrantInQueue(
|
||||
? '已由新共享会话替换'
|
||||
: reason === 'scheduler_failure'
|
||||
? '无法建立可靠的到期调度,已安全撤销'
|
||||
: reason === 'activation_failure' ? 'Agent Runtime 初始化失败,已安全撤销' : undefined,
|
||||
: undefined,
|
||||
});
|
||||
return { state, previousGrant, previousHandoff };
|
||||
}
|
||||
@@ -258,11 +234,14 @@ export function replaceActiveGrant(grant: BridgeGrant): Promise<GrantTransition>
|
||||
try {
|
||||
state = await updateState((current) => {
|
||||
previousGrant = current.activeGrant;
|
||||
previousHandoff = current.handoff;
|
||||
previousHandoff = current.activeGrant
|
||||
&& current.handoff?.taskId === current.activeGrant.taskId
|
||||
? current.handoff
|
||||
: undefined;
|
||||
return {
|
||||
...current,
|
||||
activeGrant: grant,
|
||||
handoff: cancelledHandoff(current.handoff, now),
|
||||
handoff: cancelledHandoff(previousHandoff, now) || current.handoff,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -271,18 +250,8 @@ export function replaceActiveGrant(grant: BridgeGrant): Promise<GrantTransition>
|
||||
}
|
||||
|
||||
if (previousGrant && previousGrant.id !== grant.id) {
|
||||
cancelActiveRequestsBestEffort(previousGrant);
|
||||
await cleanupGrantResources(previousGrant, 'replaced');
|
||||
}
|
||||
try {
|
||||
await startAgentRuntime(grant);
|
||||
} catch (error) {
|
||||
await endActiveGrantInQueue('activation_failure', grant.id);
|
||||
throw new ExtensionError(
|
||||
'grant_activation_failed',
|
||||
`无法初始化浏览器共享会话: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
await publishCancelledHandoff(previousHandoff, state.handoff, 'replaced');
|
||||
return { state, previousGrant, previousHandoff };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { BridgeGrant } from '@/types/models';
|
||||
|
||||
const fixture = vi.hoisted(() => ({
|
||||
access: vi.fn(async (): Promise<BridgeGrant> => ({
|
||||
id: 'paired-browser-instance',
|
||||
taskId: 'paired-browser-instance',
|
||||
targets: [],
|
||||
scopes: ['browser.dom.read'],
|
||||
createdAt: 0,
|
||||
expiresAt: Number.MAX_SAFE_INTEGER,
|
||||
})),
|
||||
dispatch: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
|
||||
vi.mock('wxt/browser', () => ({
|
||||
browser: { runtime: { getManifest: () => ({ version: '1.0.0' }) } },
|
||||
}));
|
||||
vi.mock('./capability-context', () => ({
|
||||
browserInstanceAccess: fixture.access,
|
||||
}));
|
||||
vi.mock('./capability-router', () => ({
|
||||
dispatchCapability: fixture.dispatch,
|
||||
}));
|
||||
|
||||
import { routeCapability } from './service';
|
||||
|
||||
describe('paired browser capability routing', () => {
|
||||
it('routes page access through the paired instance without an active page grant', async () => {
|
||||
await expect(routeCapability('browser.context', { includeDom: true })).resolves.toEqual({ ok: true });
|
||||
expect(fixture.access).toHaveBeenCalledWith('browser.dom.read');
|
||||
expect(fixture.dispatch).toHaveBeenCalledWith(expect.objectContaining({
|
||||
method: 'browser.context',
|
||||
input: { includeDom: true },
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@/protocol/capabilities';
|
||||
import { parseCapabilityParams } from '@/protocol/bridge';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
import { activeGrant, type CapabilityEngineRequest } from './capability-context';
|
||||
import { browserInstanceAccess, type CapabilityEngineRequest } from './capability-context';
|
||||
import { dispatchCapability } from './capability-router';
|
||||
|
||||
export { CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
|
||||
@@ -17,9 +17,18 @@ export async function routeCapability(
|
||||
requestEngine?: CapabilityEngineRequest,
|
||||
): Promise<unknown> {
|
||||
if (method === 'system.ping') {
|
||||
const userAgent = globalThis.navigator?.userAgent || '';
|
||||
const browserName = /Firefox\//i.test(userAgent)
|
||||
? 'Firefox'
|
||||
: /Edg\//i.test(userAgent)
|
||||
? 'Edge'
|
||||
: /Chrom(?:e|ium)\//i.test(userAgent)
|
||||
? 'Chrome'
|
||||
: undefined;
|
||||
return {
|
||||
now: Date.now(),
|
||||
extensionVersion: browser.runtime.getManifest().version,
|
||||
browserName,
|
||||
};
|
||||
}
|
||||
if (import.meta.env.FIREFOX
|
||||
@@ -35,6 +44,6 @@ export async function routeCapability(
|
||||
? 'browser.page.eval.program'
|
||||
: capabilityBaseScope(method);
|
||||
if (!required) throw new Error(`不支持的 Bridge 方法: ${method}`);
|
||||
const grant = await activeGrant(required);
|
||||
const grant = await browserInstanceAccess(required);
|
||||
return dispatchCapability({ method, input, grant, requestEngine });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user