mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-22 03:10:43 +08:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b11efcc79c | ||
|
|
c7891a6a9d | ||
|
|
726a97849b | ||
|
|
f9ed85c068 | ||
|
|
86f76eb730 | ||
|
|
9604b711fb | ||
|
|
7c19f0c710 | ||
|
|
5069778155 | ||
|
|
96ea8af1ad | ||
|
|
80a60d4aff | ||
|
|
ab69b18d6c | ||
|
|
0e3a7ccbcd | ||
|
|
83466b6c77 | ||
|
|
b9d1e104b0 | ||
|
|
75fdb0a5aa | ||
|
|
467089745a | ||
|
|
828f8ea895 | ||
|
|
dde16515ec | ||
|
|
273ea4878f | ||
|
|
f5e19165e6 | ||
|
|
8de9bbb277 | ||
|
|
2f1acfc1dc | ||
|
|
a8de7e8834 | ||
|
|
e35113d82f | ||
|
|
103a40fdf5 | ||
|
|
4a99b773be | ||
|
|
7fe4731b91 | ||
|
|
08af08c9a8 | ||
|
|
e4b2b90507 | ||
|
|
82d1c066cd | ||
|
|
2481b22d8e | ||
|
|
6013a7ae4e | ||
|
|
8742711b68 | ||
|
|
f463076def | ||
|
|
43743d729f | ||
|
|
8801de3441 | ||
|
|
9a63f596b8 | ||
|
|
2253bdffb1 | ||
|
|
322cd6dcd6 | ||
|
|
f0fb3f6cf0 | ||
|
|
5066e9d89a | ||
|
|
35184f4b25 | ||
|
|
1eee8217ec | ||
|
|
1149cfeb4d | ||
|
|
cf10abc3f8 | ||
|
|
86dd6cd40c | ||
|
|
06c053228c | ||
|
|
6f82842c92 | ||
|
|
1e25561fae | ||
|
|
8c30607514 | ||
|
|
692ab7760e | ||
|
|
08796f91c4 | ||
|
|
7c3eb80872 |
@@ -1,37 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Test and build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test, compile, build and audit all variants
|
||||
run: pnpm verify:production
|
||||
+48
-133
@@ -2,167 +2,82 @@ name: Build and Release
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# Serializes publishes so two runs can never interleave the
|
||||
# fetch-existing-manifest / upload-manifest sequence.
|
||||
concurrency:
|
||||
group: oss-extension-release
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
PUBLIC_BASE_URL: https://aliyun-oss.yaklang.com/chrome-extension
|
||||
OSS_ENDPOINT: https://oss-accelerate.aliyuncs.com
|
||||
OSS_BUCKET: yaklang
|
||||
MANIFEST_MAX_VERSIONS: '10'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build, package and publish to OSS
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Test, compile and build all variants
|
||||
run: pnpm verify:production
|
||||
- name: Build project
|
||||
run: yarn build
|
||||
|
||||
- name: Read version
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(date +'%Y-%m-%d %H:%M:%S')" >> "$GITHUB_OUTPUT"
|
||||
VERSION=$(jq -r '.version' build/manifest.json)
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "build_time=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Package release artifacts
|
||||
run: node scripts/package-release.mjs --dist=dist --public-base-url=${PUBLIC_BASE_URL}
|
||||
|
||||
- name: Upload release artifacts to OSS (immutable)
|
||||
- name: Zip build artifacts
|
||||
run: |
|
||||
node scripts/publish-oss.mjs release \
|
||||
--release-entry=dist/release-entry.json \
|
||||
--dist=dist \
|
||||
--endpoint=${OSS_ENDPOINT} \
|
||||
--bucket=${OSS_BUCKET}
|
||||
env:
|
||||
OSS_KEY_ID: ${{ secrets.OSS_KEY_ID }}
|
||||
OSS_KEY_SECRET: ${{ secrets.OSS_KEY_SECRET }}
|
||||
cd build
|
||||
zip -r ../extension.zip .
|
||||
|
||||
- name: Fetch existing manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Query parameter busts the CDN's 5-minute manifest cache.
|
||||
url="${PUBLIC_BASE_URL}/manifest.json?mirror_build=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
code=$(curl --compressed -sS -o dist/existing-manifest.json -w '%{http_code}' --retry 4 --retry-all-errors "$url")
|
||||
if [ "$code" = "200" ]; then
|
||||
echo "Existing manifest fetched."
|
||||
elif [ "$code" = "404" ]; then
|
||||
rm -f dist/existing-manifest.json
|
||||
echo "No existing manifest (first release)."
|
||||
else
|
||||
echo "Unexpected HTTP ${code} fetching ${url}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build bounded manifest
|
||||
run: |
|
||||
existing=""
|
||||
if [ -f dist/existing-manifest.json ]; then
|
||||
existing="--existing-manifest=dist/existing-manifest.json"
|
||||
fi
|
||||
node scripts/build-manifest.mjs \
|
||||
--release-entry=dist/release-entry.json \
|
||||
${existing} \
|
||||
--max-versions=${MANIFEST_MAX_VERSIONS} \
|
||||
--output=dist/manifest.json \
|
||||
--checksum-output=dist/manifest.json.sha256.txt
|
||||
|
||||
- name: Publish manifest to OSS
|
||||
run: |
|
||||
node scripts/publish-oss.mjs manifest \
|
||||
--manifest=dist/manifest.json \
|
||||
--manifest-checksum=dist/manifest.json.sha256.txt \
|
||||
--endpoint=${OSS_ENDPOINT} \
|
||||
--bucket=${OSS_BUCKET}
|
||||
env:
|
||||
OSS_KEY_ID: ${{ secrets.OSS_KEY_ID }}
|
||||
OSS_KEY_SECRET: ${{ secrets.OSS_KEY_SECRET }}
|
||||
|
||||
- name: Upload release entry for verification
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-entry
|
||||
path: dist/release-entry.json
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ steps.version.outputs.version }}
|
||||
name: Release v${{ steps.version.outputs.version }}
|
||||
release_name: Release v${{ steps.version.outputs.version }}
|
||||
body: |
|
||||
Branch: ${{ github.ref_name }}
|
||||
Commit: ${{ github.sha }}
|
||||
Build Time: ${{ steps.version.outputs.build_time }}
|
||||
Manifest: ${{ env.PUBLIC_BASE_URL }}/manifest.json
|
||||
files: |
|
||||
dist/${{ steps.version.outputs.version }}/*
|
||||
dist/manifest.json
|
||||
dist/manifest.json.sha256.txt
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
- name: Write job summary
|
||||
run: |
|
||||
{
|
||||
echo "## Release v${{ steps.version.outputs.version }}"
|
||||
echo
|
||||
echo "- Manifest: ${PUBLIC_BASE_URL}/manifest.json"
|
||||
echo "- Commit: \`${{ github.sha }}\`"
|
||||
echo
|
||||
echo "| Variant | Size | SHA-256 |"
|
||||
echo "| --- | --- | --- |"
|
||||
jq -r '.artifacts[] | "| \(.variant) | \(.size) | `\(.sha256)` |"' dist/release-entry.json
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
verify:
|
||||
name: Verify public release
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Upload Release Asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./extension.zip
|
||||
asset_name: yakit-chrome-extension-v${{ steps.version.outputs.version }}.zip
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download release entry
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Upload Extension To OSS
|
||||
uses: tvrcgo/upload-to-oss@master
|
||||
with:
|
||||
name: release-entry
|
||||
path: dist
|
||||
key-id: ${{ secrets.OSS_KEY_ID }}
|
||||
key-secret: ${{ secrets.OSS_KEY_SECRET }}
|
||||
region: oss-accelerate
|
||||
bucket: yaklang
|
||||
assets: |
|
||||
extension.zip:/chrome-extension/yakit-chrome-extension-v${{ steps.version.outputs.version }}.zip
|
||||
|
||||
- name: Update OSS latest version file
|
||||
run: echo ${{ steps.version.outputs.version }} > ./extension-version.txt
|
||||
|
||||
- name: Upload Version File to OSS
|
||||
uses: tvrcgo/upload-to-oss@master
|
||||
with:
|
||||
key-id: ${{ secrets.OSS_KEY_ID }}
|
||||
key-secret: ${{ secrets.OSS_KEY_SECRET }}
|
||||
region: oss-accelerate
|
||||
bucket: yaklang
|
||||
assets: |
|
||||
./extension-version.txt:/chrome-extension/latest-version.txt
|
||||
|
||||
- name: Verify from public endpoint
|
||||
run: node scripts/verify-public.mjs --public-base-url=${PUBLIC_BASE_URL} --release-entry=dist/release-entry.json
|
||||
|
||||
@@ -1,414 +1,84 @@
|
||||
<p align="center">
|
||||
<img src="./public/yak.svg" width="96" alt="Yak Logo" />
|
||||
</p>
|
||||
# Yakit Browser Agent
|
||||
|
||||
<h1 align="center">Yakit Browser Agent</h1>
|
||||
Browser security tools and a consent-gated context bridge for Yak AI agents.
|
||||
|
||||
<p align="center">
|
||||
面向真实浏览器上下文的安全测试与 AI Agent 协作扩展
|
||||
</p>
|
||||
The WXT extension includes proxy profiles and PAC routing rules, Cookie and User-Agent tools, a Shadow DOM edge panel, authenticated-tab context capture, controlled execution in the page's real JavaScript world, and a Chromium Deep Capture debugger for real frontend crypto workflows. Structured context uses bounded text, forms, authentication signals, open Shadow DOM traversal, context diffs, and document-bound node references instead of exporting full page HTML. The Recorder discovers business Traces through one crypto-adapter model for WebCrypto, CryptoJS, JSEncrypt, sm-crypto, and node-forge, plus Beacon/Worker/SharedWorker/MessagePort boundaries. An exact receiver-bound primitive-to-request-field chain can become a plaintext gateway directly, while stateful or multi-call AES/RSA/signature envelopes are promoted to a request-level graph and captured as one business callable. Deep Capture can pause the next selected crypto call, message boundary, or request, deterministically rank live page frames, recover ESM/module script URLs, and retain an in-scope closure—including a closure holding a `CryptoKey` or `WebAssembly.Instance`—without exporting key material. The Browser Transform Gateway composes those callables with typed Pipeline v2 nodes so Yakit Web Fuzzer can edit plaintext while the live browser produces and consumes the real wire format. AI access is bound to a concrete tab, frame, document, origin, task, scope set, and expiration time. Yak/Yakit product assets are kept in `public/` and exposed to content scripts through explicit web-accessible resources.
|
||||
|
||||
<p align="center">
|
||||
连接浏览器、Yak 引擎与 Yakit,让登录态、页面函数、前端加解密、网络请求和人工操作成为可授权、可复用、可审计的测试能力。
|
||||
</p>
|
||||
When an Agent reaches a QR code, MFA, CAPTCHA, or device confirmation, it can create a human handoff. The target tab is focused, the extension presents the request in Popup, Options, and the edge panel, and the Agent receives a completion or cancellation event after the user decides. The network workspace can capture a granted document's real Fetch/XHR requests and open an authenticated replay packet in Yakit Web Fuzzer. Sensitive headers, Cookie, and body capture are off by default and session-only. A separate local audit log stores only method, target, timing, and outcome metadata; it does not store page content, Cookie values, Eval source, network payloads, arguments, or results.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Yakit Browser Agent 面向已获授权的安全测试、企业自测和教学环境。扩展具备读取登录态、捕获网络请求、调用页面函数和调试页面执行现场等高权限能力;请只对你拥有或明确获准测试的目标使用。
|
||||
|
||||
## 项目定位
|
||||
|
||||
Yakit Browser Agent 是 Yak / Yakit 生态中的浏览器执行端。它运行在用户真实使用的浏览器里,在用户明确授权后,将特定标签页的页面上下文、安全测试能力和人工交互能力提供给 Yak 引擎、Yakit 工作区以及 AI Agent。
|
||||
|
||||
它重点解决传统安全测试工具难以自然处理的几类问题:
|
||||
|
||||
- 目标功能依赖已经登录的浏览器环境,无法仅靠离线 HTTP 请求复现;
|
||||
- 请求参数由混淆后的前端代码、闭包状态、动态密钥、`CryptoKey`、Worker 或 WebAssembly 现场生成;
|
||||
- 测试者希望编辑明文,但目标服务器只接受页面产生的密文、签名或动态请求封装;
|
||||
- 流程包含扫码、MFA、CAPTCHA、设备确认等必须由用户参与的步骤;
|
||||
- 双身份授权测试需要可靠隔离登录态、复用真实请求并保留可复核证据;
|
||||
- AI Agent 需要浏览器提供真实、结构化、受控的上下文,而不是依赖截图猜测或导出完整浏览器配置。
|
||||
|
||||
本项目并不是只针对某个靶场编写的加解密脚本,也不是一个简单的 JS-RPC 转发器。它以通用的调用证据、业务 Trace、请求边界、页面 Callable 和类型化 Pipeline 为基础:能由确定性证据完成的步骤交给代码验证,证据不足或语义复杂的部分再交给用户与 AI 辅助分析。
|
||||
|
||||
## 设计原则
|
||||
|
||||
| 原则 | 说明 |
|
||||
| --- | --- |
|
||||
| 真实现场优先 | 复用页面正在运行的函数、receiver、闭包和浏览器状态,不要求先把密钥或完整算法导出到外部。 |
|
||||
| 证据驱动 | 通过值指纹、调用顺序、业务 Trace、请求字段关联和响应归属建立结论,不因“发现某个加密库”就直接猜测转换逻辑。 |
|
||||
| 用户明确授权 | 所有远程能力绑定具体标签页、Frame、文档、来源、任务、权限范围和有效期,授权可见、可暂停、可撤销。 |
|
||||
| 确定性验证与 AI 协作 | AI 用于解释、归纳和提出方案;协议校验、Profile 编译、真实回放和结果比较由确定性代码完成。 |
|
||||
| 通用能力优先 | 对库、算法和业务形态建立适配层,不以固定字段名、固定接口或单一靶场流程作为产品逻辑。 |
|
||||
| 本地与性能优先 | 大型代理规则使用 IndexedDB 分块存储和编译缓存;敏感页面数据默认短时保留,诊断与审计只记录必要元数据。 |
|
||||
|
||||
## 系统组成
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Browser[用户浏览器]
|
||||
Page[目标页面\n登录态 · DOM · JS · Worker]
|
||||
Extension[Yakit Browser Agent\n录制 · 调试 · 代理 · 授权]
|
||||
Page <--> Extension
|
||||
end
|
||||
|
||||
subgraph Local[本地安全测试环境]
|
||||
Yak[Yak Engine\nBridge v3 · Capability Router]
|
||||
Yakit[Yakit\n浏览器工作区 · Web Fuzzer]
|
||||
Agent[AI Agent\n证据分析 · 流程编排]
|
||||
Yak <--> Yakit
|
||||
Yak <--> Agent
|
||||
end
|
||||
|
||||
User[测试人员] <--> Extension
|
||||
Extension <-- 配对身份 / 签名挑战 / 流式任务 --> Yak
|
||||
```
|
||||
|
||||
扩展不允许远程调用方直接访问浏览器 API。所有命令统一经过 Capability Router、参数 Schema、授权 Scope 和文档生命周期检查,再由对应功能模块执行。
|
||||
|
||||
## 核心能力
|
||||
|
||||
### 1. 前端加解密录制与明文网关
|
||||
|
||||
这是 Yakit Browser Agent 的核心工作流。测试者只需要在真实页面完成一次尽可能短的业务操作,扩展会从页面输入、密码调用、编码转换、通信边界和最终请求中还原数据流。
|
||||
|
||||
主要能力包括:
|
||||
|
||||
- 录制 Fetch、XHR、表单导航、Beacon、WebSocket、Worker、SharedWorker 和 MessagePort 等业务边界;
|
||||
- 记录加解密调用的输入、输出指纹、调用栈、receiver、固定参数模板与请求先后关系;
|
||||
- 将同一业务动作组织为按时间排序的 Trace,区分页面原始函数与扩展注入的观测 Hook;
|
||||
- 自动推断明文来源、密码调用和线上请求字段之间的关联;
|
||||
- 在证据充分时直接生成请求或响应方向的明文网关;
|
||||
- 在普通录制无法保留状态时,使用 Chromium Deep Capture 捕获闭包、模块脚本、`CryptoKey`、WebAssembly 实例或多调用请求事务;
|
||||
- 将捕获到的业务调用保存为文档绑定的页面 Callable,无需向外导出页面密钥;
|
||||
- 使用类型化 Pipeline 组合上下文读取、页面调用、白名单转换、字段装配和输出写入;
|
||||
- 在本地回放中使用录制的短时样本验证转换关系;
|
||||
- 与 Yakit Web Fuzzer 联动:编辑逻辑明文,由真实页面生成线上密文或签名,同时并排查看“明文 / 线上”报文。
|
||||
|
||||
当前观测与推断适配层覆盖:
|
||||
|
||||
- Web Crypto API;
|
||||
- CryptoJS;
|
||||
- JSEncrypt;
|
||||
- jsrsasign;
|
||||
- node-forge;
|
||||
- sm-crypto;
|
||||
- JOSE;
|
||||
- libsodium;
|
||||
- TweetNaCl;
|
||||
- Noble;
|
||||
- OpenPGP。
|
||||
|
||||
适配器用于识别通用调用语义,并不意味着所有混淆代码都可以无条件自动还原。自动化程度取决于录制证据是否能够证明“明文输入 → 页面调用 → 请求字段”或“线上响应字段 → 页面调用 → 明文输出”的完整链路;证据不足时,界面会明确展示缺失环节,并引导进入深度捕获或 AI 辅助分析。
|
||||
|
||||
### 2. 登录态上下文与 AI Agent 协作
|
||||
|
||||
扩展可以把用户明确共享的浏览器页面转换为适合 Agent 使用的结构化上下文,而不是直接导出完整 HTML 或浏览器 Profile。
|
||||
|
||||
- 采集页面文档信息、认证信号、表单、交互元素、开放 Shadow DOM、Storage 与 Cookie 清单;
|
||||
- 使用文档绑定的节点引用执行检查、点击、聚焦、滚动和输入等操作;
|
||||
- 追踪上下文差异,帮助 Agent 判断登录、跳转和业务状态变化;
|
||||
- 在独立权限下调用页面已有函数,或执行表达式与程序级 Eval;
|
||||
- 捕获已授权文档的真实网络请求,并生成可在 Yakit 中重放的报文;
|
||||
- 将录制 Trace、Callable、Transform Profile、请求事务和验证结果作为 Agent 工具能力;
|
||||
- 在 Agent 遇到扫码、MFA、CAPTCHA 或设备确认时创建“人工接管”任务,聚焦目标标签页并等待用户完成后继续。
|
||||
|
||||
程序级 Eval、敏感网络字段、深度捕获和页面控制均属于高风险 Scope,不会被只读共享会话隐式包含。
|
||||
|
||||
### 3. 双身份水平与垂直授权测试
|
||||
|
||||
授权测试工作区用于组织两个真实登录身份,并以可复核的方式验证资源访问或权限动作。
|
||||
|
||||
- 在普通窗口、无痕窗口或其他受支持的隔离上下文中选择身份 A / B;
|
||||
- 校验 Cookie Store、认证材料和页面上下文是否真正隔离;
|
||||
- 自动读取双方最近的同类业务请求,建立 A/B 正常基线;
|
||||
- 从 Query、Path、Header、表单或结构化 Body 中提取资源候选;
|
||||
- 水平授权测试使用固定请求预算构造 A-own、B-own、A-to-B、B-to-A 四项矩阵;
|
||||
- 垂直授权测试对比低权限控制请求与高权限目标动作,并明确提示潜在副作用;
|
||||
- 对状态码、响应结构、业务字段和目标身份正常响应进行差异比较;
|
||||
- 对时间戳、请求 ID 等易变噪声进行归一化,保留可解释的业务差异;
|
||||
- 将短时证据包交给 Yakit 与 AI Agent 深入分析,同时避免用插件预判结论暗示 AI;
|
||||
- 由确定性证据给出“观察到什么”,由用户、业务规则和独立 AI 复核决定是否构成真实授权缺陷。
|
||||
|
||||
扩展不会因为交叉请求返回 `200` 就直接判定越权,也不会绕过真实身份隔离要求。
|
||||
|
||||
### 4. 自动代理与规则系统
|
||||
|
||||
代理模块面向日常安全测试和多出口切换,交互方式接近现代化的 SwitchyOmega / ZeroOmega 工作流,但针对大规则集和扩展运行时做了重新设计。
|
||||
|
||||
- 创建并管理多个代理出口;
|
||||
- 为不同域名、URL 模式或规则条件选择指定出口;
|
||||
- 支持直连、代理和自动切换情景;
|
||||
- 在 Popup 中快速切换当前出口,或为当前站点建立规则;
|
||||
- 导入和更新远程规则订阅;
|
||||
- 将规则编译为 PAC,并在应用前完成规范化和错误检查;
|
||||
- 使用 IndexedDB 按块保存大型规则源,避免把完整订阅反复塞入同步状态;
|
||||
- 缓存有限数量的编译产物,并清理过期 Revision;
|
||||
- 通过分页与搜索读取规则,不要求一次渲染全部内容。
|
||||
|
||||
该模块只决定浏览器请求应当走哪个代理出口。Yak MITM 可以作为其中一个代理出口使用,但扩展不会替用户控制或改变 MITM 内部规则。
|
||||
|
||||
### 5. Cookie Editor 与 User-Agent 快速切换
|
||||
|
||||
Popup 提供针对当前站点的高频操作,Options 提供完整管理界面。
|
||||
|
||||
- 查看、添加、编辑和删除当前站点 Cookie;
|
||||
- 支持 Domain、Path、SameSite、安全标记与分区 Cookie 元数据;
|
||||
- 支持 Cookie 过滤、导入与导出;
|
||||
- 提供常用设备 User-Agent 模板;
|
||||
- 创建、保存和删除自定义 User-Agent;
|
||||
- 将 User-Agent 分配给指定 Hostname,并立即作用于真实网络请求头。
|
||||
|
||||
User-Agent 工具只修改网络请求头,不伪装 `navigator`、Client Hints、屏幕信息、Canvas、TLS 或其他浏览器指纹。界面会明确提示这一边界。
|
||||
|
||||
### 6. 安全配对与浏览器共享会话
|
||||
|
||||
Yak gRPC 进程内置 Browser Bridge v3,扩展无需额外启动桥接脚本,也不依赖手工复制的长期 Token。
|
||||
|
||||
- 首次配对显示六位校验码,由用户同时在扩展与 Yakit 中确认;
|
||||
- 扩展生成不可导出的 ECDSA P-256 安装身份,Yak 保存独立引擎身份;
|
||||
- 后续连接通过双向身份、签名挑战、扩展 Origin 和安装 ID 完成认证;
|
||||
- 支持多个浏览器设备同时在线,并按设备 ID 精确路由任务;
|
||||
- 共享会话绑定标签页、Frame、文档、Origin、Task、Scope 和过期时间;
|
||||
- 页面刷新、文档替换或跨来源导航不会静默继承原授权;
|
||||
- 用户可以随时暂停 Agent、撤销共享会话或在 Yakit 中撤销整个浏览器设备;
|
||||
- 审计日志只记录方法、目标类型、耗时、结果和错误码等元数据,不保存 Cookie 值、页面正文、Eval 源码、请求载荷或执行结果。
|
||||
|
||||
## 产品界面
|
||||
|
||||
| 界面 | 主要用途 |
|
||||
| --- | --- |
|
||||
| Popup | 查看当前页面与引擎状态;快速切换代理、Cookie 和 User-Agent;进入完整工作区。 |
|
||||
| Options | 运行概览、授权测试、代理出口、自动切换、规则订阅、网络活动、Cookie Editor、UA 管理、登录态工作区、引擎连接与操作记录。 |
|
||||
| 页面悬浮面板 | 贴近当前页面的轻量操作入口,可吸附屏幕边缘,并在需要时展开代理、接管和任务状态。 |
|
||||
| Yakit 浏览器集成 | 管理已配对设备、执行浏览器任务、查看录制与明文网关,并与 Web Fuzzer 联动。 |
|
||||
| Yak AI Agent | 调用经过授权的浏览器能力,分析证据、编排流程,并在必须人工参与时等待用户接管。 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境准备
|
||||
|
||||
完整能力需要以下组件:
|
||||
|
||||
- Chrome / Chromium / Edge,或受支持的 Firefox;
|
||||
- Node.js 与 pnpm;
|
||||
- 包含 Browser Bridge v3 的 Yak 引擎;
|
||||
- 包含“浏览器集成”工作区的 Yakit。
|
||||
|
||||
仅使用代理、Cookie、User-Agent 等本地工具时,不要求连接 Yak 引擎。
|
||||
|
||||
### 安装依赖并启动开发模式
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yaklang/yaklang-chrome-extension.git
|
||||
cd yaklang-chrome-extension
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
如果浏览器没有自动加载扩展:
|
||||
|
||||
1. 打开 `chrome://extensions`;
|
||||
2. 开启“开发者模式”;
|
||||
3. 选择“加载已解压的扩展程序”;
|
||||
4. 加载 `.output/chrome-mv3-dev`。
|
||||
|
||||
### WSL 开发
|
||||
|
||||
WXT 在 WSL 环境中不会自动打开浏览器。项目提供了单独的启动器:
|
||||
WXT intentionally refuses to launch browsers automatically when it detects WSL, even when WSLg and a Linux Chrome are available. Use the project runner instead:
|
||||
|
||||
```bash
|
||||
pnpm dev:wsl
|
||||
```
|
||||
|
||||
开发 Profile 位于 `.wxt/chrome-wsl-profile`。当前稳定版 Chrome 不接受无人值守的 `--load-extension` 参数时,首次仍需在 `chrome://extensions` 手动加载 `.output/chrome-mv3-dev`;之后该 Profile 会记住扩展。
|
||||
It keeps the development profile in `.wxt/chrome-wsl-profile`. Official Chrome 137+ no longer accepts `--load-extension`, so the first run opens `chrome://extensions`: enable Developer mode and load `.output/chrome-mv3-dev` once. The profile remembers it on later runs.
|
||||
|
||||
如需指定 Chromium 或 Chrome for Testing:
|
||||
Chromium and Chrome for Testing still support automatic loading. Select one with:
|
||||
|
||||
```bash
|
||||
CHROME_PATH=/path/to/chromium pnpm dev:wsl
|
||||
```
|
||||
|
||||
## 连接 Yak 与 Yakit
|
||||
Production builds:
|
||||
|
||||
在 Yak 仓库中启动 gRPC 引擎:
|
||||
```bash
|
||||
# Chrome Web Store: User Scripts MAIN, no direct Eval bridge
|
||||
pnpm build
|
||||
# Explicitly named store output
|
||||
pnpm build:store
|
||||
# Managed/local deployment: User Scripts MAIN with packaged bridge fallback
|
||||
pnpm build:enterprise
|
||||
# Local/enterprise Firefox MV2 injected bridge
|
||||
pnpm build:firefox
|
||||
# Public Firefox MV3 AMO invoke-only package
|
||||
pnpm build:firefox:amo
|
||||
```
|
||||
|
||||
Chrome 138+ requires the user to enable **Allow User Scripts** on the extension details page before the store build can run page-world Eval. The extension reports this condition explicitly and does not fall back to direct Eval.
|
||||
|
||||
Production verification:
|
||||
|
||||
```bash
|
||||
pnpm verify:production
|
||||
pnpm verify:ui:store
|
||||
pnpm verify:ui:enterprise
|
||||
pnpm verify:ui:enterprise:fallback
|
||||
pnpm verify:native
|
||||
```
|
||||
|
||||
`verify:production` runs Vitest and enforces permission, managed-policy, execution-channel, `webRequest`, `debugger`, fixture-leakage, and web-accessible-resource policies across four packages. Content-script, background, recorder, compressed-background, and total package sizes remain visible as advisory reference metrics; exceeding those references does not block a build. Runtime performance is verified with bounded workloads and real browser flows instead of treating bundle size as a proxy for responsiveness. Browser E2E covers Chrome Store User Scripts, Enterprise User Scripts, and the Enterprise injected fallback, including document-bound grants, context diff, stable node operations, expression/program scope separation, pause/resume/revoke, human handoff, request capture, exact value and correlated channel Trace links, form/query field evidence, short-sample replay, recording-to-callable interaction, callable lifecycle management, same-tab navigation continuation and browser Back, deep-capture frame provenance/scope expansion, Yakit workflows, split storage, Service Worker restart, audit/diagnostic redaction, strict CSP, fail-closed tab teardown, and 320/390/desktop UI bounds. The Chromium fixture additionally uses real sm-crypto and minified node-forge browser bundles, a randomized non-global ESM closure holding a real WebAssembly instance, and an opaque Worker path; every retained callable/profile is checked by an independent server. The performance gate covers 1,000 small calls, 10 × 1 MiB calls, event exhaustion, oversized replay-handle rejection, and post-stop API restoration. `verify:native` builds the Go host and exercises Chromium Native Messaging through the host into a loopback Yak Bridge fixture; because Playwright cannot operate Chrome's toolbar permission prompt, only its disposable test copy pre-grants `nativeMessaging`, while the source Store package is asserted to remain optional.
|
||||
|
||||
Browser verification prefers `CHROMIUM_PATH`, then `CHROME_PATH`, Playwright's Chromium cache, Chrome for Testing, or system Chromium. It deliberately does not auto-select stable Google Chrome because current stable Chrome ignores unattended `--load-extension` startup flags.
|
||||
|
||||
## Pair with Yak and Yakit
|
||||
|
||||
The Yak gRPC process owns the local browser Bridge. The standard command starts Bridge v3 on `127.0.0.1:64333` automatically, so there is no separate Bridge script or shared token to configure:
|
||||
|
||||
```bash
|
||||
go run common/yak/cmd/yak.go grpc --host 0.0.0.0
|
||||
```
|
||||
|
||||
标准启动会在 `127.0.0.1:64333` 自动启动 Browser Bridge,无需额外参数。
|
||||
Open **系统设置 -> 浏览器集成** in Yakit, then open **引擎连接** in the extension and choose **查找本机 Yakit**. Both surfaces display the same six-digit verification code. Compare the code and approve the pending browser in Yakit. The approval persists an origin-bound device identity; later connections authenticate automatically with signed challenges. Removing the device in Yakit immediately disconnects it and requires a new approval.
|
||||
|
||||
首次配对:
|
||||
To run a browser task, create a control sharing grant for the target tab in the extension, return to **系统设置 -> 浏览器集成**, and click the online browser row. The default browser-workspace view contains Plaintext Gateway, Recorder and Deep Capture modes; raw capability JSON and Yak code with request-bound `browser.ExtensionCall` remain advanced modes. Select a saved browser/profile pair from Web Fuzzer's **浏览器明文** control to make its editor the logical plaintext view; **明文 / 线上** shows the actual transmitted request and response beside it. Task state, logs, JSON results, cancellation, and errors are streamed in that workspace. Do not use the generic `ExecYakScript`/`grpc_execYak` runner for this flow: that runner starts a child Yak process and cannot own the parent gRPC process's live browser connections.
|
||||
|
||||
1. 在 Yakit 打开“系统设置 → 浏览器集成”;
|
||||
2. 在扩展 Options 打开“引擎连接”;
|
||||
3. 点击“查找本机 Yakit”;
|
||||
4. 对比扩展与 Yakit 显示的六位校验码;
|
||||
5. 确认一致后,在 Yakit 批准待配对浏览器。
|
||||
Advanced transport settings remain available for a non-default loopback port or Native Messaging deployment. `--browser-extension-bridge-port` changes the Yak listener, and `--disable-browser-extension-bridge` disables it explicitly.
|
||||
|
||||
配对完成后,设备身份会持久保存,后续通过签名挑战自动认证。若在 Yakit 中撤销设备,当前连接会立即关闭,浏览器必须重新配对。
|
||||
## Native Host and deployment
|
||||
|
||||
非默认部署可以使用:
|
||||
Build the Native Messaging transport from the Yak repository and register it with the signed or unpacked extension ID:
|
||||
|
||||
- `--browser-extension-bridge-port`:修改 Bridge 监听端口;
|
||||
- `--disable-browser-extension-bridge`:显式关闭 Browser Bridge;
|
||||
- Native Messaging:在浏览器无法直接访问回环 Bridge,或需要受管部署时使用。
|
||||
|
||||
Native Host 的构建与注册方式见 [native-host/README.md](./native-host/README.md)。
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 从真实页面生成明文网关
|
||||
|
||||
1. 打开目标页面,在扩展中选择对应标签页;
|
||||
2. 进入“网络活动”,在“录制”中开始一次操作;
|
||||
3. 回到目标页面,完成一次登录、查询、提交或解密操作;
|
||||
4. 停止录制,查看按时间排序的业务 Trace 与自动推断 Profile;
|
||||
5. 证据充分时直接生成明文网关;证据不足时按提示进入“深度捕获”,再执行一次最小业务动作;
|
||||
6. 在“明文网关”查看数据流、页面函数、字段映射和本地回放结果;
|
||||
7. 保存后创建或复用浏览器共享会话;
|
||||
8. 在 Yakit Web Fuzzer 选择对应的浏览器明文网关,编辑明文并发送。
|
||||
|
||||
页面刷新或跨文档导航后,依赖旧页面闭包的 Callable 可能失效。扩展会保留录制证据,但不会把旧函数静默绑定到新文档。
|
||||
|
||||
### 进行双身份授权测试
|
||||
|
||||
1. 准备两个已经登录不同账号、且认证上下文真正隔离的页面;
|
||||
2. 在“授权测试”中分别选择身份 A 与身份 B;
|
||||
3. 执行身份校验并开始双方请求捕获;
|
||||
4. 在两个页面分别完成同类业务动作;
|
||||
5. 让扩展自动选择正常基线,或手动确认请求;
|
||||
6. 选择资源字段或权限动作,审阅确定性测试计划;
|
||||
7. 确认请求预算与潜在副作用后执行;
|
||||
8. 查看四项矩阵、报文、结构化差异和业务归属证据;
|
||||
9. 将短时证据包交给 Yakit / AI Agent 做独立复核。
|
||||
|
||||
同一浏览器普通窗口中的两个标签页通常共享 Cookie,不能仅凭“两个 Tab”证明身份隔离。Chromium 推荐使用普通窗口与无痕窗口,Firefox 可使用受支持的隔离上下文。
|
||||
|
||||
### 让 Agent 使用登录后的页面
|
||||
|
||||
1. 在“引擎连接”中勾选需要共享的 Tab 与 Frame;
|
||||
2. 选择只读或控制权限预设,并设置 15 分钟至 4 小时的有效期;
|
||||
3. 如确有必要,单独开启程序级 Eval;
|
||||
4. 创建浏览器共享会话;
|
||||
5. 在 Yakit 或 Yak AI Agent 中选择对应设备执行任务;
|
||||
6. 随时在扩展中暂停、恢复或撤销会话。
|
||||
|
||||
## 构建与验证
|
||||
|
||||
### 常用命令
|
||||
|
||||
| 命令 | 用途 |
|
||||
| --- | --- |
|
||||
| `pnpm dev` | 启动 Chromium 开发模式。 |
|
||||
| `pnpm dev:wsl` | 使用持久 Profile 启动 WSL 开发环境。 |
|
||||
| `pnpm dev:firefox` | 启动 Firefox 开发模式。 |
|
||||
| `pnpm compile` | 运行 TypeScript 类型检查,不生成文件。 |
|
||||
| `pnpm test` | 运行 Vitest 测试。 |
|
||||
| `pnpm build:store` | 构建 Chrome Store 包。 |
|
||||
| `pnpm build:enterprise` | 构建本地或企业受管部署包。 |
|
||||
| `pnpm build:firefox` | 构建 Firefox MV2 包。 |
|
||||
| `pnpm build:firefox:amo` | 构建 Firefox MV3 AMO 包。 |
|
||||
| `pnpm verify:production` | 运行测试、类型检查、多目标构建和生产策略审计。 |
|
||||
| `pnpm verify:ui:store` | 验证 Chrome Store 运行路径。 |
|
||||
| `pnpm verify:ui:enterprise` | 验证 Enterprise User Scripts 路径。 |
|
||||
| `pnpm verify:ui:enterprise:fallback` | 验证 Enterprise 注入回退路径。 |
|
||||
| `pnpm verify:native` | 验证 Native Messaging Host 与 Bridge 链路。 |
|
||||
|
||||
`verify:production` 会连续执行测试、类型检查和多个浏览器目标构建,资源占用明显高于单项命令。日常开发建议先运行与改动相关的测试和 `pnpm compile`,发布前再执行完整验证。
|
||||
|
||||
### 构建差异
|
||||
|
||||
| 构建 | 页面执行通道 | 适用场景 |
|
||||
| --- | --- | --- |
|
||||
| Chrome Store | User Scripts MAIN,不包含直接 Eval 注入回退 | 商店策略兼容分发。 |
|
||||
| Chrome Enterprise | User Scripts MAIN,并提供受控的打包回退通道 | 本地安装、企业受管与高级测试。 |
|
||||
| Firefox MV2 | Firefox 页面注入通道 | 本地与企业 Firefox 环境。 |
|
||||
| Firefox MV3 AMO | Invoke-only 公共分发包 | Firefox AMO 策略兼容分发。 |
|
||||
|
||||
Chrome Store 构建声明 Chrome 138+。用户需要在扩展详情页开启“允许用户脚本”,页面主世界能力才能正常工作;未开启时扩展会明确报告原因,不会静默降级为直接 Eval。
|
||||
|
||||
### 发布与下载
|
||||
|
||||
发布由 GitHub Actions 的 **Build and Release** workflow(手动触发)完成:执行 `verify:production` 全量校验后,将四个变体打包为不可变的版本化产物上传到 OSS,再发布机器可读的 manifest,并从公网侧回读验证。CI 在每次 push / PR 时运行同一套构建与审计。
|
||||
|
||||
**下载入口**(不要硬编码版本号):
|
||||
|
||||
```
|
||||
https://aliyun-oss.yaklang.com/chrome-extension/manifest.json
|
||||
```bash
|
||||
go build -o yakit-browser-agent-host ./common/browser/nativehostcmd
|
||||
./native-host/install.sh --host-binary /absolute/path/to/yakit-browser-agent-host --extension-id YOUR_EXTENSION_ID
|
||||
```
|
||||
|
||||
manifest 的 `latest` 指向最新版本,`versions[0]` 为完整记录,最多保留 10 个历史版本。每个版本按 `variant`(`chrome-store` / `chrome-enterprise` / `firefox` / `firefox-amo`)匹配 artifact,字段包括 `url`、`filename`、`sha256`、`size` 与 `checksum_url`;manifest 自身的 SHA-256 在同目录的 `manifest.json.sha256.txt`。
|
||||
|
||||
推荐的消费流程:
|
||||
|
||||
1. 拉取 `manifest.json`(缓存 5 分钟),按需选择版本与变体;
|
||||
2. 下载 artifact(版本化 URL 永不变更,缓存一年)到临时文件;
|
||||
3. 校验 `size` 与 `sha256`(或对比 `checksum_url` 内容)后,解压并安装;
|
||||
4. 变体用途见上表“构建差异”。
|
||||
|
||||
**发布契约**:
|
||||
|
||||
- 版本化产物不可变:URL 形如 `…/chrome-extension/<version>/<variant>-<version>.zip`,重复发布同版本时内容一致则跳过、不一致则流水线报错拒绝覆盖;
|
||||
- `manifest.json` 可变、缓存 5 分钟,先发布 manifest 再发布其校验文件,消费方可用校验文件识别中间态;
|
||||
- 发布 job 结束前有独立的 verify job 从公网下载全部产物,复核 sha256、缓存头与 zip 内 `manifest.json` 版本。
|
||||
|
||||
## 权限与数据边界
|
||||
|
||||
扩展声明 `tabs`、`scripting`、`cookies`、`proxy`、`webRequest`、`webNavigation`、`debugger` 等权限,是为了在用户主动选择的目标页面上提供对应安全测试能力。`nativeMessaging` 是可选权限,仅在用户选择 Native 模式时请求。
|
||||
|
||||
默认数据策略:
|
||||
|
||||
- Cookie、Storage、表单值、请求 Header / Body 和录制值预览分别受独立 Scope 控制;
|
||||
- 敏感网络字段与录制短时样本默认关闭或仅在当前会话保留;
|
||||
- 页面上下文采用有界结构化快照,不导出完整页面 HTML;
|
||||
- Deep Capture 可以保留页面内对象引用,但不会主动导出不可提取密钥;
|
||||
- 操作审计与诊断导出不包含 URL 参数值、Cookie、载荷、Eval 代码、调用参数或结果;
|
||||
- 远程订阅、代理配置和浏览器状态保存在扩展本地存储或 IndexedDB;
|
||||
- 撤销 Grant、关闭目标文档或断开设备会终止相关远程能力。
|
||||
|
||||
更完整的 Capability、Grant、Bridge、Native Messaging、网络捕获、Recorder、Deep Capture 和 Transform Gateway 设计见 [ARCHITECTURE.md](./ARCHITECTURE.md)。视觉与交互规范见 [DESIGN.md](./DESIGN.md)。
|
||||
|
||||
## 浏览器支持与已知边界
|
||||
|
||||
- Chromium 系浏览器提供完整 Deep Capture 调试能力;Firefox 不提供完全相同的 Debugger 协议能力;
|
||||
- Store、Enterprise、Firefox MV2 与 Firefox AMO 构建的页面执行通道和权限不同;
|
||||
- 页面导航可能使文档绑定节点、Grant、Callable 和 Transform Profile 失效,扩展不会跨文档静默复用高权限引用;
|
||||
- 前端代码严重混淆、动态加载、原生模块、远程证明或服务端参与的算法不保证全自动还原;
|
||||
- 自动推断只在证据满足约束时生成 Profile,必要时仍需要测试者选择业务函数、确认字段语义或使用 AI 分析;
|
||||
- User-Agent 修改不等于完整设备指纹伪装;
|
||||
- 浏览器会话能够复用登录态,但不能替代目标授权、业务理解和测试人员的最终判断。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
src/
|
||||
├── app/background/ # Background 路由与功能 Handler
|
||||
├── components/ # 品牌与共享 UI 组件
|
||||
├── entrypoints/ # Popup、Options、Content、Floating、MAIN-world 入口
|
||||
├── features/ # 代理、录制、加解密、授权测试、Bridge 等领域模块
|
||||
├── platform/ # 浏览器、策略、消息与存储适配层
|
||||
├── protocol/ # Bridge、Capability、Storage 与 Transform Schema
|
||||
├── shared/ # 无状态公共工具
|
||||
├── styles/ # 设计 Token、主题与基础 UI 样式
|
||||
└── types/ # 跨模块领域模型
|
||||
|
||||
native-host/ # Native Messaging 安装脚本与说明
|
||||
public/ # 图标、品牌资源与 Managed Storage Schema
|
||||
scripts/ # 构建审计、浏览器验证与契约测试
|
||||
ARCHITECTURE.md # 系统架构与安全边界
|
||||
DESIGN.md # UI 设计系统
|
||||
wxt.config.ts # WXT 与浏览器 Manifest 配置
|
||||
```
|
||||
|
||||
## 相关项目
|
||||
|
||||
- [Yaklang](https://github.com/yaklang/yaklang):Yak 语言、安全引擎、Browser Bridge 与 AI Agent 能力;
|
||||
- [Yakit](https://github.com/yaklang/yakit):安全测试桌面端、浏览器集成工作区与 Web Fuzzer;
|
||||
- [WXT](https://wxt.dev/):本扩展使用的跨浏览器扩展开发框架。
|
||||
|
||||
## 负责任地使用
|
||||
|
||||
浏览器真实上下文能够显著降低复杂登录态、前端加密和授权测试的操作成本,也意味着错误操作可能读取敏感数据、发送真实请求或改变业务状态。请在执行前确认目标范围、账号权限、请求预算和副作用,并保留必要的人工复核。
|
||||
|
||||
Yakit Browser Agent 的目标不是替用户隐藏风险,而是把风险、证据、权限和执行现场放在同一个可理解、可控制的工作流中。
|
||||
Windows uses `native-host/install.ps1`. Native Messaging is an optional browser permission requested only when Native mode is selected. See [Browser Transform Gateway](docs/BROWSER_TRANSFORM_GATEWAY.md), [Deep Capture architecture](docs/DEEP_CAPTURE_ARCHITECTURE.md), the [frontend crypto generalization roadmap](docs/FRONTEND_CRYPTO_GENERALIZATION_ROADMAP.md), [Native Host installation](native-host/README.md), [enterprise policy](docs/ENTERPRISE_POLICY.md), [permissions](docs/PERMISSIONS.md), [privacy](docs/PRIVACY_POLICY.md), and the [release review packet](docs/store-review/RELEASE_CHECKLIST.md).
|
||||
|
||||
+1
-5
@@ -2,9 +2,8 @@
|
||||
"name": "yakit-chrome-client",
|
||||
"description": "Yakit Browser Extension",
|
||||
"private": true,
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
"dev:wsl": "node scripts/dev-wsl.mjs",
|
||||
@@ -50,13 +49,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsrsasign": "10.5.15",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@wxt-dev/module-react": "^1.2.2",
|
||||
"adm-zip": "^0.5.16",
|
||||
"ali-oss": "^6.21.0",
|
||||
"jose": "6.2.3",
|
||||
"jsencrypt": "3.5.4",
|
||||
"jsrsasign": "11.1.3",
|
||||
|
||||
Generated
-585
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
import { access, readFile, stat } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
|
||||
@@ -12,7 +11,6 @@ const TOTAL_PACKAGE_BUDGET = Math.floor(1.25 * MIB);
|
||||
const BRIDGE_BACKGROUND_BUDGET = 204 * 1024;
|
||||
const BRIDGE_BACKGROUND_GZIP_BUDGET = 60 * 1024;
|
||||
const ENTERPRISE_BACKGROUND_GZIP_BUDGET = 61 * 1024;
|
||||
const CHROMIUM_EXTENSION_ID = 'mcnaombmlombekhbonfndagbcfhmoail';
|
||||
// Recorder, callable registry and Pipeline runtime are installed only for an
|
||||
// explicitly selected document. Keep their budget separate from the always-on
|
||||
// Service Worker so moving work out of startup code remains measurable.
|
||||
@@ -105,12 +103,6 @@ for (const target of targets) {
|
||||
const resources = (manifest.web_accessible_resources || []).flatMap((entry) => typeof entry === 'string' ? [entry] : entry.resources || []);
|
||||
const dynamicResourceGroup = (manifest.web_accessible_resources || []).find((entry) => typeof entry !== 'string' && entry.resources?.includes('floating.html'));
|
||||
|
||||
if (!isFirefox) {
|
||||
assert(typeof manifest.key === 'string', `${target.name} 缺少固定扩展公钥`);
|
||||
const extensionId = createHash('sha256').update(Buffer.from(manifest.key, 'base64')).digest('hex').slice(0, 32).replace(/[0-9a-f]/g, (digit) => String.fromCharCode(97 + Number.parseInt(digit, 16)));
|
||||
assert(extensionId === CHROMIUM_EXTENSION_ID, `${target.name} 扩展 ID 漂移:${extensionId}`);
|
||||
}
|
||||
|
||||
if (contentBytes > target.contentBudget) sizeAdvisories.push(`content script ${contentBytes}B > ${target.contentBudget}B reference`);
|
||||
if (backgroundBytes > target.backgroundBudget) sizeAdvisories.push(`background ${backgroundBytes}B > ${target.backgroundBudget}B reference`);
|
||||
if (backgroundGzipBytes > target.backgroundGzipBudget) sizeAdvisories.push(`background gzip ${backgroundGzipBytes}B > ${target.backgroundGzipBudget}B reference`);
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Merges the freshly packaged release (dist/release-entry.json) into the
|
||||
* public manifest and writes manifest.json + manifest.json.sha256.txt.
|
||||
*
|
||||
* The manifest is the single entry point consumers read: `latest` plus a
|
||||
* bounded `versions[]` history. Artifact objects are immutable and their URLs
|
||||
* are never rewritten; only this manifest moves.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-manifest.mjs --release-entry=dist/release-entry.json \
|
||||
* [--existing-manifest=dist/existing-manifest.json] [--max-versions=10] \
|
||||
* --output=dist/manifest.json --checksum-output=dist/manifest.json.sha256.txt
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (const arg of argv) {
|
||||
if (!arg.startsWith('--')) throw new Error(`unexpected argument: ${arg}`);
|
||||
const eq = arg.indexOf('=');
|
||||
const key = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
||||
out[key] = eq === -1 ? true : arg.slice(eq + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function artifactFingerprint(artifacts) {
|
||||
return artifacts.map((a) => `${a.variant}:${a.sha256}`).sort().join('|');
|
||||
}
|
||||
|
||||
function toVersionEntry(entry) {
|
||||
return {
|
||||
version: entry.version,
|
||||
published_at: entry.built_at,
|
||||
commit: entry.commit ?? null,
|
||||
artifacts: entry.artifacts.map((a) => ({
|
||||
variant: a.variant,
|
||||
browser: a.browser,
|
||||
mode: a.mode,
|
||||
filename: a.filename,
|
||||
url: a.url,
|
||||
sha256: a.sha256,
|
||||
size: a.size,
|
||||
checksum_url: a.checksum_url,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function validate(manifest) {
|
||||
if (!Array.isArray(manifest.versions) || manifest.versions.length === 0) {
|
||||
throw new Error('manifest must contain at least one version');
|
||||
}
|
||||
if (manifest.latest !== manifest.versions[0].version) {
|
||||
throw new Error(`manifest.latest (${manifest.latest}) must equal versions[0].version (${manifest.versions[0].version})`);
|
||||
}
|
||||
const seen = new Set();
|
||||
for (const versionEntry of manifest.versions) {
|
||||
if (seen.has(versionEntry.version)) throw new Error(`duplicate version in manifest: ${versionEntry.version}`);
|
||||
seen.add(versionEntry.version);
|
||||
if (!Array.isArray(versionEntry.artifacts) || versionEntry.artifacts.length === 0) {
|
||||
throw new Error(`version ${versionEntry.version} has no artifacts`);
|
||||
}
|
||||
const variants = new Set();
|
||||
for (const artifact of versionEntry.artifacts) {
|
||||
if (variants.has(artifact.variant)) throw new Error(`duplicate variant ${artifact.variant} in version ${versionEntry.version}`);
|
||||
variants.add(artifact.variant);
|
||||
if (!/^[0-9a-f]{64}$/.test(artifact.sha256)) throw new Error(`artifact ${artifact.filename}: bad sha256`);
|
||||
if (!Number.isInteger(artifact.size) || artifact.size <= 0) throw new Error(`artifact ${artifact.filename}: bad size`);
|
||||
if (!/^https?:\/\//.test(artifact.url)) throw new Error(`artifact ${artifact.filename}: url must be absolute`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args['release-entry']) throw new Error('--release-entry is required');
|
||||
if (!args.output) throw new Error('--output is required');
|
||||
if (!args['checksum-output']) throw new Error('--checksum-output is required');
|
||||
|
||||
const entry = JSON.parse(await readFile(resolve(root, String(args['release-entry'])), 'utf8'));
|
||||
const maxVersions = Number.parseInt(String(args['max-versions'] ?? '10'), 10);
|
||||
if (!Number.isInteger(maxVersions) || maxVersions < 1) throw new Error('--max-versions must be a positive integer');
|
||||
|
||||
let versions = [];
|
||||
let existingUpdatedAt = null;
|
||||
let existingManifestBytes = null;
|
||||
if (args['existing-manifest']) {
|
||||
try {
|
||||
existingManifestBytes = await readFile(resolve(root, String(args['existing-manifest'])));
|
||||
const existing = JSON.parse(existingManifestBytes.toString('utf8'));
|
||||
versions = Array.isArray(existing.versions) ? existing.versions : [];
|
||||
existingUpdatedAt = typeof existing.updated_at === 'string' ? existing.updated_at : null;
|
||||
} catch (err) {
|
||||
if (err?.code !== 'ENOENT') throw err;
|
||||
console.log('existing manifest not found; starting a fresh history');
|
||||
}
|
||||
}
|
||||
|
||||
const newEntry = toVersionEntry(entry);
|
||||
const idx = versions.findIndex((v) => v.version === entry.version);
|
||||
if (idx >= 0 && artifactFingerprint(versions[idx].artifacts) === artifactFingerprint(entry.artifacts)) {
|
||||
// Idempotent rerun: keep the original entry (published_at stays stable).
|
||||
console.log(`version ${entry.version} already in manifest with identical artifacts; kept as-is`);
|
||||
} else {
|
||||
if (idx >= 0) {
|
||||
versions.splice(idx, 1);
|
||||
console.log(`version ${entry.version} re-published with different artifacts; replaced entry`);
|
||||
}
|
||||
versions.unshift(newEntry);
|
||||
}
|
||||
versions = versions.slice(0, maxVersions);
|
||||
|
||||
// Preserve the previous updated_at when nothing actually changed: a no-op
|
||||
// re-publish would otherwise produce new manifest bytes (and a new checksum)
|
||||
// for identical content, racing the CDN's cache window.
|
||||
const candidate = { latest: versions[0].version, updated_at: '__now__', versions };
|
||||
const rebuildWith = (updatedAt) => JSON.stringify({ ...candidate, updated_at: updatedAt }, null, 2);
|
||||
const previousBytes = existingManifestBytes ? existingManifestBytes.toString('utf8').trimEnd() : null;
|
||||
const unchanged = existingUpdatedAt !== null && previousBytes === rebuildWith(existingUpdatedAt);
|
||||
const manifest = { ...candidate, updated_at: unchanged ? existingUpdatedAt : new Date().toISOString() };
|
||||
validate(manifest);
|
||||
|
||||
const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||
await writeFile(resolve(root, String(args.output)), bytes);
|
||||
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
||||
await writeFile(resolve(root, String(args['checksum-output'])), `${sha256} manifest.json\n`);
|
||||
console.log(`manifest written: ${args.output} (latest=${manifest.latest}, ${versions.length} version(s) retained${unchanged ? ', content unchanged' : ''})`);
|
||||
@@ -1,143 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Packages the release variants from .output into dist/<version>/ and writes
|
||||
* dist/release-entry.json recording filename/size/sha256/url for every
|
||||
* artifact, plus per-artifact .sha256.txt checksum files.
|
||||
*
|
||||
* The variant table must stay in sync with `verify:production` (package.json)
|
||||
* and scripts/audit-build.mjs — those define the published surface.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/package-release.mjs --public-base-url=https://aliyun-oss.yaklang.com/chrome-extension [--dist=dist]
|
||||
*/
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createReadStream, readdirSync } from 'node:fs';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { promisify } from 'node:util';
|
||||
import AdmZip from 'adm-zip';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
const VARIANTS = [
|
||||
{ variant: 'chrome-store', browser: 'chrome', mode: 'store', dir: '.output/chrome-mv3-store' },
|
||||
{ variant: 'chrome-enterprise', browser: 'chrome', mode: 'enterprise', dir: '.output/chrome-mv3-enterprise' },
|
||||
{ variant: 'firefox', browser: 'firefox', mode: 'production', dir: '.output/firefox-mv2' },
|
||||
{ variant: 'firefox-amo', browser: 'firefox', mode: 'store', dir: '.output/firefox-mv3-store' },
|
||||
];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (const arg of argv) {
|
||||
if (!arg.startsWith('--')) throw new Error(`unexpected argument: ${arg}`);
|
||||
const eq = arg.indexOf('=');
|
||||
const key = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
||||
out[key] = eq === -1 ? true : arg.slice(eq + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function exists(path) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256File(path) {
|
||||
const hash = createHash('sha256');
|
||||
await pipeline(createReadStream(path), hash);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args['public-base-url']) {
|
||||
throw new Error('--public-base-url is required (e.g. https://aliyun-oss.yaklang.com/chrome-extension)');
|
||||
}
|
||||
const baseUrl = String(args['public-base-url']).replace(/\/+$/, '');
|
||||
const distDir = resolve(root, String(args.dist ?? 'dist'));
|
||||
|
||||
const pkg = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'));
|
||||
const { version } = pkg;
|
||||
|
||||
let commit = null;
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: root });
|
||||
commit = stdout.trim();
|
||||
} catch {
|
||||
// Not fatal: local runs outside a git worktree still package fine.
|
||||
}
|
||||
// Reproducibility must hold per VERSION, not per commit: a workflow that fails
|
||||
// late (e.g. at the summary step) gets fixed on a follow-up commit and re-run
|
||||
// for the same version, and the immutable no-overwrite guard then needs the
|
||||
// rebuilt zip to match byte-for-byte. So pin entry timestamps to a fixed
|
||||
// epoch (SOURCE_DATE_EPOCH convention) instead of anything commit-derived.
|
||||
const FIXED_EPOCH = Date.UTC(2025, 0, 1);
|
||||
const pinned = new Date(Math.floor((Number(process.env.SOURCE_DATE_EPOCH) || FIXED_EPOCH) / 2000) * 2000); // DOS time has 2s granularity
|
||||
|
||||
// readdir order is not stable across machines, and adm-zip preserves it.
|
||||
// Walk sorted so every runner emits entries in the same order.
|
||||
function collectSorted(dir, base = '') {
|
||||
const files = [];
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||
for (const entry of entries) {
|
||||
const rel = base ? `${base}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) files.push(...collectSorted(join(dir, entry.name), rel));
|
||||
else files.push(rel);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const versionDir = resolve(distDir, version);
|
||||
await mkdir(versionDir, { recursive: true });
|
||||
|
||||
const artifacts = [];
|
||||
for (const target of VARIANTS) {
|
||||
const outputDir = resolve(root, target.dir);
|
||||
if (!(await exists(resolve(outputDir, 'manifest.json')))) {
|
||||
throw new Error(`${target.variant}: ${target.dir}/manifest.json missing — run the build first (pnpm verify:production)`);
|
||||
}
|
||||
const builtManifest = JSON.parse(await readFile(resolve(outputDir, 'manifest.json'), 'utf8'));
|
||||
if (builtManifest.version !== version) {
|
||||
throw new Error(`${target.variant}: built manifest version ${builtManifest.version} != package.json version ${version}`);
|
||||
}
|
||||
|
||||
const filename = `${target.variant}-${version}.zip`;
|
||||
const zipPath = resolve(versionDir, filename);
|
||||
// Entry paths are relative to the output dir so manifest.json sits at the
|
||||
// zip root, which is what browsers expect from a sideloaded extension.
|
||||
const zip = new AdmZip();
|
||||
for (const rel of collectSorted(outputDir)) {
|
||||
const slash = rel.lastIndexOf('/');
|
||||
const dir = slash === -1 ? '' : rel.slice(0, slash);
|
||||
zip.addLocalFile(join(outputDir, rel), dir, rel.slice(slash + 1));
|
||||
}
|
||||
for (const entry of zip.getEntries()) entry.header.time = pinned;
|
||||
await zip.writeZipPromise(zipPath);
|
||||
const sha256 = await sha256File(zipPath);
|
||||
const size = (await stat(zipPath)).size;
|
||||
await writeFile(resolve(versionDir, `${filename}.sha256.txt`), `${sha256} ${filename}\n`);
|
||||
|
||||
artifacts.push({
|
||||
variant: target.variant,
|
||||
browser: target.browser,
|
||||
mode: target.mode,
|
||||
filename,
|
||||
url: `${baseUrl}/${version}/${filename}`,
|
||||
sha256,
|
||||
size,
|
||||
checksum_url: `${baseUrl}/${version}/${filename}.sha256.txt`,
|
||||
});
|
||||
console.log(`packaged ${filename} (${size} bytes, sha256 ${sha256.slice(0, 12)}…)`);
|
||||
}
|
||||
|
||||
const entry = { version, commit, built_at: new Date().toISOString(), artifacts };
|
||||
await writeFile(resolve(distDir, 'release-entry.json'), `${JSON.stringify(entry, null, 2)}\n`);
|
||||
console.log(`release entry written: ${resolve(distDir, 'release-entry.json').slice(root.length + 1)} (version ${version})`);
|
||||
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Publishes release artifacts and the manifest to Aliyun OSS.
|
||||
*
|
||||
* The contract mirrors yaklang/browser-binaries-mirror:
|
||||
* - versioned artifacts are immutable: one-year immutable cache headers,
|
||||
* sha256 user meta, x-oss-forbid-overwrite on upload; an existing object
|
||||
* with a different sha256 is a hard error, an identical one is skipped
|
||||
* - manifest.json is mutable: five-minute cache; it is published first and
|
||||
* its checksum second, so consumers can always detect a torn publish by
|
||||
* verifying the checksum file
|
||||
*
|
||||
* Credentials come from OSS_KEY_ID / OSS_KEY_SECRET (org-level secrets).
|
||||
*
|
||||
* Usage:
|
||||
* OSS_KEY_ID=… OSS_KEY_SECRET=… node scripts/publish-oss.mjs release \
|
||||
* --release-entry=dist/release-entry.json [--dist=dist]
|
||||
* [--endpoint=https://oss-accelerate.aliyuncs.com] [--bucket=yaklang] [--prefix=chrome-extension]
|
||||
* OSS_KEY_ID=… OSS_KEY_SECRET=… node scripts/publish-oss.mjs manifest \
|
||||
* --manifest=dist/manifest.json --manifest-checksum=dist/manifest.json.sha256.txt [endpoint/bucket/prefix]
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import OSSModule from 'ali-oss';
|
||||
|
||||
const OSS = OSSModule.default ?? OSSModule;
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
const ARTIFACT_CACHE = 'public, max-age=31536000, immutable';
|
||||
const MANIFEST_CACHE = 'public, max-age=300, must-revalidate';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (const arg of argv) {
|
||||
if (!arg.startsWith('--')) throw new Error(`unexpected argument: ${arg}`);
|
||||
const eq = arg.indexOf('=');
|
||||
const key = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
||||
out[key] = eq === -1 ? true : arg.slice(eq + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const [subcommand, ...rest] = process.argv.slice(2);
|
||||
const args = parseArgs(rest);
|
||||
const endpoint = String(args.endpoint ?? 'https://oss-accelerate.aliyuncs.com');
|
||||
const bucket = String(args.bucket ?? 'yaklang');
|
||||
const prefix = String(args.prefix ?? 'chrome-extension').replace(/^\/+|\/+$/g, '');
|
||||
|
||||
const accessKeyId = process.env.OSS_KEY_ID;
|
||||
const accessKeySecret = process.env.OSS_KEY_SECRET;
|
||||
if (!accessKeyId || !accessKeySecret) {
|
||||
throw new Error('OSS_KEY_ID and OSS_KEY_SECRET must be set in the environment');
|
||||
}
|
||||
if (subcommand !== 'release' && subcommand !== 'manifest') {
|
||||
throw new Error(`unknown subcommand: ${subcommand ?? '(none)'} — expected "release" or "manifest"`);
|
||||
}
|
||||
|
||||
const client = new OSS({ accessKeyId, accessKeySecret, bucket, endpoint, secure: true });
|
||||
|
||||
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
// head() resolves to { meta, res, status }: raw headers live at res.headers
|
||||
// and x-oss-meta-* values are pre-parsed into meta.
|
||||
async function headObject(key) {
|
||||
try {
|
||||
const result = await client.head(key);
|
||||
return {
|
||||
size: Number(result.res.headers['content-length']),
|
||||
sha256: result.meta?.sha256 ?? null,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err && (err.status === 404 || err.code === 'NoSuchKey')) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function putObject(key, buffer, { mime, cacheControl, forbidOverwrite }) {
|
||||
const digest = sha256(buffer);
|
||||
await client.put(key, buffer, {
|
||||
mime,
|
||||
headers: {
|
||||
'Cache-Control': cacheControl,
|
||||
...(forbidOverwrite ? { 'x-oss-forbid-overwrite': 'true' } : {}),
|
||||
},
|
||||
meta: { sha256: digest },
|
||||
});
|
||||
const head = await headObject(key);
|
||||
if (!head) throw new Error(`upload verification failed, object missing: oss://${bucket}/${key}`);
|
||||
if (head.size !== buffer.length || head.sha256 !== digest) {
|
||||
throw new Error(`upload verification failed: oss://${bucket}/${key} (size ${head.size}/${buffer.length}, sha256 ${head.sha256}/${digest})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function putImmutable(key, buffer, mime) {
|
||||
const digest = sha256(buffer);
|
||||
const existing = await headObject(key);
|
||||
if (existing) {
|
||||
if (existing.size === buffer.length && existing.sha256 === digest) {
|
||||
console.log(`skip (identical object already published): oss://${bucket}/${key}`);
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`refusing to overwrite non-matching immutable object: oss://${bucket}/${key} ` +
|
||||
`(remote size=${existing.size} sha256=${existing.sha256 ?? 'unknown'}, local size=${buffer.length} sha256=${digest})`,
|
||||
);
|
||||
}
|
||||
await putObject(key, buffer, { mime, cacheControl: ARTIFACT_CACHE, forbidOverwrite: true });
|
||||
console.log(`uploaded: oss://${bucket}/${key} (${buffer.length} bytes)`);
|
||||
}
|
||||
|
||||
async function putMutable(key, buffer, mime) {
|
||||
await putObject(key, buffer, { mime, cacheControl: MANIFEST_CACHE, forbidOverwrite: false });
|
||||
console.log(`published: oss://${bucket}/${key}`);
|
||||
}
|
||||
|
||||
async function runRelease() {
|
||||
if (!args['release-entry']) throw new Error('release subcommand requires --release-entry');
|
||||
const entry = JSON.parse(await readFile(resolve(root, String(args['release-entry'])), 'utf8'));
|
||||
const versionDir = resolve(root, String(args.dist ?? 'dist'), entry.version);
|
||||
for (const artifact of entry.artifacts) {
|
||||
const zip = await readFile(resolve(versionDir, artifact.filename));
|
||||
const digest = sha256(zip);
|
||||
if (digest !== artifact.sha256) {
|
||||
throw new Error(`${artifact.filename}: on-disk sha256 ${digest} != release entry ${artifact.sha256}`);
|
||||
}
|
||||
await putImmutable(`${prefix}/${entry.version}/${artifact.filename}`, zip, 'application/zip');
|
||||
const checksum = await readFile(resolve(versionDir, `${artifact.filename}.sha256.txt`));
|
||||
await putImmutable(`${prefix}/${entry.version}/${artifact.filename}.sha256.txt`, checksum, 'text/plain; charset=utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
async function runManifest() {
|
||||
if (!args.manifest) throw new Error('manifest subcommand requires --manifest');
|
||||
if (!args['manifest-checksum']) throw new Error('manifest subcommand requires --manifest-checksum');
|
||||
const manifest = await readFile(resolve(root, String(args.manifest)));
|
||||
const checksum = await readFile(resolve(root, String(args['manifest-checksum'])), 'utf8');
|
||||
const expected = `${sha256(manifest)} manifest.json\n`;
|
||||
if (checksum !== expected) {
|
||||
throw new Error('manifest checksum file does not match manifest.json content');
|
||||
}
|
||||
await putMutable(`${prefix}/manifest.json`, manifest, 'application/json; charset=utf-8');
|
||||
await putMutable(`${prefix}/manifest.json.sha256.txt`, Buffer.from(checksum, 'utf8'), 'text/plain; charset=utf-8');
|
||||
}
|
||||
|
||||
if (subcommand === 'release') {
|
||||
await runRelease();
|
||||
} else {
|
||||
await runManifest();
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verifies a freshly published release from the public endpoint: artifact
|
||||
* bytes and checksum files, cache headers, manifest consistency, and zip
|
||||
* layout (manifest.json at the zip root with the expected version).
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-public.mjs --public-base-url=https://aliyun-oss.yaklang.com/chrome-extension \
|
||||
* --release-entry=dist/release-entry.json
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import AdmZip from 'adm-zip';
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (const arg of argv) {
|
||||
if (!arg.startsWith('--')) throw new Error(`unexpected argument: ${arg}`);
|
||||
const eq = arg.indexOf('=');
|
||||
const key = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
||||
out[key] = eq === -1 ? true : arg.slice(eq + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args['public-base-url']) throw new Error('--public-base-url is required');
|
||||
if (!args['release-entry']) throw new Error('--release-entry is required');
|
||||
const baseUrl = String(args['public-base-url']).replace(/\/+$/, '');
|
||||
|
||||
// Cache-busting query parameter: the manifest may be served from a 5-minute
|
||||
// CDN cache, and we must observe the state right after this publish.
|
||||
const bust = `verify=${Date.now()}`;
|
||||
|
||||
async function fetchOk(url) {
|
||||
const res = await fetch(`${url}?${bust}`);
|
||||
if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
const entry = JSON.parse(await readFile(resolve(root, String(args['release-entry'])), 'utf8'));
|
||||
|
||||
for (const artifact of entry.artifacts) {
|
||||
const res = await fetchOk(artifact.url);
|
||||
const contentType = res.headers.get('content-type') ?? '';
|
||||
const cacheControl = res.headers.get('cache-control') ?? '';
|
||||
assert(contentType.startsWith('application/'), `${artifact.filename}: unexpected content-type "${contentType}"`);
|
||||
assert(cacheControl.includes('max-age=31536000') && cacheControl.includes('immutable'),
|
||||
`${artifact.filename}: unexpected cache-control "${cacheControl}" for an immutable artifact`);
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
assert(body.length === artifact.size, `${artifact.filename}: content-length ${body.length} != expected ${artifact.size}`);
|
||||
assert(sha256(body) === artifact.sha256, `${artifact.filename}: sha256 mismatch`);
|
||||
|
||||
const checksumRes = await fetchOk(artifact.checksum_url);
|
||||
assert((await checksumRes.text()) === `${artifact.sha256} ${artifact.filename}\n`,
|
||||
`${artifact.filename}: checksum file content mismatch`);
|
||||
|
||||
const zip = new AdmZip(body);
|
||||
const innerEntry = zip.getEntry('manifest.json');
|
||||
assert(innerEntry, `${artifact.filename}: manifest.json missing at zip root`);
|
||||
const innerManifest = JSON.parse(zip.readAsText(innerEntry));
|
||||
assert(innerManifest.version === entry.version,
|
||||
`${artifact.filename}: zip manifest version ${innerManifest.version} != ${entry.version}`);
|
||||
const backgroundEntry = zip.getEntry('background.js');
|
||||
assert(backgroundEntry && backgroundEntry.getData().length > 0,
|
||||
`${artifact.filename}: background.js missing or empty in zip`);
|
||||
|
||||
console.log(`verified ${artifact.filename} (${artifact.size} bytes)`);
|
||||
}
|
||||
|
||||
const manifestRes = await fetchOk(`${baseUrl}/manifest.json`);
|
||||
const manifestBytes = Buffer.from(await manifestRes.arrayBuffer());
|
||||
const manifestCache = manifestRes.headers.get('cache-control') ?? '';
|
||||
// The CDN in front of aliyun-oss.yaklang.com rewrites JSON cache-control to
|
||||
// max-age=60 (the browser mirror gets the same treatment), so assert the
|
||||
// effective freshness window is short instead of matching our upload value.
|
||||
const manifestMaxAge = Number(/max-age=(\d+)/.exec(manifestCache)?.[1] ?? 0);
|
||||
assert(manifestMaxAge > 0 && manifestMaxAge <= 300,
|
||||
`manifest.json: unexpected cache-control "${manifestCache}"`);
|
||||
const manifest = JSON.parse(manifestBytes.toString('utf8'));
|
||||
assert(manifest.latest === entry.version, `manifest.latest ${manifest.latest} != ${entry.version}`);
|
||||
const versionEntry = manifest.versions.find((v) => v.version === entry.version);
|
||||
assert(versionEntry, `manifest has no entry for version ${entry.version}`);
|
||||
assert(versionEntry.artifacts.length === entry.artifacts.length,
|
||||
`manifest artifacts count ${versionEntry.artifacts.length} != ${entry.artifacts.length}`);
|
||||
for (const artifact of entry.artifacts) {
|
||||
const remote = versionEntry.artifacts.find((a) => a.variant === artifact.variant);
|
||||
assert(remote, `manifest missing variant ${artifact.variant} for version ${entry.version}`);
|
||||
assert(remote.sha256 === artifact.sha256, `manifest sha256 mismatch for variant ${artifact.variant}`);
|
||||
assert(remote.url === artifact.url, `manifest url mismatch for variant ${artifact.variant}`);
|
||||
}
|
||||
|
||||
const checksumRes = await fetchOk(`${baseUrl}/manifest.json.sha256.txt`);
|
||||
assert((await checksumRes.text()) === `${sha256(manifestBytes)} manifest.json\n`,
|
||||
'manifest.json.sha256.txt does not match the served manifest');
|
||||
|
||||
console.log(`manifest verified: latest=${manifest.latest}, ${manifest.versions.length} version(s) in history`);
|
||||
+11
-57
@@ -1,11 +1,11 @@
|
||||
import { browser, type Browser } from 'wxt/browser';
|
||||
import {
|
||||
clearNetworkRequests, exportNetworkRequest, listNetworkRequests, networkCaptureStatus,
|
||||
rebindNetworkCapturesForGrant, startNetworkCapture, stopNetworkCapture, stopNetworkCapturesForGrant,
|
||||
rebindNetworkCapturesForGrant, startNetworkCapture, stopNetworkCapture,
|
||||
} from '@/features/network-capture/service';
|
||||
import { capturedRequestEnginePayload } from '@/features/network-capture/workflows';
|
||||
import { initializeBrowserRecordingService, stopBrowserRecordingsForGrant } from '@/features/browser-recording/service';
|
||||
import { initializeDeepCaptureService, stopDeepCapturesForGrant } from '@/features/deep-capture/service';
|
||||
import { initializeBrowserRecordingService } from '@/features/browser-recording/service';
|
||||
import { initializeDeepCaptureService } from '@/features/deep-capture/service';
|
||||
import { initializeBrowserTransformService } from '@/features/browser-transform/service';
|
||||
import { initializeFloatingPanelLifecycle } from '@/features/floating-panel/lifecycle';
|
||||
import type { ExtensionRequest, ExtensionResponse } from '@/types/messages';
|
||||
@@ -36,9 +36,6 @@ import {
|
||||
import {
|
||||
applyPolicyToBridge, applyPolicyToState, assertGrantPolicy, getEnterprisePolicy,
|
||||
} from '@/platform/policy/managed';
|
||||
import {
|
||||
browserInstanceAccess, PAIRED_BROWSER_INSTANCE_ACCESS_ID,
|
||||
} from '@/features/grants/capability-context';
|
||||
import { createDiagnosticsBundle } from '@/features/diagnostics/export';
|
||||
import { getRuntimeMetrics, recordServiceWorkerStart, resetRuntimeMetrics } from '@/features/diagnostics/metrics';
|
||||
import {
|
||||
@@ -71,16 +68,6 @@ function originOf(url: string): string {
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
async function syncManagedInstanceBadge(managedInstance?: { badge: string }): Promise<void> {
|
||||
const badge = managedInstance?.badge || '';
|
||||
await browser.action.setBadgeText({ text: badge });
|
||||
if (badge) {
|
||||
const color = badge === 'A' ? '#F26215' : badge === 'B' ? '#2563EB' : badge === 'C' ? '#16A34A' : '#7C3AED';
|
||||
await browser.action.setBadgeBackgroundColor({ color });
|
||||
}
|
||||
await browser.action.setTitle({ title: badge ? `Yakit Browser Agent · 实例 ${badge}` : 'Yakit Browser Agent' });
|
||||
}
|
||||
|
||||
async function createGrantTargets(inputs: Array<{ tabId: number; frameId: number }>): Promise<BridgeGrantTarget[]> {
|
||||
const unique = [...new Map(inputs.map((target) => [`${target.tabId}:${target.frameId}`, target])).values()];
|
||||
const tabIds = [...new Set(unique.map((target) => target.tabId))];
|
||||
@@ -119,12 +106,6 @@ const domainHandlers: readonly BackgroundRequestHandler[] = [
|
||||
handleTransformRequest,
|
||||
];
|
||||
|
||||
const stopPairedBrowserTasks = () => Promise.all([
|
||||
stopNetworkCapturesForGrant(PAIRED_BROWSER_INSTANCE_ACCESS_ID),
|
||||
stopBrowserRecordingsForGrant(PAIRED_BROWSER_INSTANCE_ACCESS_ID),
|
||||
stopDeepCapturesForGrant(PAIRED_BROWSER_INSTANCE_ACCESS_ID),
|
||||
]);
|
||||
|
||||
async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.MessageSender): Promise<ExtensionResponse> {
|
||||
const domainResponse = await dispatchBackgroundHandlers(request, sender, domainHandlers);
|
||||
if (domainResponse !== undefined) return domainResponse;
|
||||
@@ -309,10 +290,7 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
|
||||
};
|
||||
});
|
||||
const handoff = state.handoff!;
|
||||
await setAgentRuntimeState(
|
||||
input.outcome === 'completed' ? 'running' : 'paused',
|
||||
await browserInstanceAccess('browser.tabs.read'),
|
||||
);
|
||||
await setAgentRuntimeState(input.outcome === 'completed' ? 'running' : 'paused', state.activeGrant);
|
||||
await browser.action.setBadgeText({ text: '', tabId: handoff.target.tabId });
|
||||
engineBridge.emitEvent('browser.handoff.changed', handoff);
|
||||
void appendAuditEvent({
|
||||
@@ -410,15 +388,14 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
|
||||
}
|
||||
case 'agent.runtime.get': return ok(await getAgentRuntime());
|
||||
case 'agent.pause': {
|
||||
const grant = await browserInstanceAccess('browser.tabs.read');
|
||||
const grant = await requireActiveGrant();
|
||||
engineBridge.cancelActiveRequests();
|
||||
await stopPairedBrowserTasks();
|
||||
const runtime = await setAgentRuntimeState('paused', grant);
|
||||
void appendAuditEvent({ category: 'grant', action: 'agent.pause', outcome: 'success', taskId: grant.taskId });
|
||||
return ok(runtime);
|
||||
}
|
||||
case 'agent.resume': {
|
||||
const grant = await browserInstanceAccess('browser.tabs.read');
|
||||
const grant = await requireActiveGrant();
|
||||
const runtime = await setAgentRuntimeState('running', grant);
|
||||
void appendAuditEvent({ category: 'grant', action: 'agent.resume', outcome: 'success', taskId: grant.taskId });
|
||||
return ok(runtime);
|
||||
@@ -431,29 +408,10 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
|
||||
case 'bridge.config.save': {
|
||||
const config = applyPolicyToBridge(request.payload, (await getEnterprisePolicy()).policy);
|
||||
const state = await updateState((current) => ({ ...current, bridge: config }));
|
||||
await syncManagedInstanceBadge(state.bridge.managedInstance);
|
||||
if (config.autoConnect && config.pairedEngine) await engineBridge.connect(config);
|
||||
else engineBridge.disconnect();
|
||||
return ok(state);
|
||||
}
|
||||
case 'bridge.managed-instance.bind': {
|
||||
const senderURL = sender.url ? new URL(sender.url) : undefined;
|
||||
const bootstrapURL = new URL(browser.runtime.getURL('/ytray-bootstrap.html'));
|
||||
if (senderURL?.origin !== bootstrapURL.origin || senderURL.pathname !== bootstrapURL.pathname) {
|
||||
throw new ExtensionError('forbidden', '浏览器实例身份只能由受管启动页设置');
|
||||
}
|
||||
const state = await updateState((current) => ({
|
||||
...current,
|
||||
bridge: { ...current.bridge, managedInstance: request.payload },
|
||||
}));
|
||||
await syncManagedInstanceBadge(state.bridge.managedInstance);
|
||||
if (state.bridge.autoConnect && state.bridge.pairedEngine) {
|
||||
engineBridge.disconnect();
|
||||
await stopPairedBrowserTasks();
|
||||
await engineBridge.connect(state.bridge);
|
||||
}
|
||||
return ok(engineBridge.getStatus());
|
||||
}
|
||||
case 'bridge.pair': {
|
||||
const status = await engineBridge.startPairing();
|
||||
void appendAuditEvent({ category: 'bridge', action: 'bridge.pair', outcome: 'success' });
|
||||
@@ -463,7 +421,6 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
|
||||
case 'bridge.pair.status': return ok(engineBridge.getPairingStatus());
|
||||
case 'bridge.unpair': {
|
||||
await engineBridge.unpair();
|
||||
await stopPairedBrowserTasks();
|
||||
void appendAuditEvent({ category: 'bridge', action: 'bridge.unpair', outcome: 'success' });
|
||||
return ok(await getState());
|
||||
}
|
||||
@@ -474,7 +431,6 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
|
||||
}
|
||||
case 'bridge.disconnect': {
|
||||
engineBridge.disconnect();
|
||||
await stopPairedBrowserTasks();
|
||||
void appendAuditEvent({ category: 'bridge', action: 'bridge.disconnect', outcome: 'success' });
|
||||
return ok(engineBridge.getStatus());
|
||||
}
|
||||
@@ -487,11 +443,10 @@ let backgroundStarted = false;
|
||||
|
||||
async function restoreBackgroundState(): Promise<void> {
|
||||
const storedState = await restoreGrantLifecycle();
|
||||
const policy = (await getEnterprisePolicy()).policy;
|
||||
const state = applyPolicyToState(storedState, policy);
|
||||
const state = applyPolicyToState(storedState, (await getEnterprisePolicy()).policy);
|
||||
if (JSON.stringify(state.bridge) !== JSON.stringify(storedState.bridge)
|
||||
|| JSON.stringify(state.floatingPanel) !== JSON.stringify(storedState.floatingPanel)) {
|
||||
await updateState((current) => applyPolicyToState(current, policy));
|
||||
await updateState(() => state);
|
||||
}
|
||||
try {
|
||||
await reconcileUserAgentRuntime();
|
||||
@@ -505,10 +460,8 @@ async function restoreBackgroundState(): Promise<void> {
|
||||
summary: (error instanceof Error ? error.message : String(error)).slice(0, 512),
|
||||
});
|
||||
}
|
||||
const currentState = await getState();
|
||||
await syncManagedInstanceBadge(currentState.bridge.managedInstance);
|
||||
if (currentState.bridge.autoConnect && currentState.bridge.pairedEngine) {
|
||||
await engineBridge.connect(currentState.bridge).catch(console.error);
|
||||
if (state.bridge.autoConnect && state.bridge.pairedEngine) {
|
||||
await engineBridge.connect(state.bridge).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,6 +470,7 @@ export function runBackground(): void {
|
||||
backgroundStarted = true;
|
||||
|
||||
configureGrantLifecycleHooks({
|
||||
cancelActiveRequests: () => engineBridge.cancelActiveRequests(),
|
||||
emitHandoffChanged: (handoff) => engineBridge.emitEvent('browser.handoff.changed', handoff),
|
||||
});
|
||||
registerGrantLifecycleListeners();
|
||||
|
||||
@@ -19,6 +19,8 @@ import { ProxyProfilesView } from '@/features/proxy/ui/ProxyProfilesView';
|
||||
import { RuleSourcesView } from '@/features/proxy/ui/RuleSourcesView';
|
||||
import { RecordingWorkspace } from '@/features/browser-recording/RecordingWorkspace';
|
||||
import { AuthorizationTestingWorkspace } from '@/features/authorization-testing/ui/AuthorizationTestingWorkspace';
|
||||
import { gatewayShareActive, gatewayShareGrantInput } from '@/features/grants/gateway-share';
|
||||
import { CAPABILITY_LABELS, CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES, isControlScopeSet } from '@/protocol/capabilities';
|
||||
import { AGENT_RUNTIME_STORAGE_KEY, AUDIT_STORAGE_KEY, isStateStorageChange } from '@/protocol/storage';
|
||||
import type {
|
||||
ActiveTabInfo, AgentRuntime, AuditEvent, BridgePairingStatus, BridgeStatus, BrowserCookie, BrowserRequestAnalysisBundle, CookieInput, CookieTransferFormat, EnterprisePolicyStatus, ExtensionState, HumanHandoff,
|
||||
@@ -225,7 +227,7 @@ function App() {
|
||||
<span className="topbar-tab__favicon">{tab?.favIconUrl ? <img src={tab.favIconUrl} alt="" /> : <Radio size={13} />}</span>
|
||||
<select className="target-tab-select" aria-label="目标标签页" value={tab?.id || ''} onChange={(event) => void selectTab(Number(event.target.value))}><option value="" disabled>选择目标标签页</option>{tabs.map((item) => <option value={item.id} key={item.id}>{item.title}</option>)}</select>
|
||||
</div>}
|
||||
<div className="topbar-actions"><span className={`permission-state ${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>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
{handoff && <HandoffBanner handoff={handoff} setState={setState} run={run} busy={busy} />}
|
||||
@@ -238,7 +240,7 @@ function App() {
|
||||
{section === 'sources' && <RuleSourcesView state={state} setState={setState} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'cookies' && <CookieEditor key={tab?.id || 0} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'user-agent' && <UserAgents state={state} setState={setState} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'network' && <NetworkActivity key={tab?.id || 0} tab={tab} bridge={bridge} run={run} busy={busy} />}
|
||||
{section === 'network' && <NetworkActivity key={tab?.id || 0} state={state} setState={setState} tab={tab} bridge={bridge} run={run} busy={busy} />}
|
||||
{section === 'context' && <ContextTool key={tab?.id || 0} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'engine' && <EngineSettings state={state} setState={setState} bridge={bridge} setBridge={setBridge} tabs={tabs} run={run} busy={busy} />}
|
||||
{section === 'activity' && <ActivityLog run={run} busy={busy} />}
|
||||
@@ -311,7 +313,7 @@ function ActivityLog({ run, busy }: { run: (task: () => Promise<void>, success?:
|
||||
return <div className="section-view activity-view">
|
||||
<div className="page-heading"><div><h1>Agent 操作时间线</h1><p>实时动作保存在浏览器 session;长期审计只保存脱敏摘要,不记录参数、页面内容、Cookie 或执行结果。</p></div><div className="activity-heading-actions"><span className={`agent-runtime-state ${runtime.state}`}><Activity size={15} />{runtimeLabel}</span><Button variant="ghost" disabled={busy} onClick={() => void downloadDiagnostics()}><Download size={15} />导出诊断</Button></div></div>
|
||||
<section className="agent-runtime-band">
|
||||
<div className="agent-runtime-summary"><div><span>浏览器实例</span><strong>{runtime.taskId ? '已接入 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>
|
||||
<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>
|
||||
{runtime.actions.length === 0 ? <div className="agent-actions-empty">当前 session 尚无 Agent 能力调用。</div> : <div className="agent-action-list" role="list">{[...runtime.actions].reverse().slice(0, 50).map((action) => <div key={action.id} className="agent-action-row" role="listitem"><span className={`action-state ${action.state}`} /> <time>{new Date(action.startedAt).toLocaleTimeString()}</time><code title={action.method}>{action.method}</code><span>{action.targetTabId ? `Tab ${action.targetTabId}` : '扩展本机'}</span><strong className={action.state}>{action.state}</strong><span>{action.durationMs === undefined ? '进行中' : `${action.durationMs} ms`}</span></div>)}</div>}
|
||||
</section>
|
||||
<div className="activity-subheading"><div><h2>持久化脱敏审计</h2><p>最近 500 条授权、Bridge、接管与能力结果。</p></div><Button variant="ghost" disabled={busy || events.length === 0} onClick={() => void run(async () => { await request('audit.clear'); setEvents([]); }, '操作记录已清空')}><Trash2 size={15} />清空审计</Button></div>
|
||||
@@ -360,12 +362,12 @@ function Overview({ state, bridge, tab, navigate, run, busy }: { state: Extensio
|
||||
<div className="page-heading"><div><h1>运行概览</h1><p>{tab?.title || '选择一个 HTTP(S) 标签页,建立浏览器现场。'}</p></div><span className={`large-status ${bridge.state}`}><Radio size={16} />{bridge.state === 'connected' ? `Yak ${bridge.engineVersion || '引擎'} 在线` : 'Yak 引擎离线'}</span></div>
|
||||
<div className="task-command-bar">
|
||||
<div className="task-site-identity"><KeyRound size={18} /><span><strong>{loginContext?.authentication.status === 'authenticated' ? '检测到登录环境' : loginContext?.authentication.status === 'unauthenticated' ? '未检测到登录态' : '登录环境待采集'}</strong><small>{site ? `${site.protocol.replace(':', '').toUpperCase()} · ${site.origin}` : '当前页面不可访问'}</small></span></div>
|
||||
<div className="task-quick-actions"><Button disabled={busy || !tab} onClick={() => void captureLoginEnvironment()}><Braces size={15} />采集登录环境</Button><Button disabled={busy || !tab || network?.active} onClick={() => void startCapture()}><Activity size={15} />{network?.active ? '正在捕获' : '抓取请求'}</Button><Button variant="primary" onClick={() => navigate('engine')}><Bot size={15} />管理 Agent 连接</Button></div>
|
||||
<div className="task-quick-actions"><Button disabled={busy || !tab} onClick={() => void captureLoginEnvironment()}><Braces size={15} />采集登录环境</Button><Button disabled={busy || !tab || network?.active} onClick={() => void startCapture()}><Activity size={15} />{network?.active ? '正在捕获' : '抓取请求'}</Button><Button variant="primary" onClick={() => navigate('engine')}><Bot size={15} />共享给 Agent</Button></div>
|
||||
</div>
|
||||
<div className="task-status-grid">
|
||||
<section><span>浏览器现场</span><strong>{loginContext ? `${loginContext.document?.forms.length || 0} 表单 / ${loginContext.document?.interactive.length || 0} 节点` : '尚未采集'}</strong><small>{loginContext?.authentication.evidence[0] || 'Cookie、Storage 与认证信号仅在用户点击后读取'}</small><button onClick={() => navigate('context')}>打开上下文<ChevronRight size={15} /></button></section>
|
||||
<section><span>代理与流量</span><strong>{activeProxy}</strong><small>{network?.active ? `${network.count} 条请求,${network.droppedCount} 条丢弃` : `${state.proxyRules.filter((rule) => rule.enabled).length} 条手动规则 · ${state.proxyRuleSources.filter((source) => source.enabled).length} 个订阅源`}</small><button onClick={() => navigate(network?.active ? 'network' : 'rules')}>查看流量策略<ChevronRight size={15} /></button></section>
|
||||
<section><span>Agent 连接</span><strong>{bridge.state === 'connected' ? `实例在线 · ${runtime.state}` : '实例离线'}</strong><small>{bridge.state === 'connected' ? '当前浏览器内的 HTTP(S) 页面可直接被引用' : '配对并连接 Yakit 后即可使用,无需逐页授权'}</small><button onClick={() => navigate('activity')}>查看动作时间线<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 className={state.handoff?.state === 'waiting_for_user' ? 'needs-attention' : ''}><span>需要用户处理</span><strong>{state.handoff?.state === 'waiting_for_user' ? HANDOFF_REASON_LABELS[state.handoff.reason] : runtime.state === 'paused' ? 'Agent 已暂停' : '没有待办步骤'}</strong><small>{state.handoff?.state === 'waiting_for_user' ? state.handoff.message : latestAction ? `最近 ${latestAction.method} · ${latestAction.state}` : '二维码、MFA 与 CAPTCHA 会在这里出现'}</small><button onClick={() => navigate('activity')}>会话控制<ChevronRight size={15} /></button></section>
|
||||
</div>
|
||||
<div className="task-workflow-list">
|
||||
@@ -516,11 +518,15 @@ function networkLabel(record: NetworkRequestRecord): { host: string; path: strin
|
||||
}
|
||||
|
||||
function NetworkActivity({
|
||||
state,
|
||||
setState,
|
||||
tab,
|
||||
bridge,
|
||||
run,
|
||||
busy,
|
||||
}: {
|
||||
state: ExtensionState;
|
||||
setState: (state: ExtensionState) => void;
|
||||
tab?: ActiveTabInfo;
|
||||
bridge: BridgeStatus;
|
||||
run: (task: () => Promise<void>, success?: string) => Promise<void>;
|
||||
@@ -537,11 +543,11 @@ function NetworkActivity({
|
||||
const [captureHeaders, setCaptureHeaders] = useState(false);
|
||||
const [captureBody, setCaptureBody] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const transformShared = bridge.state === 'connected';
|
||||
const transformShared = gatewayShareActive(state.activeGrant, tab);
|
||||
|
||||
const shareTransform = async () => {
|
||||
if (!tab) throw new Error('请先选择需要使用的页面');
|
||||
if (bridge.state !== 'connected') await request('bridge.connect');
|
||||
if (!tab) throw new Error('请先选择需要共享的页面');
|
||||
setState(await request('grant.create', gatewayShareGrantInput(state, tab)));
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -660,6 +666,8 @@ function NetworkActivity({
|
||||
busy={busy}
|
||||
run={run}
|
||||
gatewayShared={transformShared}
|
||||
gatewayShareExpiresAt={transformShared ? state.activeGrant?.expiresAt : undefined}
|
||||
gatewayBridgeConnected={bridge.state === 'connected'}
|
||||
onShareGateway={shareTransform}
|
||||
/>
|
||||
</div>;
|
||||
@@ -791,7 +799,15 @@ function EngineSettings({ state, setState, bridge, setBridge, tabs, run, busy }:
|
||||
const [draft, setDraft] = useState(state.bridge);
|
||||
const [pairing, setPairing] = useState<BridgePairingStatus>({ state: 'idle', message: state.bridge.pairedEngine ? '当前浏览器已配对' : '尚未配对' });
|
||||
const [panelDraft, setPanelDraft] = useState(state.floatingPanel);
|
||||
const [framesByTab, setFramesByTab] = useState<Record<number, PageFrameSummary[]>>({});
|
||||
const [selectedTargets, setSelectedTargets] = useState<string[]>(state.activeGrant?.targets.map((target) => `${target.tabId}:${target.frameId}`) || []);
|
||||
const [grantLevel, setGrantLevel] = useState<'read' | 'control'>(state.activeGrant && isControlScopeSet(state.activeGrant.scopes) ? 'control' : 'read');
|
||||
const [allowProgramEval, setAllowProgramEval] = useState(Boolean(state.activeGrant?.scopes.includes('browser.page.eval.program')));
|
||||
const [policy, setPolicy] = useState<EnterprisePolicyStatus>({ managed: false, policy: {}, warnings: [] });
|
||||
const [durationMinutes, setDurationMinutes] = useState(30);
|
||||
const selectedGrantScopes = grantLevel === 'control'
|
||||
? [...CONTROL_CAPABILITY_SCOPES, ...(allowProgramEval ? ['browser.page.eval.program' as const] : [])]
|
||||
: READ_CAPABILITY_SCOPES;
|
||||
useEffect(() => {
|
||||
void request('policy.status').then(setPolicy).catch(() => undefined);
|
||||
void request('bridge.pair.status').then(setPairing).catch(() => undefined);
|
||||
@@ -802,7 +818,23 @@ function EngineSettings({ state, setState, bridge, setBridge, tabs, run, busy }:
|
||||
browser.runtime.onMessage.addListener(listener);
|
||||
return () => browser.runtime.onMessage.removeListener(listener);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void Promise.all(tabs.map(async (item) => [item.id, await request('frame.list', { tabId: item.id }).catch(() => [])] as const))
|
||||
.then((inventories) => {
|
||||
if (active) setFramesByTab(Object.fromEntries(inventories));
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [tabs]);
|
||||
useEffect(() => setDraft(state.bridge), [state.bridge]);
|
||||
const toggleTarget = (key: string, checked: boolean) => setSelectedTargets((current) => checked
|
||||
? [...new Set([...current, key])]
|
||||
: current.filter((item) => item !== key));
|
||||
const toggleTab = (tabId: number, checked: boolean) => {
|
||||
const mainKey = `${tabId}:0`;
|
||||
if (checked) toggleTarget(mainKey, true);
|
||||
else setSelectedTargets((current) => current.filter((key) => !key.startsWith(`${tabId}:`)));
|
||||
};
|
||||
const save = () => run(async () => {
|
||||
if (draft.transport === 'native') {
|
||||
// Permission requests must be the first browser call made from the click gesture.
|
||||
@@ -847,9 +879,8 @@ function EngineSettings({ state, setState, bridge, setBridge, tabs, run, busy }:
|
||||
<label className="toggle-row"><span><strong>全屏自动收起</strong><small>进入全屏、演示或视频场景时关闭展开内容</small></span><Switch checked={panelDraft.autoCollapseFullscreen} onCheckedChange={(autoCollapseFullscreen) => setPanelDraft({ ...panelDraft, autoCollapseFullscreen })} /></label>
|
||||
<div className="editor-actions"><Button disabled={busy} onClick={() => void savePanel()}><Save size={16} />保存面板策略</Button></div>
|
||||
</section>
|
||||
<div className="grant-editor"><h2>浏览器实例访问</h2><p>配对成功后,Yakit 可直接引用此浏览器中的全部 HTTP(S) 页面;刷新、跳转和新标签页会自动跟随,不再逐页授权。</p><div className="grant-status"><strong>{bridge.state === 'connected' ? '实例已连接' : state.bridge.pairedEngine ? '实例已配对,当前离线' : '实例尚未配对'}</strong><span>{tabs.length} 个可访问页面 · 浏览器内部页始终排除 · 无痕窗口沿用浏览器自己的独立访问开关</span></div><div className="grant-scope-list"><span>人工:逐次确认 · 协同 AI:按风险判断 · YOLO:自动执行</span><span>{policy.policy.allowProgramEval === false ? '程序 Eval 已被企业策略禁用' : '程序 Eval 在 YOLO 下无需手动批准,仍受浏览器与企业策略限制'}</span>{policy.policy.grantAllowedOrigins?.length ? <span>企业来源白名单:{policy.policy.grantAllowedOrigins.length} 项</span> : null}</div></div>
|
||||
</div>
|
||||
<div className="protocol-panel"><h2>Bridge 方法</h2><div><code>browser.tabs / tab.open / frames</code><span>列出当前实例的 HTTP(S) 标签页、打开网页并读取完整 frame inventory</span></div><div><code>browser.context</code><span>生成结构化快照、存储 inventory、认证信号与上下文 diff</span></div><div><code>browser.node.*</code><span>检查或操作快照内的文档绑定节点引用</span></div><div><code>browser.cookies</code><span>读取指定标签页的浏览器 Cookie</span></div><div><code>browser.network.*</code><span>控制有界网络捕获、读取请求时间线并导出重放包</span></div><div><code>browser.takeover</code><span>将页面切到前台,交给用户扫码或二次验证</span></div><div><code>browser.invoke</code><span>调用页面已有全局函数</span></div><div><code>browser.eval</code><span>在页面主世界执行代码,支持 Promise 和超时</span></div><div><code>proxy.list / switch</code><span>读取并切换扩展代理配置</span></div></div>
|
||||
<div className="grant-editor"><h2>浏览器共享会话</h2><p>只把明确勾选的 frame 和能力授权给当前 Agent;子 frame、刷新和跨来源导航不会静默继承授权。</p><div className="tab-picker">{tabs.map((tabItem) => { const frames = framesByTab[tabItem.id] || []; const mainSelected = selectedTargets.includes(`${tabItem.id}:0`); return <div className="tab-picker-group" key={tabItem.id}><label><input type="checkbox" checked={mainSelected} onChange={(event) => toggleTab(tabItem.id, event.target.checked)} /><span><strong>{tabItem.title}</strong><small>{tabItem.url}</small></span></label>{mainSelected && frames.filter((frame) => !frame.isTop).map((frame) => <label className="frame-target" key={frame.frameId}><input type="checkbox" disabled={!frame.accessible || !frame.origin} checked={selectedTargets.includes(`${tabItem.id}:${frame.frameId}`)} onChange={(event) => toggleTarget(`${tabItem.id}:${frame.frameId}`, event.target.checked)} /><span><strong>{frame.title || frame.name || `Frame ${frame.frameId}`}</strong><small>#{frame.frameId} · {frame.sameOrigin ? '同源' : '跨源'} · {frame.origin || frame.url}</small></span></label>)}</div>; })}</div><div className="grant-options"><Field label="权限预设"><select value={grantLevel} onChange={(event) => setGrantLevel(event.target.value as 'read' | 'control')}><option value="read">只读:页面、Storage、Cookie</option><option value="control">控制:页面操作、网络敏感字段、深度捕获、代理</option></select></Field><Field label="有效期"><select value={durationMinutes} onChange={(event) => setDurationMinutes(Number(event.target.value))}><option value="15">15 分钟</option><option value="30">30 分钟</option><option value="60">1 小时</option><option value="240">4 小时</option></select></Field></div>{grantLevel === 'control' && <label className="toggle-row grant-risk-toggle"><span><strong>允许程序 Eval</strong><small>独立高风险 scope,可执行多条语句并产生页面副作用</small></span><Switch disabled={policy.policy.allowProgramEval === false} checked={allowProgramEval && policy.policy.allowProgramEval !== false} onCheckedChange={setAllowProgramEval} /></label>}<div className="grant-scope-list">{selectedGrantScopes.filter((scope) => policy.policy.allowProgramEval !== false || scope !== 'browser.page.eval.program').map((scope) => <span key={scope}>{CAPABILITY_LABELS[scope]}</span>)}</div><div className="editor-actions"><button className="primary-button" disabled={busy || selectedTargets.length === 0} onClick={() => void run(async () => setState(await request('grant.create', { targets: selectedTargets.map((key) => { const [tabId, frameId] = key.split(':').map(Number); return { tabId, frameId }; }), scopes: selectedGrantScopes.filter((scope) => policy.policy.allowProgramEval !== false || scope !== 'browser.page.eval.program'), durationMinutes })), '共享会话已创建')}><ShieldCheck size={16} />创建会话</button>{state.activeGrant && <button className="danger-button" onClick={() => void run(async () => setState(await request('grant.revoke')), '共享会话已撤销')}><X size={16} />立即撤销</button>}</div>{state.activeGrant && <div className="grant-status"><strong>{isControlScopeSet(state.activeGrant.scopes) ? '控制会话' : '只读会话'}</strong><span>{state.activeGrant.targets.length} 个 frame · {state.activeGrant.scopes.length} 项能力 · {new Date(state.activeGrant.expiresAt).toLocaleString()} 到期</span></div>}</div></div>
|
||||
<div className="protocol-panel"><h2>Bridge 方法</h2><div><code>browser.tabs / frames</code><span>列出授权标签页与完整 frame inventory</span></div><div><code>browser.context</code><span>生成结构化快照、存储 inventory、认证信号与上下文 diff</span></div><div><code>browser.node.*</code><span>检查或操作快照内的文档绑定节点引用</span></div><div><code>browser.cookies</code><span>读取指定标签页的浏览器 Cookie</span></div><div><code>browser.network.*</code><span>控制有界网络捕获、读取请求时间线并导出重放包</span></div><div><code>browser.takeover</code><span>将页面切到前台,交给用户扫码或二次验证</span></div><div><code>browser.invoke</code><span>以控制权限调用页面已有全局函数</span></div><div><code>browser.eval</code><span>以控制权限在页面主世界执行代码,支持 Promise 和超时</span></div><div><code>proxy.list / switch</code><span>读取并切换扩展代理配置</span></div></div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,46 +0,0 @@
|
||||
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,6 +29,8 @@ interface RecordingWorkspaceProps {
|
||||
busy: boolean;
|
||||
run: RunTask;
|
||||
gatewayShared: boolean;
|
||||
gatewayShareExpiresAt?: number;
|
||||
gatewayBridgeConnected: boolean;
|
||||
onShareGateway: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -181,6 +183,8 @@ export function RecordingWorkspace({
|
||||
busy,
|
||||
run,
|
||||
gatewayShared,
|
||||
gatewayShareExpiresAt,
|
||||
gatewayBridgeConnected,
|
||||
onShareGateway,
|
||||
}: RecordingWorkspaceProps) {
|
||||
const [workspaceMode, setWorkspaceMode] = useState<'gateway' | 'recording' | 'deep'>('recording');
|
||||
@@ -630,6 +634,8 @@ 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')}
|
||||
|
||||
@@ -219,9 +219,7 @@ describe('browser recording storage, snapshot and retained-value budgets', () =>
|
||||
expect(snapshot.status.retainedCallBytes).toBe(4_096);
|
||||
});
|
||||
|
||||
// The budget-trimming path needs ~1MiB of events per tab, which alone takes
|
||||
// seconds on CI hardware; the data volume is the point of the test.
|
||||
it('drops the globally oldest events to keep each snapshot and all sessions bounded', { timeout: 30_000 }, async () => {
|
||||
it('drops the globally oldest events to keep each snapshot and all sessions bounded', async () => {
|
||||
const service = await freshService();
|
||||
for (let tabId = 10; tabId < 15; tabId += 1) {
|
||||
fixture.pages.set(tabId, rawSnapshot(
|
||||
|
||||
@@ -45,6 +45,8 @@ interface BrowserTransformWorkspaceProps {
|
||||
busy: boolean;
|
||||
run: RunTask;
|
||||
gatewayShared: boolean;
|
||||
gatewayShareExpiresAt?: number;
|
||||
gatewayBridgeConnected: boolean;
|
||||
onShareGateway: () => Promise<void>;
|
||||
onOpenCapture: () => void;
|
||||
onOpenRecovery: (profileId: string) => void;
|
||||
@@ -245,6 +247,8 @@ export function BrowserTransformWorkspace({
|
||||
busy,
|
||||
run,
|
||||
gatewayShared,
|
||||
gatewayShareExpiresAt,
|
||||
gatewayBridgeConnected,
|
||||
onShareGateway,
|
||||
onOpenCapture,
|
||||
onOpenRecovery,
|
||||
@@ -922,9 +926,11 @@ 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, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import { AlertTriangle, CheckCircle2, FlaskConical, Play, Share2, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type {
|
||||
ActiveTabInfo,
|
||||
@@ -28,6 +28,8 @@ export function TransformReplayPanel({
|
||||
replayPersistenceLabel,
|
||||
replayPersistenceTitle,
|
||||
gatewayShared,
|
||||
gatewayShareExpiresAt,
|
||||
gatewayBridgeConnected,
|
||||
onShareGateway,
|
||||
onClear,
|
||||
canExecute,
|
||||
@@ -54,6 +56,8 @@ export function TransformReplayPanel({
|
||||
replayPersistenceLabel: string;
|
||||
replayPersistenceTitle: string;
|
||||
gatewayShared: boolean;
|
||||
gatewayShareExpiresAt?: number;
|
||||
gatewayBridgeConnected: boolean;
|
||||
onShareGateway: () => Promise<void>;
|
||||
onClear: () => Promise<void>;
|
||||
canExecute: boolean;
|
||||
@@ -83,19 +87,21 @@ export function TransformReplayPanel({
|
||||
</div>
|
||||
</header>
|
||||
{draft?.id && <section className={`transform-gateway-share ${gatewayShared ? 'is-active' : ''}`}>
|
||||
<span className="transform-gateway-share__mark"><ShieldCheck size={15} /></span>
|
||||
<span className="transform-gateway-share__mark">{gatewayShared ? <ShieldCheck size={15} /> : <Share2 size={15} />}</span>
|
||||
<div>
|
||||
<strong>{gatewayShared ? '当前浏览器实例已接入 Yakit' : '连接 Yakit 后使用这个网关'}</strong>
|
||||
<small>{gatewayShared
|
||||
? '页面刷新、跳转后仍可使用,无需续接授权'
|
||||
: '连接后由 Agent 操作审核策略统一控制'}</small>
|
||||
<strong>{gatewayShared ? '当前页面已共享给 Yakit' : '在 Yakit 中使用这个网关'}</strong>
|
||||
<small>{gatewayShared && gatewayShareExpiresAt
|
||||
? `控制会话 · ${new Date(gatewayShareExpiresAt).toLocaleTimeString()} 到期`
|
||||
: gatewayBridgeConnected
|
||||
? '创建 30 分钟控制会话,并保留已共享页面'
|
||||
: '可先创建会话;引擎重连后即可使用'}</small>
|
||||
</div>
|
||||
{!gatewayShared && <Button
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
variant={gatewayShared ? 'ghost' : 'primary'}
|
||||
disabled={busy || !tab}
|
||||
onClick={() => void onShareGateway()}
|
||||
>连接</Button>}
|
||||
>{gatewayShared ? '刷新' : '一键共享'}</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,29 +34,6 @@ 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 {
|
||||
const fields = [
|
||||
return [
|
||||
'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,15 +142,7 @@ export function clientAuthPayload(input: {
|
||||
input.envelope.capabilityCatalog?.hash || '',
|
||||
input.envelope.taskId || '', input.envelope.grantId || '',
|
||||
input.envelope.resumeSessionId || '',
|
||||
];
|
||||
if (input.envelope.managedInstance) {
|
||||
fields.push(
|
||||
input.envelope.managedInstance.manager,
|
||||
input.envelope.managedInstance.instanceId,
|
||||
input.envelope.managedInstance.badge,
|
||||
);
|
||||
}
|
||||
return fields.join('\n');
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function pairingVerificationCode(input: {
|
||||
@@ -161,16 +153,11 @@ export async function pairingVerificationCode(input: {
|
||||
clientNonce: string;
|
||||
serverNonce: string;
|
||||
publicKey: BridgePublicKey;
|
||||
managedInstance?: BridgeEnvelope['managedInstance'];
|
||||
}): Promise<string> {
|
||||
const fields = [
|
||||
const payload = [
|
||||
'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,
|
||||
];
|
||||
if (input.managedInstance) {
|
||||
fields.push(input.managedInstance.manager, input.managedInstance.instanceId, input.managedInstance.badge);
|
||||
}
|
||||
const payload = fields.join('\n');
|
||||
].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 { browserInstanceAccess } from '@/features/grants/capability-context';
|
||||
import { currentActiveGrant } from '@/features/grants/lifecycle';
|
||||
import { errorCode, ExtensionError, isDeniedErrorCode } from '@/shared/errors';
|
||||
import { appendAuditEvent } from '@/features/diagnostics/audit';
|
||||
import { beginAgentAction, finishAgentAction } from '@/features/agent-runtime/service';
|
||||
@@ -301,7 +301,6 @@ 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,
|
||||
@@ -523,13 +522,18 @@ export class EngineBridge {
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const grant = await browserInstanceAccess('browser.tabs.read');
|
||||
taskId = grant.taskId;
|
||||
actionId = (await beginAgentAction(grant, {
|
||||
requestId: message.id,
|
||||
method: message.method,
|
||||
targetTabId,
|
||||
})).id;
|
||||
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 operation = routeCapability(message.method, message.params, (method, params) => this.requestEngine(method, params));
|
||||
const result = await Promise.race([operation, cancelled]);
|
||||
const durationMs = performance.now() - startedAt;
|
||||
@@ -798,7 +802,6 @@ 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));
|
||||
@@ -869,7 +872,6 @@ 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,8 +5,10 @@ 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';
|
||||
@@ -29,6 +31,9 @@ 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;
|
||||
|
||||
@@ -172,9 +177,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> : <>
|
||||
{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>
|
||||
{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>
|
||||
<Button variant="ghost" disabled={!tab?.url} onClick={() => void hideCurrentSite()}><EyeOff size={14} />在此站点隐藏</Button>
|
||||
</>}
|
||||
</TabsContent>
|
||||
|
||||
@@ -2,12 +2,10 @@ 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;
|
||||
@@ -22,23 +20,8 @@ export interface CapabilityDomainHandler {
|
||||
handle(context: CapabilityRouteContext): Promise<unknown>;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
export async function activeGrant(required: CapabilityScope): Promise<BridgeGrant> {
|
||||
const grant = await requireActiveGrant();
|
||||
requireScope(grant, required);
|
||||
return grant;
|
||||
}
|
||||
@@ -53,15 +36,22 @@ 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 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 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 currentFrame = await browser.webNavigation.getFrame({
|
||||
tabId: target.tabId,
|
||||
frameId: target.frameId,
|
||||
@@ -72,20 +62,27 @@ export async function allowedTarget(
|
||||
currentOrigin = (await getFrameInventory(target.tabId))
|
||||
.find((frame) => frame.frameId === target.frameId)?.origin || '';
|
||||
}
|
||||
if (!currentOrigin) {
|
||||
throw new ExtensionError('target_unavailable', '目标 frame 不是可访问的 HTTP(S) 页面');
|
||||
if (currentOrigin !== target.origin) {
|
||||
throw new ExtensionError('origin_changed', '目标 frame 已经跨来源导航,请重新授权');
|
||||
}
|
||||
assertBrowserAccessPolicy((await getEnterprisePolicy()).policy, { origin: currentOrigin });
|
||||
if (typeof input.documentId === 'string' && currentFrame.documentId
|
||||
&& input.documentId !== currentFrame.documentId) {
|
||||
throw new ExtensionError('stale_document', '请求的页面文档已经刷新或导航,请重新获取页面上下文');
|
||||
if (target.documentId && currentFrame.documentId
|
||||
&& target.documentId !== currentFrame.documentId) {
|
||||
throw new ExtensionError('stale_document', '目标 frame 已经刷新或导航,请重新授权');
|
||||
}
|
||||
const currentTarget = { ...target, documentId: currentFrame.documentId };
|
||||
return resolveInPage ? resolveDocumentTarget(currentTarget) : currentTarget;
|
||||
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;
|
||||
}
|
||||
|
||||
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,10 +20,7 @@ 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,7 +3,6 @@ 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';
|
||||
@@ -17,23 +16,15 @@ export const handoffCapabilityHandler: CapabilityDomainHandler = {
|
||||
return handoff?.taskId === grant.taskId ? handoff : { state: 'idle' };
|
||||
}
|
||||
const resolvedTarget = await allowedTarget(grant, input);
|
||||
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 grantTarget = grant.targets.find((target) => (
|
||||
target.tabId === resolvedTarget.tabId && target.frameId === resolvedTarget.frameId
|
||||
));
|
||||
if (!grantTarget) throw new Error('目标标签页不在本次共享会话中');
|
||||
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,8 +1,7 @@
|
||||
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 { activateTab, getTab, scheduleBrowserInstanceClose } from '@/platform/browser/targets';
|
||||
import { getTab } from '@/platform/browser/targets';
|
||||
import {
|
||||
createBrowserIsolationProof,
|
||||
deleteFirefoxContainerIdentity,
|
||||
@@ -12,88 +11,70 @@ 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 { 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 }),
|
||||
};
|
||||
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);
|
||||
}
|
||||
if (method === 'browser.frames') {
|
||||
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)));
|
||||
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);
|
||||
}
|
||||
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)
|
||||
: 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),
|
||||
};
|
||||
: grantedTabIds;
|
||||
if (requestedTabIds.some((tabId) => !grantedTabIds.includes(tabId))) {
|
||||
throw new ExtensionError(
|
||||
'target_denied',
|
||||
'身份隔离检查只能读取本次共享会话中的标签页',
|
||||
);
|
||||
}
|
||||
return inspectBrowserIsolation(requestedTabIds);
|
||||
}
|
||||
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);
|
||||
await Promise.all([
|
||||
allowedTarget(grant, { tabId: leftTabId }, false),
|
||||
allowedTarget(grant, { tabId: rightTabId }, false),
|
||||
]);
|
||||
if (![leftTabId, rightTabId].every((tabId) => (
|
||||
grant.targets.some((target) => target.tabId === tabId)
|
||||
))) {
|
||||
throw new ExtensionError(
|
||||
'target_denied',
|
||||
'隔离证明的两个身份都必须在本次共享会话中',
|
||||
);
|
||||
}
|
||||
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,7 +48,12 @@ export const pageCapabilityHandler: CapabilityDomainHandler = {
|
||||
tabId: target.tabId,
|
||||
frameId: target.frameId,
|
||||
});
|
||||
const url = frame?.url || '';
|
||||
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 || ''}/`;
|
||||
if (!/^https?:/i.test(url)) {
|
||||
throw new ExtensionError(
|
||||
'target_unavailable',
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 {
|
||||
@@ -125,9 +124,11 @@ 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 frame = await browser.webNavigation.getFrame(target);
|
||||
if (!frame?.url || profileInput.origin !== new URL(frame.url).origin) {
|
||||
throw new ExtensionError('target_denied', '转换配置来源与当前页面不一致');
|
||||
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', '转换配置来源不在本次共享会话中');
|
||||
}
|
||||
return saveBrowserTransformProfile({ ...profileInput, target });
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ 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),
|
||||
}));
|
||||
@@ -48,6 +50,10 @@ 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,
|
||||
}));
|
||||
@@ -118,15 +124,19 @@ 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);
|
||||
});
|
||||
|
||||
@@ -142,6 +152,7 @@ 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 });
|
||||
});
|
||||
|
||||
@@ -157,16 +168,15 @@ 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: previous,
|
||||
activeGrant: grant('handoff-old'),
|
||||
handoff: waiting,
|
||||
});
|
||||
configureGrantLifecycleHooks({ emitHandoffChanged });
|
||||
@@ -178,21 +188,6 @@ 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 });
|
||||
@@ -223,6 +218,7 @@ 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 () => {
|
||||
@@ -251,14 +247,20 @@ 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('does not couple an authorization-test grant to Agent runtime state', async () => {
|
||||
const active = grant('authorization-only');
|
||||
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'));
|
||||
|
||||
await expect(replaceActiveGrant(active)).resolves.toMatchObject({
|
||||
state: { activeGrant: { id: active.id } },
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
it('clears authorization state even when one resource cleanup reports a failure', async () => {
|
||||
|
||||
@@ -2,6 +2,9 @@ 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 {
|
||||
@@ -11,9 +14,10 @@ import { ExtensionError } from '@/shared/errors';
|
||||
|
||||
export const ACTIVE_GRANT_EXPIRY_ALARM = 'yakit.active-grant.expiry';
|
||||
|
||||
type GrantEndReason = 'revoked' | 'expired' | 'replaced' | 'scheduler_failure';
|
||||
type GrantEndReason = 'revoked' | 'expired' | 'replaced' | 'scheduler_failure' | 'activation_failure';
|
||||
|
||||
interface GrantLifecycleHooks {
|
||||
cancelActiveRequests?: () => void;
|
||||
emitHandoffChanged?: (handoff: HumanHandoff) => void;
|
||||
}
|
||||
|
||||
@@ -76,6 +80,23 @@ 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,
|
||||
@@ -103,10 +124,12 @@ 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;
|
||||
@@ -136,11 +159,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?.taskId === grant.taskId ? current.handoff : undefined;
|
||||
previousHandoff = current.handoff;
|
||||
return {
|
||||
...current,
|
||||
activeGrant: undefined,
|
||||
handoff: cancelledHandoff(previousHandoff, now) || current.handoff,
|
||||
handoff: cancelledHandoff(current.handoff, now),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -150,6 +173,7 @@ async function endActiveGrantInQueue(
|
||||
return { state };
|
||||
}
|
||||
|
||||
cancelActiveRequestsBestEffort(previousGrant);
|
||||
await clearExpiryAlarmBestEffort(previousGrant);
|
||||
await cleanupGrantResources(previousGrant, reason);
|
||||
await publishCancelledHandoff(previousHandoff, state.handoff, reason);
|
||||
@@ -163,7 +187,7 @@ async function endActiveGrantInQueue(
|
||||
? '已由新共享会话替换'
|
||||
: reason === 'scheduler_failure'
|
||||
? '无法建立可靠的到期调度,已安全撤销'
|
||||
: undefined,
|
||||
: reason === 'activation_failure' ? 'Agent Runtime 初始化失败,已安全撤销' : undefined,
|
||||
});
|
||||
return { state, previousGrant, previousHandoff };
|
||||
}
|
||||
@@ -234,14 +258,11 @@ export function replaceActiveGrant(grant: BridgeGrant): Promise<GrantTransition>
|
||||
try {
|
||||
state = await updateState((current) => {
|
||||
previousGrant = current.activeGrant;
|
||||
previousHandoff = current.activeGrant
|
||||
&& current.handoff?.taskId === current.activeGrant.taskId
|
||||
? current.handoff
|
||||
: undefined;
|
||||
previousHandoff = current.handoff;
|
||||
return {
|
||||
...current,
|
||||
activeGrant: grant,
|
||||
handoff: cancelledHandoff(previousHandoff, now) || current.handoff,
|
||||
handoff: cancelledHandoff(current.handoff, now),
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -250,8 +271,18 @@ 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 };
|
||||
});
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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 { browserInstanceAccess, type CapabilityEngineRequest } from './capability-context';
|
||||
import { activeGrant, type CapabilityEngineRequest } from './capability-context';
|
||||
import { dispatchCapability } from './capability-router';
|
||||
|
||||
export { CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
|
||||
@@ -17,18 +17,9 @@ 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
|
||||
@@ -44,6 +35,6 @@ export async function routeCapability(
|
||||
? 'browser.page.eval.program'
|
||||
: capabilityBaseScope(method);
|
||||
if (!required) throw new Error(`不支持的 Bridge 方法: ${method}`);
|
||||
const grant = await browserInstanceAccess(required);
|
||||
const grant = await activeGrant(required);
|
||||
return dispatchCapability({ method, input, grant, requestEngine });
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface IsolationCookieStore {
|
||||
export interface IsolationTabDescriptor {
|
||||
id: number;
|
||||
windowId: number;
|
||||
active?: boolean;
|
||||
title: string;
|
||||
url: string;
|
||||
incognito: boolean;
|
||||
@@ -174,7 +173,6 @@ export function activeTabInfo(
|
||||
return {
|
||||
id: tab.id,
|
||||
windowId: tab.windowId,
|
||||
active: Boolean(tab.active),
|
||||
title: tab.title || '未命名页面',
|
||||
url: tab.url,
|
||||
incognito: tab.incognito,
|
||||
@@ -191,7 +189,6 @@ export function browserTabDescriptor(tab: Browser.tabs.Tab): IsolationTabDescrip
|
||||
return {
|
||||
id: tab.id,
|
||||
windowId: tab.windowId,
|
||||
active: tab.active,
|
||||
title: tab.title || '未命名页面',
|
||||
url: tab.url,
|
||||
incognito: tab.incognito,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fixture = vi.hoisted(() => ({
|
||||
getTab: vi.fn(),
|
||||
updateTab: vi.fn(),
|
||||
getWindow: vi.fn(),
|
||||
getAllWindows: vi.fn(),
|
||||
updateWindow: vi.fn(),
|
||||
removeWindow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('wxt/browser', () => ({
|
||||
browser: {
|
||||
tabs: { get: fixture.getTab, update: fixture.updateTab },
|
||||
windows: {
|
||||
get: fixture.getWindow,
|
||||
getAll: fixture.getAllWindows,
|
||||
update: fixture.updateWindow,
|
||||
remove: fixture.removeWindow,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { activateTab, scheduleBrowserInstanceClose } from './targets';
|
||||
|
||||
describe('browser window actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('restores a minimized window before bringing it to the front', async () => {
|
||||
fixture.getTab.mockResolvedValue({ id: 7, windowId: 3 });
|
||||
fixture.getWindow.mockResolvedValue({ id: 3, state: 'minimized' });
|
||||
|
||||
await activateTab(7);
|
||||
|
||||
expect(fixture.updateTab).toHaveBeenCalledWith(7, { active: true });
|
||||
expect(fixture.updateWindow).toHaveBeenNthCalledWith(1, 3, { state: 'normal' });
|
||||
expect(fixture.updateWindow).toHaveBeenNthCalledWith(2, 3, { focused: true });
|
||||
});
|
||||
|
||||
it('acknowledges the request before closing every window in the instance', async () => {
|
||||
vi.useFakeTimers();
|
||||
fixture.getAllWindows.mockResolvedValue([{ id: 3 }, { id: 4 }]);
|
||||
fixture.removeWindow.mockResolvedValue(undefined);
|
||||
|
||||
await expect(scheduleBrowserInstanceClose()).resolves.toEqual({ closing: true, windowCount: 2 });
|
||||
expect(fixture.removeWindow).not.toHaveBeenCalled();
|
||||
await vi.runAllTimersAsync();
|
||||
expect(fixture.removeWindow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,7 @@ export async function resolveDocumentTarget(input: BrowserTarget | number): Prom
|
||||
}
|
||||
if (!probe) throw new ExtensionError('target_unavailable', '无法定位目标页面文档');
|
||||
if (requested.documentId && probe.documentId && requested.documentId !== probe.documentId) {
|
||||
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新获取页面上下文');
|
||||
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新授权');
|
||||
}
|
||||
return { tabId: requested.tabId, frameId: probe.frameId, documentId: probe.documentId || requested.documentId };
|
||||
}
|
||||
@@ -64,19 +64,6 @@ export const getActiveTab = () => getTab();
|
||||
|
||||
export async function activateTab(tabId?: number): Promise<void> {
|
||||
const tab = tabId ? await browser.tabs.get(tabId) : await browser.tabs.get((await getActiveTab()).id);
|
||||
await browser.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 };
|
||||
await browser.tabs.update(tab.id, { active: true });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { vi, describe, expect, it } from 'vitest';
|
||||
vi.mock('wxt/browser', () => ({ browser: { storage: {} } }));
|
||||
|
||||
import type { BridgeConfig } from '@/types/models';
|
||||
import { applyPolicyToBridge, assertBrowserAccessPolicy, assertGrantPolicy } from './managed';
|
||||
import { applyPolicyToBridge, assertGrantPolicy } from './managed';
|
||||
|
||||
const bridge: BridgeConfig = {
|
||||
transport: 'websocket', endpoint: 'ws://127.0.0.1:64333/extension', nativeHost: 'default.host',
|
||||
@@ -21,15 +21,4 @@ describe('managed policy enforcement', () => {
|
||||
expect(() => assertGrantPolicy({ allowProgramEval: false }, { durationMinutes: 5, origins: [], programEval: true })).toThrow('禁止');
|
||||
expect(() => assertGrantPolicy({ grantAllowedOrigins: ['https://a.test'] }, { durationMinutes: 5, origins: ['https://b.test'], programEval: false })).toThrow('不允许');
|
||||
});
|
||||
|
||||
it('keeps enterprise restrictions on paired instance access', () => {
|
||||
expect(() => assertBrowserAccessPolicy(
|
||||
{ grantAllowedOrigins: ['https://a.test'] },
|
||||
{ origin: 'https://a.test' },
|
||||
)).not.toThrow();
|
||||
expect(() => assertBrowserAccessPolicy(
|
||||
{ grantAllowedOrigins: ['https://a.test'] },
|
||||
{ origin: 'https://b.test' },
|
||||
)).toThrow('不允许');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,20 +79,12 @@ export function assertGrantPolicy(
|
||||
policy: EnterprisePolicy,
|
||||
input: { durationMinutes: number; origins: string[]; programEval: boolean },
|
||||
): number {
|
||||
if (input.programEval) assertBrowserAccessPolicy(policy, { programEval: true });
|
||||
for (const origin of input.origins) assertBrowserAccessPolicy(policy, { origin });
|
||||
return Math.min(input.durationMinutes, policy.maxGrantMinutes || input.durationMinutes);
|
||||
}
|
||||
|
||||
export function assertBrowserAccessPolicy(
|
||||
policy: EnterprisePolicy,
|
||||
input: { origin?: string; programEval?: boolean },
|
||||
): void {
|
||||
if (input.programEval && policy.allowProgramEval === false) {
|
||||
throw new ExtensionError('policy_denied', '企业策略禁止 browser.page.eval.program');
|
||||
}
|
||||
if (input.origin && policy.grantAllowedOrigins?.length
|
||||
&& !policy.grantAllowedOrigins.includes(input.origin)) {
|
||||
throw new ExtensionError('policy_denied', `企业策略不允许访问 origin: ${input.origin}`);
|
||||
if (policy.grantAllowedOrigins?.length) {
|
||||
const denied = input.origins.find((origin) => !policy.grantAllowedOrigins!.includes(origin));
|
||||
if (denied) throw new ExtensionError('policy_denied', `企业策略不允许授权 origin: ${denied}`);
|
||||
}
|
||||
return Math.min(input.durationMinutes, policy.maxGrantMinutes || input.durationMinutes);
|
||||
}
|
||||
|
||||
@@ -67,24 +67,6 @@ describe('split state storage', () => {
|
||||
expect(state.floatingPanel.side).toBe('left');
|
||||
});
|
||||
|
||||
it('keeps only validated manager-owned browser instance identity', async () => {
|
||||
await setState({
|
||||
...structuredClone(DEFAULT_STATE),
|
||||
bridge: {
|
||||
...structuredClone(DEFAULT_STATE.bridge),
|
||||
managedInstance: { manager: 'ytray', instanceId: 'instance-1', badge: 'C' },
|
||||
},
|
||||
});
|
||||
expect((await getState()).bridge.managedInstance).toEqual({
|
||||
manager: 'ytray', instanceId: 'instance-1', badge: 'C',
|
||||
});
|
||||
|
||||
stores.local[BRIDGE_SETTINGS_STORAGE_KEY] = {
|
||||
bridge: { ...structuredClone(DEFAULT_STATE.bridge), managedInstance: { manager: 'web', instanceId: '../bad', badge: '3' } },
|
||||
};
|
||||
expect((await getState()).bridge.managedInstance).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops a session grant that is not bound to an isolation context', async () => {
|
||||
const now = Date.now();
|
||||
stores.session[ACTIVE_SESSION_STORAGE_KEY] = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import type {
|
||||
BridgeConfig, BridgeGrant, BridgeGrantTarget, BridgeRuntimeSession, CapabilityScope, ExtensionState,
|
||||
BridgeGrant, BridgeGrantTarget, BridgeRuntimeSession, CapabilityScope, ExtensionState,
|
||||
ProxyConditionType, ProxyProfile,
|
||||
} from '@/types/models';
|
||||
import {
|
||||
@@ -15,7 +15,7 @@ interface StorageArea {
|
||||
}
|
||||
|
||||
let mutationQueue: Promise<void> = Promise.resolve();
|
||||
const sessionStorage = (browser.storage as unknown as { session?: StorageArea } | undefined)?.session;
|
||||
const sessionStorage = (browser.storage as unknown as { session?: StorageArea }).session;
|
||||
|
||||
export const DEFAULT_STATE: ExtensionState = {
|
||||
version: 7,
|
||||
@@ -96,19 +96,6 @@ function normalizeActiveGrant(input: unknown): BridgeGrant | undefined {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeManagedInstance(input: unknown): BridgeConfig['managedInstance'] {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) return undefined;
|
||||
const value = input as Partial<NonNullable<BridgeConfig['managedInstance']>>;
|
||||
if (
|
||||
!['ytray', 'yakit'].includes(value.manager || '')
|
||||
|| typeof value.instanceId !== 'string'
|
||||
|| !/^[A-Za-z0-9-]{1,160}$/.test(value.instanceId)
|
||||
|| typeof value.badge !== 'string'
|
||||
|| !/^[A-Z]{1,2}$/.test(value.badge)
|
||||
) return undefined;
|
||||
return value as NonNullable<BridgeConfig['managedInstance']>;
|
||||
}
|
||||
|
||||
function normalizeState(value: Partial<ExtensionState>): ExtensionState {
|
||||
const profileMap = new Map(defaultProfiles().map((profile) => [profile.id, profile]));
|
||||
const storedProfiles = Array.isArray(value.proxyProfiles) ? value.proxyProfiles.slice(0, 500) : [];
|
||||
@@ -188,11 +175,7 @@ function normalizeState(value: Partial<ExtensionState>): ExtensionState {
|
||||
: 'direct',
|
||||
customUserAgentProfiles: userAgentState.customUserAgentProfiles,
|
||||
userAgentAssignments: userAgentState.userAgentAssignments,
|
||||
bridge: {
|
||||
...DEFAULT_STATE.bridge,
|
||||
...value.bridge,
|
||||
managedInstance: normalizeManagedInstance(value.bridge?.managedInstance),
|
||||
},
|
||||
bridge: { ...DEFAULT_STATE.bridge, ...value.bridge },
|
||||
floatingPanel: {
|
||||
...DEFAULT_STATE.floatingPanel, ...value.floatingPanel,
|
||||
siteOrigins: [...new Set(value.floatingPanel?.siteOrigins || [])].slice(0, 500),
|
||||
|
||||
@@ -57,12 +57,6 @@ describe('Bridge v3 protocol', () => {
|
||||
expect(() => parseBridgeEnvelope('x'.repeat(BRIDGE_MAX_MESSAGE_BYTES + 1))).toThrow('16 MiB');
|
||||
});
|
||||
|
||||
it('opens only HTTP(S) pages in the attached browser instance', () => {
|
||||
expect(parseCapabilityParams('browser.tab.open', { url: 'https://www.baidu.com/' }))
|
||||
.toEqual({ url: 'https://www.baidu.com/' });
|
||||
expect(() => parseCapabilityParams('browser.tab.open', { url: 'chrome://settings' })).toThrow('HTTP(S)');
|
||||
});
|
||||
|
||||
it('accepts exact Worker boundary handles for remote deep capture', () => {
|
||||
expect(parseCapabilityParams('browser.deep_capture.start', {
|
||||
matcher: {
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface BridgePairingEnvelope {
|
||||
protocolVersion?: number;
|
||||
requestId?: string;
|
||||
installationId?: string;
|
||||
managedInstance?: BridgeEnvelope['managedInstance'];
|
||||
client?: string;
|
||||
version?: string;
|
||||
nonce?: string;
|
||||
@@ -100,8 +99,6 @@ const authorizationResourceValue = v.strictObject({
|
||||
export const capabilityParams = {
|
||||
'system.ping': v.optional(v.strictObject({})),
|
||||
'browser.tabs': v.optional(v.strictObject({})),
|
||||
'browser.tab.open': v.strictObject({ url: httpUrl }),
|
||||
'browser.thumbnail': v.optional(v.strictObject({ tabId: optionalTabId })),
|
||||
'browser.frames': v.optional(v.strictObject({ tabId: optionalTabId })),
|
||||
'browser.isolation.inspect': v.optional(v.strictObject({
|
||||
tabIds: v.optional(v.pipe(v.array(tabId), v.minLength(1), v.maxLength(256))),
|
||||
@@ -186,7 +183,6 @@ export const capabilityParams = {
|
||||
}), v.check((input) => input.action !== 'setValue' || typeof input.value === 'string', 'setValue 操作必须提供 value')),
|
||||
'browser.cookies': v.optional(v.strictObject(targetFields)),
|
||||
'browser.takeover': v.optional(v.strictObject(targetFields)),
|
||||
'browser.instance.close': v.optional(v.strictObject({})),
|
||||
'browser.handoff.request': v.strictObject({
|
||||
...targetFields,
|
||||
reason: v.picklist(['qr_code', 'mfa', 'captcha', 'device_confirmation', 'other']),
|
||||
@@ -358,7 +354,6 @@ export function parseBridgeEnvelope(raw: unknown): BridgeEnvelope {
|
||||
const allowedKeys = new Set([
|
||||
'id', 'type', 'method', 'params', 'result', 'error', 'client', 'version', 'protocolVersion',
|
||||
'capabilities', 'capabilityCatalog', 'sessionId', 'taskId', 'grantId', 'installationId',
|
||||
'managedInstance',
|
||||
'engineInstanceId', 'engineIdentityId', 'challenge', 'signature', 'publicKey', 'connectionId',
|
||||
'resumeSessionId', 'resumed', 'sequence', 'timestamp', 'replyTimestamp', 'transferId', 'index',
|
||||
'total', 'data', 'originalBytes',
|
||||
@@ -366,16 +361,6 @@ export function parseBridgeEnvelope(raw: unknown): BridgeEnvelope {
|
||||
const unexpected = Object.keys(message).find((key) => !allowedKeys.has(key));
|
||||
if (unexpected) throw new Error(`Bridge 消息包含未声明字段 $.${unexpected}`);
|
||||
if (typeof message.type !== 'string') throw new Error('Bridge 消息缺少 type');
|
||||
if (message.managedInstance !== undefined) {
|
||||
const managed = message.managedInstance as Record<string, unknown>;
|
||||
if (!managed || typeof managed !== 'object' || Array.isArray(managed)
|
||||
|| !['ytray', 'yakit'].includes(String(managed.manager || ''))
|
||||
|| typeof managed.instanceId !== 'string' || !/^[A-Za-z0-9-]{1,160}$/.test(managed.instanceId)
|
||||
|| typeof managed.badge !== 'string' || !/^[A-Z]{1,2}$/.test(managed.badge)
|
||||
|| Object.keys(managed).some((key) => !['manager', 'instanceId', 'badge'].includes(key))) {
|
||||
throw new Error('Bridge 浏览器实例身份无效');
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === 'challenge') {
|
||||
if (message.protocolVersion !== BRIDGE_PROTOCOL_VERSION) throw new Error(`Bridge 协议版本不兼容: ${String(message.protocolVersion)}`);
|
||||
|
||||
@@ -33,19 +33,11 @@ const CAPABILITY_METADATA = {
|
||||
scopes: [], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.tabs': {
|
||||
domain: 'page', access: 'read', summary: '列出当前浏览器实例中的全部 HTTP(S) 标签页;配对实例无需逐页授权',
|
||||
domain: 'page', access: 'read', summary: '列出当前 grant 明确共享的标签页',
|
||||
scopes: ['browser.tabs.read'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.tab.open': {
|
||||
domain: 'page', access: 'write', summary: '在当前浏览器实例中新建并前台打开 HTTP(S) 页面',
|
||||
scopes: ['browser.tabs.write'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.thumbnail': {
|
||||
domain: 'page', access: 'read', summary: '读取当前可见标签页的低清预览图,供 Yakit 实例列表展示',
|
||||
scopes: ['browser.tabs.read'], targetMode: 'tab', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.isolation.inspect': {
|
||||
domain: 'isolation', access: 'read', summary: '读取浏览器实例内标签页的 Cookie Store 与身份隔离上下文',
|
||||
domain: 'isolation', access: 'read', summary: '读取共享标签页的 Cookie Store 与身份隔离上下文',
|
||||
scopes: ['browser.isolation.read'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.isolation.proof': {
|
||||
@@ -75,7 +67,7 @@ const CAPABILITY_METADATA = {
|
||||
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.authorization.context.get': {
|
||||
domain: 'authorization', access: 'sensitive-read', summary: '实时复核并读取当前浏览器实例中的短时认证上下文句柄',
|
||||
domain: 'authorization', access: 'sensitive-read', summary: '实时复核并读取当前共享会话中的短时认证上下文句柄',
|
||||
scopes: ['browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
|
||||
targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
@@ -135,7 +127,7 @@ const CAPABILITY_METADATA = {
|
||||
targetMode: 'none', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||
},
|
||||
'browser.frames': {
|
||||
domain: 'page', access: 'read', summary: '列出浏览器实例指定标签页中的 Frame',
|
||||
domain: 'page', access: 'read', summary: '列出共享标签页中的 Frame',
|
||||
scopes: ['browser.tabs.read'], targetMode: 'tab', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.context': {
|
||||
@@ -163,10 +155,6 @@ const CAPABILITY_METADATA = {
|
||||
domain: 'page', access: 'write', summary: '将目标标签页切换到前台',
|
||||
scopes: ['browser.tab.activate'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.instance.close': {
|
||||
domain: 'page', access: 'dangerous', summary: '关闭当前浏览器实例的全部窗口',
|
||||
scopes: ['browser.instance.close'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.handoff.request': {
|
||||
domain: 'handoff', access: 'write', summary: '请求用户完成扫码、MFA、验证码或设备确认',
|
||||
scopes: ['browser.human.takeover'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
@@ -186,7 +174,7 @@ const CAPABILITY_METADATA = {
|
||||
scopes: ['browser.network.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.network.list': {
|
||||
domain: 'network', access: 'read', summary: '列出已捕获请求,敏感字段仍由 Agent 操作审核策略保护',
|
||||
domain: 'network', access: 'read', summary: '列出已捕获请求,敏感字段继续受 grant 约束',
|
||||
scopes: ['browser.network.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.network.clear': {
|
||||
@@ -397,11 +385,9 @@ export const READ_CAPABILITY_SCOPES: CapabilityScope[] = [
|
||||
|
||||
export const CONTROL_CAPABILITY_SCOPES: CapabilityScope[] = [
|
||||
...READ_CAPABILITY_SCOPES,
|
||||
'browser.tabs.write',
|
||||
'browser.dom.write',
|
||||
'browser.isolation.manage',
|
||||
'browser.tab.activate',
|
||||
'browser.instance.close',
|
||||
...(!(import.meta.env.FIREFOX && import.meta.env.MODE === 'store')
|
||||
? ['browser.page.invoke' as const, 'browser.page.eval.expression' as const]
|
||||
: []),
|
||||
@@ -424,7 +410,6 @@ export const CONTROL_CAPABILITY_SCOPES: CapabilityScope[] = [
|
||||
|
||||
export const CAPABILITY_LABELS: Record<CapabilityScope, string> = {
|
||||
'browser.tabs.read': '标签页列表',
|
||||
'browser.tabs.write': '打开网页',
|
||||
'browser.isolation.read': '读取身份隔离状态',
|
||||
'browser.isolation.manage': '创建隔离身份页面',
|
||||
'browser.dom.read': '页面 DOM',
|
||||
@@ -432,7 +417,6 @@ export const CAPABILITY_LABELS: Record<CapabilityScope, string> = {
|
||||
'browser.storage.read': '页面 Storage',
|
||||
'browser.cookies.read': 'Cookie',
|
||||
'browser.tab.activate': '切到前台',
|
||||
'browser.instance.close': '关闭浏览器实例',
|
||||
'browser.page.invoke': '调用页面函数',
|
||||
'browser.page.eval.expression': '执行页面表达式',
|
||||
'browser.page.eval.program': '执行页面程序',
|
||||
|
||||
@@ -120,17 +120,6 @@ describe('extension request schemas', () => {
|
||||
})).toThrow('HTTP(S)');
|
||||
});
|
||||
|
||||
it('validates manager-owned browser instance binding', () => {
|
||||
expect(parseExtensionRequest({
|
||||
action: 'bridge.managed-instance.bind',
|
||||
payload: { manager: 'ytray', instanceId: '13367db6-232a-40d1-ad84-81ee5d97634f', badge: 'B' },
|
||||
}).action).toBe('bridge.managed-instance.bind');
|
||||
expect(() => parseExtensionRequest({
|
||||
action: 'bridge.managed-instance.bind',
|
||||
payload: { manager: 'web', instanceId: '../shared', badge: '3' },
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('validates recording bounds and recorded page callables', () => {
|
||||
expect(parseExtensionRequest({
|
||||
action: 'recording.start',
|
||||
|
||||
@@ -167,19 +167,12 @@ const userAgentProfileInput = v.strictObject({
|
||||
userAgent: userAgentValue,
|
||||
});
|
||||
|
||||
const managedInstance = v.strictObject({
|
||||
manager: v.picklist(['ytray', 'yakit']),
|
||||
instanceId: v.pipe(v.string(), v.regex(/^[A-Za-z0-9-]{1,160}$/)),
|
||||
badge: v.pipe(v.string(), v.regex(/^[A-Z]{1,2}$/)),
|
||||
});
|
||||
|
||||
const bridgeConfig = v.strictObject({
|
||||
transport: v.picklist(['native', 'websocket']),
|
||||
nativeHost: v.pipe(v.string(), v.trim(), v.maxLength(253)),
|
||||
endpoint: v.pipe(v.string(), v.trim(), v.maxLength(2_048)),
|
||||
autoConnect: v.boolean(),
|
||||
installationId: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)),
|
||||
managedInstance: v.optional(managedInstance),
|
||||
pairedEngine: v.optional(v.strictObject({
|
||||
engineIdentityId: id,
|
||||
deviceId: id,
|
||||
@@ -233,7 +226,6 @@ const contextOptions = {
|
||||
|
||||
const capabilityScopes: readonly CapabilityScope[] = [
|
||||
'browser.tabs.read',
|
||||
'browser.tabs.write',
|
||||
'browser.isolation.read',
|
||||
'browser.isolation.manage',
|
||||
'browser.dom.read',
|
||||
@@ -241,7 +233,6 @@ const capabilityScopes: readonly CapabilityScope[] = [
|
||||
'browser.storage.read',
|
||||
'browser.cookies.read',
|
||||
'browser.tab.activate',
|
||||
'browser.instance.close',
|
||||
'browser.page.invoke',
|
||||
'browser.page.eval.expression',
|
||||
'browser.page.eval.program',
|
||||
@@ -471,7 +462,6 @@ const payloadSchemas = {
|
||||
'metrics.get': noPayload,
|
||||
'metrics.reset': noPayload,
|
||||
'bridge.config.save': bridgeConfig,
|
||||
'bridge.managed-instance.bind': managedInstance,
|
||||
'bridge.pair': noPayload,
|
||||
'bridge.pair.cancel': noPayload,
|
||||
'bridge.pair.status': noPayload,
|
||||
|
||||
@@ -243,10 +243,6 @@ export interface ExtensionRequestMap {
|
||||
'metrics.get': { input: undefined; output: RuntimeMetrics };
|
||||
'metrics.reset': { input: undefined; output: RuntimeMetrics };
|
||||
'bridge.config.save': { input: BridgeConfig; output: ExtensionState };
|
||||
'bridge.managed-instance.bind': {
|
||||
input: NonNullable<BridgeConfig['managedInstance']>;
|
||||
output: BridgeStatus;
|
||||
};
|
||||
'bridge.pair': { input: undefined; output: BridgePairingStatus };
|
||||
'bridge.pair.cancel': { input: undefined; output: BridgePairingStatus };
|
||||
'bridge.pair.status': { input: undefined; output: BridgePairingStatus };
|
||||
@@ -337,7 +333,6 @@ export interface BridgeEnvelope {
|
||||
taskId?: string;
|
||||
grantId?: string;
|
||||
installationId?: string;
|
||||
managedInstance?: BridgeConfig['managedInstance'];
|
||||
engineInstanceId?: string;
|
||||
engineIdentityId?: string;
|
||||
challenge?: string;
|
||||
|
||||
+1
-9
@@ -196,11 +196,6 @@ export interface BridgeConfig {
|
||||
endpoint: string;
|
||||
autoConnect: boolean;
|
||||
installationId: string;
|
||||
managedInstance?: {
|
||||
manager: 'ytray' | 'yakit';
|
||||
instanceId: string;
|
||||
badge: string;
|
||||
};
|
||||
pairedEngine?: BridgePairedEngine;
|
||||
}
|
||||
|
||||
@@ -231,7 +226,6 @@ export interface BridgePairingStatus {
|
||||
|
||||
export type CapabilityScope =
|
||||
| 'browser.tabs.read'
|
||||
| 'browser.tabs.write'
|
||||
| 'browser.isolation.read'
|
||||
| 'browser.isolation.manage'
|
||||
| 'browser.dom.read'
|
||||
@@ -239,7 +233,6 @@ export type CapabilityScope =
|
||||
| 'browser.storage.read'
|
||||
| 'browser.cookies.read'
|
||||
| 'browser.tab.activate'
|
||||
| 'browser.instance.close'
|
||||
| 'browser.page.invoke'
|
||||
| 'browser.page.eval.expression'
|
||||
| 'browser.page.eval.program'
|
||||
@@ -413,7 +406,7 @@ export interface NetworkRequestRecord {
|
||||
}
|
||||
|
||||
export interface NetworkCaptureStatus {
|
||||
active?: boolean;
|
||||
active: boolean;
|
||||
target: BrowserTarget;
|
||||
startedAt?: number;
|
||||
count: number;
|
||||
@@ -1490,7 +1483,6 @@ export interface ExtensionState {
|
||||
export interface ActiveTabInfo {
|
||||
id: number;
|
||||
windowId: number;
|
||||
active?: boolean;
|
||||
title: string;
|
||||
url: string;
|
||||
incognito: boolean;
|
||||
|
||||
+2
-9
@@ -1,12 +1,5 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { defineConfig } from 'wxt';
|
||||
|
||||
// package.json is the single source of truth for the version; release
|
||||
// packaging asserts the built manifest matches it.
|
||||
const { version } = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'));
|
||||
const CHROMIUM_EXTENSION_KEY = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1bj9d0jEOY87aT9nk4Ov7svZVnrFPD0dJsS39exzqMIJGMkGmqQ7J4TfFLlAV3Ckm9uszkMyw1oKKM/5ejd662B2uTcolHcSzmEVKLTGLvwUylWE6YJWcb3b5G88bzkcQepnNdz3gg3JvMhwPBNMk4qeSAHtX7u6S5zjoX4AyvQg5/qs29zViUTZoPcSEprJidaMilKwGxsJ5VpgtUXCE7JoKgadm/CK4iwJF5yCmKrkCi6xFwrt/qfrLAd6qXae7d5PDztxNyU+KSHX6FUHFfvJx9cmeIjIIJiZ35RHV78oT2beATSrU70uxg6in2JMy0z9SnpoV4euJ4Xyh6f/cwIDAQAB';
|
||||
|
||||
// See https://wxt.dev/api/config.html
|
||||
export default defineConfig({
|
||||
srcDir: 'src',
|
||||
@@ -19,7 +12,7 @@ export default defineConfig({
|
||||
manifest: ({ mode, browser }) => ({
|
||||
name: 'Yakit Browser Agent',
|
||||
description: 'Yakit 浏览器安全测试工具与 AI 上下文桥接',
|
||||
version,
|
||||
version: '0.2.0',
|
||||
action: {
|
||||
default_title: 'Yakit Browser Agent',
|
||||
},
|
||||
@@ -30,7 +23,7 @@ export default defineConfig({
|
||||
storage: {
|
||||
managed_schema: 'managed-storage-schema.json',
|
||||
},
|
||||
...(browser !== 'firefox' ? { key: CHROMIUM_EXTENSION_KEY, incognito: 'spanning' as const } : {}),
|
||||
...(browser !== 'firefox' ? { incognito: 'spanning' as const } : {}),
|
||||
permissions: [
|
||||
'proxy',
|
||||
'storage',
|
||||
|
||||
Reference in New Issue
Block a user