diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index 73a16283..94006e97 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -10,6 +10,11 @@ on: description: 已存在的 release tag,留空则自动按 manifest.json 版本拼成 v{version} required: false type: string + agent_bridge_disclosure_confirmed: + description: 如果本版本包含 Agent Bridge,确认已更新 Chrome/Edge/Firefox 商店隐私披露、数据用途说明、用户可见说明和 release note + required: false + type: boolean + default: false permissions: contents: write @@ -37,9 +42,143 @@ jobs: - name: 安装依赖 run: pnpm install --frozen-lockfile - - name: 构建扩展 + - name: Lint + run: pnpm run lint + + - name: 构建注入脚本 + run: pnpm run build:injected + + - name: 单元测试 + run: pnpm run test:unit + + - name: 类型检查(仓库脚本会刷新 dist) + run: pnpm run typecheck + + - name: 文档构建 + run: pnpm run docs:build + + - name: 发布前刷新扩展 dist run: pnpm run build + - name: 校验发布产物边界 + shell: bash + run: | + set -euo pipefail + + node --input-type=module <<'NODE' + import { readdirSync, readFileSync, statSync } from 'node:fs' + import { basename, join, relative } from 'node:path' + + const root = 'dist' + const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')) + const failures = [] + const agentOnlySourceFiles = [ + 'stackprism-bridge.mjs', + 'stackprism_bridge.py', + 'capture-site.mjs', + 'capture-site-args.mjs', + 'capture-runtime.mjs', + 'capture-screenshot-artifact.mjs', + 'capture-store.mjs', + 'http-handlers.mjs', + 'http-server.mjs', + 'open-browser.mjs', + 'protocol.mjs', + 'security.mjs', + 'url-policy.mjs' + ] + const agentOnlySourceFileSet = new Set(agentOnlySourceFiles) + const agentOnlyPathPatterns = [ + /(?:^|\/)agent-skill(?:\/|$)/, + /(?:^|\/)docs\/superpowers(?:\/|$)/, + /(?:^|\/)tests(?:\/|$)/ + ] + const pathBasename = value => basename(String(value || '').replaceAll('\\', '/')) + const isAgentOnlySourcePath = path => agentOnlySourceFileSet.has(pathBasename(path)) + + if (Object.prototype.hasOwnProperty.call(manifest, 'externally_connectable')) { + failures.push('dist/manifest.json must not expose externally_connectable') + } + + for (const resource of manifest.web_accessible_resources || []) { + for (const path of resource.resources || []) { + if ( + agentOnlyPathPatterns.some(pattern => pattern.test(path)) || + isAgentOnlySourcePath(path) || + path.includes('experience-profiler.iife.js') + ) { + failures.push(`web_accessible_resources exposes agent-only path: ${path}`) + } + } + } + + const disallowed = [ + ...agentOnlyPathPatterns, + /(?:^|\/)__pycache__(?:\/|$)/, + /\.py[co]?$/ + ] + + const walk = dir => { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + const rel = relative(root, full) + if (statSync(full).isDirectory()) { + walk(full) + continue + } + if (disallowed.some(pattern => pattern.test(rel)) || isAgentOnlySourcePath(rel)) { + failures.push(`dist contains agent-only or test artifact: ${rel}`) + } + } + } + walk(root) + + if (failures.length) { + for (const failure of failures) console.error(`::error::${failure}`) + process.exit(1) + } + NODE + + - name: 校验 Agent Bridge 发布披露确认 + shell: bash + env: + AGENT_BRIDGE_DISCLOSURE_CONFIRMED: ${{ inputs.agent_bridge_disclosure_confirmed }} + run: | + set -euo pipefail + + node --input-type=module <<'NODE' + import { readFileSync } from 'node:fs' + import { join } from 'node:path' + + const manifest = JSON.parse(readFileSync(join('dist', 'manifest.json'), 'utf8')) + const bridgeMatches = entry => (entry.matches || []).includes('http://127.0.0.1/*') + const hasAgentBridgeContentScript = (manifest.content_scripts || []).some(entry => + bridgeMatches(entry) && (entry.js || []).some(path => path.includes('agent-bridge')) + ) + const hasAgentBridgeResource = (manifest.web_accessible_resources || []).some(entry => + bridgeMatches(entry) && (entry.resources || []).some(path => path.includes('agent-bridge')) + ) + + if (!hasAgentBridgeContentScript && !hasAgentBridgeResource) { + process.exit(0) + } + + const eventPath = process.env.GITHUB_EVENT_PATH + const event = eventPath ? JSON.parse(readFileSync(eventPath, 'utf8')) : {} + const releaseBody = event.release?.body || '' + const workflowDispatchConfirmed = process.env.AGENT_BRIDGE_DISCLOSURE_CONFIRMED === 'true' + const releaseBodyConfirmed = /\[(x|X)\]\s*Agent Bridge disclosure confirmed/.test(releaseBody) + + if (workflowDispatchConfirmed || releaseBodyConfirmed) { + process.exit(0) + } + + console.error( + '::error::Agent Bridge is present in dist. Confirm Chrome Web Store / Edge Add-ons / Firefox Add-ons privacy disclosure, user-visible data-use text, and release notes before packaging. For workflow_dispatch set agent_bridge_disclosure_confirmed=true; for release events add a checked release-note line: [x] Agent Bridge disclosure confirmed.' + ) + process.exit(1) + NODE + - name: 读取版本号并校验 id: meta shell: bash diff --git a/.gitignore b/.gitignore index f900e0cf..c7e9fb21 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Local dependencies and caches node_modules/ .cache/ +cache/ .parcel-cache/ .vite/ docs/.vitepress/cache/ @@ -15,6 +16,7 @@ release/ releases/ artifacts/ public/injected/ +vite.config.ts.timestamp-*.mjs *.zip *.crx *.xpi @@ -33,9 +35,15 @@ pnpm-debug.log* # Local-only scripts and scratch tools tools/ scripts/ +!agent-skill/ +!agent-skill/**/ +!agent-skill/**/scripts/ +!agent-skill/**/scripts/** tmp/ temp/ *.local.* +__pycache__/ +*.py[cod] # Editor and OS files .DS_Store diff --git a/.prettierignore b/.prettierignore index 4833d71e..4239737c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ node_modules/ +!agent-skill/**/scripts/** dist/ build/ release/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..a4ea2f98 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,139 @@ +# AGENTS.md - StackPrism 仓库执行约束 + +## 1. 沟通与事实边界 + +- 全程使用中文沟通,答案直接收束当次诉求,不附加无关建议。 +- 结论必须基于代码、测试、构建、日志、浏览器验证或 git 证据,不凭印象判断。 +- 本仓库是基于 Vite、Vue 3、TypeScript、`@crxjs/vite-plugin` 的 Chrome/Edge Manifest V3 扩展。不要套用 Rust、Python、后端服务或数据库项目流程。 +- 阶段目标优先级:先保证检测、消息、构建和导出行为等价,再考虑性能优化、压缩体积和基准测试。 +- 禁止为“先跑通”添加隐藏回退、静默容错、假成功 Mock 或吞异常继续的路径。失败必须显式暴露到错误、日志、状态或失败测试。 + +## 2. 任务来源与原子边界 + +- 当前仓库没有固定的 `issues.csv` 或 `tasks.md`。默认任务来源按优先级为:用户当前明确指令、已确认的 GitHub issue/PR、正在执行的 `docs/superpowers/plans/` 计划。 +- `docs/reviews/` 是验证与审计记录目录,不是默认任务驱动源。若用户明确指定某份审计报告或阶段计划,则以该文件中的未完成项作为当前边界。 +- 每次只处理一个原子任务。开始前先确认目标文件、验收标准和最小验证命令;发现范围扩大时先记录事实,再等待明确指令或单独开新任务。 +- 不并行开发多个任务,不顺手修无关问题。可在回复或审计文档中记录遗漏,但不要混进当前 diff。 +- 修改前先看 `git status --short --branch`。工作区可能已有用户改动,禁止回滚或覆盖非本次改动。 +- 每个原子任务结束前必须自审:对照验收标准、对照审查要求、运行最小相关验证、用 `git diff --name-only` 确认无范围外残余。 + +## 3. 最新认知索引 + +- 架构总览优先查 `docs/dev/architecture.md`。 +- 规则格式与贡献约束优先查 `docs/dev/rule-format.md` 和 `docs/dev/contribute-rules.md`。 +- Agent Bridge 当前设计优先查 `docs/dev/agent-bridge.md`。 +- Agent Bridge 阶段计划优先查 `docs/superpowers/plans/2026-05-21-stackprism-agent-bridge-plan.md`。 +- Agent Bridge 当前验证状态优先查 `docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md`。 +- README 面向用户安装、规则维护、功能说明;当 README 和代码事实冲突时,以代码事实和最新验证记录为准,并同步回写文档。 + +## 4. 仓库结构速查 + +- `src/background/`:MV3 service worker、消息路由、检测调度、规则加载、tab 缓存、响应头采集、Agent Bridge 捕获编排。 +- `src/content/`:content script,负责 DOM、资源、交互与 Agent Bridge 传输。 +- `src/injected/`:注入到页面 MAIN world 的检测脚本,由 `build-scripts/build-injected.mjs` 单独构建为 IIFE。 +- `src/ui/`:popup、settings、help 三个 Vue 页面及共享组件、样式 token。 +- `src/types/`:跨脚本共享类型,尤其是 `messages.ts`、`rules.ts`、`settings.ts`、`popup.ts`。 +- `src/utils/`:共享 helper。跨 background、content、ui 使用前确认不会引入不兼容的 Chrome 或 DOM 运行时依赖。 +- `public/rules/`:技术识别规则 JSON。规则变更优先只改数据文件,除非现有 matcher 无法表达需求。 +- `public/tech-links.json`:技术名到官网链接映射。 +- `agent-skill/stackprism-site-experience/`:仓库内 Agent Bridge skill 包,不会自动安装到全局 skill registry。 +- `docs/`:VitePress 文档。 +- `dist/` 和 `docs/.vitepress/dist/` 是构建产物,除非任务明确要求,不纳入提交。 + +## 5. 开发命令 + +- 安装依赖:`pnpm install` +- 开发服务:`pnpm run dev` +- 注入脚本构建:`pnpm run build:injected` +- 生产构建:`pnpm run build` +- 单元测试:`pnpm run test:unit` +- 类型与构建检查:`pnpm run typecheck` +- Lint:`pnpm run lint` +- 文档开发:`pnpm run docs:dev` +- 文档构建:`pnpm run docs:build` +- 技术链接检查:`pnpm run check:links` + +验证优先级按风险选择:小工具函数改动至少跑相关 `node --test` 或 `pnpm run test:unit`;类型、消息协议、构建链路、manifest、规则预编译、注入脚本相关改动至少跑 `pnpm run typecheck` 或 `pnpm run build`;文档站改动跑 `pnpm run docs:build`。浏览器扩展可加载性以 `pnpm run build` 后的 `dist/manifest.json` 和加载 `/Volumes/Work/code/stackprism-1.3.70/dist` 为准。 + +## 6. MV3、消息协议与运行时约束 + +- Service worker 不是常驻进程。不要依赖内存中的长期状态;需要跨事件保留的数据优先使用 `chrome.storage.session` 或已有缓存模块。 +- 所有跨脚本消息必须经过 `src/types/messages.ts` 的 discriminated union 和 `src/utils/messaging.ts`。新增消息要同步更新类型、发送端、接收端和测试。 +- `src/injected/*` 不能依赖扩展上下文 API。注入脚本通过 IIFE 文件注入页面 MAIN world,输入输出边界必须显式、可序列化。 +- 不要把 `public/rules/` 或 `public/tech-links.json` 直接 `import` 进 runtime bundle;运行时通过 `chrome.runtime.getURL` 与 `fetch` 加载,避免 service worker 冷启动膨胀。 +- 主动检测、动态快照、tab 状态和 popup 缓存改动要考虑标签页切换、页面跳转、URL 最终态、Chrome 系统页不可注入、service worker 重启。 +- 修改 Agent Bridge、active tab、capture 或 profile 传输逻辑时,必须显式覆盖目标 URL 校验、最终 URL 校验、tab 关闭、扩展重载、service worker 重启、超时、取消和重复提交边界。 + +## 7. 规则、数据格式与报告 schema + +- 规则 JSON 的文件清单由 `public/rules/index.json` 管理。新增或移动规则文件必须同步清单。 +- 规则字段、分组和置信度参考 `docs/dev/rule-format.md` 与 `docs/dev/contribute-rules.md`。 +- 优先使用高特征信号:专属响应头、专属资源 URL、`meta generator`、明确全局变量、专属 selector、官方 SDK 包名。 +- 避免短 keyword、宽泛 regex 和裸 `html` 命中。拿不准时降低置信度,并补充能复现误报边界的测试或说明。 +- 处理 JSON、manifest、消息 payload、导出报告、Agent Bridge profile schema 时,必须明确字段顺序、类型和含义;相关测试要覆盖缺字段、空值、错位和兼容分支。 +- 复制报告功能的边界是“当前弹窗结果”,不是未过滤的全部检测结果;不要把 raw JSON、完整原始线索和完整技术栈报告混成同一个交互面。 + +## 8. Agent Bridge 安全与隐私边界 + +- Agent Bridge 默认关闭,只能由用户在扩展设置中显式启用。 +- 本机 bridge 只能绑定 `127.0.0.1`。API token 只允许保存在进程内或请求头中,禁止写入源码、文档示例的真实值、日志或测试 fixture。 +- `bridgeToken`、nonce、capture id 只能绑定一次捕获流程。不得复用旧 token,不得把失败捕获伪装成成功 profile。 +- 目标 URL 仅允许 `http:` 和 `https:`。私有网络目标必须显式设置 `allowPrivateNetworkTarget`,默认 fail closed。 +- 禁止采集或输出 Cookie、Authorization、localStorage/sessionStorage 明文、完整敏感文本、签名 URL 和账号私密内容。 +- 首版信任边界是“本机用户启动的 bridge 进程和当前浏览器 profile”。不要宣称它能防同机恶意进程或同 profile 其他恶意扩展。 + +## 9. UI 与前端约束 + +- UI 改动遵循现有 Vue 单文件组件、组合式 API 和 `src/ui/tokens.css` 设计 token,不引入新的状态管理库。 +- popup、settings、help 是独立扩展页面。共享状态以 `chrome.storage.sync`、消息协议或明确的 utility 表达,不假设页面间共享内存。 +- 控件优先复用 `src/ui/components/` 现有组件。按钮、输入、选择器、复选框和主题切换要保持现有视觉密度和键盘可用性。 +- 不在界面上加入解释功能实现、快捷键或内部机制的说明性文字,除非产品本身需要。 +- UI 文案必须区分当前弹窗结果、完整原始线索、完整技术栈报告和 Agent Bridge profile,不用同一词描述不同数据面。 + +## 10. 代码质量红线 + +- 只改当前原子任务需要的文件。提交前用 `git diff --name-only` 确认没有范围外残余。 +- 函数尽量保持短小,超过约 50 行应优先拆分。嵌套超过 3 层时用卫语句或提取函数降低复杂度。 +- 不硬编码密钥、私有路径、用户环境或只为测试成立的假设。 +- 外部输入包括页面 DOM、URL、响应头、storage、用户自定义规则、剪贴板内容、本机 bridge 请求和导入的 JSON,必须在边界处校验或规范化。 +- 代码、注释、日志、Markdown 文档中禁止使用 Emoji 或装饰性 Unicode 符号。列表用 `-`、`*` 或数字,强调用 Markdown 加粗。 +- 注释只解释非直观原因、协议约束或复杂边界;不要复述代码做了什么。 + +## 11. 测试与验证记录 + +- 测试放在 `tests/*.test.mjs` 或现有测试目录,不在 `src/` 内新增内联测试块。 +- Node 测试使用仓库脚本 `pnpm run test:unit`,该脚本已设置 `--test-timeout=60000`。 +- 对消息协议、Agent Bridge、manifest、报告格式、规则匹配、动态采集等行为改动,优先新增或更新自动化测试。 +- 结构化数据、规则 JSON、manifest、profile schema、报告 schema 的变更必须有格式校验或契约测试,至少覆盖缺字段、错字段类型、空值和多版本兼容分支。 +- 无法运行完整验证时,必须说明未运行的命令、原因和剩余风险。不得把局部测试通过表述成真实浏览器环境已通过。 +- 主线回归或阶段验收应记录到 `docs/reviews/`,包含命令、退出码、通过或失败摘要、跳过原因和未覆盖风险。 + +## 12. Code Review 模式 + +- 当任务标题或用户请求包含 `Code Review`、`review`、`审计`、`检查未提交更改` 时,先进入只读审查:读取 `git status`、`git diff`、相关文件和可用测试结果。 +- 审查输出优先列问题,按严重程度排序,绑定具体文件和行号。没有问题时明确说明,并列出未覆盖的验证缺口。 +- 正式审计报告写入 `docs/reviews/CR-{ID}.md` 或用户指定路径。若发现严重问题,在报告中列出后续修复任务;只有用户明确要求时才修改业务代码。 +- 若用户要求“review 并修复”,先给发现,再实施明确可验证的修复。 + +## 13. Skills 使用规则 + +- 开始任务前只需轻量确认仓库内可用技能文档。当前已知 repo-local skill 是 `agent-skill/stackprism-site-experience/SKILL.md`。 +- 当任务需要通过用户已安装的 StackPrism 扩展采集目标网站体验 profile 时,必须阅读并遵循该 skill。 +- JavaScript bridge 优先使用 `node agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs`;仅当 Node 不可用时使用 Python fallback。 +- bridge stdout 只能读取 ready JSON 行;普通日志和启动错误应在 stderr。解析失败要显式报错,不得继续伪造 ready 状态。 +- 启用技能时在沟通中声明技能名称与用途。 + +## 14. 提交与文件卫生 + +- 每个原子任务单独提交。提交信息说明实际改动,不使用泛泛标题。 +- 提交前至少检查:`git status --short`、`git diff --name-only`、相关测试或构建命令。 +- 不提交临时文件、日志、缓存、个人配置、`dist/`、`docs/.vitepress/dist/`、`node_modules/`。 +- PR-only 截图或演示资产不要留在主项目 diff 中;如需保留,使用单独资产分支或用户指定位置。 +- 分支创建默认使用 `codex/` 前缀,除非用户指定其它命名。 + +## 15. 历史踩坑备忘 + +- 安装扩展时必须给出真实构建产物路径:`/Volumes/Work/code/stackprism-1.3.70/dist`,不要只说 `dist/`。 +- `typecheck` 会触发生产构建;报告验证结果时要说明它覆盖了 `vue-tsc --noEmit` 和 `pnpm build`。 +- Agent Bridge 不能只靠单元测试宣称完成真实 E2E;真实浏览器加载、目标捕获、profile 轮询和扩展重启边界必须单独验证或记录缺口。 +- 文档、README、guide 和 UI 文案必须同步区分 raw JSON、完整原始线索、当前弹窗结果和完整技术栈报告。 diff --git a/PRIVACY.md b/PRIVACY.md index 81261794..3647fe7a 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -8,7 +8,7 @@ StackPrism / 栈棱镜(以下简称「本扩展」)是一款基于 Chrome / Edge ## 访问的数据 -为了实现「识别您当前访问页面所使用的技术栈」这一唯一功能,本扩展会在您主动访问网页时**本地读取**以下信息。所有读取到的内容**仅在您的浏览器内存或 chrome.storage 中处理**,绝不会离开您的浏览器。 +为了实现「识别您当前访问页面所使用的技术栈」这一核心功能,本扩展会在您主动访问网页时**本地读取**以下信息。默认情况下,所有读取到的内容**仅在您的浏览器内存或 chrome.storage 中处理**。只有当您在设置页显式启用 Agent Bridge 时,浏览器侧可观测的技术栈与体验摘要才会发送到您本机 `127.0.0.1` 上的 bridge 进程,供本机 Agent 读取。 ### 1. 网站内容 @@ -34,12 +34,14 @@ StackPrism / 栈棱镜(以下简称「本扩展」)是一款基于 Chrome / Edge - **识别结果**:仅存储在 `chrome.storage.session`,浏览器关闭即销毁,且每个标签页独立保存 - **您的设置**(禁用的分类、黑名单技术名、自定义规则、主题偏好等):存储在 `chrome.storage.sync`,由浏览器加密同步至您的 Google / Microsoft 账号,本扩展**无法访问**您账号内的任何其他数据 +- **Agent Bridge 启用状态**:仅存储在当前浏览器 profile 的本机 `chrome.storage.local`,不会随 Chrome sync 同步到其他设备、其他浏览器或其他 profile。换设备、换浏览器 profile 或重装扩展后,需要在设置页重新显式启用 - 本扩展**不**存储完整页面 HTML、Cookie 完整值、登录凭据、表单填写内容、密码或任何其他敏感信息 ## 数据传输 - 本扩展**不会**向任何远程服务器上传您的浏览数据、识别结果、个人信息或任何其他数据 - 没有遥测、没有广告 SDK、没有第三方分析、没有错误上报到云端 +- 若您在设置页显式启用 Agent Bridge,本扩展可以把当前浏览器可观测的技术栈与体验摘要发送到您本机 `127.0.0.1` 上由您或本机 Agent 启动的 bridge 进程。该能力默认关闭,不会向远程服务器发送数据,也不会采集 Cookie、Authorization、localStorage/sessionStorage 明文或完整敏感文本。本版本不防同机恶意进程伪造兼容 bridge,也不把 DOM 中的 bridge token 视为对其他已安装扩展保密。 - 扩展只发起两类网络请求,**均不**携带您的任何身份信息或浏览数据: 1. 异步抓取**您当前访问页面自身已经加载的**少量 JS 文件首段(走浏览器缓存,不增加额外网络流量),用于在本地扫描该 JS 中的版权注释和 OAuth 入口 URL 2. 技术品牌图标**优先从本扩展内置的图标库读取**(已离线打包 1300+ 主流技术的 SVG / PNG,不发起任何网络请求);仅当某项技术不在内置图标库中时,才会从 `cdn.simpleicons.org` 拉取兜底(请求体只包含一个公开技术名 slug,例如 react / vuedotjs / docker,不含您正在访问的页面 URL、识别结果或任何个人信息);CDN 如果 2 秒内未加载成功,扩展会自动回落到本地生成的首字母色块,绝不阻塞识别流程 diff --git a/README.md b/README.md index 6de9bc7d..39e3c71e 100644 --- a/README.md +++ b/README.md @@ -10,31 +10,26 @@ [![Platform](https://img.shields.io/badge/Platform-Chrome%20%7C%20Edge%20%7C%20Firefox-26A69A)](#安装) [![PRs welcome!](https://img.shields.io/badge/PRs-Welcome-FF6F61)](https://github.com/setube/stackprism/issues) -# StackPrism / 栈棱镜 —— 网页技术栈识别浏览器扩展 +# StackPrism / 栈棱镜 -> 从页面运行时、DOM、资源 URL、响应头、动态加载和源码版权注释 6 个维度收集线索,把站点用的前后端栈分门别类列清楚。 +> 浏览器里的网页技术栈识别与体验采集工具。 -[简介](#简介) • [功能特性](#功能特性) • [规则维护](#规则维护) • [注意事项](#注意事项) • [参与共建](#参与共建) • [赞助者](SPONSORS.md) • [星标趋势](#星标趋势) • [开源协议](#开源协议) +[简介](#简介) • [安装](#安装) • [Agent Bridge](#agent-bridge) • [规则维护](#规则维护) • [参与共建](#参与共建) • [开源协议](#开源协议) ## 简介 -StackPrism(以下简称 **栈棱镜**)是一款基于 **Manifest V3** 的网页技术栈识别扩展,支持 **Chrome / Edge / Firefox**。 +StackPrism 是一款基于 **Manifest V3** 的网页技术栈识别扩展,支持 **Chrome / Edge / Firefox**。它从页面运行时、DOM、资源 URL、响应头、动态资源和 JS 版权注释中收集证据,把前端、后端、CDN、SaaS、统计、支付、登录、CMS 等线索按类目展示。 -不同于市面上多数只看 HTML 资源 URL 的同类工具,栈棱镜把检测拆成 4 个独立通道并行收集线索: +检测链路分成 4 个互补通道: -- **静态扫描**:页面加载时由注入脚本扫 DOM、全局变量、CSS 类名、CSS 变量、`` -- **响应头观察**:Service Worker 监听 `webRequest.onHeadersReceived`,捕获主文档/XHR/iframe 的响应头与 HTTP 版本 -- **动态资源监听**:content script 用 `PerformanceObserver` + `MutationObserver` 累积页面交互后新加载的脚本/样式/iframe/feed -- **JS 版权注释扫描**:后台异步抓主 bundle 的开头版权注释,识别打包进 `index/main/vendor` chunk 的第三方依赖 +- **静态扫描**:DOM、全局变量、CSS 类名、CSS 变量、`` +- **响应头观察**:主文档、XHR、iframe 的响应头与 HTTP 版本 +- **动态资源监听**:交互后新增脚本、样式、iframe、feed +- **源码版权注释扫描**:主 bundle 开头注释中的第三方依赖 -4 路结果合并去重后,按 50+ 个类目分组展示,并对**伪造响应头、自指检测、模糊误报**等场景做了主动收敛。 - -### 技术特性 - -- **MV3 service worker 架构**:无后台常驻进程,事件驱动,内存占用低 -- **规则即数据**:50+ 个 JSON 规则文件(`public/rules/`)集中维护,构建期预编译 hint prefilter 与 keyword 合并正则,运行时只跑命中候选 +结果会合并去重并按 50+ 个类目分组,同时对伪造响应头、自指检测、宽泛关键词误报做主动收敛。 ### 识别覆盖 @@ -47,9 +42,9 @@ StackPrism(以下简称 **栈棱镜**)是一款基于 **Manifest V3** 的网页 | 营销 | 广告 / 营销 / 统计 / 分析 / 标签管理 | | 安全 | HTTPS / HTTP/2 / HTTP/3 / CSP / Cookie 同意 | -### 安装 +## 安装 -**从源码加载**(开发模式): +从源码加载: ```bash git clone https://github.com/setube/stackprism.git @@ -62,37 +57,62 @@ pnpm run build:firefox # Firefox **Chrome / Edge:** 1. 打开 `chrome://extensions` 或 `edge://extensions` -2. 右上角开启「开发者模式」 -3. 点「加载已解压的扩展程序」,选 `dist/` 目录 -4. 访问任意网页,扩展图标显示识别数量 +2. 开启「开发者模式」 +3. 选择「加载已解压的扩展程序」,加载构建出的 `dist/` +4. 刷新目标网页并打开扩展 popup 查看结果 **Firefox:** 1. 打开 `about:debugging#/runtime/this-firefox` -2. 点「临时载入附加组件」,选 `dist-firefox/manifest.json` -3. 或者运行 `pnpm run build:firefox` 后在 `release/` 目录获取 `.xpi` 文件双击安装 +2. 选择「临时载入附加组件」,加载 `dist-firefox/manifest.json` +3. 或运行 `pnpm run build:firefox`,在 `release/` 目录获取 `.xpi` -### 开发 +## 开发 ```bash -pnpm run dev # 开发模式 + HMR -pnpm run typecheck # vue-tsc 类型检查 -pnpm run lint # ESLint -pnpm run build # 生产构建(含规则预编译) -pnpm run build:firefox # Firefox 构建 + .xpi 打包 -pnpm run docs:dev # VitePress 文档站本地预览 +pnpm run dev +pnpm run test:unit +pnpm run lint +pnpm run typecheck +pnpm run build +pnpm run build:firefox +pnpm run docs:dev ``` +`pnpm run typecheck` 会执行 `vue-tsc --noEmit` 并触发生产构建。 + +## Agent Bridge + +Agent Bridge 是默认关闭的本机能力。用户在扩展设置中显式启用后,本机 AI Agent 可以通过 `127.0.0.1` bridge 获取 `stackprism.site_experience_profile.v1`,用于参考目标站点的技术、视觉、布局、组件、交互、UX 和资源摘要。 + +- 只读采集:不会点击页面、提交表单或登录账号。 +- 本机边界:启用状态保存在当前浏览器 profile 的 `chrome.storage.local`,不随 Chrome sync 同步。 +- 隐私约束:不采集 Cookie、Authorization、localStorage/sessionStorage 明文或完整敏感文本。 +- Profile 下载:下载的是纯 JSON;截图不内嵌 base64,而是提供按需下载链接。要核对实际视觉效果,请打开或下载 `visualProfile.screenshot.downloadUrl`。 + +常用采集命令: + +```bash +TARGET_URL="https://public-or-desensitized.example" +node agent-skill/stackprism-site-experience/scripts/capture-site.mjs \ + --url "$TARGET_URL" \ + --out /tmp/stackprism-profile.json \ + --include tech,visual,layout,components,interaction,ux,assets +``` + +本机开发目标、`localhost`、`127.0.0.1`、私网地址和真实内网目标需要同时在扩展设置中开启高风险网络目标选项,并在 helper 请求中使用 `--allow-private-network`。不要复用被 `PRIVATE_NETWORK_TARGET_BLOCKED` 拒绝的旧 bridge URL。 + +更完整的协议、生命周期和安全说明见 [docs/dev/agent-bridge.md](docs/dev/agent-bridge.md) 与 [agent-skill/stackprism-site-experience/SKILL.md](agent-skill/stackprism-site-experience/SKILL.md)。 + ## 规则维护 -栈棱镜的规则系统是数据驱动的——绝大多数检测改规则 JSON 即可,无需碰代码。 +StackPrism 的规则系统是数据驱动的。绝大多数检测只需要修改规则 JSON,不需要改运行时代码。 -- **加载清单**:[public/rules/index.json](public/rules/index.json) 列出所有要加载的规则文件 -- **页面规则**:`public/rules/page/*.json` 处理页面源码、DOM、资源 URL、动态资源 -- **响应头规则**:`public/rules/headers/*.json` 处理服务端响应头与 Cookie -- **自指抑制**:[public/rules/self-host-suppress.json](public/rules/self-host-suppress.json) 当用户在某主流站点上时跳过同名识别 -- **技术链接**:[public/tech-links.json](public/tech-links.json) 集中维护技术名 → 官网/仓库 URL 的映射,识别结果可点击跳转 -- **构建期 prefilter**:[vite.config.ts](vite.config.ts) 的 `precompileRulesPlugin` 在 `closeBundle` 阶段为每条 leaf rule 注入 `__hints`(最长 literal 段去重排序取前 3)和 `__keywordCombined`(keyword 合并正则),运行时 `rule-matcher.ts` 优先用它们做候选过滤 +- [public/rules/index.json](public/rules/index.json):规则加载清单 +- `public/rules/page/*.json`:页面源码、DOM、资源 URL、动态资源 +- `public/rules/headers/*.json`:响应头与 Cookie 线索 +- [public/rules/self-host-suppress.json](public/rules/self-host-suppress.json):自指抑制 +- [public/tech-links.json](public/tech-links.json):技术名到官网/仓库 URL 映射 新规则的字段: @@ -111,11 +131,11 @@ pnpm run docs:dev # VitePress 文档站本地预览 } ``` -### 规则质量约束 +### 规则质量 -- 优先用**高特征信号**:响应头、专属资源 URL、``、`window.`、独家 CSS 选择器、JS 版权注释、官方 SDK 包名 -- 避免短或过宽的 keyword(`spring` / `phoenix` / `column` / `container` 这些会命中竞品讨论、Bootstrap 与 Tailwind 通用类) -- 优先限定 `matchIn`,**优先** `resources` / `url` / `headers` 而不是裸 `html`,减少正文误报 +- 优先使用响应头、专属资源 URL、``、`window.`、独家 CSS 选择器、JS 版权注释、官方 SDK 包名。 +- 避免短 keyword 和宽泛 regex,例如 `spring`、`phoenix`、`column`、`container`。 +- 优先限定 `matchIn`,尽量用 `resources`、`url`、`headers`,少用裸 `html`。 ## 注意事项 @@ -128,19 +148,13 @@ pnpm run docs:dev # VitePress 文档站本地预览 ## 参与共建 -栈棱镜目前内置 50+ 类目下 2000+ 条识别规则,但前端生态变化快,**新框架、新 SaaS、新规则误报**都欢迎参与: +StackPrism 目前内置 50+ 类目下 2000+ 条识别规则。新框架、新 SaaS、漏识别和误报都欢迎反馈: - **Bug / 误识 / 漏识反馈**:[Issues](https://github.com/setube/stackprism/issues) — popup 上直接点「识别不准确」按钮会自动填好议题模板 - **规则贡献**:扩展设置页点「提交规则贡献」,或直接 PR 到 [`public/rules/`](public/rules/) - **讨论与提案**:[Discussions](https://github.com/setube/stackprism/discussions) -提交代码前请跑 `pnpm run typecheck && pnpm run lint && pnpm run build` 三个检查全过。 - -## 星标趋势 - -如果觉得有帮助,欢迎点 Star 让更多人看到这个项目。 - -Star History +提交代码前请至少运行 `pnpm run test:unit && pnpm run lint && pnpm run typecheck`。 ## 开源协议 diff --git a/agent-skill/stackprism-site-experience/README.md b/agent-skill/stackprism-site-experience/README.md new file mode 100644 index 00000000..111f02d8 --- /dev/null +++ b/agent-skill/stackprism-site-experience/README.md @@ -0,0 +1,61 @@ +# StackPrism Site Experience Skill + +This is a repo-local skill package for StackPrism Agent Bridge. + +It is not automatically installed into Codex or any global skill registry. Run the scripts by path from this repository, or copy/symlink this directory into your agent's skill directory if you want global discovery. + +Paths in this package are relative to the StackPrism repository root. If an agent starts from another working directory, it should either `cd ` before launching the bridge or resolve `agent-skill/...` to an absolute script path. The bridge scripts are repo-local tools, not global commands. + +## Scripts + +- `scripts/capture-site.mjs`: preferred one-shot capture client. It starts the JavaScript bridge, keeps stdin open, creates the capture, polls for the completed profile, writes the profile JSON, downloads the screenshot image when present, and rewrites the Profile screenshot reference to the local image file before exiting. +- `scripts/stackprism-bridge.mjs`: JavaScript loopback bridge, preferred. +- `scripts/stackprism_bridge.py`: Python standard-library fallback. + +The direct bridge scripts print a single ready JSON line to stdout after the HTTP server is bound. Logs and startup errors go to stderr. + +Use `capture-site.mjs` for ordinary agent work. Use `stackprism-bridge.mjs` or the Python fallback directly only for protocol debugging or custom orchestration. + +`capture-site.mjs` prints one JSON summary to stdout on success and one JSON error object to stderr on failure. It bounds each bridge API request with `--request-timeout-ms`, defaulting to 30000 ms, so a stalled local bridge fails explicitly instead of hanging the calling agent. It also accepts `--include tech,visual,layout,components,interaction,ux,assets` and `--max-resource-urls ` so retry attempts can reduce profile size without editing scripts. + +The bridge page opened in the browser becomes a result workbench after completion: target URL, screenshot preview, enlarged screenshot preview, screenshot download/copy, one-click Markdown summary, and grouped profile content cards. The page reads only the status preview with its one-capture `bridgeToken`; raw `/profile` still requires the API token. + +The JavaScript bridge and Python fallback intentionally share the same bridge page CSS and client script text. If `scripts/bridge/bridge-page-assets.mjs` changes, update `scripts/stackprism_bridge_lib/bridge_page_assets.py` in the same patch and keep `tests/stackprism_bridge_py.test.mjs` passing. + +Profile JSON is standard JSON and cannot contain comments. Screenshot guidance is stored in `note`, `profileJsonNote`, and `agentGuidance.recreationPlan.visualReference.screenshotDownloadHint`. Screenshot base64 is intentionally omitted; open `visualProfile.screenshot.downloadUrl` to inspect the actual visual appearance. + +Lifecycle: direct bridge screenshot links are valid only while the local bridge process is running and before the completed result TTL expires. The capture helper avoids that race by downloading the image during the live bridge window and saving a stable local `file://` URL plus `localPath` in the written Profile. + +When selecting a non-default browser or profile, keep the opener executable and its arguments separate: `STACKPRISM_BROWSER_OPEN_COMMAND` is only the executable or platform opener, while `STACKPRISM_BROWSER_OPEN_ARGS_JSON` is a JSON string array of opener/profile arguments. The bridge URL is appended by the script as the final argv item. + +Local development targets such as `localhost`, `127.0.0.1`, RFC1918 addresses, and real intranet hosts require both the extension's high-risk all-network-targets setting and the helper/request `--allow-private-network` override. Treat a `PRIVATE_NETWORK_TARGET_BLOCKED` response as a safety gate, not as a reason to reuse the old bridge URL. Localhost support is only for public, demo, or explicitly desensitized development pages; do not capture local or internal pages that contain private data. + +For local development captures, open the browser profile where StackPrism is installed. Replace the profile placeholder below; use `Default` only if StackPrism is really enabled there. + +```bash +cd +STACKPRISM_BROWSER_OPEN_COMMAND="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ +STACKPRISM_BROWSER_OPEN_ARGS_JSON='["--profile-directory="]' \ +node agent-skill/stackprism-site-experience/scripts/capture-site.mjs \ + --url http://127.0.0.1:5173/ \ + --allow-private-network \ + --out /tmp/stackprism-local-profile.json \ + --result-out /tmp/stackprism-local-result.json \ + --screenshot-out /tmp/stackprism-local-screenshot.jpg \ + --include tech,visual,layout,components,interaction,ux,assets +``` + +Do not capture private or logged-in pages. Use a public demo URL, a desensitized test URL, a design brief, or an already-redacted screenshot/recording instead. Do not ask users to create new screenshots of private pages. If they say "current browser page" without a URL, ask for a public or desensitized `http:` or `https:` URL. + +Large-page transfer failures should retry with less data, not partial results. Keep the same URL, browser/profile env, and private-network flag on every retry. First use `--include tech,visual,layout,components,ux --max-resource-urls 150`; only if the user accepts losing screenshot evidence, use `--include tech,layout,components,ux --max-resource-urls 50 --no-screenshot`. + +For Firefox E2E, use an exact safe public URL and an explicit Firefox profile; if the URL is missing, ask for one and do not choose an arbitrary public page or `example.com`. For example, use `STACKPRISM_BROWSER_OPEN_COMMAND="/Applications/Firefox.app/Contents/MacOS/firefox"` with `STACKPRISM_BROWSER_OPEN_ARGS_JSON='["-P",""]'`. Record `browserName: "Firefox"` and the profile identifier in the evidence. + +For Agent Bridge E2E reports, record an evidence manifest outside the repository: browser/profile label, browser version, extension version, Agent Bridge status, target and final URL, command template, exit code, parsed stdout/stderr JSON, failure code, profile/result/screenshot paths, file sizes, SHA-256 hashes, `screenshotWritten`, `profileDownloadReady`, `techCount`, limitations, whether `--allow-private-network` was used, and uncovered risks. Never paste or commit `apiToken`, `bridgeToken`, nonce, token-bearing bridge URLs, `Authorization` headers, raw ready JSON, screenshot data URLs, cookies, credentials, signed URLs, account data, or unredacted `captureId`. Use `shasum -a 256` and `stat -f '%N %z bytes'` for artifact hashes and sizes, and redact `captureId` from any copied stdout summary. + +## Security Notes + +- API tokens are process-local and must not be written into files. +- The bridge binds to `127.0.0.1`. +- The browser extension must be explicitly enabled for Agent Bridge in the current browser profile. +- This first version does not defend against malicious local processes or malicious extensions in the same browser profile. diff --git a/agent-skill/stackprism-site-experience/SKILL.md b/agent-skill/stackprism-site-experience/SKILL.md new file mode 100644 index 00000000..78889eb5 --- /dev/null +++ b/agent-skill/stackprism-site-experience/SKILL.md @@ -0,0 +1,306 @@ +--- +name: stackprism-site-experience +description: Use when an AI agent must capture a StackPrism Agent Bridge profile from a specific http(s) URL for browser-observed evidence, website recreation, UX comparison, live fact verification, or Agent Bridge E2E verification. Do not use for generic UI edits, backend-only work, web search, or StackPrism internal code review unless a target URL profile capture is required. +--- + +# StackPrism Site Experience + +StackPrism helps AI agents recreate websites from real browser evidence: tech stack, UI design, layout, components, interactions, UX, and assets. + +Use this skill proactively when the task involves: + +- Capturing a StackPrism profile or browser evidence from a specific `http:` or `https:` URL. +- Building or cloning a page, app, component set, landing page, dashboard, or marketing site similar to an existing URL. +- Reviewing or improving UI/UX by comparing against a live product, competitor, reference site, or production page. +- Choosing implementation details from evidence: detected technologies, visual tokens, layout density, component patterns, interaction behavior, asset dependencies, and first-order UX structure. +- Verifying that a target page's browser-observed facts match a design brief, audit claim, migration requirement, or Agent Bridge E2E claim. + +Do not use this skill for backend-only tasks, generic web search, SEO content extraction, login-protected private data capture, generic UI work with no target URL, or StackPrism internal source review, refactor, or maintenance unless the task actually needs a target URL Profile capture through Agent Bridge. + +## Preconditions + +- The user has installed the StackPrism extension in the browser profile that will open the bridge page. +- StackPrism Agent Bridge is enabled in the extension settings for that local browser profile. +- The target URL is `http:` or `https:`. +- For local development targets, first confirm the dev server is running and the URL is reachable before starting the bridge helper. +- Local development targets require both extension settings consent for all network targets and helper/request option `"allowPrivateNetworkTarget": true`. +- Localhost support is only for public, demo, or explicitly desensitized development pages. Do not capture local, intranet, or internal pages that contain login state, account data, billing data, inbox content, customer data, or other private information. +- Public hostnames routed through common local proxy/TUN fake-IP ranges such as `198.18.0.0/15` are supported by default. Direct private IPs, `localhost`, RFC1918, link-local, and other special-use targets still require `"allowPrivateNetworkTarget": true`. +- If the user has enabled the extension's high-risk "allow all network targets" setting, still pass `--allow-private-network` for local development, direct private IP, or real intranet targets; the extension setting affects the browser-observed final target gate, while the helper's create-stage URL policy remains explicit per capture. + +## Private Page Boundary + +Do not run the helper or manual Bridge API against login-protected, account-specific, billing, admin, inbox, dashboard, internal, or private user pages, even when the user says they own the account. Screenshots are not pixel-redacted and profile summaries can still expose private text patterns. + +Use this refusal template: + +```text +I cannot automatically capture that private or logged-in page with StackPrism. Please provide a public demo URL, a desensitized test-environment URL, a design brief, or an anonymized page-structure summary. If you already have a redacted screenshot or recording with private content removed, you can provide it; do not create a new screenshot of the private page for this request. I can use StackPrism only after the target is public or explicitly desensitized. +``` + +If the user provides a safe public demo or desensitized target, continue with the normal capture flow. Do not request screenshots for private pages, do not ask the user to create screenshots for private pages, and do not add `--allow-private-network` as a workaround for privacy. + +If the user says the target is "the current browser page" but does not provide a URL, do not use `active_tab` or infer a target. Ask for a public or explicitly desensitized `http:` or `https:` URL first. Accept a user-provided redacted screenshot or recording only if the user already has one and has removed private content; do not ask the user to create a screenshot of a private page for StackPrism capture. + +## Preferred Capture Command + +Use the capture helper first. It keeps the bridge child process alive, creates one fresh bridge per capture, polls the profile endpoint, and can decode the optional screenshot: + +```bash +cd +node agent-skill/stackprism-site-experience/scripts/capture-site.mjs \ + --url https://target.example \ + --out /tmp/stackprism-profile.json \ + --result-out /tmp/stackprism-result.json \ + --screenshot-out /tmp/stackprism-screenshot.jpg \ + --include tech,visual,layout,components,interaction,ux,assets +``` + +Set `STACKPRISM_BROWSER_OPEN_COMMAND` and `STACKPRISM_BROWSER_OPEN_ARGS_JSON` only when the default opener is not the browser/profile with StackPrism installed. On macOS, for example, use `STACKPRISM_BROWSER_OPEN_COMMAND=open` and `STACKPRISM_BROWSER_OPEN_ARGS_JSON='["-a","Google Chrome"]'` to force Chrome. + +For localhost or direct private-network development targets, use a full explicit command and record that the override was controlled. Replace `` with the browser profile that actually has StackPrism installed; do not use `Default` unless the user confirms StackPrism is installed and enabled there. + +```bash +cd +STACKPRISM_BROWSER_OPEN_COMMAND="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ +STACKPRISM_BROWSER_OPEN_ARGS_JSON='["--profile-directory="]' \ +node agent-skill/stackprism-site-experience/scripts/capture-site.mjs \ + --url http://127.0.0.1:5173/ \ + --allow-private-network \ + --out /tmp/stackprism-local-profile.json \ + --result-out /tmp/stackprism-local-result.json \ + --screenshot-out /tmp/stackprism-local-screenshot.jpg \ + --include tech,visual,layout,components,interaction,ux,assets +``` + +The helper prints one JSON summary on stdout. On failure it writes one JSON error object to stderr with `error.code`, `error.message`, and sanitized `error.details`. `screenshotPresent` means the profile contains screenshot evidence; `screenshotWritten` means the screenshot image was written to `screenshotPath` and can be opened by image-capable coding tools. If `--screenshot-out` is omitted, the helper writes a sidecar image next to `--out` and rewrites the Profile screenshot reference to that local file URL before saving the JSON. + +Reference files are available for deeper consumption details. Read `references/site-experience-profile-schema.md` when implementing or validating Profile schema handling, and read `references/agent-consumption-guide.md` when translating a captured Profile into a target UI or app implementation. + +By default the helper opens a fresh target tab and does not force-refresh it. Use `--force-refresh` only when you need a controlled cache-bypass capture; this avoids treating browser-superseded initial navigations as target load failures on large public sites. + +Each bridge API request has a bounded timeout. The default is 30000 ms; use `--request-timeout-ms ` only when a slower local browser opener or debug bridge needs more time. If it exits with `BRIDGE_REQUEST_TIMEOUT`, stop that attempt and start one fresh helper process rather than reusing a partial capture. + +The opened bridge page is also a local result workbench. After completion it shows the target URL, screenshot preview, click-to-enlarge preview, screenshot download/copy buttons, a Profile download button for the current capture, a one-click Markdown summary, and grouped profile content cards. That page uses the bridge-token status preview for rendering and a current-capture download endpoint for saving the completed Profile; direct raw `/profile` API reads still require the API token. For programmatic use, prefer the helper output or the API-token profile endpoint. + +Profile JSON is standard JSON and cannot contain comments. Screenshot handling instructions are carried in `visualProfile.screenshot.note`, `visualProfile.screenshot.profileJsonNote`, and `agentGuidance.recreationPlan.visualReference.screenshotDownloadHint`. + +If the helper exits with `PRIVATE_NETWORK_TARGET_BLOCKED` for a local development, direct private IP, or real intranet target, first confirm the StackPrism settings page has the high-risk "allow all network targets" option enabled for this browser profile, then run a second fresh helper process with `--allow-private-network` and record that this was a controlled override. Do not reuse the first bridge URL, capture id, session, or token. Do not add `--allow-private-network` just because a public hostname is routed through `198.18.0.0/15`; that proxy/TUN case is allowed by default. + +If the helper exits with `CAPTURE_BUSY`, wait a few seconds, stop any bridge child process from that attempt, and run one fresh helper process. Do not keep polling an old capture after a terminal or stale status. + +For large-page transfer failures, use a bounded retry ladder instead of inventing partial results. On `BRIDGE_TRANSPORT_DISCONNECTED`, `PROFILE_TRANSPORT_FAILED`, `PROFILE_CHUNK_MISSING`, `CAPTURE_TIMEOUT`, or `BRIDGE_REQUEST_TIMEOUT`, stop the old bridge child process and start a fresh helper. + +Retry attempts must preserve the original capture context exactly: the same `TARGET_URL`, same `STACKPRISM_BROWSER_OPEN_COMMAND`, same `STACKPRISM_BROWSER_OPEN_ARGS_JSON`, same browser/profile, and same target policy flags such as `--allow-private-network`. If the first command used command-prefix env assignments, repeat those same assignments on every reduced retry. If it used `--allow-private-network`, add that flag directly after `--url "$TARGET_URL"` on every reduced retry. The only intended retry changes are `--include`, `--max-resource-urls`, and the final `--no-screenshot` boundary. + +First keep visual evidence but reduce high-volume sections: + +```bash +cd +# Public target form. For an original local/private attempt, add +# --allow-private-network immediately after --url "$TARGET_URL". +# Repeat any original STACKPRISM_BROWSER_OPEN_COMMAND/ARGS_JSON env prefix here. +node agent-skill/stackprism-site-experience/scripts/capture-site.mjs \ + --url "$TARGET_URL" \ + --out /tmp/stackprism-profile-retry-1.json \ + --result-out /tmp/stackprism-result-retry-1.json \ + --screenshot-out /tmp/stackprism-screenshot-retry-1.jpg \ + --include tech,visual,layout,components,ux \ + --max-resource-urls 150 +``` + +If that still fails and the user explicitly accepts losing screenshot evidence, run one final reduced non-visual attempt, then stop and report the remaining failure. Do not run the final `--no-screenshot` retry until the user confirms that losing screenshot and visual evidence is acceptable, unless that approval is already stated in the current request. + +Before the final non-visual attempt, state this boundary explicitly: "I can retry without screenshot evidence, but the result can only support structural, technology, component, and limited UX findings; it cannot support visual parity or exact visual claims." + +```bash +cd +# Preserve the same browser/profile env. If the original command used +# --allow-private-network, add it immediately after --url "$TARGET_URL". +node agent-skill/stackprism-site-experience/scripts/capture-site.mjs \ + --url "$TARGET_URL" \ + --out /tmp/stackprism-profile-retry-2.json \ + --result-out /tmp/stackprism-result-retry-2.json \ + --include tech,layout,components,ux \ + --max-resource-urls 50 \ + --no-screenshot +``` + +Without `visualProfile` or screenshot evidence, only claim structural, technology, and limited UX findings from the returned sections. Do not claim visual parity, pixel-level accuracy, exact colors, exact spacing, or that missing visual elements do not exist. + +The current experience profile is passive. It can surface observed hover, focus, transition, animation, loading, scroll, and UX cues, but it does not click, type, submit forms, or exercise workflows. For interaction comparison, pair the profile with destination-app smoke tests and state which interactions were not actively exercised. + +When wrapping retries in shell scripts, avoid reserved or readonly shell variable names such as `status` in zsh. Use names like `capture_status` instead so a successful helper run is not masked by wrapper errors. + +## Advanced Bridge API + +All script paths in this skill are repository-relative. Run commands from the StackPrism checkout root, or resolve `agent-skill/...` to an absolute script path before spawning the bridge from another working directory. These scripts are repo-local tools, not global executables. Use the manual bridge API only when you need protocol-level debugging or custom orchestration beyond the capture helper. + +For advanced use, prefer the JavaScript bridge: + +```bash +cd +node agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs +``` + +Use the Python fallback only when Node is unavailable: + +```bash +cd +python3 agent-skill/stackprism-site-experience/scripts/stackprism_bridge.py +``` + +The Python fallback is a compatibility path built on the standard library HTTP server. Prefer the JavaScript bridge for long-running or repeated captures; if the Python fallback stalls under local connection pressure, stop the child process, start a fresh bridge, and retry the capture instead of reusing partial state. + +Read exactly one JSON line from stdout within 10 seconds. Treat timeout as `BRIDGE_START_TIMEOUT`, any non-JSON stdout before readiness as `BRIDGE_READY_PARSE_FAILED`, and a missing or mismatched `protocolVersion` as `BRIDGE_PROTOCOL_UNSUPPORTED`. + +The ready line contains `baseUrl`, `healthUrl`, and `apiToken`. Send ordinary logs to stderr only. Never paste or store the token in source files. + +Always stop the bridge child process in a `finally` block after the capture finishes or fails. On startup failure, protocol mismatch, or parse failure, terminate the child process and wait for it to exit before reporting the error. If a fixed `STACKPRISM_BRIDGE_PORT` is already occupied, the script exits non-zero with `PORT_IN_USE` on stderr and no ready JSON. + +If StackPrism is installed in a non-default browser or browser profile, set `STACKPRISM_BROWSER_OPEN_COMMAND` to the platform opener or browser executable, and put only opener/profile arguments in `STACKPRISM_BROWSER_OPEN_ARGS_JSON` as a JSON string array. The bridge script appends the bridge URL as the final argv item. Do not include the bridge URL in the environment variable. + +Keep the executable and arguments separate. For example, use `STACKPRISM_BROWSER_OPEN_COMMAND=open` with `STACKPRISM_BROWSER_OPEN_ARGS_JSON='["-a","Google Chrome"]'`, not `STACKPRISM_BROWSER_OPEN_COMMAND='open -a Google Chrome'`. + +Platform notes: + +- macOS default opener: `open`. To force Chrome, use `STACKPRISM_BROWSER_OPEN_COMMAND=open` and `STACKPRISM_BROWSER_OPEN_ARGS_JSON='["-a","Google Chrome"]'`. To select a Chrome profile, use the Chrome executable as the command and pass profile args, for example `["--profile-directory=Default"]`. +- Firefox profiles: set `STACKPRISM_BROWSER_OPEN_COMMAND` to the Firefox executable and put profile arguments in `STACKPRISM_BROWSER_OPEN_ARGS_JSON`, for example `["-P","default-release"]` or `["--profile","/absolute/path/to/profile"]`. Do not pass the bridge URL in those args. +- Windows default opener: command `rundll32.exe` with the built-in argument `url.dll,FileProtocolHandler`. To force Chrome or Edge, set `STACKPRISM_BROWSER_OPEN_COMMAND` to the full browser `.exe` path and put profile args such as `["--profile-directory=Default"]` in `STACKPRISM_BROWSER_OPEN_ARGS_JSON`. +- Linux default opener: `xdg-open`. To force Chrome or Chromium, set `STACKPRISM_BROWSER_OPEN_COMMAND` to `google-chrome`, `chromium`, or the absolute executable path, and put profile args such as `["--profile-directory=Default"]` in `STACKPRISM_BROWSER_OPEN_ARGS_JSON`. + +Cross-platform explicit profile examples: + +Set environment variables with the syntax of the current shell; the examples below describe the values, not portable copy-paste shell assignments. + +- Windows Chrome: set `STACKPRISM_BROWSER_OPEN_COMMAND` to `C:\Program Files\Google\Chrome\Application\chrome.exe` with `STACKPRISM_BROWSER_OPEN_ARGS_JSON=["--profile-directory="]`. +- Windows Edge: use the full `msedge.exe` path and the same `--profile-directory=` args JSON. +- Linux Chrome/Chromium: `STACKPRISM_BROWSER_OPEN_COMMAND=google-chrome` or `chromium` with `STACKPRISM_BROWSER_OPEN_ARGS_JSON=["--profile-directory="]`. +- Linux Firefox: `STACKPRISM_BROWSER_OPEN_COMMAND=firefox` with `STACKPRISM_BROWSER_OPEN_ARGS_JSON=["-P",""]`. + +## Firefox E2E Validation + +For Firefox Agent Bridge E2E verification, require an exact safe public `http:` or `https:` smoke URL. If no exact safe public smoke URL is provided, ask for one; do not choose an arbitrary public page and do not use `https://example.com` as a default target. + +Use an explicit Firefox executable and profile so the bridge opens in the profile where StackPrism is installed: + +```bash +cd +STACKPRISM_BROWSER_OPEN_COMMAND="/Applications/Firefox.app/Contents/MacOS/firefox" \ +STACKPRISM_BROWSER_OPEN_ARGS_JSON='["-P",""]' \ +node agent-skill/stackprism-site-experience/scripts/capture-site.mjs \ + --url "$TARGET_URL" \ + --out /tmp/stackprism-firefox-profile.json \ + --result-out /tmp/stackprism-firefox-result.json \ + --screenshot-out /tmp/stackprism-firefox-screenshot.jpg \ + --include tech,visual,layout,components,interaction,ux,assets +``` + +If the profile is identified by path instead of name, use `STACKPRISM_BROWSER_OPEN_ARGS_JSON='["--profile","/absolute/path/to/firefox-profile"]'`. In the E2E manifest, set `browserName` to `Firefox` and set `profileIdentifier` to the exact `-P` profile name or `--profile` path label used for the run. + +## Capture A Target + +Call `POST /v1/captures` with `Authorization: Bearer {apiToken}`: + +```json +{ + "url": "https://target.example", + "mode": "experience", + "waitMs": 3000, + "include": ["tech", "visual", "layout", "components", "interaction", "ux", "assets"], + "viewports": [{ "name": "desktop", "width": 1440, "height": 900, "deviceScaleFactor": 1 }], + "options": { + "forceRefresh": false, + "captureScreenshotMetadata": false, + "captureScreenshot": true, + "keepTabOpen": false, + "allowPrivateNetworkTarget": false, + "targetMode": "reuse_or_new_tab", + "maxResourceUrls": 300 + } +} +``` + +Use the real target URL for the task. Do not treat `https://example.com` as the default smoke target. Public hostnames routed through `198.18.*` by local proxy/TUN software are accepted by default, but direct private IPs, `localhost`, RFC1918, link-local, and real intranet targets remain fail-closed unless private-network targets are explicitly allowed for a controlled test. + +Then poll `GET /v1/captures/{id}` and read `GET /v1/captures/{id}/profile` when status is `completed`. + +If the consuming model can read images, set `"captureScreenshot": true` with `include` containing `"visual"`. Chrome may capture the visible target viewport and the saved Profile will expose it as `visualProfile.screenshot.downloadUrl`, not as embedded base64. To inspect actual visual appearance, download or open that image. When using `capture-site.mjs`, the helper downloads the image while the bridge is still alive and rewrites `downloadUrl` to a local `file://` URL plus `localPath`; this remains available after the bridge exits as long as the file is not moved or deleted. When reading directly from the live bridge API or manually downloading from the bridge page, the screenshot URL is a temporary `127.0.0.1` endpoint and must be used before the local bridge exits or the capture result expires. The screenshot is not text-redacted pixel by pixel, so do not request it for login-protected or private user pages. + +Large pages can produce multi-chunk profile transfers. If the browser extension reports `BRIDGE_TRANSPORT_DISCONNECTED`, `PROFILE_TRANSPORT_FAILED`, `PROFILE_CHUNK_MISSING`, or `CAPTURE_TIMEOUT`, treat the capture as failed and follow the bounded retry ladder above. Do not synthesize a profile from partial chunks. + +Handle user-actionable failures explicitly: + +- `AGENT_BRIDGE_DISABLED`: ask the user to enable Agent Bridge in the StackPrism settings for this local browser profile. Do not retry or fall back to a mock profile. +- `EXTENSION_NOT_CONNECTED`: the opened browser/profile probably does not have StackPrism installed or enabled. Set `STACKPRISM_BROWSER_OPEN_COMMAND` and `STACKPRISM_BROWSER_OPEN_ARGS_JSON` for the correct Chrome, Edge, or Firefox profile. +- `BROWSER_OPEN_FAILED`: surface the sanitized stderr/details and keep the capture failed. Do not ask the user to paste a token-bearing bridge URL. + +## E2E Evidence Manifest + +For Agent Bridge E2E validation, write evidence outside the repository or to an explicitly ignored artifact directory. Record this manifest shape in the report: + +- `browserName`, `browserVersion`, `profileIdentifier`, `extensionVersion`, and `agentBridgeEnabled`. +- `targetUrl`, `finalUrl`, `privateNetworkOverrideUsed`, command template, exit code, and parsed stdout/stderr JSON status. +- `errorCode`, `errorMessage`, and sanitized `error.details` when failed. +- `profilePath`, `resultPath`, `screenshotPath`, file sizes, SHA-256 hashes, `screenshotWritten`, `profileDownloadReady`, `techCount`, `limitations`, and uncovered risks. + +Safe to report: sanitized error code/message/details, target/final URL after redaction, artifact paths outside the repo, hashes, file sizes, exit code, extension version, browser/profile label, and whether `--allow-private-network` was used. + +Never paste or commit: `apiToken`, `bridgeToken`, nonce, token-bearing bridge URLs, `Authorization` headers, raw ready JSON, raw profile JSON containing private content, screenshot data URLs, cookies, credentials, signed URLs, or account data. `captureId` may appear in local result artifacts, but redact it from public issue text and PR summaries unless it is needed for a local-only debug handoff. + +Collect file metadata with ordinary shell tools after the helper exits: + +```bash +shasum -a 256 /tmp/stackprism-profile.json /tmp/stackprism-result.json /tmp/stackprism-screenshot.jpg +stat -f '%N %z bytes' /tmp/stackprism-profile.json /tmp/stackprism-result.json /tmp/stackprism-screenshot.jpg +``` + +Use this public report template instead of pasting raw stdout: + +```json +{ + "browserName": "", + "browserVersion": "recorded separately", + "profileIdentifier": "", + "extensionVersion": "1.3.74", + "agentBridgeEnabled": true, + "targetUrl": "https://target.example/", + "finalUrl": "https://target.example/", + "privateNetworkOverrideUsed": false, + "exitCode": 0, + "stdout": { + "ok": true, + "captureId": "[redacted]", + "screenshotWritten": true, + "profileDownloadReady": true, + "techCount": 0 + }, + "artifacts": { + "profilePath": "/tmp/stackprism-profile.json", + "resultPath": "/tmp/stackprism-result.json", + "screenshotPath": "/tmp/stackprism-screenshot.jpg", + "sha256": ["recorded separately"], + "fileSizes": ["recorded separately"] + }, + "limitations": [] +} +``` + +If browser/profile identity matters for the E2E claim, record one non-sensitive proof such as the browser name/version from the browser UI or CLI, the profile label used in `STACKPRISM_BROWSER_OPEN_ARGS_JSON`, the StackPrism extension version shown on the extension details page, and the Agent Bridge enabled state shown in StackPrism settings. Do not include extension internal UUIDs, bridge URLs, or tokens. + +## Use The Profile + +- Read `limitations` first. Do not infer that a missing section means the site lacks that feature. +- When the user asks for a recreation brief, read `references/agent-consumption-guide.md` and structure the brief from `agentGuidance.recreationPlan` plus screenshot-backed visual evidence when available. +- Record the captured viewport names and dimensions in briefs and E2E notes. Do not infer mobile or responsive breakpoint behavior unless that viewport was captured or verified separately. +- Start from `agentGuidance.recreationPlan`. Follow its `implementationOrder`, then map `designTokens`, `layoutBlueprint`, `componentInventory`, `interactionChecklist`, `uxChecklist`, `assetHints`, `visualReference`, and `verificationChecklist` into the target project. +- Use `visualProfile.screenshot.downloadUrl` as an optional visual reference only when it is present and your model supports image input. The Profile JSON intentionally omits screenshot base64; download or open the image to see the actual visual effect, and do not use screenshots from login-protected or private pages because pixels are not redacted. +- Treat `techProfile` as implementation guidance, not a mandate to copy the source site's private stack. +- Prioritize layout density, visual hierarchy, interaction feedback, and information architecture. +- Respect `limitations`; missing fields may mean a section was not requested or was truncated. +- Verify the result with `agentGuidance.recreationPlan.verificationChecklist` plus destination-app screenshots, DOM geometry, and interaction smoke tests. +- Do not reproduce sensitive text, account data, tokens, signed URLs, or private user content. + +## Trust Boundary + +The first version trusts the local bridge process started by the user or agent. Loopback, nonce, and `bridgeToken` bind one capture to one local browser page, but they do not prove the process was not spoofed by another local process. The DOM-readable `bridgeToken` is also not secret from other installed extensions in the same browser profile. diff --git a/agent-skill/stackprism-site-experience/agents/openai.yaml b/agent-skill/stackprism-site-experience/agents/openai.yaml new file mode 100644 index 00000000..57f9b7dc --- /dev/null +++ b/agent-skill/stackprism-site-experience/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: 'StackPrism Site Experience' + short_description: 'Capture StackPrism Agent Bridge URL profiles' + brand_color: '#0F766E' + default_prompt: 'Use $stackprism-site-experience to capture StackPrism Agent Bridge profile for http(s) URL website recreation, UX comparison, live browser evidence, or Agent Bridge E2E validation; confirm extension/Bridge are enabled and target is not private login/account.' +policy: + allow_implicit_invocation: true diff --git a/agent-skill/stackprism-site-experience/references/agent-consumption-guide.md b/agent-skill/stackprism-site-experience/references/agent-consumption-guide.md new file mode 100644 index 00000000..91c9434b --- /dev/null +++ b/agent-skill/stackprism-site-experience/references/agent-consumption-guide.md @@ -0,0 +1,43 @@ +# Agent Consumption Guide + +Use the profile as evidence for recreating a similar website, not as a copyable page dump. For recreation briefs, structure the brief from `agentGuidance.recreationPlan` plus screenshot-backed visual evidence when available. + +1. Read `limitations` first. Do not infer that a missing section means the site lacks that feature. +2. Start from `agentGuidance.recreationPlan`; it turns observed evidence into implementation order, design tokens, layout blueprint, component inventory, interaction checklist, UX checklist, asset hints, visual reference, and verification checks. +3. Open or download `visualProfile.screenshot.downloadUrl` only when present and needed for visual comparison. The Profile JSON intentionally omits screenshot base64; do not assume the JSON text alone shows the final visual appearance. +4. Cross-check `visualProfile`, `layoutProfile`, and `componentProfile` before choosing layout, density, and component patterns. +5. Use `techProfile` to select equivalent implementation tools only when the destination project has no stronger local convention. +6. Use `interactionProfile` conservatively. The first version is passive and does not click or submit controls. +7. Use `assetProfile` for dependency and CDN clues, but do not copy signed or sensitive URLs. +8. Implement against the destination project's conventions, then verify with `agentGuidance.recreationPlan.verificationChecklist`, screenshots, DOM geometry, and interaction smoke tests. + +Recreation brief structure: + +- Evidence and limitations: target URL, final URL, captured viewport names and dimensions, screenshot availability, unavailable sections, and explicit non-claims. +- Technical direction: observed stack, destination-project conventions that override the source, and dependencies worth matching or replacing. +- Visual direction: screenshot-backed colors, typography, spacing, density, hierarchy, and any visual unknowns. +- Layout and components: first-viewport structure, landmarks, repeated regions, component inventory, states, and responsive assumptions that still need verification. +- Interaction and UX: observed hover, focus, transitions, scroll, loading, navigation cues, friction points, and workflows not actively exercised. +- Assets and verification: key resource domains, asset handling notes, acceptance checks, smoke tests, and follow-up captures needed before claiming parity. + +Hard evidence gates: + +- If `visualProfile` or screenshot evidence is missing, do not claim visual parity. Limit the conclusion to the available sections, or recapture with `include` containing `visual` and screenshot capture enabled. +- A tech-only profile supports technology, dependency, and runtime observations only. It is not sufficient for UI implementation, visual comparison, or visual verification. +- A reduced non-visual retry supports structural, technology, and limited UX findings only. Do not claim pixel-level accuracy, exact colors, exact spacing, visual parity, or that missing visual elements do not exist. +- A reduced retry is valid only if it preserved the original capture context: same target URL, browser/profile opener env, and target policy flags such as `--allow-private-network`. +- Destination project conventions override the source page's stack for component library, routing, state, CSS architecture, test framework, accessibility baseline, and build tooling. +- Interaction smoke tests should cover viewport, key path, hover and focus states, empty and error states, overlays or navigation, scroll and sticky behavior, responsive breakpoint, screenshot or DOM geometry evidence, and explicit limitations. +- StackPrism experience capture is passive. It can report observed interaction cues, but it does not click, type, submit forms, or exercise workflows. + +The bridge page status preview, `copyText`, and grouped `contentSummary` are convenience views derived from the completed profile. Raw `/profile` access still requires the API token. + +Safe report fields are sanitized error code/message/details, redacted target and final URL, artifact paths outside the repository, hashes, file sizes, exit code, extension version, browser/profile label, `screenshotWritten`, `profileDownloadReady`, `techCount`, limitations, and whether `--allow-private-network` was used. Never copy `apiToken`, `bridgeToken`, nonce, token-bearing bridge URLs, `Authorization` headers, raw ready JSON, raw profile JSON containing private content, screenshot data URLs, cookies, credentials, signed URLs, account data, or unredacted `captureId` into downstream code, issue text, PR summaries, or prompts. + +Never reproduce private text, account identifiers, credentials, tokens, or user-specific data from a target page. Screenshots are not pixel-redacted, so do not request or consume them for login-protected, account-specific, billing, admin, inbox, dashboard, internal, or private user pages, even when the user owns the account. Use this response and ask for safer inputs instead: + +```text +I cannot automatically capture that private or logged-in page with StackPrism. Please provide a public demo URL, a desensitized test-environment URL, a design brief, or an anonymized page-structure summary. If you already have a redacted screenshot or recording with private content removed, you can provide it; do not create a new screenshot of the private page for this request. I can use StackPrism only after the target is public or explicitly desensitized. +``` + +Localhost or intranet targets are acceptable only when they are public, demo, or explicitly desensitized development pages. If the user refers to the "current browser page" without a URL, ask for a public or desensitized `http:` or `https:` URL first instead of using active-tab capture. Accept redacted screenshots or recordings only when the user already has them and has removed private content; do not ask the user to create new screenshots of private pages. diff --git a/agent-skill/stackprism-site-experience/references/site-experience-profile-schema.md b/agent-skill/stackprism-site-experience/references/site-experience-profile-schema.md new file mode 100644 index 00000000..253dcf62 --- /dev/null +++ b/agent-skill/stackprism-site-experience/references/site-experience-profile-schema.md @@ -0,0 +1,22 @@ +# Site Experience Profile Schema + +Schema id: `stackprism.site_experience_profile.v1` + +Top-level fields: + +- `target`: normalized target URL, final URL, title, `language`, viewport summaries, and page support status. `language` is taken from page language attributes when available; it is not inferred from user identity or account data. +- `browserContext`: extension version, capture time, requested viewports, bridge protocol version, and extension capabilities. +- `techProfile`: detected technologies, confidence summary, and implementation notes. +- `visualProfile`: colors, typography, spacing, shape, elevation, density, theme mode, and optional screenshot reference. Screenshot image base64 is intentionally omitted from downloaded Profile JSON. When present, `visualProfile.screenshot.downloadUrl` points to the image to open for actual visual inspection; direct bridge URLs expire with the local bridge/result TTL, while helper-written profiles use a local `file://` URL and `localPath`. +- `layoutProfile`: landmarks, hero, grids, responsive behavior, sticky elements, and above-fold summary. +- `componentProfile`: buttons, links, forms, cards, navigation, overlays, and data display patterns. +- `interactionProfile`: passive hover/focus/transition/animation evidence and loading or scroll behavior. +- `uxProfile`: bounded first-order UX signals: `pagePurpose`, `primaryUserPath`, `informationHierarchy`, `ctaStrategy`, `trustSignals`, `navigationDepth`, `contentGrouping`, `frictionPoints`, and limited `textSamples`. +- `assetProfile`: scripts, stylesheets, resource domains, image/font hints, manifest, favicon, and redaction policy. +- `evidence`: confidence buckets, raw counts, source coverage, and truncation metadata. +- `limitations`: explicit capture boundaries and omitted sections. +- `agentGuidance`: implementation priorities, cautions, and `recreationPlan` for downstream agents. `recreationPlan` contains `implementationOrder` for task order, `designTokens` for visual system choices, `layoutBlueprint` for structure, `componentInventory` for reusable UI pieces, `interactionChecklist` for passive behavior cues, `uxChecklist` for user-flow priorities, `assetHints` for dependency and media clues, `visualReference` for optional screenshot handling, and `verificationChecklist` for destination-app acceptance checks. + +Profiles must not contain cookie values, authorization values, localStorage/sessionStorage plaintext, signed URL secrets, full sensitive query strings, or copied private page text. UX labels and text samples must be short, best-effort summaries with token-like values, email, phone numbers, long numeric identifiers, hashes, and sensitive query values redacted before they reach the bridge. + +Profile JSON is standard JSON and cannot include comments. Durable handling guidance is stored as fields such as `visualProfile.screenshot.note`, `visualProfile.screenshot.profileJsonNote`, and `agentGuidance.recreationPlan.visualReference.screenshotDownloadHint`. diff --git a/agent-skill/stackprism-site-experience/scripts/bridge/bridge-page-assets.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/bridge-page-assets.mjs new file mode 100644 index 00000000..22a743d8 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/bridge-page-assets.mjs @@ -0,0 +1,82 @@ +export const bridgePageStyle = ` +:root{color-scheme:light dark;--sp-bg:#eef7f5;--sp-panel:#ffffff;--sp-panel-alt:#fbfefd;--sp-line:#c3ddd7;--sp-soft-line:#dce9e6;--sp-neutral-line:#e5e9ee;--sp-ink:#111827;--sp-muted:#637381;--sp-accent:#0f766e;--sp-accent-2:#14b8a6;--sp-accent-soft:#e8f8f3;--sp-warn:#a45a00;--sp-danger:#b42318;--sp-danger-soft:#fff5f5;--sp-warn-soft:#fff8ed;--sp-shadow:0 26px 70px rgba(15,118,110,.13)} +*{box-sizing:border-box}body{margin:0;min-height:100vh;background:linear-gradient(180deg,#fbfdfd 0%,var(--sp-bg) 100%);color:var(--sp-ink);font:15px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.bridge-shell{min-height:100vh;padding:24px 24px 42px}.bridge-card{width:min(1240px,100%);margin:0 auto 24px;overflow:hidden;border:1px solid var(--sp-line);border-radius:16px;background:var(--sp-panel);box-shadow:var(--sp-shadow)} +.bridge-header{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:24px 30px;border-bottom:1px solid var(--sp-line);background:linear-gradient(90deg,#f3fbf8 0%,#fff 74%)}.bridge-brand{display:flex;min-width:0;align-items:center;gap:16px}.bridge-mark{width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--sp-accent);color:#fff;font-size:17px;font-weight:800;letter-spacing:0;box-shadow:0 14px 30px rgba(15,118,110,.24)}.bridge-kicker{margin:0 0 3px;color:var(--sp-accent);font-size:12px;font-weight:800;letter-spacing:0;text-transform:uppercase}.bridge-title{margin:0;font-size:24px;line-height:1.15;letter-spacing:0}.bridge-copy{margin:6px 0 0;color:var(--sp-muted);font-size:15px}.bridge-badge{flex:0 0 auto;padding:8px 14px;border:1px solid var(--sp-line);border-radius:999px;background:var(--sp-accent-soft);color:var(--sp-accent);font-weight:800;white-space:nowrap} +.bridge-body{padding:20px 26px 26px}.status-panel{display:grid;grid-template-columns:minmax(0,1fr);gap:10px;margin-bottom:14px}.bridge-card[data-status="completed"] .status-panel{display:none}.state-label{font-size:20px;font-weight:850;letter-spacing:0}.status-text{margin:3px 0 0;color:var(--sp-muted)}.progress-row{display:grid;grid-template-columns:minmax(0,1fr);align-items:center}.progress{height:8px;overflow:hidden;border-radius:999px;background:#ecf3f2}.progress span{position:relative;display:block;width:8%;height:100%;overflow:hidden;border-radius:inherit;background:linear-gradient(90deg,var(--sp-accent),var(--sp-accent-2));transition:width .25s ease}.bridge-card[data-status="failed"] .progress span,.bridge-card[data-status="expired"] .progress span{background:linear-gradient(90deg,#b42318,#ef4444)}.bridge-card[data-status="cancelled"] .progress span,.bridge-card[data-status="disconnected"] .progress span{background:linear-gradient(90deg,#a45a00,#f59e0b)}.bridge-card[data-status="running"] .progress span::after,.bridge-card[data-status="cancel_requested"] .progress span::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.42),transparent);animation:sp-progress-sweep 1.2s linear infinite}@keyframes sp-progress-sweep{from{transform:translateX(-100%)}to{transform:translateX(100%)}} +.target-panel{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px 18px;align-items:center;margin-bottom:14px;padding:16px 18px;border:1px solid #a9d8cd;border-radius:12px;background:linear-gradient(90deg,#f3fbf8 0%,#fff 70%);box-shadow:0 12px 28px rgba(15,118,110,.08)}.target-copy{min-width:0}.preview-label{margin:0 0 5px;color:var(--sp-muted);font-size:12px;font-weight:800;letter-spacing:0;text-transform:uppercase}.target-url{margin:0;display:-webkit-box;overflow:hidden;overflow-wrap:anywhere;word-break:break-word;-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--sp-ink);font-size:17px;font-weight:850;line-height:1.25;text-decoration:none}.target-url[href]{cursor:pointer}.target-url[href]:hover{text-decoration:underline;text-decoration-thickness:2px;text-underline-offset:4px}.target-url[aria-disabled="true"]{cursor:default}.target-helper{margin:5px 0 0;overflow-wrap:anywhere;color:var(--sp-muted);font-size:13px}.target-actions{display:flex;min-width:0;flex-wrap:wrap;gap:10px;justify-content:flex-end}.target-open-link{min-width:132px;display:inline-flex;align-items:center;justify-content:center;text-decoration:none}.profile-download-button{min-width:132px}.copy-status{grid-column:1/-1;min-height:0;margin:0;padding:0;overflow-wrap:anywhere;color:var(--sp-muted);font-size:14px;opacity:0;transform:translateY(-2px);transition:opacity .16s ease,transform .16s ease}.copy-status[data-visible="true"]{min-height:32px;padding:6px 10px;border:1px solid var(--sp-line);border-radius:8px;background:var(--sp-accent-soft);color:var(--sp-accent);opacity:1;transform:translateY(0)}.copy-status[data-state="error"]{border-color:#f0b7b2;background:var(--sp-danger-soft);color:var(--sp-danger)} +.result-grid{display:grid;grid-template-columns:minmax(0,1.22fr) minmax(320px,.82fr);gap:16px;align-items:start}.capture-panel,.screenshot-panel{padding:16px;border:1px solid var(--sp-soft-line);border-radius:12px;background:var(--sp-panel-alt)}.capture-panel{min-width:0}.section-title{display:flex;align-items:center;gap:10px;margin-bottom:12px}.section-title h2{margin:0;font-size:17px;line-height:1.2}.section-dot{flex:0 0 auto;width:8px;height:22px;border:0;border-radius:999px;background:var(--sp-accent);box-shadow:none}.panel-copy{margin:-4px 0 12px;color:var(--sp-muted);font-size:13px}.summary-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:10px}.summary-tile{min-height:58px;padding:10px;border:1px solid var(--sp-neutral-line);border-radius:10px;background:#fff}.summary-tile p{margin:0 0 4px;color:var(--sp-muted);font-size:12px;font-weight:800}.summary-tile strong{display:block;overflow-wrap:anywhere;font-size:14px;line-height:1.25}.summary-note{padding:10px 12px;border:1px solid var(--sp-neutral-line);border-radius:10px;background:#fff}.summary-note p{margin:0 0 5px;color:var(--sp-accent);font-size:12px;font-weight:850;letter-spacing:0}.summary-note ul{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin:0;padding-left:18px;color:var(--sp-muted);font-size:13px}.summary-note li{line-height:1.35}.summary-handoff{display:flex;align-items:center;gap:12px;margin-top:12px;padding-top:10px;color:var(--sp-muted);font-size:12px}.summary-handoff p{margin:0;color:var(--sp-accent);font-weight:850}.summary-handoff div{display:flex;flex-wrap:wrap;gap:6px}.summary-handoff span{padding:4px 8px;border:1px solid var(--sp-neutral-line);border-radius:999px;background:#fff;font-weight:750}.state-chip{margin-left:auto;padding:4px 9px;border:1px solid #d6e7e3;border-radius:999px;background:#fff;color:var(--sp-muted);font-size:12px;font-weight:850;white-space:nowrap}.state-chip[data-state="ready"]{border-color:#a9d8cd;background:#e8f8f1;color:var(--sp-accent)}.state-chip[data-state="failed"]{border-color:#f0b7b2;background:var(--sp-danger-soft);color:var(--sp-danger)}.state-chip[data-state="empty"]{border-color:#f0d2a8;background:var(--sp-warn-soft);color:var(--sp-warn)}.flow-panel{margin-top:14px;padding:10px 12px;border:1px solid var(--sp-neutral-line);border-radius:12px;background:var(--sp-panel-alt)}.flow-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:9px}.bridge-card[data-status="completed"] .flow-head{margin-bottom:0}.flow-head h2{margin:0;font-size:14px}.flow-state{display:flex;align-items:center;justify-content:flex-end;gap:8px;min-width:0}.step-summary{margin:0;color:var(--sp-muted);font-size:13px;font-weight:750}.flow-toggle{display:none;min-height:30px;padding:0 10px;border:1px solid #b9dcd5;border-radius:999px;background:#fff;color:var(--sp-accent);font:inherit;font-size:12px;font-weight:850;cursor:pointer}.bridge-card[data-status="completed"] .flow-toggle{display:inline-flex;align-items:center}.steps{display:grid;grid-template-columns:repeat(8,minmax(0,1fr));gap:6px;margin:0;padding:0;list-style:none}.bridge-card[data-status="completed"]:not([data-steps-open="true"]) .steps{display:none}.bridge-card[data-status="completed"][data-steps-open="true"] .steps{display:grid;margin-top:9px}.step{min-height:36px;position:relative;display:flex;align-items:center;gap:6px;padding:7px;border:1px solid var(--sp-neutral-line);border-radius:9px;background:#fff;color:var(--sp-muted)}.step-index{display:inline-grid;flex:0 0 auto;place-items:center;width:20px;height:20px;border-radius:999px;background:#edf3f2;color:var(--sp-muted);font-size:11px;font-weight:800}.step div{font-size:12px;line-height:1.2;word-break:break-word}.step.done{border-color:#a9d8cd;background:#f2fbf8;color:var(--sp-accent)}.step.done .step-index,.step.current .step-index{background:var(--sp-accent);color:#fff}.step.current{border-color:var(--sp-accent);color:var(--sp-ink);box-shadow:0 0 0 2px rgba(15,118,110,.08)}.step.failed{border-color:#f0b7b2;background:var(--sp-danger-soft);color:var(--sp-danger);box-shadow:0 0 0 2px rgba(180,35,24,.08)}.step.failed .step-index{background:var(--sp-danger);color:#fff} +.screenshot-frame{width:100%;height:clamp(190px,14vw,230px);display:grid;place-items:center;overflow:hidden;border:1px solid var(--sp-line);border-radius:12px;background:#eef8f7;cursor:pointer;padding:0;text-align:inherit;transition:border-color .16s ease,box-shadow .16s ease}.screenshot-frame:disabled{cursor:not-allowed}.screenshot-frame.has-image:hover{border-color:var(--sp-accent);box-shadow:0 0 0 3px rgba(15,118,110,.1)}.screenshot-frame img{display:none;width:100%;height:100%;object-fit:cover;object-position:top center;background:#fff}.screenshot-frame.has-image img{display:block}.screenshot-frame.has-image .screenshot-empty{display:none}.screenshot-empty{max-width:260px;padding:18px;color:var(--sp-muted);text-align:center;font-weight:750;line-height:1.45;white-space:pre-line}.screenshot-meta{min-height:20px;margin:12px 0 0;color:var(--sp-muted);font-size:13px;line-height:1.35}.preview-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:12px}.preview-button,.modal-close{min-height:38px;padding:0 12px;border:1px solid #b9dcd5;border-radius:8px;background:#fff;color:var(--sp-accent);font:inherit;font-weight:800;cursor:pointer}.preview-button.primary{min-height:44px;background:var(--sp-accent);border-color:var(--sp-accent);color:#fff;box-shadow:0 10px 22px rgba(15,118,110,.2)}.preview-button.primary:disabled{box-shadow:none}.target-copy-button{min-width:156px}.preview-button:disabled,.modal-close:disabled,.preview-button[aria-disabled="true"]{cursor:not-allowed;opacity:1;background:#f7fbfa;border-color:#d8e8e4;color:#8aa5a0}.target-url:focus-visible,.screenshot-frame:focus-visible,.preview-button:focus-visible,.modal-close:focus-visible{outline:3px solid rgba(15,118,110,.35);outline-offset:2px;border-radius:6px} +.content-section{margin-top:14px;padding:16px;border:1px solid var(--sp-soft-line);border-radius:12px;background:var(--sp-panel-alt)}.section-head{display:flex;justify-content:space-between;gap:14px;align-items:end;margin-bottom:12px}.section-head h2{margin:0;font-size:16px}.section-head p{margin:3px 0 0;color:var(--sp-muted);font-size:13px}.content-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:8px}.content-card{min-width:0;min-height:88px;padding:10px;overflow:hidden;border:1px solid var(--sp-neutral-line);border-radius:10px;background:#fff}.content-card *{min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word}.content-card h3{margin:0 0 6px;font-size:14px;line-height:1.3}.content-card ul{display:grid;min-width:0;gap:3px;margin:0;padding-left:18px;color:var(--sp-muted);font-size:13px}.content-card li{min-width:0;line-height:1.38;white-space:normal} +.bridge-footer{display:flex;flex-wrap:wrap;gap:12px 18px;align-items:flex-start;justify-content:space-between;margin-top:20px;padding-top:18px;border-top:1px solid #e7eeee}.bridge-note{max-width:760px;margin:0;color:var(--sp-muted);line-height:1.55}.pills{display:flex;flex-wrap:wrap;gap:8px;margin-left:auto}.pill{padding:6px 10px;border:1px solid #d6e7e3;border-radius:999px;color:var(--sp-muted);background:var(--sp-panel-alt);font-size:13px}.bridge-card[data-status="completed"] .bridge-badge{border-color:#a9d8cd;background:#e8f8f1;color:var(--sp-accent)}.bridge-card[data-status="failed"] .bridge-badge,.bridge-card[data-status="expired"] .bridge-badge{border-color:#f0b7b2;background:var(--sp-danger-soft);color:var(--sp-danger)}.bridge-card[data-status="cancelled"] .bridge-badge,.bridge-card[data-status="disconnected"] .bridge-badge{border-color:#f0d2a8;background:var(--sp-warn-soft);color:var(--sp-warn)} +.screenshot-modal{position:fixed;inset:0;z-index:20;display:none;place-items:center;padding:28px;background:rgba(10,18,24,.72);overscroll-behavior:contain}.screenshot-modal[data-open="true"]{display:grid}.modal-card{width:min(1180px,100%);max-height:92vh;display:grid;grid-template-rows:auto auto minmax(0,1fr);overflow:hidden;border:1px solid #25443f;border-radius:14px;background:#081311;box-shadow:0 28px 90px rgba(0,0,0,.42)}.modal-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;border-bottom:1px solid #1e3934;color:#e7f7f3}.modal-title{margin:0;font-size:15px;font-weight:800}.modal-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end}.modal-copy-status{display:none;margin:0;padding:0 14px 10px;color:#b7d6cf;font-size:13px;font-weight:600;text-align:right}.modal-copy-status[data-visible="true"]{display:block}.modal-copy-status[data-state="error"]{color:#fca5a5}.modal-close{border-color:#35665f;background:#10211e;color:#e7f7f3}.modal-image{display:block;width:100%;height:100%;max-height:calc(92vh - 82px);object-fit:contain;background:#030807} +@media (prefers-color-scheme:dark){:root{--sp-bg:#091312;--sp-panel:#101b1a;--sp-panel-alt:#0d1716;--sp-line:#244842;--sp-soft-line:#1f3935;--sp-neutral-line:#223935;--sp-ink:#ecfdf8;--sp-muted:#9fb8b3;--sp-accent-soft:#12302c;--sp-shadow:0 24px 60px rgba(0,0,0,.35)}body{background:linear-gradient(180deg,#07100f 0%,var(--sp-bg) 100%)}.bridge-header,.target-panel{background:linear-gradient(90deg,#0f2522 0%,#101b1a 76%)}.summary-tile,.summary-note,.summary-handoff span,.content-card,.state-chip{background:#101b1a}.progress{background:#18302d}.screenshot-frame{background:#0a1312}.screenshot-frame img{background:#07100f}.preview-button,.flow-toggle{background:#10211e;border-color:#35665f}.preview-button.primary{background:var(--sp-accent);border-color:var(--sp-accent);color:#fff}.preview-button:disabled,.modal-close:disabled,.preview-button[aria-disabled="true"]{background:#0d1716;border-color:#244842;color:#698984}.copy-status[data-state="error"]{background:#2a1211;border-color:#7f1d1d;color:#fca5a5}.bridge-card[data-status="failed"] .bridge-badge,.bridge-card[data-status="expired"] .bridge-badge{background:#2a1211;color:#fca5a5}.bridge-card[data-status="cancelled"] .bridge-badge,.bridge-card[data-status="disconnected"] .bridge-badge{background:#2a1d0b;color:#fbbf24}.state-chip[data-state="failed"]{background:#2a1211;color:#fca5a5}.state-chip[data-state="empty"]{background:#2a1d0b;color:#fbbf24}.content-section,.bridge-footer{border-color:#1f3935}.step{background:#0d1716}.step.done{background:#102822}.step.failed{border-color:#7f1d1d;background:#2a1211;color:#fca5a5}.step.failed .step-index{background:#ef4444;color:#fff}.pill{border-color:#244842}} +@media (max-width:980px){.result-grid{grid-template-columns:1fr 1fr}.summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.summary-note ul{grid-template-columns:1fr}.summary-handoff{display:none}.preview-actions{grid-template-columns:repeat(2,minmax(0,1fr))}.steps{grid-template-columns:repeat(4,minmax(0,1fr))}} +@media (max-width:760px){.bridge-shell{padding:12px 10px 22px}.bridge-card{border-radius:14px}.bridge-header{position:relative;display:block;padding:12px 92px 12px 12px}.bridge-brand{gap:10px}.bridge-mark{width:36px;height:36px}.bridge-kicker{font-size:10px}.bridge-title{font-size:19px}.bridge-copy{margin-top:3px;font-size:13px;line-height:1.32}.bridge-badge{position:absolute;top:12px;right:12px;padding:6px 9px;font-size:12px}.bridge-body{padding:13px}.target-panel{grid-template-columns:1fr;padding:10px 12px}.target-url{font-size:15px}.target-helper{font-size:12px}.target-actions{justify-content:flex-start}.target-open-link,.profile-download-button,.target-copy-button{width:min(100%,220px);min-width:0}.result-grid{grid-template-columns:1fr}.summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.summary-tile{min-height:54px;padding:8px}.summary-note{padding:9px 10px}.summary-note ul{grid-template-columns:1fr;gap:4px}.section-title{align-items:center;gap:8px}.section-title h2{font-size:16px}.state-chip{padding:3px 8px;font-size:11px}.screenshot-frame{height:172px}.preview-actions{gap:12px}.flow-head{display:grid;gap:4px}.flow-state{align-items:flex-start;justify-content:space-between}.step-summary{margin-top:0}.steps{grid-template-columns:repeat(2,minmax(0,1fr))}.bridge-card[data-status="completed"][data-steps-open="true"] .steps{margin-top:8px}.step{min-height:44px;padding:8px}.step-index{width:21px;height:21px}.step div{font-size:12px;line-height:1.25}.screenshot-modal{padding:14px}.modal-bar,.section-head{align-items:flex-start;flex-direction:column}.modal-actions{width:100%;justify-content:flex-end}.modal-copy-status{text-align:left}.bridge-footer{gap:12px;margin-top:20px}.pills{margin-left:0}} +@media (max-width:420px){.preview-actions{grid-template-columns:1fr}.target-open-link,.profile-download-button,.target-copy-button{width:100%}} +@media (hover:none){.preview-button,.modal-close{min-height:44px;min-width:44px}} +@media (prefers-reduced-motion:reduce){.bridge-card[data-status="running"] .progress span::after,.bridge-card[data-status="cancel_requested"] .progress span::after{animation:none}.copy-status,.progress span,.screenshot-frame{transition:none}} +` + +export const bridgePageScript = ` +const config=JSON.parse(document.getElementById('stackprism-agent-bridge-config').textContent); +const ids=['status','stateLabel','statusBadge','progressBar','bridgeCard','targetUrl','openTargetUrl','targetHelper','screenshotFrame','targetScreenshot','screenshotMeta','screenshotDownload','copyScreenshot','copyAllInfo','downloadProfile','copyStatus','modalCopyStatus','stepSummary','profileContentSection','profileContentGrid','screenshotModal','modalScreenshot','modalClose','modalDownload','modalCopyScreenshot','screenshotTileValue','screenshotEmpty','screenshotStateBadge','toggleSteps']; +const el=Object.fromEntries(ids.map(id=>[id,document.getElementById(id)])); +const steps=[...document.querySelectorAll('[data-phase]')]; +let currentScreenshot=null,currentCopyText='',currentProfileBlob=null,currentProfileFetchPromise=null,copyStatusTimer=0,copyButtonTimer=0,stepsOpen=false,stepsUserToggled=false,lastStatus='',lastBody=null; +const phases=['bridge_connected','request_loaded','target_opening','target_loaded','detecting_tech','profiling_experience','posting_profile','cleanup']; +const phaseLabels={bridge_connected:'扩展已连接',request_loaded:'读取采集请求',target_opening:'打开目标页面',target_loaded:'目标页面已加载',detecting_tech:'识别技术栈',profiling_experience:'分析视觉与体验',posting_profile:'回传 Profile',cleanup:'清理采集环境'}; +const statusLabels={queued:'等待扩展连接',waiting_extension:'等待扩展连接',running:'正在采集',cancel_requested:'正在取消',cancelled:'已取消',completed:'采集完成',failed:'采集失败',expired:'结果已过期',disconnected:'连接已关闭'}; +const finalStatuses=['completed','failed','cancelled','expired','disconnected']; +const targetHrefFor=value=>{const text=String(value||'');if(!text)return '';try{const url=new URL(text);if(url.protocol!=='http:'&&url.protocol!=='https:')return '';if(url.pathname.includes('[redacted]'))return '';if(url.search.includes('[redacted]'))url.search='';return url.toString();}catch{return '';}}; +const setTargetLink=(node,href)=>{if(href){node.href=href;node.removeAttribute('aria-disabled');node.removeAttribute('tabindex');}else{node.removeAttribute('href');node.setAttribute('aria-disabled','true');node.setAttribute('tabindex','-1');}}; +const setTargetUrl=value=>{const targetText=value||'等待读取目标网址';const targetHref=targetHrefFor(targetText);el.targetUrl.textContent=targetText;el.targetUrl.title=targetText;setTargetLink(el.targetUrl,targetHref);setTargetLink(el.openTargetUrl,targetHref);}; +const setStatus=value=>{el.status.textContent=value}; +const setCopyStatus=(node,value,type='ok')=>{node.textContent=value;node.dataset.state=type;node.dataset.visible=value?'true':'false';}; +const showCopyStatus=(value,type='ok')=>{if(copyStatusTimer)clearTimeout(copyStatusTimer);setCopyStatus(el.copyStatus,'');setCopyStatus(el.modalCopyStatus,'');setCopyStatus(modalOpen()?el.modalCopyStatus:el.copyStatus,value,type);if(value)copyStatusTimer=setTimeout(()=>{setCopyStatus(el.copyStatus,'');setCopyStatus(el.modalCopyStatus,'');},2600);}; +const flashCopyButton=value=>{if(copyButtonTimer)clearTimeout(copyButtonTimer);el.copyAllInfo.textContent=value;copyButtonTimer=setTimeout(()=>{el.copyAllInfo.textContent='复制全部信息';},1800);}; +const screenshotExtension=()=>currentScreenshot?.mimeType==='image/png'?'png':currentScreenshot?.mimeType==='image/webp'?'webp':'jpg'; +const screenshotFilename=()=>('stackprism-'+config.captureId+'-screenshot.'+screenshotExtension()); +const profileFilename=()=>('stackprism-'+config.captureId+'-profile.json'); +const profileDownloadUrl=()=>('/v1/captures/'+config.captureId+'/profile-download'); +const formatBytes=value=>{const bytes=Number(value);if(!Number.isFinite(bytes)||bytes<=0)return '';if(bytes<1024)return bytes+' B';if(bytes<1048576)return Math.round(bytes/1024)+' KB';return (bytes/1048576).toFixed(1)+' MB';}; +const screenshotMetaText=screenshot=>[screenshot.mimeType||'',formatBytes(screenshot.byteLength),screenshot.scope||''].filter(Boolean).join(' - ')||'截图已包含'; +const screenshotEmptyText=(status,preview)=>status==='completed'?'本次采集未返回截图':status==='failed'?'采集失败,未返回截图':preview?.screenshot?'':'采集完成后显示可用截图'; +const setScreenshotState=(text,state)=>{el.screenshotTileValue.textContent=text;el.screenshotStateBadge.textContent=text;el.screenshotStateBadge.dataset.state=state;}; +let currentScreenshotBlob=null,currentScreenshotObjectUrl=''; +const clearScreenshotObjectUrl=()=>{if(currentScreenshotObjectUrl){URL.revokeObjectURL(currentScreenshotObjectUrl);currentScreenshotObjectUrl='';}}; +const screenshotRequestInit=url=>{try{const target=new URL(url,location.href);return {headers:target.origin===location.origin?{Authorization:'Bearer '+config.bridgeToken}:{},cache:'no-store'};}catch{return {cache:'no-store'};}}; +const fetchScreenshotBlob=async()=>{if(currentScreenshotBlob)return currentScreenshotBlob;if(!currentScreenshot?.downloadUrl)throw new Error('SCREENSHOT_DOWNLOAD_MISSING');const res=await fetch(currentScreenshot.downloadUrl,screenshotRequestInit(currentScreenshot.downloadUrl));if(!res.ok)throw new Error('SCREENSHOT_DOWNLOAD_FAILED');currentScreenshotBlob=await res.blob();return currentScreenshotBlob;}; +const setScreenshotImageUrl=async screenshot=>{try{const blob=await fetchScreenshotBlob();if(currentScreenshot!==screenshot)return;clearScreenshotObjectUrl();currentScreenshotObjectUrl=URL.createObjectURL(blob);el.targetScreenshot.src=currentScreenshotObjectUrl;el.modalScreenshot.src=currentScreenshotObjectUrl;}catch{invalidateScreenshot();}}; +const setScreenshot=screenshot=>{ +const next=screenshot?.downloadUrl?screenshot:null;if(currentScreenshot?.downloadUrl!==next?.downloadUrl){currentScreenshotBlob=null;clearScreenshotObjectUrl();} +currentScreenshot=next; +if(currentScreenshot){setScreenshotImageUrl(currentScreenshot);el.targetScreenshot.alt='目标页面截图预览';el.modalScreenshot.alt='目标页面截图放大预览';}else{el.targetScreenshot.removeAttribute('src');el.modalScreenshot.removeAttribute('src');el.targetScreenshot.alt='';el.modalScreenshot.alt='';} +el.screenshotFrame.classList.toggle('has-image',Boolean(currentScreenshot)); +el.screenshotMeta.textContent=currentScreenshot?screenshotMetaText(currentScreenshot):'截图可用后会显示格式与范围'; +if(currentScreenshot)setScreenshotState('截图可用','ready'); +for(const item of [el.screenshotFrame,el.screenshotDownload,el.copyScreenshot,el.modalDownload,el.modalCopyScreenshot])item.disabled=!currentScreenshot; +}; +const invalidateScreenshot=()=>{currentScreenshot=null;currentScreenshotBlob=null;clearScreenshotObjectUrl();el.targetScreenshot.removeAttribute('src');el.modalScreenshot.removeAttribute('src');el.targetScreenshot.alt='';el.modalScreenshot.alt='';el.screenshotFrame.classList.remove('has-image');el.screenshotMeta.textContent='截图预览无法加载';el.screenshotEmpty.textContent='截图预览无法加载';setScreenshotState('截图失败','failed');for(const item of [el.screenshotFrame,el.screenshotDownload,el.copyScreenshot,el.modalDownload,el.modalCopyScreenshot])item.disabled=true;showCopyStatus('截图预览无法加载,可重新采集或下载 Profile 查看图片链接。','error');}; +const downloadBlob=(blob,filename)=>{const href=URL.createObjectURL(blob);const link=document.createElement('a');link.href=href;link.download=filename;document.body.append(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(href),0);}; +const downloadScreenshot=async()=>{if(!currentScreenshot)return;try{downloadBlob(await fetchScreenshotBlob(),screenshotFilename());}catch{showCopyStatus('下载截图失败:结果可能已过期或本机 bridge 已关闭。','error');}}; +const fetchProfileBlob=async()=>{const res=await fetch(profileDownloadUrl(),{headers:{Authorization:'Bearer '+config.bridgeToken},cache:'no-store'});if(!res.ok){let code='PROFILE_DOWNLOAD_FAILED';try{code=(await res.json())?.error?.code||code;}catch{}throw new Error(code);}const blob=await res.blob();currentProfileBlob=blob;return blob;}; +const ensureProfileCached=()=>{if(currentProfileBlob)return Promise.resolve(currentProfileBlob);if(currentProfileFetchPromise)return currentProfileFetchPromise;currentProfileFetchPromise=fetchProfileBlob().catch(error=>{currentProfileFetchPromise=null;throw error;});return currentProfileFetchPromise;}; +const downloadProfile=async()=>{if(el.downloadProfile.disabled)return;el.downloadProfile.disabled=true;try{downloadBlob(await ensureProfileCached(),profileFilename());showCopyStatus('已下载 Profile JSON。');}catch{showCopyStatus('下载 Profile 失败:结果可能已过期或本机 bridge 已关闭。','error');}finally{el.downloadProfile.disabled=lastBody?.status!=='completed';}}; +const copyText=async()=>{if(!currentCopyText)return;try{await navigator.clipboard.writeText(currentCopyText);showCopyStatus('已复制全部信息。');flashCopyButton('已复制');}catch{showCopyStatus('复制失败:浏览器未允许写入剪切板。','error');}}; +const clipboardScreenshotBlob=async()=>{const blob=await fetchScreenshotBlob();if(blob.type==='image/png')return blob;const bitmap=await createImageBitmap(blob);try{const canvas=document.createElement('canvas');canvas.width=bitmap.width;canvas.height=bitmap.height;const context=canvas.getContext('2d');if(!context)throw new Error('Canvas unavailable');context.drawImage(bitmap,0,0);return await new Promise((resolve,reject)=>canvas.toBlob(output=>output?resolve(output):reject(new Error('PNG conversion failed')),'image/png'));}finally{bitmap.close?.();}}; +const copyScreenshot=async()=>{if(!currentScreenshot)return;try{const blob=await clipboardScreenshotBlob();await navigator.clipboard.write([new ClipboardItem({'image/png':blob})]);showCopyStatus('已复制截图。');}catch{showCopyStatus('复制截图失败:浏览器未允许写入剪切板,或截图格式无法转换。','error');}}; +const modalOpen=()=>el.screenshotModal.dataset.open==='true'; +const modalControls=()=>[...el.screenshotModal.querySelectorAll('button:not(:disabled),a[href],input:not(:disabled),select:not(:disabled),textarea:not(:disabled),[tabindex]:not([tabindex="-1"])')]; +const openScreenshot=()=>{if(currentScreenshot){el.screenshotModal.dataset.open='true';document.body.style.overflow='hidden';el.modalClose.focus();}}; +const closeScreenshot=()=>{if(!modalOpen())return;el.screenshotModal.dataset.open='false';setCopyStatus(el.modalCopyStatus,'');document.body.style.overflow='';const restore=el.screenshotFrame.disabled?el.bridgeCard:el.screenshotFrame;restore.focus({preventScroll:true});}; +const renderSummary=summary=>{const cards=Array.isArray(summary?.cards)?summary.cards:[];el.profileContentGrid.replaceChildren();el.profileContentSection.hidden=!cards.length;for(const card of cards){const node=document.createElement('article');node.className='content-card';const title=document.createElement('h3');title.textContent=card.title||'Profile';const list=document.createElement('ul');for(const item of Array.isArray(card.items)?card.items:[]){const li=document.createElement('li');li.textContent=item;list.append(li);}node.append(title,list);el.profileContentGrid.append(node);}}; +const setStepsOpen=(value,user=false)=>{stepsOpen=Boolean(value);if(user)stepsUserToggled=true;el.bridgeCard.dataset.stepsOpen=stepsOpen?'true':'false';el.toggleSteps.setAttribute('aria-expanded',stepsOpen?'true':'false');el.toggleSteps.textContent=stepsOpen?'收起步骤':'展开步骤';}; +const updateSteps=(phase,status)=>{const index=status==='completed'?phases.length-1:Math.max(0,phases.indexOf(phase));const safeIndex=index<0?0:index;const failed=status==='failed';steps.forEach(step=>{const stepIndex=phases.indexOf(step.dataset.phase);const current=stepIndex===safeIndex&&!finalStatuses.includes(status);const failedCurrent=failed&&stepIndex===safeIndex;step.classList.toggle('done',stepIndex{const status=body?.status||'waiting_extension';const phase=body?.phase||'bridge_connected';const label=statusLabels[status]||status;const preview=body?.preview||{};const targetText=preview.targetUrl||config.targetUrl||'等待读取目标网址';lastBody=body;currentCopyText=typeof preview.copyText==='string'?preview.copyText:'';el.bridgeCard.dataset.status=status;el.bridgeCard.dataset.phase=phase;el.stateLabel.textContent=label;el.statusBadge.textContent=label;setStatus(body?.error?.code||phaseLabels[phase]||status);setTargetUrl(targetText);el.targetHelper.textContent=status==='completed'&¤tCopyText?'已生成 Agent 可读摘要,可复制给本机 Coding Agent 使用。':finalStatuses.includes(status)?'本次采集已结束,可查看可用摘要。':'采集完成后可复制给本机 Coding Agent 使用。';setScreenshot(preview.screenshot);if(!currentScreenshot){el.screenshotEmpty.textContent=screenshotEmptyText(status,preview);setScreenshotState(finalStatuses.includes(status)?'未返回截图':'等待截图',finalStatuses.includes(status)?'empty':'pending');}el.copyAllInfo.disabled=!currentCopyText;el.downloadProfile.disabled=status!=='completed';if(status==='completed')ensureProfileCached().catch(()=>{});if(!currentCopyText)el.copyAllInfo.textContent='复制全部信息';renderSummary(preview.contentSummary);updateSteps(phase,status);}; +el.screenshotFrame.addEventListener('click',openScreenshot);el.screenshotDownload.addEventListener('click',downloadScreenshot);el.copyScreenshot.addEventListener('click',copyScreenshot);el.copyAllInfo.addEventListener('click',copyText);el.downloadProfile.addEventListener('click',downloadProfile);el.modalDownload.addEventListener('click',downloadScreenshot);el.modalCopyScreenshot.addEventListener('click',copyScreenshot);el.modalClose.addEventListener('click',closeScreenshot);el.targetScreenshot.addEventListener('error',invalidateScreenshot);el.modalScreenshot.addEventListener('error',invalidateScreenshot); +el.toggleSteps.addEventListener('click',()=>setStepsOpen(!stepsOpen,true)); +el.screenshotModal.addEventListener('click',event=>{if(event.target===el.screenshotModal)closeScreenshot();}); +document.addEventListener('keydown',event=>{if(!modalOpen())return;if(event.key==='Escape'){closeScreenshot();return;}if(event.key!=='Tab')return;const controls=modalControls();if(!controls.length)return;const first=controls[0],last=controls[controls.length-1];if(!el.screenshotModal.contains(document.activeElement)){event.preventDefault();(event.shiftKey?last:first).focus();}else if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();}}); +const poll=async()=>{try{const res=await fetch('/v1/captures/'+config.captureId,{headers:{Authorization:'Bearer '+config.bridgeToken},cache:'no-store'});const body=await res.json();if(!res.ok){render({status:'failed',phase:lastBody?.phase||'cleanup',error:{code:body?.error?.code||'Bridge request failed.'}});return;}render(body);if(finalStatuses.includes(body.status))return;}catch{if(lastBody&&finalStatuses.includes(lastBody.status))return;render({status:'disconnected',phase:lastBody?.phase||'bridge_connected',preview:lastBody?.preview,error:{code:'本机 bridge 服务已关闭,当前页面无法继续读取状态。'}});return;}setTimeout(poll,1000);}; +poll(); +` diff --git a/agent-skill/stackprism-site-experience/scripts/bridge/bridge-page.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/bridge-page.mjs new file mode 100644 index 00000000..9bf9e6ef --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/bridge-page.mjs @@ -0,0 +1,82 @@ +import { bridgePageScript, bridgePageStyle } from './bridge-page-assets.mjs' +import { htmlEscapeScriptJson, identifierSpecs } from './protocol.mjs' + +export const renderBridgePageHtml = (cspNonce, config) => { + if (!identifierSpecs.cspNonce.test(cspNonce)) { + throw new Error('INVALID_CSP_NONCE') + } + const configJson = htmlEscapeScriptJson(config) + return ` + + + + + + +StackPrism Agent Bridge + + + +
+
+
+
+ +
+

本机通道

+

StackPrism Agent Bridge

+

连接本机 Agent 与当前浏览器 profile,展示本次采集结果。

+
+
+等待扩展连接 +
+
+
+
+
等待扩展连接
+

等待 StackPrism 扩展连接。

+
+ +
+
+
+

采集目标

+等待读取目标网址 +

采集完成后可复制给本机 Coding Agent 使用。

+
+
打开目标网页
+

+
+
+
+

Profile 摘要

+

面向复刻任务整理技术栈、视觉结构、交互路径与资产线索。

+
+

Agent 用途

快速复刻
+

内容范围

技术与体验
+

采集模式

只读采集
+

截图状态

等待截图
+
+

复刻重点

  • 先看 Agent 可读内容
  • 用截图校准首屏结构
  • 必要时再读 raw profile
+

摘要包含

技术栈首屏结构交互路径资产线索
+
+
+

截图预览

等待截图
+ +

截图可用后会显示格式与范围

+
+
+
+ +

采集流程

当前步骤:扩展连接

  1. 1
    扩展连接
  2. 2
    读取请求
  3. 3
    打开目标
  4. 4
    页面加载
  5. 5
    技术识别
  6. 6
    体验分析
  7. 7
    回传 Profile
  8. 8
    清理完成
+

本页只服务当前一次采集;完整 Profile 仅在本次结果未过期时下载;摘要不含 token、nonce、raw JSON 或截图 data URL。

127.0.0.1当前 profile只读采集
+
+
+
+ + + + +` +} diff --git a/agent-skill/stackprism-site-experience/scripts/bridge/capture-store.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/capture-store.mjs new file mode 100644 index 00000000..6a180605 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/capture-store.mjs @@ -0,0 +1,189 @@ +import { newBridgeToken, newCaptureId, newNonce, newScreenshotDownloadId, newSessionId } from './protocol.mjs' +import { prepareProfileForStorage } from './profile-response.mjs' + +const CAPTURE_TIMEOUT_MS = 95000 +const EXTENSION_CONNECT_TIMEOUT_MS = 30000 +const CANCEL_TIMEOUT_MS = 10000 +const RESULT_TTL_MS = 10 * 60 * 1000 +const MAX_CAPTURE_RECORDS = 100 + +const captureDeadlineError = capture => + capture.phase === 'target_opening' + ? { code: 'TARGET_LOAD_TIMEOUT', message: 'Target tab load timed out.' } + : { code: 'CAPTURE_TIMEOUT', message: 'Capture timed out.' } + +export class CaptureStore { + constructor({ baseUrl, openBrowser, now = () => Date.now(), resultTtlMs = RESULT_TTL_MS, setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout }) { + this.baseUrl = baseUrl + this.openBrowser = openBrowser + this.now = now + this.resultTtlMs = resultTtlMs + this.setTimeout = setTimeoutFn + this.clearTimeout = clearTimeoutFn + this.captures = new Map() + this.captureLocks = new Map() + this.resultExpiryTimers = new Map() + } + + async withCaptureLock(id, task) { + const previous = this.captureLocks.get(id) || Promise.resolve() + let release + const gate = new Promise(resolve => { + release = resolve + }) + const next = previous.catch(() => {}).then(() => gate) + this.captureLocks.set(id, next) + await previous.catch(() => {}) + try { + return await task() + } finally { + release() + if (this.captureLocks.get(id) === next) this.captureLocks.delete(id) + } + } + + activeCount() { + this.pruneExpiredResults() + return [...this.captures.values()].filter(capture => + ['queued', 'waiting_extension', 'running', 'cancel_requested'].includes(capture.status) + ).length + } + + get(id) { + const capture = this.captures.get(id) + if (!capture) return null + this.expireIfNeeded(capture) + return capture + } + + async create(request) { + if (this.activeCount() > 0) { + return { ok: false, status: 429, code: 'CAPTURE_BUSY', message: 'Another capture is already active.' } + } + const capture = { + id: newCaptureId(), + sessionId: newSessionId(), + nonce: newNonce(), + bridgeToken: newBridgeToken(), + status: 'queued', + phase: undefined, + sequence: 0, + request, + profile: null, + screenshotAsset: null, + error: null, + createdAt: this.now(), + extensionDeadlineAt: this.now() + EXTENSION_CONNECT_TIMEOUT_MS, + deadlineAt: this.now() + CAPTURE_TIMEOUT_MS, + cancelDeadlineAt: null, + resultExpiresAt: null, + bridgeTokenRenderedAt: null, + bridgeTokenClaimedAt: null, + profileDownloadReadyAt: null, + screenshotDownloadId: newScreenshotDownloadId(), + screenshotUrl: null + } + capture.bridgeUrl = `${this.baseUrl}/bridge?session=${capture.sessionId}&capture=${capture.id}&nonce=${capture.nonce}` + capture.profileUrl = `${this.baseUrl}/v1/captures/${capture.id}/profile` + capture.screenshotUrl = `${this.baseUrl}/v1/captures/${capture.id}/screenshot-download/${capture.screenshotDownloadId}` + this.captures.set(capture.id, capture) + this.pruneTerminalRecords() + const opened = await this.openBrowser(capture.bridgeUrl) + if (!opened.ok) { + capture.status = 'failed' + capture.error = { code: 'BROWSER_OPEN_FAILED', message: 'Failed to open the bridge page.', details: opened.details || {} } + return { ok: false, status: 500, code: 'BROWSER_OPEN_FAILED', message: capture.error.message, details: capture.error.details } + } + return { ok: true, capture } + } + + clearResultExpiryTimer(captureId) { + const timer = this.resultExpiryTimers.get(captureId) + if (!timer) return + this.clearTimeout(timer) + this.resultExpiryTimers.delete(captureId) + } + + scheduleResultExpiry(capture) { + this.clearResultExpiryTimer(capture.id) + if (!capture.resultExpiresAt) return + const delayMs = Math.max(0, capture.resultExpiresAt - this.now()) + const timer = this.setTimeout(() => { + this.resultExpiryTimers.delete(capture.id) + this.expireIfNeeded(capture) + }, delayMs) + timer?.unref?.() + this.resultExpiryTimers.set(capture.id, timer) + } + + expireIfNeeded(capture) { + const now = this.now() + if (capture.status === 'completed' && capture.resultExpiresAt && capture.resultExpiresAt <= now) { + capture.status = 'expired' + capture.profile = null + capture.screenshotAsset = null + capture.error = { code: 'CAPTURE_RESULT_EXPIRED', message: 'Capture result expired.' } + this.clearResultExpiryTimer(capture.id) + } + if (['queued', 'waiting_extension'].includes(capture.status) && capture.extensionDeadlineAt <= now) { + capture.status = 'failed' + capture.error = { code: 'EXTENSION_NOT_CONNECTED', message: 'StackPrism extension did not connect before the deadline.' } + } + if (capture.status === 'running' && capture.deadlineAt <= now) { + capture.status = 'failed' + capture.error = captureDeadlineError(capture) + } + if (capture.status === 'cancel_requested' && capture.cancelDeadlineAt && capture.cancelDeadlineAt <= now) { + capture.status = 'cancelled' + capture.error = { + code: 'CAPTURE_TIMEOUT', + message: 'Capture cancellation timed out.', + details: { reason: 'cancel_timeout' } + } + } + } + + pruneExpiredResults() { + for (const capture of this.captures.values()) { + this.expireIfNeeded(capture) + } + } + + pruneTerminalRecords() { + const overflow = this.captures.size - MAX_CAPTURE_RECORDS + if (overflow <= 0) return + const terminal = [...this.captures.values()] + .filter(capture => !['queued', 'waiting_extension', 'running', 'cancel_requested'].includes(capture.status)) + .sort((left, right) => left.createdAt - right.createdAt) + for (const capture of terminal.slice(0, overflow)) { + this.clearResultExpiryTimer(capture.id) + this.captures.delete(capture.id) + } + } + + markProfile(capture, profile) { + capture.resultExpiresAt = this.now() + this.resultTtlMs + const prepared = prepareProfileForStorage(profile, capture) + capture.status = 'completed' + capture.phase = 'cleanup' + capture.profile = prepared.profile + capture.screenshotAsset = prepared.screenshotAsset + this.scheduleResultExpiry(capture) + } + + touchResult(capture) { + if (capture.status !== 'completed') return + capture.resultExpiresAt = this.now() + this.resultTtlMs + this.scheduleResultExpiry(capture) + } + + requestCancel(capture) { + capture.status = 'cancel_requested' + capture.cancelDeadlineAt = this.now() + CANCEL_TIMEOUT_MS + } + + clear() { + for (const captureId of this.resultExpiryTimers.keys()) this.clearResultExpiryTimer(captureId) + this.captures.clear() + } +} diff --git a/agent-skill/stackprism-site-experience/scripts/bridge/http-handlers.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/http-handlers.mjs new file mode 100644 index 00000000..32a680c9 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/http-handlers.mjs @@ -0,0 +1,195 @@ +import { fail, isKnownBridgeErrorCode, json, newCspNonce, protocolVersion, redactUrl, safeEqual } from './protocol.mjs' +import { renderBridgePageHtml } from './bridge-page.mjs' +import { profilePreviewSummary } from './profile-summary.mjs' +import { screenshotPreviewForCapture } from './profile-response.mjs' + +export const finalStates = new Set(['completed', 'failed', 'cancelled', 'expired']) + +const pluginWritableStatuses = new Set(['waiting_extension', 'running', 'cancelled', 'failed']) +const statusPhases = [ + 'bridge_connected', + 'request_loaded', + 'target_opening', + 'target_loaded', + 'detecting_tech', + 'profiling_experience', + 'posting_profile', + 'cleanup' +] +const phaseOrder = new Map(statusPhases.map((phase, index) => [phase, index])) +const endpointMethods = { + '': 'GET, DELETE', + request: 'GET', + control: 'GET', + status: 'POST', + profile: 'GET, POST', + 'profile-download': 'GET', + 'screenshot-download': 'GET' +} + +const tokenFrom = req => /^Bearer (.+)$/.exec(req.headers.authorization || '')?.[1] || '' + +export const allowForCaptureEndpoint = endpoint => endpointMethods[endpoint] || 'GET, POST, DELETE' + +export const auth = (req, capture, apiToken, scope) => { + const token = tokenFrom(req) + if (!token) return { ok: false, status: 401, code: 'UNAUTHORIZED', message: 'Bearer token is required.' } + if (['api', 'download'].includes(scope) && safeEqual(token, apiToken)) return { ok: true, tokenType: 'api' } + if (['bridge', 'download'].includes(scope) && capture && safeEqual(token, capture.bridgeToken)) return { ok: true, tokenType: 'bridge' } + if (scope === 'status' && (safeEqual(token, apiToken) || (capture && safeEqual(token, capture.bridgeToken)))) { + return { ok: true, tokenType: safeEqual(token, apiToken) ? 'api' : 'bridge' } + } + return { ok: false, status: 403, code: 'FORBIDDEN', message: 'Token is not allowed for this endpoint.' } +} + +export const scopeForEndpoint = (method, endpoint) => { + if (endpoint === '' && method === 'GET') return 'status' + if (endpoint === 'request' || endpoint === 'control' || (method === 'POST' && ['status', 'profile'].includes(endpoint))) return 'bridge' + if (method === 'GET' && ['profile-download', 'screenshot-download'].includes(endpoint)) return 'download' + return method === 'GET' && endpoint === 'profile' ? 'status' : 'api' +} + +const previewForCapture = capture => { + const targetUrl = redactUrl(capture.finalUrl || capture.request?.url) + const preview = {} + if (targetUrl) preview.targetUrl = targetUrl + const screenshot = capture.status === 'completed' ? screenshotPreviewForCapture(capture) : null + if (screenshot) preview.screenshot = screenshot + const summary = profilePreviewSummary(capture, screenshot) + if (summary) Object.assign(preview, summary) + return Object.keys(preview).length ? preview : undefined +} + +export const publicStatus = capture => { + const status = { id: capture.id, status: capture.status } + if (capture.phase) status.phase = capture.phase + if (capture.error) status.error = capture.error + if (capture.profileDownloadReadyAt) status.profileDownloadReady = true + const preview = previewForCapture(capture) + if (preview) status.preview = preview + return status +} + +export const terminalProfileErrorCode = status => (status === 'completed' ? 'CAPTURE_ALREADY_COMPLETED' : 'STALE_STATUS_UPDATE') + +export const commitProfile = (store, capture, profile, { profileSchema }) => { + if (finalStates.has(capture.status)) { + return { ok: false, status: 409, code: terminalProfileErrorCode(capture.status), details: { status: capture.status } } + } + if (!capture.finalUrl) return { ok: false, status: 409, code: 'INVALID_REQUEST', message: 'Capture final URL has not been accepted.' } + if (profile?.schema !== profileSchema || profile?.captureId !== capture.id) { + return { ok: false, status: 400, code: 'INVALID_REQUEST', message: 'Profile schema or capture id is invalid.' } + } + store.markProfile(capture, profile) + return { ok: true, status: 200, body: publicStatus(capture) } +} + +export const writeProfile = (res, result) => + result.ok + ? json(res, result.status, result.body) + : fail(res, result.status, result.code, result.message || 'Capture is already terminal.', result.details) + +export const validateStatusUpdate = (capture, body) => { + if (finalStates.has(capture.status)) { + return { ok: false, code: 'STALE_STATUS_UPDATE', message: 'Capture is already terminal.' } + } + if ( + body?.captureId !== capture.id || + body?.sessionId !== capture.sessionId || + body?.nonce !== capture.nonce || + body?.protocolVersion !== protocolVersion + ) { + return { ok: false, code: 'INVALID_REQUEST', message: 'Capture status identity is invalid.' } + } + if (!pluginWritableStatuses.has(body?.status)) { + return { ok: false, code: 'INVALID_REQUEST', message: 'Capture status is invalid.' } + } + if (!phaseOrder.has(body?.phase)) { + return { ok: false, code: 'INVALID_REQUEST', message: 'Capture phase is invalid.' } + } + if (body.status === 'cancelled' && capture.status !== 'cancel_requested') { + return { ok: false, code: 'STALE_STATUS_UPDATE', message: 'Capture cancellation was not requested.' } + } + if (capture.status === 'cancel_requested' && body.status !== 'cancelled') { + return { ok: false, code: 'STALE_STATUS_UPDATE', message: 'Capture cancellation is already requested.' } + } + if (body.status === 'failed' && (!body.error?.code || !body.error?.message)) { + return { ok: false, code: 'INVALID_REQUEST', message: 'Failed status requires a structured error.' } + } + if (body.status === 'failed' && !isKnownBridgeErrorCode(body.error.code)) { + return { ok: false, code: 'INVALID_REQUEST', message: 'Failed status error code is invalid.' } + } + if (body.status === 'cancelled' && body.phase !== 'cleanup') { + return { ok: false, code: 'INVALID_REQUEST', message: 'Cancelled status must use cleanup phase.' } + } + if (!Number.isInteger(body.sequence) || body.sequence <= capture.sequence) { + return { ok: false, code: 'STALE_STATUS_UPDATE', message: 'Capture status sequence is stale.' } + } + const currentPhaseOrder = phaseOrder.has(capture.phase) ? phaseOrder.get(capture.phase) : -1 + const nextPhaseOrder = phaseOrder.get(body.phase) + if (nextPhaseOrder < currentPhaseOrder) { + return { ok: false, code: 'STALE_STATUS_UPDATE', message: 'Capture phase cannot move backwards.' } + } + return { ok: true } +} + +const bridgePageState = capture => { + if (capture.status === 'expired') return { kind: 'fail', args: [410, 'CAPTURE_RESULT_EXPIRED', 'Capture result expired.'] } + if (finalStates.has(capture.status)) { + return { + kind: 'fail', + args: [409, capture.error?.code || 'INVALID_REQUEST', 'Capture is already terminal.', { status: capture.status }] + } + } + if (capture.bridgeTokenRenderedAt || capture.bridgeTokenClaimedAt) { + return { + kind: 'fail', + args: [409, 'INVALID_REQUEST', 'Bridge token has already been rendered or claimed.'] + } + } + return { + kind: 'config', + config: { + captureId: capture.id, + sessionId: capture.sessionId, + nonce: capture.nonce, + bridgeToken: capture.bridgeToken, + targetUrl: redactUrl(capture.request?.url), + protocolVersion + } + } +} + +const buildBridgePage = (capture, now = Date.now) => { + const state = bridgePageState(capture) + if (state.kind === 'fail') return state + const cspNonce = newCspNonce() + try { + const html = renderBridgePageHtml(cspNonce, state.config) + capture.bridgeTokenRenderedAt = now() + return { kind: 'html', cspNonce, html } + } catch { + return { kind: 'fail', args: [500, 'BRIDGE_PAGE_RENDER_FAILED', 'Bridge page render failed.'] } + } +} + +export const renderBridge = async (res, capture, { store } = {}) => { + const page = store?.withCaptureLock + ? await store.withCaptureLock(capture.id, async () => { + const lockedCapture = store.get(capture.id) + return lockedCapture ? buildBridgePage(lockedCapture, store.now) : { kind: 'fail', args: [404, 'NOT_FOUND', 'Capture not found.'] } + }) + : buildBridgePage(capture) + if (page.kind === 'fail') return fail(res, ...page.args) + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()', + 'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${page.cspNonce}'; style-src 'nonce-${page.cspNonce}'; img-src data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'` + }) + res.end(page.html) +} diff --git a/agent-skill/stackprism-site-experience/scripts/bridge/http-server.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/http-server.mjs new file mode 100644 index 00000000..782a9df2 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/http-server.mjs @@ -0,0 +1,276 @@ +import http from 'node:http' +import { CaptureStore } from './capture-store.mjs' +import { + allowForCaptureEndpoint, + auth, + commitProfile, + finalStates, + publicStatus, + renderBridge, + scopeForEndpoint, + terminalProfileErrorCode, + validateStatusUpdate, + writeProfile +} from './http-handlers.mjs' +import { openBrowser, parseOpenConfig } from './open-browser.mjs' +import { parseBridgeQuery, readJson, rejectBadRequestShell, rejectCrossOriginSensitiveRequest, validateBridgeQuery } from './security.mjs' +import { validateTargetNetworkAddress } from './target-network-policy.mjs' +import { normalizeCaptureRequest, validateFinalUrl } from './url-policy.mjs' +import { + errorBody, + fail, + isValidId, + json, + newApiToken, + profileSchema, + protocolVersion, + safeEqual, + sanitizeBridgeError, + service, + version +} from './protocol.mjs' +import { readProfile, readProfileDownload, readScreenshotDownload } from './profile-response.mjs' +import { + applyServerResourcePolicy, + DEFAULT_CREATE_LIMIT_PER_MINUTE, + DEFAULT_QUERY_LIMIT_PER_MINUTE, + DEFAULT_RESOURCE_POLICY, + makeRateLimiter +} from './resource-policy.mjs' + +const isQueryEndpoint = (method, endpoint) => method === 'GET' && (endpoint === '' || endpoint === 'profile') +const profileHeaders = { 'Referrer-Policy': 'no-referrer' } +const methodNotAllowed = (res, allow) => fail(res, 405, 'METHOD_NOT_ALLOWED', 'Method is not supported.', {}, { Allow: allow }) +const failParsedRequest = (res, parsed, code = parsed.code) => + fail(res, parsed.status, code, parsed.message, {}, parsed.close ? { Connection: 'close' } : {}) +const socketJsonError = (socket, statusLine, message) => { + if (!socket.writable) return + const body = JSON.stringify(errorBody('INVALID_REQUEST', message)) + socket.end( + [ + statusLine, + 'Content-Type: application/json; charset=utf-8', + 'Cache-Control: no-store', + 'X-Content-Type-Options: nosniff', + 'Connection: close', + `Content-Length: ${Buffer.byteLength(body)}`, + `\r\n${body}` + ].join('\r\n') + ) +} + +const captureFromPath = (store, pathname) => { + const match = + /^\/v1\/captures\/([^/]+)(?:\/(request|control|status|profile|profile-download)|\/(screenshot-download)\/([^/]+))?$/.exec(pathname) + if (!match || !isValidId('captureId', match[1])) return null + const capture = store.get(match[1]) + return capture ? { capture, endpoint: match[2] || match[3] || '', screenshotDownloadId: match[4] || '' } : { missing: true } +} + +export const createBridgeServer = ({ port = 0, env = process.env, resolveHostname, now, rateLimits, resourcePolicy, resultTtlMs } = {}) => { + const openConfig = parseOpenConfig(env) + if (!openConfig.ok) throw Object.assign(new Error(openConfig.message), { code: openConfig.code }) + const apiToken = newApiToken() + let baseUrl = '' + let store + const allowRate = makeRateLimiter(rateLimits) + const policy = { ...DEFAULT_RESOURCE_POLICY, ...(resourcePolicy || {}) } + const server = http.createServer({ requireHostHeader: false }, async (req, res) => { + const rejected = rejectBadRequestShell(req, res, baseUrl) + if (rejected) return rejected + const url = new URL(req.url || '/', baseUrl) + + if (req.method === 'OPTIONS') return methodNotAllowed(res, 'GET, POST, DELETE') + if (req.method === 'GET' && url.pathname === '/health') { + return json(res, 200, { ok: true, service, version, protocolVersion, bound: '127.0.0.1', activeCaptures: store.activeCount() }) + } + if (url.pathname === '/health') return methodNotAllowed(res, 'GET') + if (req.method === 'GET' && url.pathname === '/bridge') { + const rejectedOrigin = rejectCrossOriginSensitiveRequest(req, res, baseUrl) + if (rejectedOrigin) return rejectedOrigin + if (!validateBridgeQuery(url)) return fail(res, 400, 'INVALID_REQUEST', 'Bridge query is invalid.') + const query = parseBridgeQuery(url.search) + const capture = query?.capture && store.get(query.capture) + if (!capture || capture.sessionId !== query.session || capture.nonce !== query.nonce) { + return fail(res, 404, 'NOT_FOUND', 'Capture bridge page was not found.') + } + return renderBridge(res, capture, { store }) + } + if (url.pathname === '/bridge') return methodNotAllowed(res, 'GET') + if (req.method === 'POST' && url.pathname === '/v1/captures') { + const rejectedOrigin = rejectCrossOriginSensitiveRequest(req, res, baseUrl) + if (rejectedOrigin) return rejectedOrigin + const allowed = auth(req, null, apiToken, 'api') + if (!allowed.ok) return fail(res, allowed.status, allowed.code, allowed.message) + if (!allowRate(apiToken, 'create', rateLimits?.createLimitPerMinute ?? DEFAULT_CREATE_LIMIT_PER_MINUTE, now?.() ?? Date.now())) { + return fail(res, 429, 'RATE_LIMITED', 'Agent bridge rate limit exceeded.') + } + const parsed = await readJson(req, 5 * 1024 * 1024, policy.requestTimeoutMs) + if (!parsed.ok) return failParsedRequest(res, parsed) + const normalized = await normalizeCaptureRequest(parsed.body, baseUrl, { resolveHostname }) + if (!normalized.ok) return fail(res, 400, normalized.code, normalized.message, normalized.details) + const created = await store.create(normalized.request) + if (!created.ok) return fail(res, created.status, created.code, created.message, created.details) + return json(res, 200, { + id: created.capture.id, + status: created.capture.status, + bridgeUrl: created.capture.bridgeUrl, + profileUrl: created.capture.profileUrl + }) + } + if (url.pathname === '/v1/captures') return methodNotAllowed(res, 'POST') + + const routed = captureFromPath(store, url.pathname) + if (!routed) return fail(res, 404, 'NOT_FOUND', 'Endpoint was not found.') + if (routed.missing) return fail(res, 404, 'NOT_FOUND', 'Capture was not found.') + const { capture, endpoint, screenshotDownloadId } = routed + const rejectedOrigin = rejectCrossOriginSensitiveRequest(req, res, baseUrl) + if (rejectedOrigin) return rejectedOrigin + if (req.method === 'GET' && endpoint === 'screenshot-download') { + if (!isValidId('screenshotDownloadId', screenshotDownloadId) || !safeEqual(screenshotDownloadId, capture.screenshotDownloadId)) { + return fail(res, 403, 'FORBIDDEN', 'Screenshot download URL is not valid for this capture.', {}, profileHeaders) + } + return readScreenshotDownload(res, capture, profileHeaders, { store }) + } + const allowed = auth(req, capture, apiToken, scopeForEndpoint(req.method, endpoint)) + if (!allowed.ok) return fail(res, allowed.status, allowed.code, allowed.message) + if ( + allowed.tokenType === 'api' && + isQueryEndpoint(req.method, endpoint) && + !allowRate(apiToken, 'query', rateLimits?.queryLimitPerMinute ?? DEFAULT_QUERY_LIMIT_PER_MINUTE, now?.() ?? Date.now()) + ) { + return fail(res, 429, 'RATE_LIMITED', 'Agent bridge rate limit exceeded.') + } + + if (req.method === 'GET' && endpoint === '') return json(res, 200, publicStatus(capture)) + if (req.method === 'GET' && endpoint === 'request') { + capture.bridgeTokenClaimedAt = capture.bridgeTokenClaimedAt || (now?.() ?? Date.now()) + return json(res, 200, { + captureId: capture.id, + sessionId: capture.sessionId, + nonce: capture.nonce, + protocolVersion, + request: capture.request + }) + } + if (req.method === 'GET' && endpoint === 'control') { + return json(res, 200, { + id: capture.id, + command: ['cancel_requested', 'completed', 'cancelled', 'failed', 'expired'].includes(capture.status) ? 'cancel' : 'continue', + status: capture.status + }) + } + if (req.method === 'GET' && endpoint === 'profile') { + return readProfile(res, capture, allowed.tokenType, profileHeaders, { store }) + } + if (req.method === 'GET' && endpoint === 'profile-download') { + return readProfileDownload(res, capture, profileHeaders, { store }) + } + if (req.method === 'POST' && endpoint === 'status') { + const parsed = await readJson(req, 5 * 1024 * 1024, policy.requestTimeoutMs) + if (!parsed.ok) return failParsedRequest(res, parsed) + return store.withCaptureLock(capture.id, async () => { + const lockedCapture = store.get(capture.id) + if (!lockedCapture) return fail(res, 404, 'NOT_FOUND', 'Capture not found.') + const statusUpdate = validateStatusUpdate(lockedCapture, parsed.body) + if (!statusUpdate.ok) return fail(res, statusUpdate.code === 'INVALID_REQUEST' ? 400 : 409, statusUpdate.code, statusUpdate.message) + if (parsed.body?.status === 'running' && parsed.body?.phase === 'target_loaded' && !parsed.body?.finalUrl) { + return fail(res, 400, 'INVALID_REQUEST', 'target_loaded status requires finalUrl.') + } + if (parsed.body?.finalUrl) { + const finalUrl = await validateFinalUrl(parsed.body.finalUrl, baseUrl, lockedCapture.request, { resolveHostname }) + if (!finalUrl.ok) { + lockedCapture.status = 'failed' + lockedCapture.phase = parsed.body.phase + lockedCapture.error = { code: finalUrl.code, message: finalUrl.message, details: finalUrl.details || {} } + return fail(res, 409, finalUrl.code, finalUrl.message, finalUrl.details) + } + const network = validateTargetNetworkAddress(parsed.body.targetNetworkAddress, lockedCapture.request, { + finalUrl: finalUrl.finalUrl, + fromCache: parsed.body.targetNetworkFromCache === true + }) + if (!network.ok) { + if (network.code === 'INVALID_REQUEST') return fail(res, 400, network.code, network.message, network.details) + lockedCapture.status = 'failed' + lockedCapture.phase = parsed.body.phase + lockedCapture.error = { code: network.code, message: network.message, details: network.details || {} } + return fail(res, 409, network.code, network.message, network.details) + } + lockedCapture.finalUrl = finalUrl.finalUrl + } + lockedCapture.sequence = parsed.body.sequence + lockedCapture.status = parsed.body.status + lockedCapture.phase = parsed.body.phase + lockedCapture.error = parsed.body.error ? sanitizeBridgeError(parsed.body.error) : lockedCapture.error + return json(res, 200, publicStatus(lockedCapture)) + }) + } + if (req.method === 'POST' && endpoint === 'profile') { + if (finalStates.has(capture.status)) { + return fail(res, 409, terminalProfileErrorCode(capture.status), 'Capture is already terminal.', { status: capture.status }) + } + if (!capture.finalUrl) return fail(res, 409, 'INVALID_REQUEST', 'Capture final URL has not been accepted.') + const parsed = await readJson(req, 8 * 1024 * 1024, policy.requestTimeoutMs) + if (!parsed.ok) return failParsedRequest(res, parsed, parsed.status === 413 ? 'PROFILE_TOO_LARGE' : parsed.code) + return store.withCaptureLock(capture.id, async () => { + const lockedCapture = store.get(capture.id) + if (!lockedCapture) return fail(res, 404, 'NOT_FOUND', 'Capture not found.') + return writeProfile(res, commitProfile(store, lockedCapture, parsed.body, { profileSchema })) + }) + } + if (req.method === 'DELETE' && endpoint === '') { + if (finalStates.has(capture.status)) + return fail(res, 409, 'INVALID_REQUEST', 'Capture is already terminal.', { status: capture.status }) + if (capture.status === 'cancel_requested') + return fail(res, 409, 'STALE_STATUS_UPDATE', 'Capture cancellation is already requested.', { status: capture.status }) + store.requestCancel(capture) + return json(res, 200, publicStatus(capture)) + } + return methodNotAllowed(res, allowForCaptureEndpoint(endpoint)) + }) + + server.on('clientError', (_error, socket) => socketJsonError(socket, 'HTTP/1.1 400 Bad Request', 'Invalid HTTP request.')) + server.on('connect', (_req, socket) => + socketJsonError(socket, 'HTTP/1.1 400 Bad Request', 'Only origin-form request targets are allowed.') + ) + const headerTimers = new Map() + server.on('connection', socket => { + const timer = setTimeout(() => { + if (!socket.writable) return + socket.end(['HTTP/1.1 408 Request Timeout', 'Connection: close', 'Content-Length: 0', '', ''].join('\r\n')) + }, policy.headersTimeoutMs) + headerTimers.set(socket, timer) + socket.once('close', () => { + clearTimeout(timer) + headerTimers.delete(socket) + }) + }) + server.on('request', (req, res) => { + const socket = req.socket + const timer = headerTimers.get(socket) + if (timer) { + clearTimeout(timer) + headerTimers.delete(socket) + } + res.once('finish', () => { + if (!socket.destroyed) socket.setTimeout(policy.keepAliveTimeoutMs) + }) + }) + applyServerResourcePolicy(server, policy) + + store = new CaptureStore({ baseUrl: '', openBrowser: url => openBrowser(url, env), now, resultTtlMs }) + + const listen = () => + new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(port, '127.0.0.1', () => { + const address = server.address() + baseUrl = `http://127.0.0.1:${address.port}` + store.baseUrl = baseUrl + resolve({ server, store, apiToken, baseUrl, healthUrl: `${baseUrl}/health` }) + }) + }) + + const close = () => new Promise(resolve => server.close(resolve)) + return { listen, close, server } +} diff --git a/agent-skill/stackprism-site-experience/scripts/bridge/open-browser.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/open-browser.mjs new file mode 100644 index 00000000..07fba013 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/open-browser.mjs @@ -0,0 +1,153 @@ +import { spawn } from 'node:child_process' +import { accessSync, constants, statSync } from 'node:fs' +import { delimiter, join } from 'node:path' + +const DEFAULT_OPEN_TIMEOUT_MS = 5000 +const MAX_OPEN_TIMEOUT_MS = 30000 +const MAX_LAUNCH_PROBE_MS = 1000 +const containsNul = value => (typeof value === 'string' ? value.includes('\0') : Array.isArray(value) && value.some(containsNul)) +const hasPathSeparator = value => value.includes('/') || value.includes('\\') + +const commandCandidates = (command, env, platform) => { + const extensions = String(env.PATHEXT ?? process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD') + .split(';') + .filter(Boolean) + const lowerCommand = command.toLowerCase() + if (hasPathSeparator(command)) { + if (platform !== 'win32' || extensions.some(extension => lowerCommand.endsWith(extension.toLowerCase()))) return [command] + return extensions.map(extension => `${command}${extension}`) + } + const pathEntries = String(env.PATH ?? process.env.PATH ?? '').split(delimiter).filter(Boolean) + if (platform !== 'win32') return pathEntries.map(entry => join(entry, command)) + return pathEntries.flatMap(entry => + extensions.map(extension => { + const suffix = extension.toLowerCase() + return join(entry, lowerCommand.endsWith(suffix) ? command : `${command}${extension}`) + }) + ) +} + +const commandExists = (command, env, platform) => { + const mode = platform === 'win32' ? constants.F_OK : constants.X_OK + for (const candidate of commandCandidates(command, env, platform)) { + try { + accessSync(candidate, mode) + if (!statSync(candidate).isFile()) continue + return true + } catch {} + } + return false +} + +const validateOpenUrl = url => { + const value = String(url) + if (value.includes('\0') || value.includes('\n') || value.includes('\r')) return { ok: false, details: { reason: 'invalid_url' } } + let parsed + try { + parsed = new URL(value) + } catch { + return { ok: false, details: { reason: 'invalid_url' } } + } + if (!['http:', 'https:'].includes(parsed.protocol)) { + return { ok: false, details: { reason: 'invalid_scheme', allowed: ['http', 'https'] } } + } + if (parsed.username || parsed.password) return { ok: false, details: { reason: 'invalid_url' } } + return { ok: true } +} + +export const parseOpenTimeoutMs = env => { + const value = env.STACKPRISM_BROWSER_OPEN_TIMEOUT_MS + if (value == null || value === '') return { ok: true, timeoutMs: DEFAULT_OPEN_TIMEOUT_MS } + const parsed = Number(value) + if (Number.isInteger(parsed) && parsed >= 100 && parsed <= MAX_OPEN_TIMEOUT_MS) return { ok: true, timeoutMs: parsed } + return { ok: false, details: { reason: 'invalid_open_timeout' } } +} + +export const parseOpenConfig = env => { + for (const key of ['STACKPRISM_BROWSER_OPEN_COMMAND', 'STACKPRISM_BROWSER_OPEN_ARGS_JSON']) { + if (String(env[key] || '').includes('\0')) { + return { ok: false, code: 'BRIDGE_INVALID_ENV', message: 'Browser open environment contains NUL.' } + } + } + if (env.STACKPRISM_BROWSER_OPEN_COMMAND && env.STACKPRISM_BROWSER_OPEN_ARGS_JSON) { + try { + if (containsNul(JSON.parse(env.STACKPRISM_BROWSER_OPEN_ARGS_JSON))) { + return { ok: false, code: 'BRIDGE_INVALID_ENV', message: 'Browser open environment contains NUL.' } + } + } catch {} + } + return { ok: true } +} + +export const resolveBrowserOpenCommand = (env = process.env, platform = process.platform) => { + let command = env.STACKPRISM_BROWSER_OPEN_COMMAND + let args = [] + if (command) { + if (env.STACKPRISM_BROWSER_OPEN_ARGS_JSON) { + try { + args = JSON.parse(env.STACKPRISM_BROWSER_OPEN_ARGS_JSON) + } catch { + return { ok: false, details: { reason: 'invalid_open_args' } } + } + if (!Array.isArray(args) || args.some(arg => typeof arg !== 'string')) { + return { ok: false, details: { reason: 'invalid_open_args' } } + } + } + } else if (platform === 'darwin') { + command = 'open' + } else if (platform === 'win32') { + command = 'rundll32.exe' + args = ['url.dll,FileProtocolHandler'] + } else { + command = 'xdg-open' + } + return { ok: true, command, args } +} + +const waitForLaunchProbe = (child, timeoutMs) => + new Promise(resolve => { + let settled = false + const finish = result => { + if (settled) return + settled = true + clearTimeout(timer) + child.off('error', onError) + child.off('exit', onExit) + resolve(result) + } + const onError = error => { + finish({ ok: false, details: { reason: error?.code === 'ENOENT' ? 'command_not_found' : 'spawn_failed' } }) + } + const onExit = code => { + finish(code === 0 ? { ok: true } : { ok: false, details: { reason: 'open_failed', exitCode: code } }) + } + const timer = setTimeout(() => finish({ ok: true }), Math.min(timeoutMs, MAX_LAUNCH_PROBE_MS)) + timer.unref?.() + child.once('error', onError) + child.once('exit', onExit) + }) + +export const openBrowser = async (url, env = process.env, platform = process.platform) => { + const openConfig = parseOpenConfig(env) + if (!openConfig.ok) return { ok: false, details: { reason: openConfig.code, message: openConfig.message } } + const openUrl = validateOpenUrl(url) + if (!openUrl.ok) return openUrl + + if (env.STACKPRISM_BRIDGE_NO_OPEN === '1') return { ok: true, skipped: true } + + const resolved = resolveBrowserOpenCommand(env, platform) + if (!resolved.ok) return resolved + const timeout = parseOpenTimeoutMs(env) + if (!timeout.ok) return { ok: false, details: timeout.details } + const { command, args } = resolved + if (!commandExists(command, env, platform)) return { ok: false, details: { reason: 'command_not_found' } } + + try { + const child = spawn(command, [...args, url], { detached: true, stdio: 'ignore', shell: false }) + const launched = await waitForLaunchProbe(child, timeout.timeoutMs) + child.unref() + return launched + } catch { + return { ok: false, details: { reason: 'spawn_failed' } } + } +} diff --git a/agent-skill/stackprism-site-experience/scripts/bridge/profile-response.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/profile-response.mjs new file mode 100644 index 00000000..9631151c --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/profile-response.mjs @@ -0,0 +1,181 @@ +import { fail, json } from './protocol.mjs' + +const screenshotDataUrlPattern = /^data:image\/(jpeg|png|webp);base64,([A-Za-z0-9+/=]+)$/i +const strictBase64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ +const screenshotBase64OmittedNote = + 'Screenshot image base64 is intentionally omitted from this Profile JSON. To inspect actual visual appearance, download the image from downloadUrl while the local bridge is running and before availableUntil.' +const screenshotProfileJsonNote = + 'Profile JSON is standard JSON and cannot contain comments. This note field is the durable instruction: screenshot base64 is omitted; use downloadUrl to inspect actual visual appearance.' + +const screenshotExtensionFor = mimeType => (mimeType === 'image/png' ? 'png' : mimeType === 'image/webp' ? 'webp' : 'jpg') +const decodeStrictBase64 = value => { + if (typeof value !== 'string' || value.length % 4 !== 0 || !strictBase64Pattern.test(value)) return null + const bytes = Buffer.from(value, 'base64') + return bytes.toString('base64') === value ? bytes : null +} +const availableUntilFor = capture => { + if (!capture.resultExpiresAt) return '' + try { + return new Date(capture.resultExpiresAt).toISOString() + } catch { + return '' + } +} + +const screenshotAssetFrom = screenshot => { + const match = typeof screenshot?.dataUrl === 'string' ? screenshot.dataUrl.match(screenshotDataUrlPattern) : null + if (!match) return null + const mimeType = `image/${match[1].toLowerCase()}` + const bytes = decodeStrictBase64(match[2]) + if (!bytes?.byteLength) return null + const { dataUrl: _dataUrl, ...rest } = screenshot + return { + bytes, + mimeType, + extension: screenshotExtensionFor(mimeType), + metadata: rest + } +} + +const screenshotMetadataFor = (capture, asset) => ({ + ...asset.metadata, + mimeType: asset.mimeType, + byteLength: asset.bytes.byteLength, + downloadUrl: capture.screenshotUrl, + downloadMethod: 'GET', + lifecycle: { + requiresLocalBridge: true, + availableUntil: availableUntilFor(capture), + note: 'Download the screenshot before the local bridge process exits or the capture result expires.' + }, + profileJsonNote: screenshotProfileJsonNote, + note: screenshotBase64OmittedNote +}) + +export const screenshotPayloadForCapture = capture => { + const asset = capture.screenshotAsset || screenshotAssetFrom(capture.profile?.visualProfile?.screenshot) + if (!asset?.bytes?.byteLength) return null + return { + ...asset, + metadata: screenshotMetadataFor(capture, asset) + } +} + +export const screenshotPreviewForCapture = capture => { + const payload = screenshotPayloadForCapture(capture) + if (!payload) return null + return { + downloadUrl: capture.screenshotUrl, + mimeType: payload.mimeType, + byteLength: payload.bytes.byteLength, + scope: payload.metadata.scope + } +} + +const cloneJson = value => JSON.parse(JSON.stringify(value)) + +const ensureVisualReference = profile => { + if (!profile.agentGuidance || typeof profile.agentGuidance !== 'object') profile.agentGuidance = {} + if (!profile.agentGuidance.recreationPlan || typeof profile.agentGuidance.recreationPlan !== 'object') { + profile.agentGuidance.recreationPlan = {} + } + if ( + !profile.agentGuidance.recreationPlan.visualReference || + typeof profile.agentGuidance.recreationPlan.visualReference !== 'object' + ) { + profile.agentGuidance.recreationPlan.visualReference = {} + } + return profile.agentGuidance.recreationPlan.visualReference +} + +const updateVisualReference = (profile, capture, payload) => { + const visualReference = payload ? ensureVisualReference(profile) : profile.agentGuidance?.recreationPlan?.visualReference + if (!visualReference || typeof visualReference !== 'object') return + visualReference.screenshotIncluded = Boolean(payload) + visualReference.screenshotBase64Included = false + visualReference.screenshotDownloadUrl = payload ? capture.screenshotUrl : '' + visualReference.screenshotDownloadHint = payload + ? screenshotBase64OmittedNote + : 'No screenshot image is available in this capture. Review limitations before treating visual evidence as absent.' + visualReference.screenshotProfileJsonNote = screenshotProfileJsonNote + if (payload) { + visualReference.screenshotMimeType = payload.mimeType + visualReference.screenshotByteLength = payload.bytes.byteLength + visualReference.screenshotAvailableUntil = availableUntilFor(capture) + } else { + delete visualReference.screenshotMimeType + delete visualReference.screenshotByteLength + delete visualReference.screenshotAvailableUntil + } +} + +export const prepareProfileForStorage = (profile, capture) => { + const storedProfile = cloneJson(profile) + const asset = screenshotAssetFrom(storedProfile.visualProfile?.screenshot) + const screenshot = storedProfile.visualProfile?.screenshot + if (screenshot && typeof screenshot === 'object') { + if (asset) storedProfile.visualProfile.screenshot = screenshotMetadataFor(capture, asset) + else delete screenshot.dataUrl + } + updateVisualReference(storedProfile, capture, asset) + return { profile: storedProfile, screenshotAsset: asset } +} + +export const profileForAgent = capture => { + const profile = cloneJson(capture.profile) + const payload = screenshotPayloadForCapture(capture) + const screenshot = profile.visualProfile?.screenshot + if (screenshot && typeof screenshot === 'object') { + if (payload) profile.visualProfile.screenshot = payload.metadata + else delete screenshot.dataUrl + } + updateVisualReference(profile, capture, payload) + return profile +} + +export const readProfile = (res, capture, tokenType, headers, { store } = {}) => { + if (tokenType === 'bridge') return fail(res, 403, 'BRIDGE_TOKEN_CANNOT_READ_PROFILE', 'Bridge token cannot read the profile endpoint.', {}, headers) + if (capture.status === 'expired') return fail(res, 410, 'CAPTURE_RESULT_EXPIRED', 'Capture result expired.', {}, headers) + if (capture.status !== 'completed') + return fail(res, 409, 'INVALID_REQUEST', 'Capture profile is not ready.', { status: capture.status }, headers) + store?.touchResult?.(capture) + return json(res, 200, profileForAgent(capture), headers) +} + +export const readProfileDownload = (res, capture, headers, { store } = {}) => { + const downloadHeaders = { + ...headers, + 'Content-Disposition': `attachment; filename="stackprism-${capture.id}-profile.json"` + } + if (capture.status === 'expired') return fail(res, 410, 'CAPTURE_RESULT_EXPIRED', 'Capture result expired.', {}, downloadHeaders) + if (capture.status !== 'completed') + return fail(res, 409, 'INVALID_REQUEST', 'Capture profile is not ready.', { status: capture.status }, downloadHeaders) + capture.profileDownloadReadyAt = capture.profileDownloadReadyAt || Date.now() + store?.touchResult?.(capture) + return json(res, 200, profileForAgent(capture), downloadHeaders) +} + +export const readScreenshotDownload = (res, capture, headers, { store } = {}) => { + const payload = capture.status === 'completed' ? screenshotPayloadForCapture(capture) : null + const downloadHeaders = { + ...headers, + ...(payload + ? { + 'Content-Type': payload.mimeType, + 'Content-Disposition': `attachment; filename="stackprism-${capture.id}-screenshot.${payload.extension}"`, + 'Content-Length': payload.bytes.byteLength + } + : {}) + } + if (capture.status === 'expired') return fail(res, 410, 'CAPTURE_RESULT_EXPIRED', 'Capture result expired.', {}, downloadHeaders) + if (capture.status !== 'completed') + return fail(res, 409, 'INVALID_REQUEST', 'Capture screenshot is not ready.', { status: capture.status }, downloadHeaders) + if (!payload) return fail(res, 404, 'NOT_FOUND', 'Capture screenshot is not available.', {}, downloadHeaders) + store?.touchResult?.(capture) + res.writeHead(200, { + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + ...downloadHeaders + }) + res.end(payload.bytes) +} diff --git a/agent-skill/stackprism-site-experience/scripts/bridge/profile-summary.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/profile-summary.mjs new file mode 100644 index 00000000..a84eabd3 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/profile-summary.mjs @@ -0,0 +1,186 @@ +import { redactUrl } from './protocol.mjs' + +const MAX_TEXT = 120 +const MAX_ITEMS = 6 +const TOKEN_TEXT = /\b(apiToken|bridgeToken|authorization|cookie|nonce|secret|token)\b\s*[:=]\s*(?:Bearer\s+)?[^\s,;]+/gi +const ID_TEXT = /\b(?:spbt?_|cap_|s_|n_|xfer_|shot_)[A-Za-z0-9_-]{8,}\b/g +const EMAIL_TEXT = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi +const PHONE_TEXT = /\b(?:\+?\d[\d -]{8,}\d)\b/g +const URL_TEXT = /https?:\/\/[^\s"')\]}]+/g + +const isRecord = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value) +const compact = value => + String(value || '') + .replace(/[\u0000-\u001f\u007f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + +const safeText = (value, max = MAX_TEXT) => + compact(value) + .replace(URL_TEXT, url => redactUrl(url) || '[redacted-url]') + .replace(TOKEN_TEXT, '$1=[redacted]') + .replace(ID_TEXT, '[redacted-id]') + .replace(EMAIL_TEXT, '[redacted-email]') + .replace(PHONE_TEXT, '[redacted-number]') + .slice(0, max) + +const values = (value, limit = MAX_ITEMS) => { + const source = Array.isArray(value) ? value : typeof value === 'string' ? [value] : [] + return [...new Set(source.map(item => safeText(item)).filter(Boolean))].slice(0, limit) +} + +const objectValues = (items, keys = ['name', 'type', 'category', 'domain', 'label'], limit = MAX_ITEMS) => { + if (!Array.isArray(items)) return [] + return items + .map(item => { + if (!isRecord(item)) return safeText(item) + for (const key of keys) { + const text = safeText(item[key]) + if (text) return text + } + return '' + }) + .filter(Boolean) + .slice(0, limit) +} + +const count = value => (Array.isArray(value) ? value.length : isRecord(value) ? Object.keys(value).length : 0) +const add = (items, label, value) => { + const text = safeText(value) + if (text) items.push(`${label}: ${text}`) +} +const addList = (items, label, value, limit = MAX_ITEMS) => { + const list = values(value, limit) + if (list.length) items.push(`${label}: ${list.join(', ')}`) +} +const addObjectList = (items, label, value, limit = MAX_ITEMS) => { + const list = objectValues(value, undefined, limit) + if (list.length) items.push(`${label}: ${list.join(', ')}`) +} +const card = (id, title, items) => (items.length ? { id, title, items } : null) + +const targetCard = (profile, capture, screenshot) => { + const target = isRecord(profile.target) ? profile.target : {} + const items = [] + add(items, '目标 URL', capture.finalUrl || capture.request?.url || target.finalUrl || target.url) + add(items, '页面语言', target.language) + add(items, '生成时间', profile.generatedAt) + items.push(`截图: ${screenshot ? '已包含' : '未包含'}`) + return card('target', '目标', items) +} + +const techCard = profile => { + const tech = isRecord(profile.techProfile) ? profile.techProfile : {} + const technologies = Array.isArray(tech.technologies) ? tech.technologies : [] + const items = [] + if (technologies.length) items.push(`技术数量: ${technologies.length}`) + addObjectList(items, '主要技术', technologies) + add(items, '前端主栈', tech.primaryFrontend) + add(items, 'UI 框架', tech.uiFramework) + add(items, '构建运行时', tech.buildRuntime) + addList(items, '第三方服务', tech.thirdPartyServices) + return card('tech', '技术栈', items) +} + +const visualCard = (profile, screenshot) => { + const visual = isRecord(profile.visualProfile) ? profile.visualProfile : {} + const plan = profile.agentGuidance?.recreationPlan || {} + const tokens = isRecord(plan.designTokens) ? plan.designTokens : {} + const ref = isRecord(plan.visualReference) ? plan.visualReference : {} + const items = [] + items.push(`截图: ${screenshot ? '可用于视觉对照' : '未包含'}`) + addList(items, '颜色', visual.colorTokens || tokens.colors) + addList(items, '字体', visual.fonts || tokens.fontFamilies) + addList(items, '字号', visual.fontSizes || tokens.fontSizes) + add(items, '截图范围', ref.screenshotScope) + return card('visual', '视觉', items) +} + +const layoutCard = profile => { + const layout = isRecord(profile.layoutProfile) ? profile.layoutProfile : {} + const ux = isRecord(profile.uxProfile) ? profile.uxProfile : {} + const blueprint = profile.agentGuidance?.recreationPlan?.layoutBlueprint || {} + const items = [] + add(items, '页面目的', ux.pagePurpose) + addList(items, '主要路径', ux.primaryUserPath) + addList(items, '信息层级', ux.informationHierarchy || blueprint.informationHierarchy) + addList(items, '内容分组', ux.contentGrouping || blueprint.contentGrouping) + addList(items, 'Landmarks', layout.landmarks || blueprint.landmarks) + add(items, '导航深度', ux.navigationDepth) + return card('layout', '布局与信息结构', items) +} + +const componentsCard = profile => { + const components = isRecord(profile.componentProfile) ? profile.componentProfile : {} + const inventory = profile.agentGuidance?.recreationPlan?.componentInventory || {} + const counts = isRecord(components.counts) ? components.counts : inventory.counts + const items = [] + if (count(counts)) items.push(`组件类型数: ${count(counts)}`) + addList(items, '优先组件', inventory.priorityTypes) + addObjectList(items, '组件样本', components.samples) + add(items, '几何信息', inventory.geometryIncluded === true ? '已包含' : inventory.geometryIncluded === false ? '未包含' : '') + return card('components', '组件', items) +} + +const interactionCard = profile => { + const interaction = isRecord(profile.interactionProfile) ? profile.interactionProfile : {} + const ux = isRecord(profile.uxProfile) ? profile.uxProfile : {} + const checklist = profile.agentGuidance?.recreationPlan?.interactionChecklist || {} + const items = [] + addList(items, 'CTA', ux.ctaStrategy) + addList(items, '信任信号', ux.trustSignals) + addList(items, '转场', interaction.transitions || checklist.transitions) + addList(items, '动画', interaction.animations || checklist.animations) + addList(items, '固定元素', interaction.stickyOrFixed || checklist.stickyOrFixed) + addList(items, '交互摩擦', ux.frictionPoints) + return card('interaction', '交互与 UX', items) +} + +const assetsCard = profile => { + const assets = isRecord(profile.assetProfile) ? profile.assetProfile : {} + const hints = profile.agentGuidance?.recreationPlan?.assetHints || {} + const items = [] + if (count(assets.scripts) || hints.scriptCount) items.push(`脚本: ${count(assets.scripts) || hints.scriptCount}`) + if (count(assets.stylesheets) || hints.stylesheetCount) items.push(`样式表: ${count(assets.stylesheets) || hints.stylesheetCount}`) + addList(items, '资源域名', hints.resourceDomains || assets.resourceDomains) + addList(items, 'CDN 线索', assets.cdnHints || hints.cdnHints) + addList(items, '字体资源', assets.fontUrls || hints.fontUrls) + return card('assets', '资产', items) +} + +const guidanceCard = profile => { + const guidance = isRecord(profile.agentGuidance) ? profile.agentGuidance : {} + const plan = isRecord(guidance.recreationPlan) ? guidance.recreationPlan : {} + const items = [] + add(items, '摘要', guidance.summary) + addList(items, '实现顺序', plan.implementationOrder, 4) + addList(items, '验证项', plan.verificationChecklist, 4) + addList(items, '限制', profile.limitations, 4) + return card('guidance', '复刻建议', items) +} + +const copyTextFor = cards => { + const lines = ['# StackPrism Site Experience', '', '用于 AI Agent 快速复刻目标网站体验的受限摘要。'] + for (const item of cards) { + lines.push('', `## ${item.title}`) + for (const entry of item.items) lines.push(`- ${entry}`) + } + lines.push('', '备注: 本摘要不包含 raw profile、token、nonce、截图 data URL 或完整敏感文本。') + return lines.join('\n') +} + +export const profilePreviewSummary = (capture, screenshot) => { + const profile = capture.profile + if (capture.status !== 'completed' || !isRecord(profile)) return null + const cards = [ + guidanceCard(profile), + visualCard(profile, screenshot), + layoutCard(profile), + componentsCard(profile), + interactionCard(profile), + techCard(profile), + assetsCard(profile), + targetCard(profile, capture, screenshot) + ].filter(Boolean) + return cards.length ? { contentSummary: { cards }, copyText: copyTextFor(cards) } : null +} diff --git a/agent-skill/stackprism-site-experience/scripts/bridge/protocol.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/protocol.mjs new file mode 100644 index 00000000..a78da9a8 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/protocol.mjs @@ -0,0 +1,196 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto' + +export const service = 'stackprism-agent-bridge' +export const version = '0.1.0' +export const protocolVersion = 1 +export const profileSchema = 'stackprism.site_experience_profile.v1' + +const bridgeErrorCodes = new Set([ + 'NOT_FOUND', + 'METHOD_NOT_ALLOWED', + 'UNAUTHORIZED', + 'FORBIDDEN', + 'ORIGIN_NOT_ALLOWED', + 'UNSUPPORTED_MEDIA_TYPE', + 'UNSUPPORTED_TRANSFER_ENCODING', + 'INVALID_JSON', + 'INVALID_REQUEST', + 'REQUEST_TOO_LARGE', + 'REQUEST_TIMEOUT', + 'SERVER_BUSY', + 'STALE_STATUS_UPDATE', + 'PORT_IN_USE', + 'BRIDGE_INVALID_ENV', + 'BRIDGE_START_FAILED', + 'BRIDGE_START_TIMEOUT', + 'BRIDGE_READY_PARSE_FAILED', + 'BRIDGE_PROTOCOL_UNSUPPORTED', + 'BRIDGE_PAGE_RENDER_FAILED', + 'BRIDGE_REQUEST_TIMEOUT', + 'BRIDGE_REQUEST_MISMATCH', + 'AGENT_BRIDGE_DISABLED', + 'CAPTURE_BUSY', + 'CAPTURE_TIMEOUT', + 'EXTENSION_NOT_CONNECTED', + 'BROWSER_OPEN_FAILED', + 'BRIDGE_TOKEN_CANNOT_READ_PROFILE', + 'PRIVATE_NETWORK_TARGET_BLOCKED', + 'TARGET_DNS_LOOKUP_FAILED', + 'BRIDGE_SELF_TARGET_BLOCKED', + 'FINAL_URL_BLOCKED', + 'ACTIVE_TAB_UNAVAILABLE', + 'ACTIVE_TAB_MISMATCH', + 'INCOGNITO_NOT_SUPPORTED', + 'TARGET_LOAD_TIMEOUT', + 'TARGET_LOAD_FAILED', + 'TARGET_INJECTION_FAILED', + 'TARGET_TAB_CLOSED', + 'BRIDGE_TAB_CLOSED', + 'TARGET_NAVIGATED_AWAY', + 'SERVICE_WORKER_RESTARTED', + 'BRIDGE_TRANSPORT_DISCONNECTED', + 'PROFILE_TRANSPORT_FAILED', + 'PROFILE_CHUNK_MISSING', + 'PROFILE_HASH_MISMATCH', + 'PROFILE_TOO_LARGE', + 'RATE_LIMITED', + 'NONCE_REUSED', + 'CAPTURE_ALREADY_COMPLETED', + 'CAPTURE_RESULT_EXPIRED', + 'NOT_SUPPORTED' +]) + +const SENSITIVE_DETAIL_KEY = /authorization|cookie|token|nonce|secret/i +const ID_PATTERN = /\b(?:spbt?_|cap_|s_|n_|xfer_|shot_)[A-Za-z0-9_-]{8,}\b/g +const URL_PATTERN = /https?:\/\/[^\s"')\]}]+/g +const SENSITIVE_PATH_WORD_PATTERN = /^(?:token|secret|session|auth|authorization|signature|password|cookie|passcode)$/i +const SENSITIVE_PATH_SHORT_TOKEN_PATTERN = /(?:^|[-_.])(?:key|pass)(?:$|[-_.])/i +const SENSITIVE_PATH_COMPOUND_PATTERN = + /^(?:(?:api|access|private|public|secret|session|auth)[-_.]?(?:key|pass|token|secret|signature|code|id)|(?:key|pass)[-_.]?(?:token|secret|signature|code|id)|(?:reset|verify|access|auth|session|csrf|xsrf)[-_.]?(?:token|code|secret|key|signature))$/i +const SENSITIVE_PATH_CAMEL_PATTERN = /^(?:apiKey|privateKey|publicKey|accessToken|refreshToken|sessionId|secretToken|authToken|csrfToken|xsrfToken)$/i +const HIGH_ENTROPY_PATH_SEGMENT_PATTERN = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z0-9_-]{24,}$/ +const MAX_ERROR_TEXT_LENGTH = 512 +const MAX_ERROR_DETAIL_DEPTH = 4 +const MAX_ERROR_DETAIL_KEYS = 50 +const MAX_ERROR_DETAIL_ARRAY_ITEMS = 20 + +export const identifierSpecs = { + apiToken: /^spb_[A-Za-z0-9_-]{43}$/, + bridgeToken: /^spbt_[A-Za-z0-9_-]{43}$/, + captureId: /^cap_[A-Za-z0-9_-]{22}$/, + sessionId: /^s_[A-Za-z0-9_-]{22}$/, + nonce: /^n_[A-Za-z0-9_-]{22}$/, + screenshotDownloadId: /^shot_[A-Za-z0-9_-]{43}$/, + profileTransferId: /^xfer_[A-Za-z0-9_-]{22}$/, + cspNonce: /^[A-Za-z0-9_-]{22}$/ +} + +export const makeId = prefix => + `${prefix}${randomBytes(prefix === '' ? 16 : ['spb_', 'spbt_', 'shot_'].includes(prefix) ? 32 : 16).toString('base64url')}` + +export const newApiToken = () => makeId('spb_') +export const newBridgeToken = () => makeId('spbt_') +export const newCaptureId = () => makeId('cap_') +export const newSessionId = () => makeId('s_') +export const newNonce = () => makeId('n_') +export const newScreenshotDownloadId = () => makeId('shot_') +export const newCspNonce = () => makeId('') + +export const isValidId = (kind, value) => typeof value === 'string' && Boolean(identifierSpecs[kind]?.test(value)) + +export const isKnownBridgeErrorCode = value => typeof value === 'string' && bridgeErrorCodes.has(value) + +export const safeEqual = (left, right) => { + if (typeof left !== 'string' || typeof right !== 'string') return false + const a = Buffer.from(left) + const b = Buffer.from(right) + const comparisonLength = Math.max(a.length, b.length) + const paddedA = Buffer.alloc(comparisonLength) + const paddedB = Buffer.alloc(comparisonLength) + a.copy(paddedA) + b.copy(paddedB) + return timingSafeEqual(paddedA, paddedB) && a.length === b.length +} + +export const errorBody = (code, message, details = {}) => ({ error: { code, message, details } }) + +export const json = (res, status, body, extraHeaders = {}) => { + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + ...extraHeaders + }) + res.end(JSON.stringify(body)) +} + +export const fail = (res, status, code, message, details = {}, extraHeaders = {}) => + json(res, status, errorBody(code, message, details), extraHeaders) + +export const htmlEscapeScriptJson = value => + JSON.stringify(value) + .replaceAll('<', '\\u003c') + .replaceAll('>', '\\u003e') + .replaceAll('&', '\\u0026') + .replaceAll('\u2028', '\\u2028') + .replaceAll('\u2029', '\\u2029') + +const isSensitivePathSegment = segment => + SENSITIVE_PATH_WORD_PATTERN.test(segment) || + SENSITIVE_PATH_SHORT_TOKEN_PATTERN.test(segment) || + SENSITIVE_PATH_COMPOUND_PATTERN.test(segment) || + SENSITIVE_PATH_CAMEL_PATTERN.test(segment) || + /^[0-9a-f]{16,}$/i.test(segment) || + HIGH_ENTROPY_PATH_SEGMENT_PATTERN.test(segment) || + segment.includes('=') + +const redactUrlPathname = pathname => + String(pathname || '') + .split('/') + .map(segment => (segment && isSensitivePathSegment(segment) ? '[redacted]' : segment)) + .join('/') + +export const redactUrl = value => { + try { + const url = new URL(String(value || '')) + url.hash = '' + url.username = '' + url.password = '' + url.pathname = redactUrlPathname(url.pathname) + if (url.search) url.search = '?[redacted]' + return url.toString() + } catch { + return '' + } +} + +const redactErrorText = value => + String(value || '') + .replace(URL_PATTERN, url => redactUrl(url) || '[redacted-url]') + .replace(ID_PATTERN, '[redacted-id]') + .slice(0, MAX_ERROR_TEXT_LENGTH) + +const sanitizeErrorValue = (key, value, depth) => { + if (SENSITIVE_DETAIL_KEY.test(key)) return '[redacted]' + if (typeof value === 'string') return redactErrorText(value) + if (!value || typeof value !== 'object') return value + if (depth >= MAX_ERROR_DETAIL_DEPTH) return '[redacted-object]' + if (Array.isArray(value)) { + return value.slice(0, MAX_ERROR_DETAIL_ARRAY_ITEMS).map(item => sanitizeErrorValue('', item, depth + 1)) + } + + return Object.fromEntries( + Object.entries(value) + .slice(0, MAX_ERROR_DETAIL_KEYS) + .map(([childKey, child]) => [redactErrorText(childKey).slice(0, 64) || 'field', sanitizeErrorValue(childKey, child, depth + 1)]) + ) +} + +export const sanitizeBridgeError = error => { + const source = error && typeof error === 'object' ? error : {} + const rawCode = typeof source.code === 'string' ? source.code : '' + const code = isKnownBridgeErrorCode(rawCode) ? rawCode : 'INVALID_REQUEST' + const message = redactErrorText(source.message || code || 'Capture status failed.') || 'Capture status failed.' + const details = sanitizeErrorValue('details', source.details || {}, 0) + return { code, message, details } +} diff --git a/agent-skill/stackprism-site-experience/scripts/bridge/resource-policy.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/resource-policy.mjs new file mode 100644 index 00000000..36902178 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/resource-policy.mjs @@ -0,0 +1,33 @@ +export const DEFAULT_CREATE_LIMIT_PER_MINUTE = 10 +export const DEFAULT_QUERY_LIMIT_PER_MINUTE = 120 +export const DEFAULT_RESOURCE_POLICY = { + maxOpenConnections: 20, + headersTimeoutMs: 5000, + requestTimeoutMs: 35000, + keepAliveTimeoutMs: 2000 +} + +export const makeRateLimiter = ({ + createLimitPerMinute = DEFAULT_CREATE_LIMIT_PER_MINUTE, + queryLimitPerMinute = DEFAULT_QUERY_LIMIT_PER_MINUTE +} = {}) => { + const buckets = new Map() + return (token, bucketName, limit, now = Date.now()) => { + const key = `${token}:${bucketName}` + const windowStart = now - (now % 60000) + const bucket = buckets.get(key) + if (!bucket || bucket.windowStart !== windowStart) { + buckets.set(key, { windowStart, count: 1 }) + return true + } + bucket.count += 1 + return bucket.count <= limit + } +} + +export const applyServerResourcePolicy = (server, policy) => { + server.maxConnections = policy.maxOpenConnections + server.headersTimeout = policy.headersTimeoutMs + server.requestTimeout = policy.requestTimeoutMs + server.keepAliveTimeout = policy.keepAliveTimeoutMs +} diff --git a/agent-skill/stackprism-site-experience/scripts/bridge/security.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/security.mjs new file mode 100644 index 00000000..9c3e68ad --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/security.mjs @@ -0,0 +1,170 @@ +import { fail, isValidId } from './protocol.mjs' + +const duplicateSensitiveHeaders = new Set(['host', 'authorization', 'content-type', 'content-length']) + +const hasDuplicateSensitiveHeaders = rawHeaders => { + const seen = new Set() + for (let index = 0; index < rawHeaders.length; index += 2) { + const name = String(rawHeaders[index] || '').toLowerCase() + if (!duplicateSensitiveHeaders.has(name)) continue + if (seen.has(name)) return true + seen.add(name) + } + return false +} + +export const rejectBadRequestShell = (req, res, baseUrl) => { + if (hasDuplicateSensitiveHeaders(req.rawHeaders || [])) { + fail(res, 400, 'INVALID_REQUEST', 'Ambiguous request headers are not allowed.') + return true + } + if (req.headers.host !== new URL(baseUrl).host) { + fail(res, 400, 'INVALID_REQUEST', 'Host is not allowed.') + return true + } + const target = String(req.url || '') + if (!target.startsWith('/') || target.startsWith('//')) { + fail(res, 400, 'INVALID_REQUEST', 'Only origin-form request targets are allowed.') + return true + } + const [rawPath, rawQuery = ''] = target.split('?', 2) + if (/%2e|%2f|%5c|\\/i.test(target)) { + fail(res, 400, 'INVALID_REQUEST', 'Encoded path separators or dot segments are not allowed.') + return true + } + if ( + rawPath !== '/' && + rawPath + .split('/') + .slice(1) + .some(segment => segment === '' || segment === '.' || segment === '..') + ) { + fail(res, 400, 'INVALID_REQUEST', 'Ambiguous path segments are not allowed.') + return true + } + if (rawQuery && rawPath !== '/bridge') { + fail(res, 400, 'INVALID_REQUEST', 'Query string is not allowed for this endpoint.') + return true + } + const contentLength = req.headers['content-length'] + if (contentLength && !/^\d+$/.test(String(contentLength))) { + fail(res, 400, 'INVALID_REQUEST', 'Content-Length is invalid.') + return true + } + const contentEncoding = req.headers['content-encoding'] + if (contentEncoding && String(contentEncoding).toLowerCase() !== 'identity') { + fail(res, 415, 'UNSUPPORTED_MEDIA_TYPE', 'Content-Encoding is not supported.') + return true + } + if (req.headers['transfer-encoding'] && req.headers['content-length']) { + fail(res, 400, 'INVALID_REQUEST', 'Content-Length and Transfer-Encoding cannot be combined.') + return true + } + if (req.headers['transfer-encoding']) { + fail(res, 400, 'UNSUPPORTED_TRANSFER_ENCODING', 'Transfer-Encoding is not supported.') + return true + } + return false +} + +export const rejectCrossOriginSensitiveRequest = (req, res, baseUrl) => { + let allowedOrigin = baseUrl + try { + allowedOrigin = new URL(baseUrl).origin + } catch {} + + const { origin } = req.headers + if (origin && origin !== allowedOrigin) { + fail(res, 403, 'ORIGIN_NOT_ALLOWED', 'Origin is not allowed.') + return true + } + + const { referer } = req.headers + if (referer) { + try { + if (new URL(referer).origin !== allowedOrigin) { + fail(res, 403, 'ORIGIN_NOT_ALLOWED', 'Referer is not allowed.') + return true + } + } catch { + fail(res, 403, 'ORIGIN_NOT_ALLOWED', 'Referer is not allowed.') + return true + } + } + + const fetchSite = req.headers['sec-fetch-site'] + if (fetchSite && !['same-origin', 'none'].includes(fetchSite)) { + fail(res, 403, 'ORIGIN_NOT_ALLOWED', 'Sec-Fetch-Site is not allowed.') + return true + } + return false +} + +const bodyTimeout = Symbol('bodyTimeout') + +export const readJson = async (req, limit = 5 * 1024 * 1024, timeoutMs = 0) => { + if (!/^application\/json(?:;\s*charset=utf-8)?$/i.test(req.headers['content-type'] || '')) { + return { ok: false, status: 415, code: 'UNSUPPORTED_MEDIA_TYPE', message: 'Expected application/json.' } + } + const chunks = [] + let size = 0 + const readBody = (async () => { + for await (const chunk of req) { + size += chunk.byteLength + if (size > limit) return { ok: false, status: 413, code: 'REQUEST_TOO_LARGE', message: 'Request body is too large.', close: true } + chunks.push(chunk) + } + return { ok: true } + })() + readBody.catch(() => {}) + let bodyResult + let timeoutId + try { + const timeout = + timeoutMs > 0 + ? new Promise(resolve => { + timeoutId = setTimeout(() => resolve(bodyTimeout), timeoutMs) + }) + : null + bodyResult = timeout ? await Promise.race([readBody, timeout]) : await readBody + } catch { + return { ok: false, status: 400, code: 'INVALID_JSON', message: 'Request body is not valid JSON.' } + } finally { + if (timeoutId) clearTimeout(timeoutId) + } + if (bodyResult === bodyTimeout) { + return { ok: false, status: 408, code: 'REQUEST_TIMEOUT', message: 'Request body timed out.', close: true } + } + if (!bodyResult.ok) return bodyResult + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)) + return { ok: true, body: JSON.parse(text) } + } catch { + return { ok: false, status: 400, code: 'INVALID_JSON', message: 'Request body is not valid JSON.' } + } +} + +const bridgeQueryKinds = { + session: 'sessionId', + capture: 'captureId', + nonce: 'nonce' +} + +export const parseBridgeQuery = rawSearch => { + const raw = String(rawSearch || '').replace(/^\?/, '') + const parts = raw ? raw.split('&') : [] + if (parts.length !== 3) return null + const values = {} + for (const part of parts) { + const separatorIndex = part.indexOf('=') + if (!part || separatorIndex <= 0 || part.indexOf('=', separatorIndex + 1) !== -1) return null + const name = part.slice(0, separatorIndex) + const value = part.slice(separatorIndex + 1) + const kind = bridgeQueryKinds[name] + if (!kind || Object.prototype.hasOwnProperty.call(values, name) || !isValidId(kind, value)) return null + values[name] = value + } + return Object.keys(values).length === 3 ? values : null +} + +export const validateBridgeQuery = url => Boolean(parseBridgeQuery(url.search)) diff --git a/agent-skill/stackprism-site-experience/scripts/bridge/target-network-policy.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/target-network-policy.mjs new file mode 100644 index 00000000..887b2a06 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/target-network-policy.mjs @@ -0,0 +1,37 @@ +import net from 'node:net' +import { isIpLiteral, isPrivateIpLiteral, isProxyReservedIpLiteral } from './url-policy.mjs' + +const invalidNetworkAddress = () => ({ + ok: false, + code: 'INVALID_REQUEST', + message: 'Target network address is invalid.', + details: { reason: 'invalid_network_address' } +}) + +const normalizeNetworkAddress = value => value.trim().replace(/^\[|\]$/g, '') + +export const validateTargetNetworkAddress = (value, request, { finalUrl } = {}) => { + if (request.options?.allowPrivateNetworkTarget === true) { + return { ok: true } + } + if (value === undefined || value === null) return { ok: true } + if (typeof value !== 'string') return invalidNetworkAddress() + const address = normalizeNetworkAddress(value) + if (!address) return { ok: true } + if (net.isIP(address) === 0) return invalidNetworkAddress() + if (!isPrivateIpLiteral(address)) return { ok: true } + try { + const targetUrl = new URL(finalUrl || request.url) + if (!isIpLiteral(targetUrl.hostname) && !isPrivateIpLiteral(targetUrl.hostname) && isProxyReservedIpLiteral(address)) { + return { ok: true } + } + } catch { + return invalidNetworkAddress() + } + return { + ok: false, + code: 'FINAL_URL_BLOCKED', + message: 'Final URL is blocked by target policy.', + details: { reason: 'private_network_address' } + } +} diff --git a/agent-skill/stackprism-site-experience/scripts/bridge/url-policy.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/url-policy.mjs new file mode 100644 index 00000000..aaa67cf5 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/url-policy.mjs @@ -0,0 +1,298 @@ +import net from 'node:net' +import dns from 'node:dns/promises' + +const includeOrder = ['tech', 'visual', 'layout', 'components', 'interaction', 'ux', 'assets'] +const targetModes = new Set(['reuse_or_new_tab', 'new_tab', 'active_tab']) +const requestKeys = new Set(['url', 'mode', 'waitMs', 'include', 'viewports', 'options']) +const optionKeys = new Set( + 'forceRefresh captureScreenshotMetadata captureScreenshot keepTabOpen allowPrivateNetworkTarget targetMode maxResourceUrls'.split(' ') +) +const booleanOptionKeys = ['forceRefresh', 'captureScreenshotMetadata', 'captureScreenshot', 'keepTabOpen', 'allowPrivateNetworkTarget'] +const DNS_LOOKUP_TIMEOUT_MS = 2000 + +const isPlainRecord = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value) +const hasOnlyKnownKeys = (value, keys) => Object.keys(value).every(key => keys.has(key)) +const hasValidBooleanOptions = options => booleanOptionKeys.every(key => options[key] === undefined || typeof options[key] === 'boolean') + +const createBlockList = ({ subnets = [], addresses = [] }) => { + const blockList = new net.BlockList() + for (const [address, prefix, type] of subnets) blockList.addSubnet(address, prefix, type) + for (const [address, type] of addresses) blockList.addAddress(address, type) + return blockList +} + +const privateIpBlockList = createBlockList({ + subnets: [ + ['0.0.0.0', 8, 'ipv4'], ['10.0.0.0', 8, 'ipv4'], ['100.64.0.0', 10, 'ipv4'], + ['127.0.0.0', 8, 'ipv4'], ['169.254.0.0', 16, 'ipv4'], ['172.16.0.0', 12, 'ipv4'], + ['192.0.0.0', 24, 'ipv4'], ['192.0.2.0', 24, 'ipv4'], ['192.88.99.0', 24, 'ipv4'], + ['192.168.0.0', 16, 'ipv4'], ['198.18.0.0', 15, 'ipv4'], ['198.51.100.0', 24, 'ipv4'], + ['203.0.113.0', 24, 'ipv4'], ['224.0.0.0', 4, 'ipv4'], ['240.0.0.0', 4, 'ipv4'], + ['255.255.255.255', 32, 'ipv4'], + ['::', 128, 'ipv6'], ['::1', 128, 'ipv6'], ['64:ff9b:1::', 48, 'ipv6'], ['100::', 64, 'ipv6'], + ['2001::', 23, 'ipv6'], ['2001:db8::', 32, 'ipv6'], ['2002::', 16, 'ipv6'], + ['3fff::', 20, 'ipv6'], ['fc00::', 7, 'ipv6'], ['fe80::', 10, 'ipv6'], + ['ff00::', 8, 'ipv6'] + ] +}) + +const proxyReservedIpBlockList = createBlockList({ + subnets: [['198.18.0.0', 15, 'ipv4']] +}) + +const publicIpExceptionBlockList = createBlockList({ + subnets: [ + ['2001:3::', 32, 'ipv6'], + ['2001:4:112::', 48, 'ipv6'], + ['2001:20::', 28, 'ipv6'], + ['2001:30::', 28, 'ipv6'] + ], + addresses: [ + ['192.0.0.9', 'ipv4'], + ['192.0.0.10', 'ipv4'], + ['2001:1::1', 'ipv6'], + ['2001:1::2', 'ipv6'] + ] +}) + +export const isPrivateIpLiteral = hostname => { + const host = hostname.replace(/^\[|\]$/g, '') + const lowerHost = host.toLowerCase() + const embeddedIpv4Host = normalizeEmbeddedIpv4Host(lowerHost) + if (embeddedIpv4Host !== lowerHost) return isPrivateIpLiteral(embeddedIpv4Host) + if (lowerHost === 'localhost') return true + if (net.isIP(host) === 4) return isPrivateIpv4Literal(host) + if (net.isIP(host) === 6) return isPrivateIpv6Literal(lowerHost) + return false +} + +export const isProxyReservedIpLiteral = hostname => { + const host = hostname.replace(/^\[|\]$/g, '') + const embeddedIpv4Host = normalizeEmbeddedIpv4Host(host) + if (embeddedIpv4Host !== host.toLowerCase()) return isProxyReservedIpLiteral(embeddedIpv4Host) + if (net.isIP(host) === 4) return proxyReservedIpBlockList.check(host, 'ipv4') + return false +} + +const isPrivateIpv4Literal = value => { + return privateIpBlockList.check(value, 'ipv4') && !publicIpExceptionBlockList.check(value, 'ipv4') +} + +const isPrivateIpv6Literal = value => { + return privateIpBlockList.check(value, 'ipv6') && !publicIpExceptionBlockList.check(value, 'ipv6') +} + +const mappedIpv4Address = value => { + if (net.isIP(value) === 4) return value + const match = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(value) + if (!match) return value + const high = Number.parseInt(match[1], 16) + const low = Number.parseInt(match[2], 16) + return [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.') +} + +const normalizeEmbeddedIpv4Host = value => { + const host = value.toLowerCase() + if (host.startsWith('::ffff:')) return mappedIpv4Address(host.slice('::ffff:'.length)) + if (host.startsWith('0:0:0:0:0:ffff:')) return mappedIpv4Address(host.slice('0:0:0:0:0:ffff:'.length)) + if (!host.startsWith('::')) return host + const mapped = mappedIpv4Address(host.slice(2)) + return mapped.includes('.') ? mapped : host +} + +export const isIpLiteral = hostname => net.isIP(hostname.replace(/^\[|\]$/g, '')) !== 0 +const effectivePort = parsed => parsed.port || (parsed.protocol === 'http:' ? '80' : parsed.protocol === 'https:' ? '443' : '') + +const normalizeBridgeLoopbackHost = hostname => { + const host = hostname.replace(/^\[|\]$/g, '').toLowerCase() + return normalizeEmbeddedIpv4Host(host) +} + +const isBridgeLoopbackAlias = (hostname, bridgeHostname) => { + const host = normalizeBridgeLoopbackHost(hostname) + const bridgeHost = normalizeBridgeLoopbackHost(bridgeHostname) + if (host === bridgeHost) return true + if (bridgeHost !== '127.0.0.1') return false + return host === 'localhost' || host === '::1' || host === '0:0:0:0:0:0:0:1' +} + +const isBridgeSelfTarget = (parsed, bridgeOrigin) => { + const bridge = new URL(bridgeOrigin) + return ( + parsed.protocol === bridge.protocol && + effectivePort(parsed) === effectivePort(bridge) && + isBridgeLoopbackAlias(parsed.hostname, bridge.hostname) + ) +} + +const normalizeDnsAddress = item => { + if (typeof item === 'string') return item + if (item && typeof item.address === 'string') return item.address + return '' +} + +const isBlockedResolvedAddress = item => { + const address = normalizeDnsAddress(item) + return Boolean(address && isPrivateIpLiteral(address) && !isProxyReservedIpLiteral(address)) +} + +const defaultResolveHostname = async hostname => { + let timeoutId + const lookup = dns.lookup(hostname, { all: true, verbatim: true }) + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error('DNS_LOOKUP_TIMEOUT')), DNS_LOOKUP_TIMEOUT_MS) + }) + try { + return await Promise.race([lookup, timeout]) + } finally { + clearTimeout(timeoutId) + } +} + +const validateDnsPolicy = async (parsed, allowPrivateNetworkTarget, resolveHostname) => { + if (allowPrivateNetworkTarget || isIpLiteral(parsed.hostname)) return { ok: true } + let addresses + try { + addresses = await resolveHostname(parsed.hostname) + } catch { + return dnsLookupFailed() + } + if (!Array.isArray(addresses) || !addresses.length) return dnsLookupFailed() + if (addresses.some(isBlockedResolvedAddress)) { + return { + ok: false, + code: 'PRIVATE_NETWORK_TARGET_BLOCKED', + message: 'Private network targets are disabled.', + details: { reason: 'private_network_address' } + } + } + return { ok: true } +} + +const dnsLookupFailed = () => ({ + ok: false, + code: 'TARGET_DNS_LOOKUP_FAILED', + message: 'Target hostname could not be resolved.', + details: { reason: 'dns_lookup_failed' } +}) + +const validateViewports = viewports => + Array.isArray(viewports) && + viewports.length <= 3 && + viewports.every( + viewport => + viewport && + typeof viewport === 'object' && + Object.keys(viewport).every(key => ['name', 'width', 'height', 'deviceScaleFactor'].includes(key)) && + (viewport.name === undefined || (typeof viewport.name === 'string' && /^[A-Za-z0-9_-]{1,32}$/.test(viewport.name))) && + Number.isInteger(viewport.width) && + viewport.width >= 320 && + viewport.width <= 3840 && + Number.isInteger(viewport.height) && + viewport.height >= 320 && + viewport.height <= 2160 && + typeof viewport.deviceScaleFactor === 'number' && + Number.isFinite(viewport.deviceScaleFactor) && + viewport.deviceScaleFactor >= 1 && + viewport.deviceScaleFactor <= 4 + ) + +export const normalizeCaptureRequest = async (body, bridgeOrigin, { resolveHostname = defaultResolveHostname } = {}) => { + const request = body && typeof body === 'object' && !Array.isArray(body) ? body : {} + if (!hasOnlyKnownKeys(request, requestKeys)) return { ok: false, code: 'INVALID_REQUEST', message: 'Unknown capture request field.' } + if (request.mode !== 'experience') return { ok: false, code: 'INVALID_REQUEST', message: 'Capture mode is invalid.' } + + const url = typeof request.url === 'string' ? request.url.trim() : '' + let parsed + try { + parsed = new URL(url) + } catch { + return { ok: false, code: 'INVALID_REQUEST', message: 'Capture url is invalid.' } + } + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || url.length > 4096) { + return { ok: false, code: 'INVALID_REQUEST', message: 'Capture url is invalid.' } + } + parsed.hash = '' + parsed.hostname = parsed.hostname.toLowerCase() + if (isBridgeSelfTarget(parsed, bridgeOrigin)) + return { ok: false, code: 'BRIDGE_SELF_TARGET_BLOCKED', message: 'Bridge origin cannot be captured.' } + + const options = request.options === undefined ? {} : request.options + if (!isPlainRecord(options) || !hasOnlyKnownKeys(options, optionKeys)) { + return { ok: false, code: 'INVALID_REQUEST', message: 'Unknown capture option field.' } + } + if (!hasValidBooleanOptions(options)) return { ok: false, code: 'INVALID_REQUEST', message: 'Capture options are invalid.' } + if (isPrivateIpLiteral(parsed.hostname) && options.allowPrivateNetworkTarget !== true) { + return { + ok: false, + code: 'PRIVATE_NETWORK_TARGET_BLOCKED', + message: 'Private network targets are disabled.', + details: { reason: 'private_network_address' } + } + } + const dnsPolicy = await validateDnsPolicy(parsed, options.allowPrivateNetworkTarget === true, resolveHostname) + if (!dnsPolicy.ok) return dnsPolicy + + const include = Array.isArray(request.include) ? [...new Set(includeOrder.filter(item => request.include.includes(item)))] : [] + if (!include.length || request.include.some?.(item => !includeOrder.includes(item))) { + return { ok: false, code: 'INVALID_REQUEST', message: 'Capture include is invalid.' } + } + const waitMs = request.waitMs === undefined ? 3000 : request.waitMs + if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 30000) + return { ok: false, code: 'INVALID_REQUEST', message: 'Capture waitMs is invalid.' } + const viewports = request.viewports === undefined ? [] : request.viewports + if (!validateViewports(viewports)) return { ok: false, code: 'INVALID_REQUEST', message: 'Capture viewports are invalid.' } + const targetMode = options.targetMode === undefined ? 'reuse_or_new_tab' : options.targetMode + if (!targetModes.has(targetMode)) { + return { ok: false, code: 'INVALID_REQUEST', message: 'Capture targetMode is invalid.' } + } + if ( + options.maxResourceUrls !== undefined && + (!Number.isInteger(options.maxResourceUrls) || options.maxResourceUrls < 0 || options.maxResourceUrls > 1000) + ) { + return { ok: false, code: 'INVALID_REQUEST', message: 'Capture maxResourceUrls is invalid.' } + } + + return { + ok: true, + request: { + url: parsed.toString(), + mode: 'experience', + waitMs, + include, + viewports, + options: { + forceRefresh: options.forceRefresh === true, + captureScreenshotMetadata: options.captureScreenshotMetadata === true, + captureScreenshot: options.captureScreenshot === true, + keepTabOpen: options.keepTabOpen === true, + allowPrivateNetworkTarget: options.allowPrivateNetworkTarget === true, + targetMode, + maxResourceUrls: options.maxResourceUrls ?? 300 + }, + protocolVersion: 1 + } + } +} + +export const validateFinalUrl = async (value, bridgeOrigin, request, { resolveHostname = defaultResolveHostname } = {}) => { + const finalRequest = { + url: value, + mode: request.mode, + waitMs: request.waitMs, + include: request.include, + viewports: request.viewports, + options: { + ...(request.options || {}), + allowPrivateNetworkTarget: request.options?.allowPrivateNetworkTarget === true + } + } + const normalized = await normalizeCaptureRequest(finalRequest, bridgeOrigin, { resolveHostname }) + if (normalized.ok) return { ok: true, finalUrl: normalized.request.url } + return { + ok: false, + code: 'FINAL_URL_BLOCKED', + message: 'Final URL is blocked by target policy.', + details: normalized.details || { reason: normalized.code === 'TARGET_DNS_LOOKUP_FAILED' ? 'dns_lookup_failed' : 'invalid_final_url' } + } +} diff --git a/agent-skill/stackprism-site-experience/scripts/capture-runtime.mjs b/agent-skill/stackprism-site-experience/scripts/capture-runtime.mjs new file mode 100644 index 00000000..c2b81cde --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/capture-runtime.mjs @@ -0,0 +1,130 @@ +const DEFAULT_TERMINAL_SETTLE_MS = 3000 +const MAX_TERMINAL_SETTLE_MS = 5000 +const CHILD_STOP_GRACE_MS = 2500 +const DEFAULT_REQUEST_TIMEOUT_MS = 30000 + +const timeoutSignal = ms => { + let timer + const promise = new Promise(resolve => { + timer = setTimeout(resolve, ms) + }) + return { + promise, + clear: () => clearTimeout(timer) + } +} + +export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) + +export const makeBridgeError = (code, message = code, extra = {}) => Object.assign(new Error(message), { code, ...extra }) + +export const requestJson = async (url, token, init = {}) => { + const { timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, headers: initHeaders, ...requestInit } = init + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(url, { + ...requestInit, + signal: controller.signal, + headers: { + ...(requestInit.body ? { 'Content-Type': 'application/json' } : {}), + Authorization: `Bearer ${token}`, + ...(initHeaders || {}) + } + }) + let body + try { + body = await response.json() + } catch { + body = { error: { code: 'INVALID_JSON', message: 'Bridge returned non-JSON response.' } } + } + if (!response.ok) { + const code = typeof body?.error?.code === 'string' && body.error.code ? body.error.code : `HTTP_${response.status}` + const error = makeBridgeError(code, body?.error?.message || code) + error.response = { status: response.status, body } + throw error + } + return body + } catch (error) { + if (controller.signal.aborted) { + const timeout = makeBridgeError('BRIDGE_REQUEST_TIMEOUT', `Bridge request timed out after ${timeoutMs}ms.`) + timeout.response = { + status: 504, + body: { error: { code: 'BRIDGE_REQUEST_TIMEOUT', message: `Bridge request timed out after ${timeoutMs}ms.` } } + } + throw timeout + } + throw error + } finally { + clearTimeout(timer) + } +} + +export const requestBinary = async (url, token = '', init = {}) => { + const { timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, headers: initHeaders, ...requestInit } = init + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(url, { + ...requestInit, + signal: controller.signal, + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(initHeaders || {}) + } + }) + const bytes = Buffer.from(await response.arrayBuffer()) + if (!response.ok) { + let body = { error: { code: `HTTP_${response.status}`, message: `HTTP_${response.status}` } } + try { + body = JSON.parse(bytes.toString('utf8')) + } catch {} + const code = typeof body?.error?.code === 'string' && body.error.code ? body.error.code : `HTTP_${response.status}` + const error = makeBridgeError(code, body?.error?.message || code) + error.response = { status: response.status, body } + throw error + } + return { bytes, contentType: response.headers.get('content-type') || '' } + } catch (error) { + if (controller.signal.aborted) { + const timeout = makeBridgeError('BRIDGE_REQUEST_TIMEOUT', `Bridge request timed out after ${timeoutMs}ms.`) + timeout.response = { + status: 504, + body: { error: { code: 'BRIDGE_REQUEST_TIMEOUT', message: `Bridge request timed out after ${timeoutMs}ms.` } } + } + throw timeout + } + throw error + } finally { + clearTimeout(timer) + } +} + +export const parseTerminalSettleMs = value => { + if (value == null || value === '') return DEFAULT_TERMINAL_SETTLE_MS + const parsed = Number(value) + return Number.isInteger(parsed) && parsed >= 0 && parsed <= MAX_TERMINAL_SETTLE_MS + ? parsed + : DEFAULT_TERMINAL_SETTLE_MS +} + +export const stopChild = async child => { + if (child.exitCode !== null || child.killed) return + try { + if (child.stdin?.writable && !child.stdin.destroyed) child.stdin.end() + } catch {} + const firstTimeout = timeoutSignal(CHILD_STOP_GRACE_MS) + const exited = await Promise.race([ + new Promise(resolve => child.once('exit', resolve)), + firstTimeout.promise.then(() => { + child.kill('SIGTERM') + return 'killed' + }) + ]) + firstTimeout.clear() + if (exited === 'killed') { + const secondTimeout = timeoutSignal(CHILD_STOP_GRACE_MS) + await Promise.race([new Promise(resolve => child.once('exit', resolve)), secondTimeout.promise]).catch(() => {}) + secondTimeout.clear() + } +} diff --git a/agent-skill/stackprism-site-experience/scripts/capture-screenshot-artifact.mjs b/agent-skill/stackprism-site-experience/scripts/capture-screenshot-artifact.mjs new file mode 100644 index 00000000..02ebf154 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/capture-screenshot-artifact.mjs @@ -0,0 +1,69 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, extname, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { requestBinary } from './capture-runtime.mjs' + +const stableScreenshotNote = + 'Screenshot image base64 is intentionally omitted from this Profile JSON. To inspect actual visual appearance, open or download the image from downloadUrl.' + +const screenshotExtensionForMimeType = value => { + const mimeType = String(value || '') + .split(';', 1)[0] + .trim() + .toLowerCase() + if (mimeType === 'image/png') return 'png' + if (mimeType === 'image/webp') return 'webp' + return 'jpg' +} + +const defaultScreenshotPathFor = (profilePath, screenshot) => { + const outputPath = resolve(profilePath) + const outputExtension = extname(outputPath) + const stem = outputExtension ? outputPath.slice(0, -outputExtension.length) : outputPath + return `${stem}-screenshot.${screenshotExtensionForMimeType(screenshot?.mimeType)}` +} + +const screenshotPathFor = (args, screenshot) => resolve(args.screenshotOut || defaultScreenshotPathFor(args.out, screenshot)) + +const applyStableScreenshotReference = (profile, artifact) => { + const screenshot = profile.visualProfile?.screenshot + if (!screenshot || typeof screenshot !== 'object' || !artifact) return profile + screenshot.downloadUrl = artifact.downloadUrl + screenshot.downloadMethod = 'file' + screenshot.localPath = artifact.path + screenshot.byteLength = artifact.byteLength + screenshot.lifecycle = { + ...(screenshot.lifecycle || {}), + requiresLocalBridge: false, + availableUntil: '', + note: 'The capture helper downloaded this screenshot to localPath. The image remains available until the local file is moved or deleted.' + } + screenshot.note = stableScreenshotNote + const visualReference = profile.agentGuidance?.recreationPlan?.visualReference + if (visualReference && typeof visualReference === 'object') { + visualReference.screenshotDownloadUrl = artifact.downloadUrl + visualReference.screenshotLocalPath = artifact.path + visualReference.screenshotDownloadHint = + 'To inspect actual visual appearance, open or download the screenshot image from visualProfile.screenshot.downloadUrl. The Profile JSON intentionally does not include screenshot base64.' + visualReference.screenshotAvailableUntil = '' + visualReference.screenshotByteLength = artifact.byteLength + } + return profile +} + +export const writeScreenshotArtifact = async ({ args, profile, token, timeoutMs }) => { + const screenshot = profile.visualProfile?.screenshot + if (!screenshot?.downloadUrl) return false + const result = await requestBinary(screenshot.downloadUrl, token, { timeoutMs }) + const path = screenshotPathFor(args, screenshot) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, result.bytes) + const artifact = { + path, + downloadUrl: pathToFileURL(path).href, + byteLength: result.bytes.byteLength, + contentType: result.contentType || screenshot.mimeType || '' + } + applyStableScreenshotReference(profile, artifact) + return artifact +} diff --git a/agent-skill/stackprism-site-experience/scripts/capture-site-args.mjs b/agent-skill/stackprism-site-experience/scripts/capture-site-args.mjs new file mode 100644 index 00000000..405cf94c --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/capture-site-args.mjs @@ -0,0 +1,67 @@ +import { makeBridgeError } from './capture-runtime.mjs' + +const DEFAULT_INCLUDE = ['tech', 'visual', 'layout', 'components', 'interaction', 'ux', 'assets'] +const INCLUDE_VALUES = new Set(DEFAULT_INCLUDE) +const DEFAULT_REQUEST_TIMEOUT_MS = 30000 + +const usageText = () => + [ + 'Usage: node agent-skill/stackprism-site-experience/scripts/capture-site.mjs --url --out [--screenshot-out ] [--allow-private-network]', + '', + 'Options:', + ' --url Target http/https URL.', + ' --out Write completed profile JSON to this path.', + ' --result-out Optional capture result summary JSON path.', + ' --screenshot-out Optional decoded screenshot output path; defaults to a sidecar image.', + ' --allow-private-network Allow controlled private-network targets for this attempt.', + ' --wait-ms Target settle wait, default 3000.', + ` --include Comma-separated sections, default ${DEFAULT_INCLUDE.join(',')}.`, + ' --request-timeout-ms Per bridge API request timeout, default 30000.', + ' --max-resource-urls Resource URL cap, default 300.', + ' --force-refresh Reload the target after opening it to bypass cache.', + ' --no-screenshot Do not request visible viewport screenshot.' + ].join('\n') + +export const makeArgumentError = message => makeBridgeError('INVALID_REQUEST', message, { details: { usage: usageText() } }) + +export const parseArgs = argv => { + const args = { + waitMs: 3000, + requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS, + maxResourceUrls: 300, + include: DEFAULT_INCLUDE, + captureScreenshot: true, + forceRefresh: false, + allowPrivateNetworkTarget: false + } + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (arg === '--url') args.url = argv[++index] + else if (arg === '--out') args.out = argv[++index] + else if (arg === '--result-out') args.resultOut = argv[++index] + else if (arg === '--screenshot-out') args.screenshotOut = argv[++index] + else if (arg === '--allow-private-network') args.allowPrivateNetworkTarget = true + else if (arg === '--force-refresh') args.forceRefresh = true + else if (arg === '--no-screenshot') args.captureScreenshot = false + else if (arg === '--wait-ms') args.waitMs = Number(argv[++index]) + else if (arg === '--include') args.include = String(argv[++index] || '').split(',') + else if (arg === '--request-timeout-ms') args.requestTimeoutMs = Number(argv[++index]) + else if (arg === '--max-resource-urls') args.maxResourceUrls = Number(argv[++index]) + else return { ok: false, message: `Unknown argument: ${arg}` } + } + if (!args.url || !args.out) return { ok: false, message: '--url and --out are required.' } + if (!Number.isInteger(args.waitMs) || args.waitMs < 0 || args.waitMs > 30000) { + return { ok: false, message: '--wait-ms must be an integer from 0 to 30000.' } + } + args.include = [...new Set(args.include.map(value => value.trim()).filter(Boolean))] + if (!args.include.length || args.include.some(value => !INCLUDE_VALUES.has(value))) { + return { ok: false, message: `--include must contain one or more of: ${DEFAULT_INCLUDE.join(', ')}.` } + } + if (!Number.isInteger(args.requestTimeoutMs) || args.requestTimeoutMs < 100 || args.requestTimeoutMs > 60000) { + return { ok: false, message: '--request-timeout-ms must be an integer from 100 to 60000.' } + } + if (!Number.isInteger(args.maxResourceUrls) || args.maxResourceUrls < 0 || args.maxResourceUrls > 1000) { + return { ok: false, message: '--max-resource-urls must be an integer from 0 to 1000.' } + } + return { ok: true, args } +} diff --git a/agent-skill/stackprism-site-experience/scripts/capture-site.mjs b/agent-skill/stackprism-site-experience/scripts/capture-site.mjs new file mode 100644 index 00000000..743ff3ab --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/capture-site.mjs @@ -0,0 +1,243 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process' +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { makeArgumentError, parseArgs } from './capture-site-args.mjs' +import { makeBridgeError, parseTerminalSettleMs, requestJson, sleep, stopChild } from './capture-runtime.mjs' +import { writeScreenshotArtifact } from './capture-screenshot-artifact.mjs' +import { isKnownBridgeErrorCode, sanitizeBridgeError } from './bridge/protocol.mjs' + +const DEFAULT_VIEWPORT = { name: 'desktop', width: 1440, height: 900, deviceScaleFactor: 1 } +const READY_TIMEOUT_MS = 10000 +const CAPTURE_TIMEOUT_MS = 90000 +const POLL_INTERVAL_MS = 1000 + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..') +const bridgeScript = resolve( + process.env.STACKPRISM_CAPTURE_BRIDGE_SCRIPT || resolve(repoRoot, 'agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs') +) + +const startupErrorFromStderr = stderr => { + const line = String(stderr || '') + .split('\n') + .map(value => value.trim()) + .find(value => value.startsWith('{')) + if (!line) return null + try { + const body = JSON.parse(line) + const error = body?.error + if (!isKnownBridgeErrorCode(error?.code)) return null + return makeBridgeError(error.code, error.message || error.code, { details: error.details || {}, stderr }) + } catch { + return null + } +} + +const readReady = child => + new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + const timer = setTimeout(() => reject(makeBridgeError('BRIDGE_START_TIMEOUT', 'BRIDGE_START_TIMEOUT', { stderr })), READY_TIMEOUT_MS) + child.stderr.on('data', chunk => { + stderr += String(chunk) + }) + child.stdout.on('data', chunk => { + stdout += String(chunk) + const newline = stdout.indexOf('\n') + if (newline < 0) return + clearTimeout(timer) + try { + const ready = JSON.parse(stdout.slice(0, newline)) + resolve({ ready, stderr: () => stderr }) + } catch (error) { + reject(makeBridgeError('BRIDGE_READY_PARSE_FAILED', 'BRIDGE_READY_PARSE_FAILED', { cause: error, stdout, stderr })) + } + }) + child.once('exit', code => { + clearTimeout(timer) + const startupError = startupErrorFromStderr(stderr) + if (startupError) { + reject(startupError) + return + } + reject(makeBridgeError('BRIDGE_START_FAILED', 'BRIDGE_EXITED_BEFORE_READY', { exitCode: code, stderr })) + }) + }) + +const terminalSettleMs = parseTerminalSettleMs(process.env.STACKPRISM_CAPTURE_TERMINAL_SETTLE_MS) + +const captureRequest = args => ({ + url: args.url, + mode: 'experience', + waitMs: args.waitMs, + include: args.include, + viewports: [DEFAULT_VIEWPORT], + options: { + forceRefresh: args.forceRefresh, + captureScreenshotMetadata: false, + captureScreenshot: args.captureScreenshot, + keepTabOpen: false, + allowPrivateNetworkTarget: args.allowPrivateNetworkTarget, + targetMode: 'new_tab', + maxResourceUrls: args.maxResourceUrls + } +}) + +const runCapture = async args => { + const child = spawn(process.execPath, [bridgeScript], { + cwd: repoRoot, + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'] + }) + let readyEnvelope + try { + readyEnvelope = await readReady(child) + const { ready } = readyEnvelope + if (ready.protocolVersion !== 1 || !ready.baseUrl || !ready.apiToken) { + throw makeBridgeError('BRIDGE_PROTOCOL_UNSUPPORTED') + } + const created = await requestJson(`${ready.baseUrl}/v1/captures`, ready.apiToken, { + method: 'POST', + body: JSON.stringify(captureRequest(args)), + timeoutMs: args.requestTimeoutMs + }) + const deadline = Date.now() + CAPTURE_TIMEOUT_MS + let status = created + let pollTimedOut = true + while (Date.now() < deadline) { + status = await requestJson(`${ready.baseUrl}/v1/captures/${created.id}`, ready.apiToken, { + timeoutMs: args.requestTimeoutMs + }) + if (['completed', 'failed', 'cancelled', 'expired'].includes(status.status)) { + pollTimedOut = false + break + } + await sleep(POLL_INTERVAL_MS) + } + if (status.status !== 'completed') { + const code = pollTimedOut ? 'CAPTURE_TIMEOUT' : status.error?.code || status.status || 'CAPTURE_TIMEOUT' + const error = makeBridgeError(code, status.error?.message || code) + error.response = { status: 409, body: status } + throw error + } + const downloadedProfile = await requestJson(`${ready.baseUrl}/v1/captures/${created.id}/profile-download`, ready.apiToken, { + timeoutMs: args.requestTimeoutMs + }) + const profileDownloadReady = true + const screenshotArtifact = await writeScreenshotArtifact({ + args, + profile: downloadedProfile, + token: ready.apiToken, + timeoutMs: args.requestTimeoutMs + }) + if (terminalSettleMs > 0) await sleep(terminalSettleMs) + return { + ok: true, + ready, + created, + status, + profile: downloadedProfile, + profileDownloadReady, + screenshotArtifact, + stderr: readyEnvelope.stderr() + } + } finally { + await stopChild(child).catch(() => {}) + } +} + +const writeJson = async (path, value) => { + await mkdir(dirname(resolve(path)), { recursive: true }) + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`) +} + +const safeErrorCode = value => { + const code = String(value || '') + return isKnownBridgeErrorCode(code) ? code : 'CAPTURE_FAILED' +} + +const normalizeErrorCode = error => { + const bodyError = error?.response?.body?.error + if (typeof bodyError?.code === 'string' && bodyError.code) return safeErrorCode(bodyError.code) + if (typeof bodyError === 'string' && bodyError) return safeErrorCode(bodyError) + if (typeof error?.code === 'string' && error.code) return safeErrorCode(error.code) + return 'CAPTURE_FAILED' +} + +const sanitizeErrorDetails = error => { + if (error?.details && typeof error.details === 'object') { + const details = sanitizeBridgeError({ code: 'INVALID_REQUEST', message: 'Capture failed.', details: error.details }).details || {} + if (typeof error.details.usage === 'string') details.usage = error.details.usage + return details + } + const body = error?.response?.body + if (!body || typeof body !== 'object') return {} + return sanitizeBridgeError({ code: 'CAPTURE_FAILED', message: 'Capture failed.', details: body }).details || {} +} + +const sanitizeErrorMessage = (error, code) => + sanitizeBridgeError({ code: 'INVALID_REQUEST', message: error?.message || code || 'Capture failed.' }).message + +const main = async () => { + const parsed = parseArgs(process.argv.slice(2)) + if (!parsed.ok) { + throw makeArgumentError(parsed.message) + } + const { args } = parsed + const result = await runCapture(args) + const screenshot = result.profile.visualProfile?.screenshot + const screenshotPresent = Boolean(screenshot?.downloadUrl) + const screenshotWritten = Boolean(result.screenshotArtifact) + await writeJson(args.out, result.profile) + if (args.resultOut) { + await writeJson(args.resultOut, { + ok: true, + targetUrl: args.url, + allowPrivateNetworkTarget: args.allowPrivateNetworkTarget, + include: args.include, + captureScreenshot: args.captureScreenshot, + captureId: result.created.id, + finalUrl: result.profile.target?.finalUrl || result.profile.target?.url || '', + language: result.profile.target?.language || '', + screenshotPresent, + screenshotWritten, + screenshotPath: result.screenshotArtifact?.path || '', + screenshotDownloadUrl: screenshot?.downloadUrl || '', + techCount: result.profile.techProfile?.technologies?.length || 0, + profileDownloadReady: result.profileDownloadReady, + limitations: result.profile.limitations || [] + }) + } + process.stdout.write( + `${JSON.stringify({ + ok: true, + captureId: result.created.id, + finalUrl: result.profile.target?.finalUrl || result.profile.target?.url || '', + language: result.profile.target?.language || '', + screenshotPresent, + screenshotWritten, + screenshotPath: result.screenshotArtifact?.path || '', + screenshotDownloadUrl: screenshot?.downloadUrl || '', + profileDownloadReady: result.profileDownloadReady, + include: args.include, + captureScreenshot: args.captureScreenshot, + techCount: result.profile.techProfile?.technologies?.length || 0 + })}\n` + ) +} + +main().catch(error => { + const code = normalizeErrorCode(error) + process.stderr.write( + `${JSON.stringify({ + ok: false, + error: { + code, + message: sanitizeErrorMessage(error, code), + details: sanitizeErrorDetails(error) + } + })}\n` + ) + process.exit(1) +}) diff --git a/agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs b/agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs new file mode 100644 index 00000000..e2ad22bf --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import { createBridgeServer } from './bridge/http-server.mjs' +import { parseOpenConfig } from './bridge/open-browser.mjs' +import { protocolVersion, service, version } from './bridge/protocol.mjs' + +const failStart = (code, message) => { + process.stderr.write(`${JSON.stringify({ error: { code, message } })}\n`) + process.exit(1) +} + +const parsePort = value => { + if (value === undefined) return 0 + if (!/^[0-9]+$/.test(value)) return null + const port = Number(value) + return port >= 1 && port <= 65535 ? port : null +} + +const openConfig = parseOpenConfig(process.env) +const port = parsePort(process.env.STACKPRISM_BRIDGE_PORT) +if (!openConfig.ok) { + failStart(openConfig.code, openConfig.message) +} else if (port === null) { + failStart('BRIDGE_INVALID_ENV', 'STACKPRISM_BRIDGE_PORT must be an integer from 1 to 65535.') +} else { + const bridge = createBridgeServer({ port }) + try { + const ready = await bridge.listen() + const shutdown = async () => { + ready.store.clear() + await bridge.close().catch(() => { + process.stderr.write(`${JSON.stringify({ error: { code: 'BRIDGE_CLOSE_FAILED', message: 'Bridge server close failed.' } })}\n`) + }) + process.exit(0) + } + process.once('SIGINT', shutdown) + process.once('SIGTERM', shutdown) + process.stdin.once('end', shutdown) + process.stdin.resume() + process.stdout.write( + `${JSON.stringify({ + event: 'stackprism-bridge-ready', + service, + version, + protocolVersion, + baseUrl: ready.baseUrl, + healthUrl: ready.healthUrl, + apiToken: ready.apiToken + })}\n` + ) + } catch (caught) { + const code = caught?.code === 'EADDRINUSE' ? 'PORT_IN_USE' : 'BRIDGE_START_FAILED' + const message = code === 'PORT_IN_USE' ? 'Configured bridge port is already in use.' : 'Failed to start bridge server.' + process.stderr.write( + `${JSON.stringify({ + error: { + code, + message, + details: { reason: caught?.code || caught?.name || 'unknown' } + } + })}\n` + ) + process.exit(1) + } +} diff --git a/agent-skill/stackprism-site-experience/scripts/stackprism_bridge.py b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge.py new file mode 100644 index 00000000..8655980d --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +import errno +import json +import os +import signal +import sys +import threading + +from stackprism_bridge_lib.server_factory import create_server +from stackprism_bridge_lib.open_browser import parse_open_config +from stackprism_bridge_lib.protocol import PROTOCOL_VERSION, SERVICE, VERSION + + +def fail_start(code, message): + sys.stderr.write(json.dumps({"error": {"code": code, "message": message}}) + "\n") + return 1 + + +def parse_port(value): + if value is None: + return 0 + if not value.isdecimal(): + return None + port = int(value) + return port if 1 <= port <= 65535 else None + + +def main(): + open_config_ok, open_config_code, open_config_message = parse_open_config(os.environ) + if not open_config_ok: + return fail_start(open_config_code, open_config_message) + port = parse_port(os.environ.get("STACKPRISM_BRIDGE_PORT")) + if port is None: + return fail_start("BRIDGE_INVALID_ENV", "STACKPRISM_BRIDGE_PORT must be an integer from 1 to 65535.") + try: + server, ready = create_server(port) + except OSError as exc: + if getattr(exc, "errno", None) == errno.EADDRINUSE: + return fail_start("PORT_IN_USE", "Configured bridge port is already in use.") + return fail_start("BRIDGE_START_FAILED", "Failed to start bridge server.") + + def shutdown(_signum=None, _frame=None): + server.shutdown() + + def watch_stdin(): + try: + sys.stdin.read() + finally: + shutdown() + + signal.signal(signal.SIGINT, shutdown) + signal.signal(signal.SIGTERM, shutdown) + thread = threading.Thread(target=server.serve_forever, daemon=True) + stdin_thread = threading.Thread(target=watch_stdin, daemon=True) + thread.start() + stdin_thread.start() + sys.stdout.write( + json.dumps( + { + "event": "stackprism-bridge-ready", + "service": SERVICE, + "version": VERSION, + "protocolVersion": PROTOCOL_VERSION, + "baseUrl": ready["baseUrl"], + "healthUrl": ready["healthUrl"], + "apiToken": ready["apiToken"], + } + ) + + "\n" + ) + sys.stdout.flush() + thread.join() + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/body.py b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/body.py new file mode 100644 index 00000000..79a6a471 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/body.py @@ -0,0 +1,36 @@ +import json +import re + + +def read_exact(stream, length): + chunks = [] + remaining = length + while remaining > 0: + chunk = stream.read(remaining) + if not chunk: + raise EOFError("Request body ended before Content-Length bytes were read.") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def read_json_body(handler, limit, too_large_code): + if not re.fullmatch(r"application/json(?:;\s*charset=utf-8)?", handler.headers.get("Content-Type", ""), re.IGNORECASE): + close_request_connection(handler) + handler.fail(415, "UNSUPPORTED_MEDIA_TYPE", "Expected application/json.") + return None + try: + length = int(handler.headers.get("Content-Length", "0")) + if length > limit: + close_request_connection(handler) + handler.fail(413, too_large_code, "Request body is too large.") + return None + return json.loads(read_exact(handler.rfile, length).decode("utf-8")) + except (EOFError, UnicodeDecodeError, json.JSONDecodeError, ValueError, OSError): + close_request_connection(handler) + handler.fail(400, "INVALID_JSON", "Request body is not valid JSON.") + return None + + +def close_request_connection(handler): + handler.close_connection = True diff --git a/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/bridge_page.py b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/bridge_page.py new file mode 100644 index 00000000..364997d1 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/bridge_page.py @@ -0,0 +1,141 @@ +from .bridge_page_assets import BRIDGE_PAGE_SCRIPT, BRIDGE_PAGE_STYLE +from .protocol import ID_PATTERNS, PROTOCOL_VERSION, html_escape_script_json, new_csp_nonce, redact_url +from .status import FINAL_STATES + + +BRIDGE_PAGE_HTML_TEMPLATE = """ + + + + + + +StackPrism Agent Bridge + + + +
+
+
+
+ +
+

本机通道

+

StackPrism Agent Bridge

+

连接本机 Agent 与当前浏览器 profile,展示本次采集结果。

+
+
+等待扩展连接 +
+
+
+
+
等待扩展连接
+

等待 StackPrism 扩展连接。

+
+ +
+
+
+

采集目标

+等待读取目标网址 +

采集完成后可复制给本机 Coding Agent 使用。

+
+
打开目标网页
+

+
+
+
+

Profile 摘要

+

面向复刻任务整理技术栈、视觉结构、交互路径与资产线索。

+
+

Agent 用途

快速复刻
+

内容范围

技术与体验
+

采集模式

只读采集
+

截图状态

等待截图
+
+

复刻重点

  • 先看 Agent 可读内容
  • 用截图校准首屏结构
  • 必要时再读 raw profile
+

摘要包含

技术栈首屏结构交互路径资产线索
+
+
+

截图预览

等待截图
+ +

截图可用后会显示格式与范围

+
+
+
+ +

采集流程

当前步骤:扩展连接

  1. 1
    扩展连接
  2. 2
    读取请求
  3. 3
    打开目标
  4. 4
    页面加载
  5. 5
    技术识别
  6. 6
    体验分析
  7. 7
    回传 Profile
  8. 8
    清理完成
+

本页只服务当前一次采集;完整 Profile 仅在本次结果未过期时下载;摘要不含 token、nonce、raw JSON 或截图 data URL。

127.0.0.1当前 profile只读采集
+
+
+
+ + + + +""" + + +def render_bridge_page_html(csp_nonce, config): + if not ID_PATTERNS["cspNonce"].match(csp_nonce): + raise ValueError("INVALID_CSP_NONCE") + escaped_config = html_escape_script_json(config) + return BRIDGE_PAGE_HTML_TEMPLATE.format(csp_nonce=csp_nonce, style=BRIDGE_PAGE_STYLE, config=escaped_config, script=BRIDGE_PAGE_SCRIPT) + + +def bridge_page_response(capture): + if capture["status"] == "expired": + return "fail", 410, "CAPTURE_RESULT_EXPIRED", "Capture result expired.", None + if capture["status"] in FINAL_STATES: + error = capture.get("error") or {} + return "fail", 409, error.get("code") or "INVALID_REQUEST", "Capture is already terminal.", {"status": capture["status"]} + if capture["bridgeTokenRenderedAt"] or capture["bridgeTokenClaimedAt"]: + return "fail", 409, "INVALID_REQUEST", "Bridge token has already been rendered or claimed.", None + return "html", { + "captureId": capture["id"], + "sessionId": capture["sessionId"], + "nonce": capture["nonce"], + "bridgeToken": capture["bridgeToken"], + "targetUrl": redact_url((capture.get("request") or {}).get("url")), + "protocolVersion": PROTOCOL_VERSION, + } + + +def render_bridge_page(handler, capture): + with handler.server.store._lock: + response = bridge_page_response(capture) + if response[0] == "fail": + handler.fail(response[1], response[2], response[3], response[4]) + return + csp_nonce = new_csp_nonce() + try: + html = render_bridge_page_html(csp_nonce, response[1]) + except ValueError: + handler.fail(500, "BRIDGE_PAGE_RENDER_FAILED", "Bridge page render failed.") + return + with handler.server.store._lock: + response = bridge_page_response(capture) + if response[0] == "fail": + failed_response = response + else: + failed_response = None + capture["bridgeTokenRenderedAt"] = handler.server.store.now() + if failed_response: + handler.fail(failed_response[1], failed_response[2], failed_response[3], failed_response[4]) + return + handler.send_response(200) + handler.send_header("Content-Type", "text/html; charset=utf-8") + handler.send_header("Cache-Control", "no-store") + handler.send_header("Referrer-Policy", "no-referrer") + handler.send_header("X-Content-Type-Options", "nosniff") + handler.send_header("X-Frame-Options", "DENY") + handler.send_header("Cross-Origin-Opener-Policy", "same-origin") + handler.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()") + handler.send_header( + "Content-Security-Policy", + f"default-src 'none'; script-src 'nonce-{csp_nonce}'; style-src 'nonce-{csp_nonce}'; img-src data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'", + ) + handler.end_headers() + handler.wfile.write(html.encode("utf-8")) diff --git a/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/bridge_page_assets.py b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/bridge_page_assets.py new file mode 100644 index 00000000..0dfc24ac --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/bridge_page_assets.py @@ -0,0 +1,3 @@ +BRIDGE_PAGE_STYLE = "\n:root{color-scheme:light dark;--sp-bg:#eef7f5;--sp-panel:#ffffff;--sp-panel-alt:#fbfefd;--sp-line:#c3ddd7;--sp-soft-line:#dce9e6;--sp-neutral-line:#e5e9ee;--sp-ink:#111827;--sp-muted:#637381;--sp-accent:#0f766e;--sp-accent-2:#14b8a6;--sp-accent-soft:#e8f8f3;--sp-warn:#a45a00;--sp-danger:#b42318;--sp-danger-soft:#fff5f5;--sp-warn-soft:#fff8ed;--sp-shadow:0 26px 70px rgba(15,118,110,.13)}\n*{box-sizing:border-box}body{margin:0;min-height:100vh;background:linear-gradient(180deg,#fbfdfd 0%,var(--sp-bg) 100%);color:var(--sp-ink);font:15px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif}.bridge-shell{min-height:100vh;padding:24px 24px 42px}.bridge-card{width:min(1240px,100%);margin:0 auto 24px;overflow:hidden;border:1px solid var(--sp-line);border-radius:16px;background:var(--sp-panel);box-shadow:var(--sp-shadow)}\n.bridge-header{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:24px 30px;border-bottom:1px solid var(--sp-line);background:linear-gradient(90deg,#f3fbf8 0%,#fff 74%)}.bridge-brand{display:flex;min-width:0;align-items:center;gap:16px}.bridge-mark{width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--sp-accent);color:#fff;font-size:17px;font-weight:800;letter-spacing:0;box-shadow:0 14px 30px rgba(15,118,110,.24)}.bridge-kicker{margin:0 0 3px;color:var(--sp-accent);font-size:12px;font-weight:800;letter-spacing:0;text-transform:uppercase}.bridge-title{margin:0;font-size:24px;line-height:1.15;letter-spacing:0}.bridge-copy{margin:6px 0 0;color:var(--sp-muted);font-size:15px}.bridge-badge{flex:0 0 auto;padding:8px 14px;border:1px solid var(--sp-line);border-radius:999px;background:var(--sp-accent-soft);color:var(--sp-accent);font-weight:800;white-space:nowrap}\n.bridge-body{padding:20px 26px 26px}.status-panel{display:grid;grid-template-columns:minmax(0,1fr);gap:10px;margin-bottom:14px}.bridge-card[data-status=\"completed\"] .status-panel{display:none}.state-label{font-size:20px;font-weight:850;letter-spacing:0}.status-text{margin:3px 0 0;color:var(--sp-muted)}.progress-row{display:grid;grid-template-columns:minmax(0,1fr);align-items:center}.progress{height:8px;overflow:hidden;border-radius:999px;background:#ecf3f2}.progress span{position:relative;display:block;width:8%;height:100%;overflow:hidden;border-radius:inherit;background:linear-gradient(90deg,var(--sp-accent),var(--sp-accent-2));transition:width .25s ease}.bridge-card[data-status=\"failed\"] .progress span,.bridge-card[data-status=\"expired\"] .progress span{background:linear-gradient(90deg,#b42318,#ef4444)}.bridge-card[data-status=\"cancelled\"] .progress span,.bridge-card[data-status=\"disconnected\"] .progress span{background:linear-gradient(90deg,#a45a00,#f59e0b)}.bridge-card[data-status=\"running\"] .progress span::after,.bridge-card[data-status=\"cancel_requested\"] .progress span::after{content:\"\";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.42),transparent);animation:sp-progress-sweep 1.2s linear infinite}@keyframes sp-progress-sweep{from{transform:translateX(-100%)}to{transform:translateX(100%)}}\n.target-panel{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px 18px;align-items:center;margin-bottom:14px;padding:16px 18px;border:1px solid #a9d8cd;border-radius:12px;background:linear-gradient(90deg,#f3fbf8 0%,#fff 70%);box-shadow:0 12px 28px rgba(15,118,110,.08)}.target-copy{min-width:0}.preview-label{margin:0 0 5px;color:var(--sp-muted);font-size:12px;font-weight:800;letter-spacing:0;text-transform:uppercase}.target-url{margin:0;display:-webkit-box;overflow:hidden;overflow-wrap:anywhere;word-break:break-word;-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--sp-ink);font-size:17px;font-weight:850;line-height:1.25;text-decoration:none}.target-url[href]{cursor:pointer}.target-url[href]:hover{text-decoration:underline;text-decoration-thickness:2px;text-underline-offset:4px}.target-url[aria-disabled=\"true\"]{cursor:default}.target-helper{margin:5px 0 0;overflow-wrap:anywhere;color:var(--sp-muted);font-size:13px}.target-actions{display:flex;min-width:0;flex-wrap:wrap;gap:10px;justify-content:flex-end}.target-open-link{min-width:132px;display:inline-flex;align-items:center;justify-content:center;text-decoration:none}.profile-download-button{min-width:132px}.copy-status{grid-column:1/-1;min-height:0;margin:0;padding:0;overflow-wrap:anywhere;color:var(--sp-muted);font-size:14px;opacity:0;transform:translateY(-2px);transition:opacity .16s ease,transform .16s ease}.copy-status[data-visible=\"true\"]{min-height:32px;padding:6px 10px;border:1px solid var(--sp-line);border-radius:8px;background:var(--sp-accent-soft);color:var(--sp-accent);opacity:1;transform:translateY(0)}.copy-status[data-state=\"error\"]{border-color:#f0b7b2;background:var(--sp-danger-soft);color:var(--sp-danger)}\n.result-grid{display:grid;grid-template-columns:minmax(0,1.22fr) minmax(320px,.82fr);gap:16px;align-items:start}.capture-panel,.screenshot-panel{padding:16px;border:1px solid var(--sp-soft-line);border-radius:12px;background:var(--sp-panel-alt)}.capture-panel{min-width:0}.section-title{display:flex;align-items:center;gap:10px;margin-bottom:12px}.section-title h2{margin:0;font-size:17px;line-height:1.2}.section-dot{flex:0 0 auto;width:8px;height:22px;border:0;border-radius:999px;background:var(--sp-accent);box-shadow:none}.panel-copy{margin:-4px 0 12px;color:var(--sp-muted);font-size:13px}.summary-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:10px}.summary-tile{min-height:58px;padding:10px;border:1px solid var(--sp-neutral-line);border-radius:10px;background:#fff}.summary-tile p{margin:0 0 4px;color:var(--sp-muted);font-size:12px;font-weight:800}.summary-tile strong{display:block;overflow-wrap:anywhere;font-size:14px;line-height:1.25}.summary-note{padding:10px 12px;border:1px solid var(--sp-neutral-line);border-radius:10px;background:#fff}.summary-note p{margin:0 0 5px;color:var(--sp-accent);font-size:12px;font-weight:850;letter-spacing:0}.summary-note ul{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin:0;padding-left:18px;color:var(--sp-muted);font-size:13px}.summary-note li{line-height:1.35}.summary-handoff{display:flex;align-items:center;gap:12px;margin-top:12px;padding-top:10px;color:var(--sp-muted);font-size:12px}.summary-handoff p{margin:0;color:var(--sp-accent);font-weight:850}.summary-handoff div{display:flex;flex-wrap:wrap;gap:6px}.summary-handoff span{padding:4px 8px;border:1px solid var(--sp-neutral-line);border-radius:999px;background:#fff;font-weight:750}.state-chip{margin-left:auto;padding:4px 9px;border:1px solid #d6e7e3;border-radius:999px;background:#fff;color:var(--sp-muted);font-size:12px;font-weight:850;white-space:nowrap}.state-chip[data-state=\"ready\"]{border-color:#a9d8cd;background:#e8f8f1;color:var(--sp-accent)}.state-chip[data-state=\"failed\"]{border-color:#f0b7b2;background:var(--sp-danger-soft);color:var(--sp-danger)}.state-chip[data-state=\"empty\"]{border-color:#f0d2a8;background:var(--sp-warn-soft);color:var(--sp-warn)}.flow-panel{margin-top:14px;padding:10px 12px;border:1px solid var(--sp-neutral-line);border-radius:12px;background:var(--sp-panel-alt)}.flow-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:9px}.bridge-card[data-status=\"completed\"] .flow-head{margin-bottom:0}.flow-head h2{margin:0;font-size:14px}.flow-state{display:flex;align-items:center;justify-content:flex-end;gap:8px;min-width:0}.step-summary{margin:0;color:var(--sp-muted);font-size:13px;font-weight:750}.flow-toggle{display:none;min-height:30px;padding:0 10px;border:1px solid #b9dcd5;border-radius:999px;background:#fff;color:var(--sp-accent);font:inherit;font-size:12px;font-weight:850;cursor:pointer}.bridge-card[data-status=\"completed\"] .flow-toggle{display:inline-flex;align-items:center}.steps{display:grid;grid-template-columns:repeat(8,minmax(0,1fr));gap:6px;margin:0;padding:0;list-style:none}.bridge-card[data-status=\"completed\"]:not([data-steps-open=\"true\"]) .steps{display:none}.bridge-card[data-status=\"completed\"][data-steps-open=\"true\"] .steps{display:grid;margin-top:9px}.step{min-height:36px;position:relative;display:flex;align-items:center;gap:6px;padding:7px;border:1px solid var(--sp-neutral-line);border-radius:9px;background:#fff;color:var(--sp-muted)}.step-index{display:inline-grid;flex:0 0 auto;place-items:center;width:20px;height:20px;border-radius:999px;background:#edf3f2;color:var(--sp-muted);font-size:11px;font-weight:800}.step div{font-size:12px;line-height:1.2;word-break:break-word}.step.done{border-color:#a9d8cd;background:#f2fbf8;color:var(--sp-accent)}.step.done .step-index,.step.current .step-index{background:var(--sp-accent);color:#fff}.step.current{border-color:var(--sp-accent);color:var(--sp-ink);box-shadow:0 0 0 2px rgba(15,118,110,.08)}.step.failed{border-color:#f0b7b2;background:var(--sp-danger-soft);color:var(--sp-danger);box-shadow:0 0 0 2px rgba(180,35,24,.08)}.step.failed .step-index{background:var(--sp-danger);color:#fff}\n.screenshot-frame{width:100%;height:clamp(190px,14vw,230px);display:grid;place-items:center;overflow:hidden;border:1px solid var(--sp-line);border-radius:12px;background:#eef8f7;cursor:pointer;padding:0;text-align:inherit;transition:border-color .16s ease,box-shadow .16s ease}.screenshot-frame:disabled{cursor:not-allowed}.screenshot-frame.has-image:hover{border-color:var(--sp-accent);box-shadow:0 0 0 3px rgba(15,118,110,.1)}.screenshot-frame img{display:none;width:100%;height:100%;object-fit:cover;object-position:top center;background:#fff}.screenshot-frame.has-image img{display:block}.screenshot-frame.has-image .screenshot-empty{display:none}.screenshot-empty{max-width:260px;padding:18px;color:var(--sp-muted);text-align:center;font-weight:750;line-height:1.45;white-space:pre-line}.screenshot-meta{min-height:20px;margin:12px 0 0;color:var(--sp-muted);font-size:13px;line-height:1.35}.preview-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:12px}.preview-button,.modal-close{min-height:38px;padding:0 12px;border:1px solid #b9dcd5;border-radius:8px;background:#fff;color:var(--sp-accent);font:inherit;font-weight:800;cursor:pointer}.preview-button.primary{min-height:44px;background:var(--sp-accent);border-color:var(--sp-accent);color:#fff;box-shadow:0 10px 22px rgba(15,118,110,.2)}.preview-button.primary:disabled{box-shadow:none}.target-copy-button{min-width:156px}.preview-button:disabled,.modal-close:disabled,.preview-button[aria-disabled=\"true\"]{cursor:not-allowed;opacity:1;background:#f7fbfa;border-color:#d8e8e4;color:#8aa5a0}.target-url:focus-visible,.screenshot-frame:focus-visible,.preview-button:focus-visible,.modal-close:focus-visible{outline:3px solid rgba(15,118,110,.35);outline-offset:2px;border-radius:6px}\n.content-section{margin-top:14px;padding:16px;border:1px solid var(--sp-soft-line);border-radius:12px;background:var(--sp-panel-alt)}.section-head{display:flex;justify-content:space-between;gap:14px;align-items:end;margin-bottom:12px}.section-head h2{margin:0;font-size:16px}.section-head p{margin:3px 0 0;color:var(--sp-muted);font-size:13px}.content-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:8px}.content-card{min-width:0;min-height:88px;padding:10px;overflow:hidden;border:1px solid var(--sp-neutral-line);border-radius:10px;background:#fff}.content-card *{min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word}.content-card h3{margin:0 0 6px;font-size:14px;line-height:1.3}.content-card ul{display:grid;min-width:0;gap:3px;margin:0;padding-left:18px;color:var(--sp-muted);font-size:13px}.content-card li{min-width:0;line-height:1.38;white-space:normal}\n.bridge-footer{display:flex;flex-wrap:wrap;gap:12px 18px;align-items:flex-start;justify-content:space-between;margin-top:20px;padding-top:18px;border-top:1px solid #e7eeee}.bridge-note{max-width:760px;margin:0;color:var(--sp-muted);line-height:1.55}.pills{display:flex;flex-wrap:wrap;gap:8px;margin-left:auto}.pill{padding:6px 10px;border:1px solid #d6e7e3;border-radius:999px;color:var(--sp-muted);background:var(--sp-panel-alt);font-size:13px}.bridge-card[data-status=\"completed\"] .bridge-badge{border-color:#a9d8cd;background:#e8f8f1;color:var(--sp-accent)}.bridge-card[data-status=\"failed\"] .bridge-badge,.bridge-card[data-status=\"expired\"] .bridge-badge{border-color:#f0b7b2;background:var(--sp-danger-soft);color:var(--sp-danger)}.bridge-card[data-status=\"cancelled\"] .bridge-badge,.bridge-card[data-status=\"disconnected\"] .bridge-badge{border-color:#f0d2a8;background:var(--sp-warn-soft);color:var(--sp-warn)}\n.screenshot-modal{position:fixed;inset:0;z-index:20;display:none;place-items:center;padding:28px;background:rgba(10,18,24,.72);overscroll-behavior:contain}.screenshot-modal[data-open=\"true\"]{display:grid}.modal-card{width:min(1180px,100%);max-height:92vh;display:grid;grid-template-rows:auto auto minmax(0,1fr);overflow:hidden;border:1px solid #25443f;border-radius:14px;background:#081311;box-shadow:0 28px 90px rgba(0,0,0,.42)}.modal-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;border-bottom:1px solid #1e3934;color:#e7f7f3}.modal-title{margin:0;font-size:15px;font-weight:800}.modal-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end}.modal-copy-status{display:none;margin:0;padding:0 14px 10px;color:#b7d6cf;font-size:13px;font-weight:600;text-align:right}.modal-copy-status[data-visible=\"true\"]{display:block}.modal-copy-status[data-state=\"error\"]{color:#fca5a5}.modal-close{border-color:#35665f;background:#10211e;color:#e7f7f3}.modal-image{display:block;width:100%;height:100%;max-height:calc(92vh - 82px);object-fit:contain;background:#030807}\n@media (prefers-color-scheme:dark){:root{--sp-bg:#091312;--sp-panel:#101b1a;--sp-panel-alt:#0d1716;--sp-line:#244842;--sp-soft-line:#1f3935;--sp-neutral-line:#223935;--sp-ink:#ecfdf8;--sp-muted:#9fb8b3;--sp-accent-soft:#12302c;--sp-shadow:0 24px 60px rgba(0,0,0,.35)}body{background:linear-gradient(180deg,#07100f 0%,var(--sp-bg) 100%)}.bridge-header,.target-panel{background:linear-gradient(90deg,#0f2522 0%,#101b1a 76%)}.summary-tile,.summary-note,.summary-handoff span,.content-card,.state-chip{background:#101b1a}.progress{background:#18302d}.screenshot-frame{background:#0a1312}.screenshot-frame img{background:#07100f}.preview-button,.flow-toggle{background:#10211e;border-color:#35665f}.preview-button.primary{background:var(--sp-accent);border-color:var(--sp-accent);color:#fff}.preview-button:disabled,.modal-close:disabled,.preview-button[aria-disabled=\"true\"]{background:#0d1716;border-color:#244842;color:#698984}.copy-status[data-state=\"error\"]{background:#2a1211;border-color:#7f1d1d;color:#fca5a5}.bridge-card[data-status=\"failed\"] .bridge-badge,.bridge-card[data-status=\"expired\"] .bridge-badge{background:#2a1211;color:#fca5a5}.bridge-card[data-status=\"cancelled\"] .bridge-badge,.bridge-card[data-status=\"disconnected\"] .bridge-badge{background:#2a1d0b;color:#fbbf24}.state-chip[data-state=\"failed\"]{background:#2a1211;color:#fca5a5}.state-chip[data-state=\"empty\"]{background:#2a1d0b;color:#fbbf24}.content-section,.bridge-footer{border-color:#1f3935}.step{background:#0d1716}.step.done{background:#102822}.step.failed{border-color:#7f1d1d;background:#2a1211;color:#fca5a5}.step.failed .step-index{background:#ef4444;color:#fff}.pill{border-color:#244842}}\n@media (max-width:980px){.result-grid{grid-template-columns:1fr 1fr}.summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.summary-note ul{grid-template-columns:1fr}.summary-handoff{display:none}.preview-actions{grid-template-columns:repeat(2,minmax(0,1fr))}.steps{grid-template-columns:repeat(4,minmax(0,1fr))}}\n@media (max-width:760px){.bridge-shell{padding:12px 10px 22px}.bridge-card{border-radius:14px}.bridge-header{position:relative;display:block;padding:12px 92px 12px 12px}.bridge-brand{gap:10px}.bridge-mark{width:36px;height:36px}.bridge-kicker{font-size:10px}.bridge-title{font-size:19px}.bridge-copy{margin-top:3px;font-size:13px;line-height:1.32}.bridge-badge{position:absolute;top:12px;right:12px;padding:6px 9px;font-size:12px}.bridge-body{padding:13px}.target-panel{grid-template-columns:1fr;padding:10px 12px}.target-url{font-size:15px}.target-helper{font-size:12px}.target-actions{justify-content:flex-start}.target-open-link,.profile-download-button,.target-copy-button{width:min(100%,220px);min-width:0}.result-grid{grid-template-columns:1fr}.summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.summary-tile{min-height:54px;padding:8px}.summary-note{padding:9px 10px}.summary-note ul{grid-template-columns:1fr;gap:4px}.section-title{align-items:center;gap:8px}.section-title h2{font-size:16px}.state-chip{padding:3px 8px;font-size:11px}.screenshot-frame{height:172px}.preview-actions{gap:12px}.flow-head{display:grid;gap:4px}.flow-state{align-items:flex-start;justify-content:space-between}.step-summary{margin-top:0}.steps{grid-template-columns:repeat(2,minmax(0,1fr))}.bridge-card[data-status=\"completed\"][data-steps-open=\"true\"] .steps{margin-top:8px}.step{min-height:44px;padding:8px}.step-index{width:21px;height:21px}.step div{font-size:12px;line-height:1.25}.screenshot-modal{padding:14px}.modal-bar,.section-head{align-items:flex-start;flex-direction:column}.modal-actions{width:100%;justify-content:flex-end}.modal-copy-status{text-align:left}.bridge-footer{gap:12px;margin-top:20px}.pills{margin-left:0}}\n@media (max-width:420px){.preview-actions{grid-template-columns:1fr}.target-open-link,.profile-download-button,.target-copy-button{width:100%}}\n@media (hover:none){.preview-button,.modal-close{min-height:44px;min-width:44px}}\n@media (prefers-reduced-motion:reduce){.bridge-card[data-status=\"running\"] .progress span::after,.bridge-card[data-status=\"cancel_requested\"] .progress span::after{animation:none}.copy-status,.progress span,.screenshot-frame{transition:none}}\n" + +BRIDGE_PAGE_SCRIPT = "\nconst config=JSON.parse(document.getElementById('stackprism-agent-bridge-config').textContent);\nconst ids=['status','stateLabel','statusBadge','progressBar','bridgeCard','targetUrl','openTargetUrl','targetHelper','screenshotFrame','targetScreenshot','screenshotMeta','screenshotDownload','copyScreenshot','copyAllInfo','downloadProfile','copyStatus','modalCopyStatus','stepSummary','profileContentSection','profileContentGrid','screenshotModal','modalScreenshot','modalClose','modalDownload','modalCopyScreenshot','screenshotTileValue','screenshotEmpty','screenshotStateBadge','toggleSteps'];\nconst el=Object.fromEntries(ids.map(id=>[id,document.getElementById(id)]));\nconst steps=[...document.querySelectorAll('[data-phase]')];\nlet currentScreenshot=null,currentCopyText='',currentProfileBlob=null,currentProfileFetchPromise=null,copyStatusTimer=0,copyButtonTimer=0,stepsOpen=false,stepsUserToggled=false,lastStatus='',lastBody=null;\nconst phases=['bridge_connected','request_loaded','target_opening','target_loaded','detecting_tech','profiling_experience','posting_profile','cleanup'];\nconst phaseLabels={bridge_connected:'扩展已连接',request_loaded:'读取采集请求',target_opening:'打开目标页面',target_loaded:'目标页面已加载',detecting_tech:'识别技术栈',profiling_experience:'分析视觉与体验',posting_profile:'回传 Profile',cleanup:'清理采集环境'};\nconst statusLabels={queued:'等待扩展连接',waiting_extension:'等待扩展连接',running:'正在采集',cancel_requested:'正在取消',cancelled:'已取消',completed:'采集完成',failed:'采集失败',expired:'结果已过期',disconnected:'连接已关闭'};\nconst finalStatuses=['completed','failed','cancelled','expired','disconnected'];\nconst targetHrefFor=value=>{const text=String(value||'');if(!text)return '';try{const url=new URL(text);if(url.protocol!=='http:'&&url.protocol!=='https:')return '';if(url.pathname.includes('[redacted]'))return '';if(url.search.includes('[redacted]'))url.search='';return url.toString();}catch{return '';}};\nconst setTargetLink=(node,href)=>{if(href){node.href=href;node.removeAttribute('aria-disabled');node.removeAttribute('tabindex');}else{node.removeAttribute('href');node.setAttribute('aria-disabled','true');node.setAttribute('tabindex','-1');}};\nconst setTargetUrl=value=>{const targetText=value||'等待读取目标网址';const targetHref=targetHrefFor(targetText);el.targetUrl.textContent=targetText;el.targetUrl.title=targetText;setTargetLink(el.targetUrl,targetHref);setTargetLink(el.openTargetUrl,targetHref);};\nconst setStatus=value=>{el.status.textContent=value};\nconst setCopyStatus=(node,value,type='ok')=>{node.textContent=value;node.dataset.state=type;node.dataset.visible=value?'true':'false';};\nconst showCopyStatus=(value,type='ok')=>{if(copyStatusTimer)clearTimeout(copyStatusTimer);setCopyStatus(el.copyStatus,'');setCopyStatus(el.modalCopyStatus,'');setCopyStatus(modalOpen()?el.modalCopyStatus:el.copyStatus,value,type);if(value)copyStatusTimer=setTimeout(()=>{setCopyStatus(el.copyStatus,'');setCopyStatus(el.modalCopyStatus,'');},2600);};\nconst flashCopyButton=value=>{if(copyButtonTimer)clearTimeout(copyButtonTimer);el.copyAllInfo.textContent=value;copyButtonTimer=setTimeout(()=>{el.copyAllInfo.textContent='复制全部信息';},1800);};\nconst screenshotExtension=()=>currentScreenshot?.mimeType==='image/png'?'png':currentScreenshot?.mimeType==='image/webp'?'webp':'jpg';\nconst screenshotFilename=()=>('stackprism-'+config.captureId+'-screenshot.'+screenshotExtension());\nconst profileFilename=()=>('stackprism-'+config.captureId+'-profile.json');\nconst profileDownloadUrl=()=>('/v1/captures/'+config.captureId+'/profile-download');\nconst formatBytes=value=>{const bytes=Number(value);if(!Number.isFinite(bytes)||bytes<=0)return '';if(bytes<1024)return bytes+' B';if(bytes<1048576)return Math.round(bytes/1024)+' KB';return (bytes/1048576).toFixed(1)+' MB';};\nconst screenshotMetaText=screenshot=>[screenshot.mimeType||'',formatBytes(screenshot.byteLength),screenshot.scope||''].filter(Boolean).join(' - ')||'截图已包含';\nconst screenshotEmptyText=(status,preview)=>status==='completed'?'本次采集未返回截图':status==='failed'?'采集失败,未返回截图':preview?.screenshot?'':'采集完成后显示可用截图';\nconst setScreenshotState=(text,state)=>{el.screenshotTileValue.textContent=text;el.screenshotStateBadge.textContent=text;el.screenshotStateBadge.dataset.state=state;};\nlet currentScreenshotBlob=null,currentScreenshotObjectUrl='';\nconst clearScreenshotObjectUrl=()=>{if(currentScreenshotObjectUrl){URL.revokeObjectURL(currentScreenshotObjectUrl);currentScreenshotObjectUrl='';}};\nconst screenshotRequestInit=url=>{try{const target=new URL(url,location.href);return {headers:target.origin===location.origin?{Authorization:'Bearer '+config.bridgeToken}:{},cache:'no-store'};}catch{return {cache:'no-store'};}};\nconst fetchScreenshotBlob=async()=>{if(currentScreenshotBlob)return currentScreenshotBlob;if(!currentScreenshot?.downloadUrl)throw new Error('SCREENSHOT_DOWNLOAD_MISSING');const res=await fetch(currentScreenshot.downloadUrl,screenshotRequestInit(currentScreenshot.downloadUrl));if(!res.ok)throw new Error('SCREENSHOT_DOWNLOAD_FAILED');currentScreenshotBlob=await res.blob();return currentScreenshotBlob;};\nconst setScreenshotImageUrl=async screenshot=>{try{const blob=await fetchScreenshotBlob();if(currentScreenshot!==screenshot)return;clearScreenshotObjectUrl();currentScreenshotObjectUrl=URL.createObjectURL(blob);el.targetScreenshot.src=currentScreenshotObjectUrl;el.modalScreenshot.src=currentScreenshotObjectUrl;}catch{invalidateScreenshot();}};\nconst setScreenshot=screenshot=>{\nconst next=screenshot?.downloadUrl?screenshot:null;if(currentScreenshot?.downloadUrl!==next?.downloadUrl){currentScreenshotBlob=null;clearScreenshotObjectUrl();}\ncurrentScreenshot=next;\nif(currentScreenshot){setScreenshotImageUrl(currentScreenshot);el.targetScreenshot.alt='目标页面截图预览';el.modalScreenshot.alt='目标页面截图放大预览';}else{el.targetScreenshot.removeAttribute('src');el.modalScreenshot.removeAttribute('src');el.targetScreenshot.alt='';el.modalScreenshot.alt='';}\nel.screenshotFrame.classList.toggle('has-image',Boolean(currentScreenshot));\nel.screenshotMeta.textContent=currentScreenshot?screenshotMetaText(currentScreenshot):'截图可用后会显示格式与范围';\nif(currentScreenshot)setScreenshotState('截图可用','ready');\nfor(const item of [el.screenshotFrame,el.screenshotDownload,el.copyScreenshot,el.modalDownload,el.modalCopyScreenshot])item.disabled=!currentScreenshot;\n};\nconst invalidateScreenshot=()=>{currentScreenshot=null;currentScreenshotBlob=null;clearScreenshotObjectUrl();el.targetScreenshot.removeAttribute('src');el.modalScreenshot.removeAttribute('src');el.targetScreenshot.alt='';el.modalScreenshot.alt='';el.screenshotFrame.classList.remove('has-image');el.screenshotMeta.textContent='截图预览无法加载';el.screenshotEmpty.textContent='截图预览无法加载';setScreenshotState('截图失败','failed');for(const item of [el.screenshotFrame,el.screenshotDownload,el.copyScreenshot,el.modalDownload,el.modalCopyScreenshot])item.disabled=true;showCopyStatus('截图预览无法加载,可重新采集或下载 Profile 查看图片链接。','error');};\nconst downloadBlob=(blob,filename)=>{const href=URL.createObjectURL(blob);const link=document.createElement('a');link.href=href;link.download=filename;document.body.append(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(href),0);};\nconst downloadScreenshot=async()=>{if(!currentScreenshot)return;try{downloadBlob(await fetchScreenshotBlob(),screenshotFilename());}catch{showCopyStatus('下载截图失败:结果可能已过期或本机 bridge 已关闭。','error');}};\nconst fetchProfileBlob=async()=>{const res=await fetch(profileDownloadUrl(),{headers:{Authorization:'Bearer '+config.bridgeToken},cache:'no-store'});if(!res.ok){let code='PROFILE_DOWNLOAD_FAILED';try{code=(await res.json())?.error?.code||code;}catch{}throw new Error(code);}const blob=await res.blob();currentProfileBlob=blob;return blob;};\nconst ensureProfileCached=()=>{if(currentProfileBlob)return Promise.resolve(currentProfileBlob);if(currentProfileFetchPromise)return currentProfileFetchPromise;currentProfileFetchPromise=fetchProfileBlob().catch(error=>{currentProfileFetchPromise=null;throw error;});return currentProfileFetchPromise;};\nconst downloadProfile=async()=>{if(el.downloadProfile.disabled)return;el.downloadProfile.disabled=true;try{downloadBlob(await ensureProfileCached(),profileFilename());showCopyStatus('已下载 Profile JSON。');}catch{showCopyStatus('下载 Profile 失败:结果可能已过期或本机 bridge 已关闭。','error');}finally{el.downloadProfile.disabled=lastBody?.status!=='completed';}};\nconst copyText=async()=>{if(!currentCopyText)return;try{await navigator.clipboard.writeText(currentCopyText);showCopyStatus('已复制全部信息。');flashCopyButton('已复制');}catch{showCopyStatus('复制失败:浏览器未允许写入剪切板。','error');}};\nconst clipboardScreenshotBlob=async()=>{const blob=await fetchScreenshotBlob();if(blob.type==='image/png')return blob;const bitmap=await createImageBitmap(blob);try{const canvas=document.createElement('canvas');canvas.width=bitmap.width;canvas.height=bitmap.height;const context=canvas.getContext('2d');if(!context)throw new Error('Canvas unavailable');context.drawImage(bitmap,0,0);return await new Promise((resolve,reject)=>canvas.toBlob(output=>output?resolve(output):reject(new Error('PNG conversion failed')),'image/png'));}finally{bitmap.close?.();}};\nconst copyScreenshot=async()=>{if(!currentScreenshot)return;try{const blob=await clipboardScreenshotBlob();await navigator.clipboard.write([new ClipboardItem({'image/png':blob})]);showCopyStatus('已复制截图。');}catch{showCopyStatus('复制截图失败:浏览器未允许写入剪切板,或截图格式无法转换。','error');}};\nconst modalOpen=()=>el.screenshotModal.dataset.open==='true';\nconst modalControls=()=>[...el.screenshotModal.querySelectorAll('button:not(:disabled),a[href],input:not(:disabled),select:not(:disabled),textarea:not(:disabled),[tabindex]:not([tabindex=\"-1\"])')];\nconst openScreenshot=()=>{if(currentScreenshot){el.screenshotModal.dataset.open='true';document.body.style.overflow='hidden';el.modalClose.focus();}};\nconst closeScreenshot=()=>{if(!modalOpen())return;el.screenshotModal.dataset.open='false';setCopyStatus(el.modalCopyStatus,'');document.body.style.overflow='';const restore=el.screenshotFrame.disabled?el.bridgeCard:el.screenshotFrame;restore.focus({preventScroll:true});};\nconst renderSummary=summary=>{const cards=Array.isArray(summary?.cards)?summary.cards:[];el.profileContentGrid.replaceChildren();el.profileContentSection.hidden=!cards.length;for(const card of cards){const node=document.createElement('article');node.className='content-card';const title=document.createElement('h3');title.textContent=card.title||'Profile';const list=document.createElement('ul');for(const item of Array.isArray(card.items)?card.items:[]){const li=document.createElement('li');li.textContent=item;list.append(li);}node.append(title,list);el.profileContentGrid.append(node);}};\nconst setStepsOpen=(value,user=false)=>{stepsOpen=Boolean(value);if(user)stepsUserToggled=true;el.bridgeCard.dataset.stepsOpen=stepsOpen?'true':'false';el.toggleSteps.setAttribute('aria-expanded',stepsOpen?'true':'false');el.toggleSteps.textContent=stepsOpen?'收起步骤':'展开步骤';};\nconst updateSteps=(phase,status)=>{const index=status==='completed'?phases.length-1:Math.max(0,phases.indexOf(phase));const safeIndex=index<0?0:index;const failed=status==='failed';steps.forEach(step=>{const stepIndex=phases.indexOf(step.dataset.phase);const current=stepIndex===safeIndex&&!finalStatuses.includes(status);const failedCurrent=failed&&stepIndex===safeIndex;step.classList.toggle('done',stepIndex{const status=body?.status||'waiting_extension';const phase=body?.phase||'bridge_connected';const label=statusLabels[status]||status;const preview=body?.preview||{};const targetText=preview.targetUrl||config.targetUrl||'等待读取目标网址';lastBody=body;currentCopyText=typeof preview.copyText==='string'?preview.copyText:'';el.bridgeCard.dataset.status=status;el.bridgeCard.dataset.phase=phase;el.stateLabel.textContent=label;el.statusBadge.textContent=label;setStatus(body?.error?.code||phaseLabels[phase]||status);setTargetUrl(targetText);el.targetHelper.textContent=status==='completed'&¤tCopyText?'已生成 Agent 可读摘要,可复制给本机 Coding Agent 使用。':finalStatuses.includes(status)?'本次采集已结束,可查看可用摘要。':'采集完成后可复制给本机 Coding Agent 使用。';setScreenshot(preview.screenshot);if(!currentScreenshot){el.screenshotEmpty.textContent=screenshotEmptyText(status,preview);setScreenshotState(finalStatuses.includes(status)?'未返回截图':'等待截图',finalStatuses.includes(status)?'empty':'pending');}el.copyAllInfo.disabled=!currentCopyText;el.downloadProfile.disabled=status!=='completed';if(status==='completed')ensureProfileCached().catch(()=>{});if(!currentCopyText)el.copyAllInfo.textContent='复制全部信息';renderSummary(preview.contentSummary);updateSteps(phase,status);};\nel.screenshotFrame.addEventListener('click',openScreenshot);el.screenshotDownload.addEventListener('click',downloadScreenshot);el.copyScreenshot.addEventListener('click',copyScreenshot);el.copyAllInfo.addEventListener('click',copyText);el.downloadProfile.addEventListener('click',downloadProfile);el.modalDownload.addEventListener('click',downloadScreenshot);el.modalCopyScreenshot.addEventListener('click',copyScreenshot);el.modalClose.addEventListener('click',closeScreenshot);el.targetScreenshot.addEventListener('error',invalidateScreenshot);el.modalScreenshot.addEventListener('error',invalidateScreenshot);\nel.toggleSteps.addEventListener('click',()=>setStepsOpen(!stepsOpen,true));\nel.screenshotModal.addEventListener('click',event=>{if(event.target===el.screenshotModal)closeScreenshot();});\ndocument.addEventListener('keydown',event=>{if(!modalOpen())return;if(event.key==='Escape'){closeScreenshot();return;}if(event.key!=='Tab')return;const controls=modalControls();if(!controls.length)return;const first=controls[0],last=controls[controls.length-1];if(!el.screenshotModal.contains(document.activeElement)){event.preventDefault();(event.shiftKey?last:first).focus();}else if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();}});\nconst poll=async()=>{try{const res=await fetch('/v1/captures/'+config.captureId,{headers:{Authorization:'Bearer '+config.bridgeToken},cache:'no-store'});const body=await res.json();if(!res.ok){render({status:'failed',phase:lastBody?.phase||'cleanup',error:{code:body?.error?.code||'Bridge request failed.'}});return;}render(body);if(finalStatuses.includes(body.status))return;}catch{if(lastBody&&finalStatuses.includes(lastBody.status))return;render({status:'disconnected',phase:lastBody?.phase||'bridge_connected',preview:lastBody?.preview,error:{code:'本机 bridge 服务已关闭,当前页面无法继续读取状态。'}});return;}setTimeout(poll,1000);};\npoll();\n" diff --git a/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/capture_store.py b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/capture_store.py new file mode 100644 index 00000000..ef8b8ca7 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/capture_store.py @@ -0,0 +1,173 @@ +import threading +import time + +from .open_browser import open_browser +from .protocol import error_body, new_bridge_token, new_capture_id, new_nonce, new_screenshot_download_id, new_session_id +from .profile_response import prepare_profile_for_storage + + +EXTENSION_CONNECT_TIMEOUT_SECONDS = 30 +CAPTURE_TIMEOUT_SECONDS = 95 +CANCEL_TIMEOUT_SECONDS = 10 +RESULT_TTL_SECONDS = 10 * 60 +MAX_CAPTURE_RECORDS = 100 + + +def capture_deadline_error(capture): + if capture.get("phase") == "target_opening": + return error_body("TARGET_LOAD_TIMEOUT", "Target tab load timed out.")["error"] + return error_body("CAPTURE_TIMEOUT", "Capture timed out.")["error"] + + +class CaptureStore: + def __init__(self, base_url, now=time.time, open_browser_fn=open_browser, result_ttl_seconds=RESULT_TTL_SECONDS, timer_factory=threading.Timer): + self.base_url = base_url + self.now = now + self.open_browser = open_browser_fn + self.result_ttl_seconds = result_ttl_seconds + self.timer_factory = timer_factory + self.captures = {} + self.result_expiry_timers = {} + self._lock = threading.RLock() + + def active_count(self): + with self._lock: + for item in list(self.captures.values()): + self.expire_if_needed(item) + return len([item for item in self.captures.values() if item["status"] in {"queued", "waiting_extension", "running", "cancel_requested"}]) + + def get(self, capture_id): + with self._lock: + capture = self.captures.get(capture_id) + if capture: + self.expire_if_needed(capture) + return capture + + def create(self, request): + with self._lock: + if self.active_count(): + return None, 429, error_body("CAPTURE_BUSY", "Another capture is already active.") + now = self.now() + capture_id = new_capture_id() + session_id = new_session_id() + nonce = new_nonce() + capture = { + "id": capture_id, + "sessionId": session_id, + "nonce": nonce, + "bridgeToken": new_bridge_token(), + "status": "queued", + "phase": None, + "sequence": 0, + "request": request, + "profile": None, + "screenshotAsset": None, + "error": None, + "createdAt": now, + "extensionDeadlineAt": now + EXTENSION_CONNECT_TIMEOUT_SECONDS, + "deadlineAt": now + CAPTURE_TIMEOUT_SECONDS, + "cancelDeadlineAt": None, + "resultExpiresAt": None, + "bridgeTokenRenderedAt": None, + "bridgeTokenClaimedAt": None, + "profileDownloadReadyAt": None, + "screenshotDownloadId": new_screenshot_download_id(), + "screenshotUrl": None, + } + capture["bridgeUrl"] = f"{self.base_url}/bridge?session={session_id}&capture={capture_id}&nonce={nonce}" + capture["profileUrl"] = f"{self.base_url}/v1/captures/{capture_id}/profile" + capture["screenshotUrl"] = f"{self.base_url}/v1/captures/{capture_id}/screenshot-download/{capture['screenshotDownloadId']}" + self.captures[capture_id] = capture + self.prune_terminal_records() + opened, details = self.open_browser(capture["bridgeUrl"]) + if not opened: + with self._lock: + capture["status"] = "failed" + capture["error"] = error_body("BROWSER_OPEN_FAILED", "Failed to open the bridge page.", details)["error"] + return None, 500, error_body("BROWSER_OPEN_FAILED", "Failed to open the bridge page.", details) + return capture, 200, None + + def request_cancel(self, capture): + with self._lock: + capture["status"] = "cancel_requested" + capture["cancelDeadlineAt"] = self.now() + CANCEL_TIMEOUT_SECONDS + + def mark_profile(self, capture, profile): + with self._lock: + capture["resultExpiresAt"] = self.now() + self.result_ttl_seconds + stored_profile, screenshot_asset = prepare_profile_for_storage(profile, capture) + capture["profile"] = stored_profile + capture["screenshotAsset"] = screenshot_asset + capture["status"] = "completed" + capture["phase"] = "cleanup" + self.schedule_result_expiry(capture) + + def touch_result(self, capture): + with self._lock: + if capture.get("status") != "completed": + return + capture["resultExpiresAt"] = self.now() + self.result_ttl_seconds + self.schedule_result_expiry(capture) + + def clear_result_expiry_timer(self, capture_id): + timer = self.result_expiry_timers.pop(capture_id, None) + if timer: + timer.cancel() + + def schedule_result_expiry(self, capture): + self.clear_result_expiry_timer(capture["id"]) + expires_at = capture.get("resultExpiresAt") + if not expires_at: + return + delay = max(0, expires_at - self.now()) + timer = self.timer_factory(delay, lambda: self.expire_result_by_id(capture["id"])) + timer.daemon = True + self.result_expiry_timers[capture["id"]] = timer + timer.start() + + def expire_result_by_id(self, capture_id): + with self._lock: + self.result_expiry_timers.pop(capture_id, None) + capture = self.captures.get(capture_id) + if capture: + self.expire_if_needed(capture) + + def expire_if_needed(self, capture): + with self._lock: + now = self.now() + if capture["status"] == "completed" and capture.get("resultExpiresAt") and capture["resultExpiresAt"] <= now: + capture["status"] = "expired" + capture["profile"] = None + capture["screenshotAsset"] = None + capture["error"] = error_body("CAPTURE_RESULT_EXPIRED", "Capture result expired.")["error"] + self.clear_result_expiry_timer(capture["id"]) + extension_deadline = capture.get("extensionDeadlineAt") + if capture["status"] in {"queued", "waiting_extension"} and extension_deadline is not None and extension_deadline <= now: + capture["status"] = "failed" + capture["error"] = error_body("EXTENSION_NOT_CONNECTED", "StackPrism extension did not connect before the deadline.")["error"] + capture_deadline = capture.get("deadlineAt") + if capture["status"] == "running" and capture_deadline is not None and capture_deadline <= now: + capture["status"] = "failed" + capture["error"] = capture_deadline_error(capture) + if capture["status"] == "cancel_requested" and capture.get("cancelDeadlineAt") and capture["cancelDeadlineAt"] <= now: + capture["status"] = "cancelled" + capture["error"] = error_body("CAPTURE_TIMEOUT", "Capture cancellation timed out.", {"reason": "cancel_timeout"})["error"] + + def prune_terminal_records(self): + with self._lock: + overflow = len(self.captures) - MAX_CAPTURE_RECORDS + if overflow <= 0: + return + terminal = sorted( + (item for item in self.captures.values() if item["status"] not in {"queued", "waiting_extension", "running", "cancel_requested"}), + key=lambda item: item.get("createdAt", 0), + ) + for item in terminal[:overflow]: + self.clear_result_expiry_timer(item["id"]) + self.captures.pop(item["id"], None) + + def clear(self): + with self._lock: + for capture_id in list(self.result_expiry_timers.keys()): + self.clear_result_expiry_timer(capture_id) + self.captures.clear() diff --git a/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/http_handler_base.py b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/http_handler_base.py new file mode 100644 index 00000000..754c6462 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/http_handler_base.py @@ -0,0 +1,55 @@ +from http.server import BaseHTTPRequestHandler +import time + +from .body import read_json_body +from .protocol import error_body, json_bytes +from .security import auth_api, auth_capture, bad_shell_error, cross_origin_error, rate_limited + + +JSON_BODY_LIMIT = 5 * 1024 * 1024 + + +class BaseBridgeHandler(BaseHTTPRequestHandler): + def log_message(self, _format, *args): + return + + def send_json(self, status, body, extra_headers=None): + payload = json_bytes(body) + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + for key, value in (extra_headers or {}).items(): + self.send_header(key, value) + self.end_headers() + self.wfile.write(payload) + + def fail(self, status, code, message, details=None, extra_headers=None): + self.send_json(status, error_body(code, message, details), extra_headers) + + def method_not_allowed(self, allow): + self.send_json(405, error_body("METHOD_NOT_ALLOWED", "Method is not supported."), {"Allow": allow}) + + def reject_bad_shell(self): + if error := bad_shell_error(self): + self.fail(*error) + return True + return False + + def reject_cross_origin_sensitive_request(self): + if error := cross_origin_error(self): + self.fail(*error) + return True + return False + + def auth_api(self): + return auth_api(self) + + def auth_capture(self, capture, scope): + return auth_capture(self, capture, scope) + + def rate_limited(self, token, bucket_name): + return rate_limited(self, token, bucket_name, int(time.time() // 60)) + + def read_json(self, limit=JSON_BODY_LIMIT, too_large_code="REQUEST_TOO_LARGE"): + return read_json_body(self, limit, too_large_code) diff --git a/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/http_server.py b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/http_server.py new file mode 100644 index 00000000..1a1770bb --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/http_server.py @@ -0,0 +1,292 @@ +from urllib.parse import urlparse +import re + +from .bridge_page import render_bridge_page +from .http_handler_base import BaseBridgeHandler +from .protocol import ( + PROTOCOL_VERSION, + SERVICE, + VERSION, + error_body, + sanitize_bridge_error, + safe_equal, + valid_id, +) +from .profile_response import get_profile, get_profile_download, get_screenshot_download +from .security import bridge_query_value, valid_bridge_query +from .status import FINAL_STATES, public_status, validate_status_update +from .url_policy import normalize_capture_request, validate_final_url, validate_target_network_address + + +PROFILE_BODY_LIMIT = 8 * 1024 * 1024 +DEFAULT_REQUEST_TIMEOUT_SECONDS = 35 + + +class BridgeHandler(BaseBridgeHandler): + server_version = "StackPrismBridge/0.1" + + def setup(self): + super().setup() + self.connection.settimeout(DEFAULT_REQUEST_TIMEOUT_SECONDS) + self.connection_accepted = False + with self.server.connection_lock: + if self.server.active_connections >= self.server.max_open_connections: + self.close_connection = True + self.connection.close() + return + self.server.active_connections += 1 + self.connection_accepted = True + + def finish(self): + try: + if self.connection_accepted: + super().finish() + finally: + if self.connection_accepted: + with self.server.connection_lock: + self.server.active_connections = max(0, self.server.active_connections - 1) + + def capture_route(self, path): + match = re.match(r"^/v1/captures/([^/]+)(?:/(request|control|status|profile|profile-download)|/(screenshot-download)/([^/]+))?$", path) + if not match or not valid_id("captureId", match[1]): + return None + return self.server.store.get(match[1]), match[2] or match[3] or "", match[4] or "" + + def do_GET(self): + if self.reject_bad_shell(): + return + parsed = urlparse(self.path) + if parsed.path == "/health": + self.send_json(200, {"ok": True, "service": SERVICE, "version": VERSION, "protocolVersion": PROTOCOL_VERSION, "bound": "127.0.0.1", "activeCaptures": self.server.store.active_count()}) + return + if parsed.path == "/bridge": + if self.reject_cross_origin_sensitive_request(): + return + if not valid_bridge_query(parsed.query): + self.fail(400, "INVALID_REQUEST", "Bridge query is invalid.") + return + capture = self.server.store.get(bridge_query_value(parsed.query, "capture")) + if not capture or capture["sessionId"] != bridge_query_value(parsed.query, "session") or capture["nonce"] != bridge_query_value(parsed.query, "nonce"): + self.fail(404, "NOT_FOUND", "Capture bridge page was not found.") + return + render_bridge_page(self, capture) + return + if parsed.path == "/v1/captures": + self.method_not_allowed("POST") + return + routed = self.capture_route(parsed.path) + if not routed: + self.fail(404, "NOT_FOUND", "Endpoint was not found.") + return + self.handle_capture_get(*routed) + + def handle_capture_get(self, capture, endpoint, screenshot_download_id=""): + if not capture: + self.fail(404, "NOT_FOUND", "Capture was not found.") + return + if self.reject_cross_origin_sensitive_request(): + return + if endpoint == "": + token_type = self.auth_capture(capture, "status") + if token_type and not (token_type == "api" and self.rate_limited(self.server.api_token, "query")): + self.send_json(200, public_status(capture)) + elif token_type == "api": + self.fail(429, "RATE_LIMITED", "Agent bridge rate limit exceeded.") + return + if endpoint == "request" and self.auth_capture(capture, "bridge"): + with self.server.store._lock: + capture["bridgeTokenClaimedAt"] = self.server.store.now() + body = { + "captureId": capture["id"], + "sessionId": capture["sessionId"], + "nonce": capture["nonce"], + "protocolVersion": PROTOCOL_VERSION, + "request": capture["request"], + } + self.send_json(200, body) + return + if endpoint == "control" and self.auth_capture(capture, "bridge"): + command = "cancel" if capture["status"] in {"cancel_requested", "completed", "cancelled", "failed", "expired"} else "continue" + self.send_json(200, {"id": capture["id"], "command": command, "status": capture["status"]}) + return + if endpoint == "profile": + get_profile(self, capture) + return + if endpoint == "profile-download": + get_profile_download(self, capture) + return + if endpoint == "screenshot-download": + if not (valid_id("screenshotDownloadId", screenshot_download_id) and safe_equal(screenshot_download_id, capture.get("screenshotDownloadId"))): + self.fail(403, "FORBIDDEN", "Screenshot download URL is not valid for this capture.", extra_headers={"Referrer-Policy": "no-referrer"}) + return + get_screenshot_download(self, capture, require_auth=False) + return + self.method_not_allowed("GET, POST" if endpoint == "profile" else "GET") + + def do_OPTIONS(self): + if self.reject_bad_shell(): + return + self.method_not_allowed("GET, POST, DELETE") + + def do_CONNECT(self): + self.close_connection = True + if self.reject_bad_shell(): + return + self.method_not_allowed("GET, POST, DELETE") + + def do_POST(self): + if self.reject_bad_shell() or self.reject_cross_origin_sensitive_request(): + return + if self.path in {"/health", "/bridge"}: + self.method_not_allowed("GET") + return + if self.path == "/v1/captures": + self.create_capture() + return + routed = self.capture_route(urlparse(self.path).path) + if not routed: + self.fail(404, "NOT_FOUND", "Endpoint was not found.") + return + capture, endpoint, _screenshot_download_id = routed + if not capture: + self.fail(404, "NOT_FOUND", "Capture was not found.") + elif endpoint == "profile": + self.post_profile(capture) + elif endpoint == "status": + self.post_status(capture) + else: + self.method_not_allowed("GET, DELETE" if endpoint == "" else "GET") + + def create_capture(self): + if not self.auth_api(): + return + if self.rate_limited(self.server.api_token, "create"): + self.fail(429, "RATE_LIMITED", "Agent bridge rate limit exceeded.") + return + body = self.read_json() + if body is None: + return + request, code, details, message = normalize_capture_request(body, self.server.store.base_url) + if not request: + self.fail(400, code, message or "Capture request is invalid.", details) + return + capture, status, err = self.server.store.create(request) + if err: + self.send_json(status, err) + return + self.send_json(200, {"id": capture["id"], "status": capture["status"], "bridgeUrl": capture["bridgeUrl"], "profileUrl": capture["profileUrl"]}) + + def post_profile(self, capture): + if not self.auth_capture(capture, "bridge"): + return + with self.server.store._lock: + if capture["status"] in FINAL_STATES: + code = "CAPTURE_ALREADY_COMPLETED" if capture["status"] == "completed" else "STALE_STATUS_UPDATE" + response = ("fail", 409, code, "Capture is already terminal.", {"status": capture["status"]}) + elif not capture.get("finalUrl"): + response = ("fail", 409, "INVALID_REQUEST", "Capture final URL has not been accepted.", None) + else: + response = None + if response and response[0] == "fail": + self.fail(response[1], response[2], response[3], response[4]) + elif response: + self.send_json(200, response[1]) + else: + body = self.read_json(PROFILE_BODY_LIMIT, "PROFILE_TOO_LARGE") + if body is None: + return + with self.server.store._lock: + if capture["status"] in FINAL_STATES: + code = "CAPTURE_ALREADY_COMPLETED" if capture["status"] == "completed" else "STALE_STATUS_UPDATE" + response = ("fail", 409, code, "Capture is already terminal.", {"status": capture["status"]}) + elif body.get("schema") != "stackprism.site_experience_profile.v1" or body.get("captureId") != capture["id"]: + response = ("fail", 400, "INVALID_REQUEST", "Profile schema or capture id is invalid.", None) + else: + self.server.store.mark_profile(capture, body) + response = ("json", public_status(capture)) + if response[0] == "fail": + self.fail(response[1], response[2], response[3], response[4]) + else: + self.send_json(200, response[1]) + + def post_status(self, capture): + if not self.auth_capture(capture, "bridge"): + return + body = self.read_json() + if body is None: + return + final_url_result = None + network_result = (None, None) + if body.get("finalUrl"): + final_url_result = validate_final_url(body["finalUrl"], self.server.store.base_url, capture["request"]) + from_cache = body.get("targetNetworkFromCache") is True + network_result = validate_target_network_address(body.get("targetNetworkAddress"), capture["request"], from_cache, final_url_result[0] if final_url_result else None) + with self.server.store._lock: + valid, code, message = validate_status_update(capture, body) + if not valid: + response = ("fail", 400 if code == "INVALID_REQUEST" else 409, code, message, None) + elif body.get("status") == "running" and body.get("phase") == "target_loaded" and not body.get("finalUrl") and not capture.get("finalUrl"): + response = ("fail", 400, "INVALID_REQUEST", "target_loaded status requires finalUrl.", None) + elif body.get("finalUrl"): + final_url, code, details = final_url_result + network_code, network_details = network_result + if code: + capture["status"] = "failed" + capture["phase"] = body["phase"] + capture["error"] = error_body(code, "Final URL is blocked by target policy.", details)["error"] + response = ("fail", 409, code, "Final URL is blocked by target policy.", details) + elif network_code == "INVALID_REQUEST": + response = ("fail", 400, network_code, "Final URL is blocked by target policy.", network_details) + elif network_code: + message = "Final URL is blocked by target policy." + capture["status"] = "failed" + capture["phase"] = body["phase"] + capture["error"] = error_body(network_code, message, network_details)["error"] + response = ("fail", 409, network_code, message, network_details) + else: + capture["finalUrl"] = final_url + capture["sequence"] = body["sequence"] + capture["status"] = body["status"] + capture["phase"] = body["phase"] + capture["error"] = sanitize_bridge_error(body["error"]) if body.get("error") else capture["error"] + response = ("json", public_status(capture)) + else: + capture["sequence"] = body["sequence"] + capture["status"] = body["status"] + capture["phase"] = body["phase"] + capture["error"] = sanitize_bridge_error(body["error"]) if body.get("error") else capture["error"] + response = ("json", public_status(capture)) + if response[0] == "fail": + self.fail(response[1], response[2], response[3], response[4]) + else: + self.send_json(200, response[1]) + + def do_DELETE(self): + if self.reject_bad_shell() or self.reject_cross_origin_sensitive_request(): + return + if self.path == "/v1/captures": + self.method_not_allowed("POST") + return + if self.path in {"/health", "/bridge"}: + self.method_not_allowed("GET") + return + routed = self.capture_route(urlparse(self.path).path) + if not routed: + self.fail(404, "NOT_FOUND", "Endpoint was not found.") + return + capture, endpoint, _screenshot_download_id = routed + if endpoint != "": + self.method_not_allowed("GET") + return + if not capture: + self.fail(404, "NOT_FOUND", "Capture was not found.") + return + if not self.auth_api(): + return + if capture["status"] in FINAL_STATES: + self.fail(409, "INVALID_REQUEST", "Capture is already terminal.", {"status": capture["status"]}) + elif capture["status"] == "cancel_requested": + self.fail(409, "STALE_STATUS_UPDATE", "Capture cancellation is already requested.", {"status": capture["status"]}) + else: + self.server.store.request_cancel(capture) + self.send_json(200, public_status(capture)) diff --git a/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/open_browser.py b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/open_browser.py new file mode 100644 index 00000000..055ac1f6 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/open_browser.py @@ -0,0 +1,131 @@ +import json +import os +import platform +import shutil +import subprocess +from urllib.parse import urlparse + + +DEFAULT_OPEN_TIMEOUT_SECONDS = 5 +MAX_OPEN_TIMEOUT_SECONDS = 30 +MAX_LAUNCH_PROBE_SECONDS = 1 +DEFAULT_WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD" + + +def contains_nul(value): + return isinstance(value, str) and "\0" in value or isinstance(value, list) and any(contains_nul(item) for item in value) + + +def has_path_separator(value): + return "/" in value or "\\" in value + + +def windows_command_candidates(command, env=os.environ): + extensions = [item for item in env.get("PATHEXT", DEFAULT_WINDOWS_PATHEXT).split(";") if item] + if any(command.lower().endswith(extension.lower()) for extension in extensions): + return [command] + return [f"{command}{extension}" for extension in extensions] + + +def parse_open_timeout_seconds(env): + value = env.get("STACKPRISM_BROWSER_OPEN_TIMEOUT_MS") + if value is None or value == "": + return True, DEFAULT_OPEN_TIMEOUT_SECONDS, None + if not str(value).isdecimal(): + return False, None, {"reason": "invalid_open_timeout"} + timeout_ms = int(value) + if timeout_ms < 100 or timeout_ms > MAX_OPEN_TIMEOUT_SECONDS * 1000: + return False, None, {"reason": "invalid_open_timeout"} + return True, timeout_ms / 1000, None + + +def parse_open_config(env=os.environ): + if any("\0" in str(env.get(key, "")) for key in ("STACKPRISM_BROWSER_OPEN_COMMAND", "STACKPRISM_BROWSER_OPEN_ARGS_JSON")): + return False, "BRIDGE_INVALID_ENV", "Browser open environment contains NUL." + if env.get("STACKPRISM_BROWSER_OPEN_COMMAND") and env.get("STACKPRISM_BROWSER_OPEN_ARGS_JSON"): + try: + if contains_nul(json.loads(env["STACKPRISM_BROWSER_OPEN_ARGS_JSON"])): + return False, "BRIDGE_INVALID_ENV", "Browser open environment contains NUL." + except Exception: + pass + return True, None, None + + +def resolve_browser_open_command(env=os.environ, system=None): + command = env.get("STACKPRISM_BROWSER_OPEN_COMMAND") + args = [] + if command: + if env.get("STACKPRISM_BROWSER_OPEN_ARGS_JSON"): + try: + args = json.loads(env["STACKPRISM_BROWSER_OPEN_ARGS_JSON"]) + except Exception: + return False, {"reason": "invalid_open_args"} + if not isinstance(args, list) or any(not isinstance(arg, str) for arg in args): + return False, {"reason": "invalid_open_args"} + elif (system or platform.system()) == "Darwin": + command = "open" + elif (system or platform.system()) == "Windows": + command = "rundll32.exe" + args = ["url.dll,FileProtocolHandler"] + else: + command = "xdg-open" + return True, {"command": command, "args": args} + + +def command_exists(command): + if has_path_separator(command): + if os.name == "nt": + return any(os.path.isfile(candidate) for candidate in windows_command_candidates(command)) + return os.path.isfile(command) and os.access(command, os.X_OK) + return shutil.which(command) is not None + + +def detached_popen_kwargs(): + kwargs = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL} + if os.name == "nt": + kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + else: + kwargs["start_new_session"] = True + return kwargs + + +def open_browser(url, env=os.environ): + ok, code, message = parse_open_config(env) + if not ok: + return False, {"reason": code, "message": message} + if any(char in url for char in ("\0", "\n", "\r")): + return False, {"reason": "invalid_url"} + parsed_url = urlparse(url) + if parsed_url.scheme.lower() not in ("http", "https"): + return False, {"reason": "invalid_scheme", "allowed": ["http", "https"]} + if parsed_url.username or parsed_url.password: + return False, {"reason": "invalid_url"} + if env.get("STACKPRISM_BRIDGE_NO_OPEN") == "1": + return True, {"skipped": True} + + resolved_ok, resolved = resolve_browser_open_command(env) + if not resolved_ok: + return False, resolved + timeout_ok, timeout_seconds, timeout_details = parse_open_timeout_seconds(env) + if not timeout_ok: + return False, timeout_details + command = resolved["command"] + args = resolved["args"] + if not command_exists(command): + return False, {"reason": "command_not_found"} + + try: + process = subprocess.Popen([command, *args, url], **detached_popen_kwargs()) + try: + code = process.wait(timeout=min(timeout_seconds, MAX_LAUNCH_PROBE_SECONDS)) + except subprocess.TimeoutExpired: + return True, {} + if code != 0: + return False, {"reason": "open_failed", "exitCode": code} + except FileNotFoundError: + return False, {"reason": "command_not_found"} + except PermissionError: + return False, {"reason": "permission_denied"} + except Exception: + return False, {"reason": "spawn_failed"} + return True, {} diff --git a/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/profile_response.py b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/profile_response.py new file mode 100644 index 00000000..04ddd762 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/profile_response.py @@ -0,0 +1,215 @@ +import base64 +import copy +import re +from datetime import datetime, timezone + + +SCREENSHOT_DATA_URL_PATTERN = re.compile(r"^data:image/(jpeg|png|webp);base64,([A-Za-z0-9+/=]+)$", re.I) +SCREENSHOT_BASE64_OMITTED_NOTE = ( + "Screenshot image base64 is intentionally omitted from this Profile JSON. " + "To inspect actual visual appearance, download the image from downloadUrl while the local bridge is running and before availableUntil." +) +SCREENSHOT_PROFILE_JSON_NOTE = ( + "Profile JSON is standard JSON and cannot contain comments. " + "This note field is the durable instruction: screenshot base64 is omitted; use downloadUrl to inspect actual visual appearance." +) + + +def as_dict(value): + return value if isinstance(value, dict) else {} + + +def profile_visual_screenshot(profile): + visual_profile = as_dict(as_dict(profile).get("visualProfile")) + return as_dict(visual_profile.get("screenshot")) + + +def screenshot_extension_for(mime_type): + if mime_type == "image/png": + return "png" + if mime_type == "image/webp": + return "webp" + return "jpg" + + +def available_until_for(capture): + expires_at = capture.get("resultExpiresAt") + if not expires_at: + return "" + timestamp = datetime.fromtimestamp(expires_at, timezone.utc) + milliseconds = timestamp.microsecond // 1000 + return f"{timestamp.strftime('%Y-%m-%dT%H:%M:%S')}.{milliseconds:03d}Z" + + +def screenshot_asset_from(screenshot): + data_url = screenshot.get("dataUrl") + match = SCREENSHOT_DATA_URL_PATTERN.match(data_url) if isinstance(data_url, str) else None + if not match: + return None + mime_type = f"image/{match.group(1).lower()}" + try: + data = base64.b64decode(match.group(2), validate=True) + except Exception: + return None + if not data: + return None + metadata = {key: value for key, value in screenshot.items() if key != "dataUrl"} + return {"data": data, "mimeType": mime_type, "extension": screenshot_extension_for(mime_type), "metadata": metadata} + + +def screenshot_metadata_for(capture, asset): + metadata = dict(asset.get("metadata") or {}) + metadata.update( + { + "mimeType": asset["mimeType"], + "byteLength": len(asset["data"]), + "downloadUrl": capture.get("screenshotUrl"), + "downloadMethod": "GET", + "lifecycle": { + "requiresLocalBridge": True, + "availableUntil": available_until_for(capture), + "note": "Download the screenshot before the local bridge process exits or the capture result expires.", + }, + "profileJsonNote": SCREENSHOT_PROFILE_JSON_NOTE, + "note": SCREENSHOT_BASE64_OMITTED_NOTE, + } + ) + return metadata + + +def screenshot_payload_for_capture(capture): + asset = capture.get("screenshotAsset") or screenshot_asset_from(profile_visual_screenshot(capture.get("profile"))) + if not asset or not asset.get("data"): + return None + return { + "data": asset["data"], + "mimeType": asset["mimeType"], + "extension": asset["extension"], + "metadata": screenshot_metadata_for(capture, asset), + } + + +def ensure_visual_reference(profile): + if not isinstance(profile.get("agentGuidance"), dict): + profile["agentGuidance"] = {} + if not isinstance(profile["agentGuidance"].get("recreationPlan"), dict): + profile["agentGuidance"]["recreationPlan"] = {} + if not isinstance(profile["agentGuidance"]["recreationPlan"].get("visualReference"), dict): + profile["agentGuidance"]["recreationPlan"]["visualReference"] = {} + return profile["agentGuidance"]["recreationPlan"]["visualReference"] + + +def update_visual_reference(profile, capture, payload): + visual_reference = ensure_visual_reference(profile) if payload else (((profile or {}).get("agentGuidance") or {}).get("recreationPlan") or {}).get("visualReference") + if not isinstance(visual_reference, dict): + return + visual_reference["screenshotIncluded"] = bool(payload) + visual_reference["screenshotBase64Included"] = False + visual_reference["screenshotDownloadUrl"] = capture.get("screenshotUrl") if payload else "" + visual_reference["screenshotDownloadHint"] = ( + SCREENSHOT_BASE64_OMITTED_NOTE + if payload + else "No screenshot image is available in this capture. Review limitations before treating visual evidence as absent." + ) + visual_reference["screenshotProfileJsonNote"] = SCREENSHOT_PROFILE_JSON_NOTE + if payload: + visual_reference["screenshotMimeType"] = payload["mimeType"] + visual_reference["screenshotByteLength"] = len(payload["data"]) + visual_reference["screenshotAvailableUntil"] = available_until_for(capture) + else: + visual_reference.pop("screenshotMimeType", None) + visual_reference.pop("screenshotByteLength", None) + visual_reference.pop("screenshotAvailableUntil", None) + + +def prepare_profile_for_storage(profile, capture): + stored_profile = copy.deepcopy(as_dict(profile)) + visual_profile = as_dict(stored_profile.get("visualProfile")) + screenshot = as_dict(visual_profile.get("screenshot")) + asset = screenshot_asset_from(screenshot) + if isinstance(screenshot, dict): + if asset: + visual_profile["screenshot"] = screenshot_metadata_for(capture, asset) + else: + screenshot.pop("dataUrl", None) + update_visual_reference(stored_profile, capture, asset) + return stored_profile, asset + + +def profile_for_agent(capture): + profile = copy.deepcopy(as_dict(capture.get("profile"))) + payload = screenshot_payload_for_capture(capture) + visual_profile = as_dict(profile.get("visualProfile")) + screenshot = as_dict(visual_profile.get("screenshot")) + if isinstance(screenshot, dict): + if payload: + visual_profile["screenshot"] = payload["metadata"] + else: + screenshot.pop("dataUrl", None) + update_visual_reference(profile, capture, payload) + return profile + + +def get_profile(handler, capture): + token_type = handler.auth_capture(capture, "status") + if not token_type: + return + profile_headers = {"Referrer-Policy": "no-referrer"} + if token_type == "bridge": + handler.fail(403, "BRIDGE_TOKEN_CANNOT_READ_PROFILE", "Bridge token cannot read the profile endpoint.", extra_headers=profile_headers) + elif token_type == "api" and handler.rate_limited(handler.server.api_token, "query"): + handler.fail(429, "RATE_LIMITED", "Agent bridge rate limit exceeded.", extra_headers=profile_headers) + elif capture["status"] == "expired": + handler.fail(410, "CAPTURE_RESULT_EXPIRED", "Capture result expired.", extra_headers=profile_headers) + elif capture["status"] != "completed": + handler.fail(409, "INVALID_REQUEST", "Capture profile is not ready.", extra_headers=profile_headers) + else: + handler.server.store.touch_result(capture) + handler.send_json(200, profile_for_agent(capture), profile_headers) + + +def get_profile_download(handler, capture): + if not handler.auth_capture(capture, "download"): + return + headers = { + "Referrer-Policy": "no-referrer", + "Content-Disposition": f'attachment; filename="stackprism-{capture["id"]}-profile.json"', + } + if capture["status"] == "expired": + handler.fail(410, "CAPTURE_RESULT_EXPIRED", "Capture result expired.", extra_headers=headers) + elif capture["status"] != "completed": + handler.fail(409, "INVALID_REQUEST", "Capture profile is not ready.", {"status": capture["status"]}, extra_headers=headers) + else: + capture["profileDownloadReadyAt"] = capture.get("profileDownloadReadyAt") or handler.server.store.now() + handler.server.store.touch_result(capture) + handler.send_json(200, profile_for_agent(capture), headers) + + +def get_screenshot_download(handler, capture, require_auth=True): + if require_auth and not handler.auth_capture(capture, "download"): + return + headers = {"Referrer-Policy": "no-referrer"} + payload = screenshot_payload_for_capture(capture) if capture.get("status") == "completed" else None + if payload: + headers.update( + { + "Content-Type": payload["mimeType"], + "Content-Disposition": f'attachment; filename="stackprism-{capture["id"]}-screenshot.{payload["extension"]}"', + "Content-Length": str(len(payload["data"])), + } + ) + if capture["status"] == "expired": + handler.fail(410, "CAPTURE_RESULT_EXPIRED", "Capture result expired.", extra_headers=headers) + elif capture["status"] != "completed": + handler.fail(409, "INVALID_REQUEST", "Capture screenshot is not ready.", {"status": capture["status"]}, extra_headers=headers) + elif not payload: + handler.fail(404, "NOT_FOUND", "Capture screenshot is not available.", extra_headers=headers) + else: + handler.server.store.touch_result(capture) + handler.send_response(200) + handler.send_header("Cache-Control", "no-store") + handler.send_header("X-Content-Type-Options", "nosniff") + for key, value in headers.items(): + handler.send_header(key, value) + handler.end_headers() + handler.wfile.write(payload["data"]) diff --git a/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/profile_summary.py b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/profile_summary.py new file mode 100644 index 00000000..145d2498 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/profile_summary.py @@ -0,0 +1,210 @@ +import re + +from .protocol import redact_url + +MAX_TEXT = 120 +MAX_ITEMS = 6 +TOKEN_TEXT = re.compile(r"\b(apiToken|bridgeToken|authorization|cookie|nonce|secret|token)\b\s*[:=]\s*(?:Bearer\s+)?[^\s,;]+", re.I) +ID_TEXT = re.compile(r"\b(?:spbt?_|cap_|s_|n_|xfer_|shot_)[A-Za-z0-9_-]{8,}\b") +EMAIL_TEXT = re.compile(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", re.I) +PHONE_TEXT = re.compile(r"\b(?:\+?\d[\d -]{8,}\d)\b") +URL_TEXT = re.compile(r"https?://[^\s\"')\]}]+") + + +def is_record(value): + return isinstance(value, dict) + + +def safe_text(value, max_len=MAX_TEXT): + text = re.sub(r"[\x00-\x1f\x7f]", " ", str(value or "")) + text = re.sub(r"\s+", " ", text).strip() + text = URL_TEXT.sub(lambda match: redact_url(match.group(0)) or "[redacted-url]", text) + text = TOKEN_TEXT.sub(lambda match: f"{match.group(1)}=[redacted]", text) + text = ID_TEXT.sub("[redacted-id]", text) + text = EMAIL_TEXT.sub("[redacted-email]", text) + text = PHONE_TEXT.sub("[redacted-number]", text) + return text[:max_len] + + +def values(value, limit=MAX_ITEMS): + source = value if isinstance(value, list) else [value] if isinstance(value, str) else [] + result = [] + for item in source: + text = safe_text(item) + if text and text not in result: + result.append(text) + if len(result) >= limit: + break + return result + + +def object_values(items, keys=("name", "type", "category", "domain", "label"), limit=MAX_ITEMS): + if not isinstance(items, list): + return [] + result = [] + for item in items: + if is_record(item): + text = next((safe_text(item.get(key)) for key in keys if safe_text(item.get(key))), "") + else: + text = safe_text(item) + if text: + result.append(text) + if len(result) >= limit: + break + return result + + +def count(value): + return len(value) if isinstance(value, (list, dict)) else 0 + + +def add(items, label, value): + text = safe_text(value) + if text: + items.append(f"{label}: {text}") + + +def add_list(items, label, value, limit=MAX_ITEMS): + text_values = values(value, limit) + if text_values: + items.append(f"{label}: {', '.join(text_values)}") + + +def add_object_list(items, label, value, limit=MAX_ITEMS): + text_values = object_values(value, limit=limit) + if text_values: + items.append(f"{label}: {', '.join(text_values)}") + + +def card(card_id, title, items): + return {"id": card_id, "title": title, "items": items} if items else None + + +def nested(record, *keys): + value = record + for key in keys: + value = value.get(key) if is_record(value) else None + return value if is_record(value) else {} + + +def target_card(profile, capture, screenshot): + target = profile.get("target") if is_record(profile.get("target")) else {} + items = [] + add(items, "目标 URL", capture.get("finalUrl") or (capture.get("request") or {}).get("url") or target.get("finalUrl") or target.get("url")) + add(items, "页面语言", target.get("language")) + add(items, "生成时间", profile.get("generatedAt")) + items.append(f"截图: {'已包含' if screenshot else '未包含'}") + return card("target", "目标", items) + + +def tech_card(profile): + tech = profile.get("techProfile") if is_record(profile.get("techProfile")) else {} + technologies = tech.get("technologies") if isinstance(tech.get("technologies"), list) else [] + items = [] + if technologies: + items.append(f"技术数量: {len(technologies)}") + add_object_list(items, "主要技术", technologies) + add(items, "前端主栈", tech.get("primaryFrontend")) + add(items, "UI 框架", tech.get("uiFramework")) + add(items, "构建运行时", tech.get("buildRuntime")) + add_list(items, "第三方服务", tech.get("thirdPartyServices")) + return card("tech", "技术栈", items) + + +def visual_card(profile, screenshot): + visual = profile.get("visualProfile") if is_record(profile.get("visualProfile")) else {} + tokens = nested(profile, "agentGuidance", "recreationPlan", "designTokens") + ref = nested(profile, "agentGuidance", "recreationPlan", "visualReference") + items = [f"截图: {'可用于视觉对照' if screenshot else '未包含'}"] + add_list(items, "颜色", visual.get("colorTokens") or tokens.get("colors")) + add_list(items, "字体", visual.get("fonts") or tokens.get("fontFamilies")) + add_list(items, "字号", visual.get("fontSizes") or tokens.get("fontSizes")) + add(items, "截图范围", ref.get("screenshotScope")) + return card("visual", "视觉", items) + + +def layout_card(profile): + layout = profile.get("layoutProfile") if is_record(profile.get("layoutProfile")) else {} + ux = profile.get("uxProfile") if is_record(profile.get("uxProfile")) else {} + blueprint = nested(profile, "agentGuidance", "recreationPlan", "layoutBlueprint") + items = [] + add(items, "页面目的", ux.get("pagePurpose")) + add_list(items, "主要路径", ux.get("primaryUserPath")) + add_list(items, "信息层级", ux.get("informationHierarchy") or blueprint.get("informationHierarchy")) + add_list(items, "内容分组", ux.get("contentGrouping") or blueprint.get("contentGrouping")) + add_list(items, "Landmarks", layout.get("landmarks") or blueprint.get("landmarks")) + add(items, "导航深度", ux.get("navigationDepth")) + return card("layout", "布局与信息结构", items) + + +def components_card(profile): + components = profile.get("componentProfile") if is_record(profile.get("componentProfile")) else {} + inventory = nested(profile, "agentGuidance", "recreationPlan", "componentInventory") + counts = components.get("counts") if is_record(components.get("counts")) else inventory.get("counts") + items = [] + if count(counts): + items.append(f"组件类型数: {count(counts)}") + add_list(items, "优先组件", inventory.get("priorityTypes")) + add_object_list(items, "组件样本", components.get("samples")) + add(items, "几何信息", "已包含" if inventory.get("geometryIncluded") is True else "未包含" if inventory.get("geometryIncluded") is False else "") + return card("components", "组件", items) + + +def interaction_card(profile): + interaction = profile.get("interactionProfile") if is_record(profile.get("interactionProfile")) else {} + ux = profile.get("uxProfile") if is_record(profile.get("uxProfile")) else {} + checklist = nested(profile, "agentGuidance", "recreationPlan", "interactionChecklist") + items = [] + add_list(items, "CTA", ux.get("ctaStrategy")) + add_list(items, "信任信号", ux.get("trustSignals")) + add_list(items, "转场", interaction.get("transitions") or checklist.get("transitions")) + add_list(items, "动画", interaction.get("animations") or checklist.get("animations")) + add_list(items, "固定元素", interaction.get("stickyOrFixed") or checklist.get("stickyOrFixed")) + add_list(items, "交互摩擦", ux.get("frictionPoints")) + return card("interaction", "交互与 UX", items) + + +def assets_card(profile): + assets = profile.get("assetProfile") if is_record(profile.get("assetProfile")) else {} + hints = nested(profile, "agentGuidance", "recreationPlan", "assetHints") + items = [] + if count(assets.get("scripts")) or hints.get("scriptCount"): + items.append(f"脚本: {count(assets.get('scripts')) or hints.get('scriptCount')}") + if count(assets.get("stylesheets")) or hints.get("stylesheetCount"): + items.append(f"样式表: {count(assets.get('stylesheets')) or hints.get('stylesheetCount')}") + add_list(items, "资源域名", hints.get("resourceDomains") or assets.get("resourceDomains")) + add_list(items, "CDN 线索", assets.get("cdnHints") or hints.get("cdnHints")) + add_list(items, "字体资源", assets.get("fontUrls") or hints.get("fontUrls")) + return card("assets", "资产", items) + + +def guidance_card(profile): + guidance = profile.get("agentGuidance") if is_record(profile.get("agentGuidance")) else {} + plan = guidance.get("recreationPlan") if is_record(guidance.get("recreationPlan")) else {} + items = [] + add(items, "摘要", guidance.get("summary")) + add_list(items, "实现顺序", plan.get("implementationOrder"), 4) + add_list(items, "验证项", plan.get("verificationChecklist"), 4) + add_list(items, "限制", profile.get("limitations"), 4) + return card("guidance", "复刻建议", items) + + +def copy_text_for(cards): + lines = ["# StackPrism Site Experience", "", "用于 AI Agent 快速复刻目标网站体验的受限摘要。"] + for item in cards: + lines.extend(["", f"## {item['title']}"]) + lines.extend([f"- {entry}" for entry in item["items"]]) + lines.extend(["", "备注: 本摘要不包含 raw profile、token、nonce、截图 data URL 或完整敏感文本。"]) + return "\n".join(lines) + + +def profile_preview_summary(capture, screenshot): + profile = capture.get("profile") + if capture.get("status") != "completed" or not is_record(profile): + return None + cards = [item for item in [ + guidance_card(profile), visual_card(profile, screenshot), layout_card(profile), + components_card(profile), interaction_card(profile), tech_card(profile), assets_card(profile), + target_card(profile, capture, screenshot) + ] if item] + return {"contentSummary": {"cards": cards}, "copyText": copy_text_for(cards)} if cards else None diff --git a/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/protocol.py b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/protocol.py new file mode 100644 index 00000000..fc74789b --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/protocol.py @@ -0,0 +1,230 @@ +import base64 +import hmac +import json +import re +import secrets + +SERVICE = "stackprism-agent-bridge" +VERSION = "0.1.0" +PROTOCOL_VERSION = 1 + +BRIDGE_ERROR_CODES = { + "NOT_FOUND", + "METHOD_NOT_ALLOWED", + "UNAUTHORIZED", + "FORBIDDEN", + "ORIGIN_NOT_ALLOWED", + "UNSUPPORTED_MEDIA_TYPE", + "UNSUPPORTED_TRANSFER_ENCODING", + "INVALID_JSON", + "INVALID_REQUEST", + "REQUEST_TOO_LARGE", + "REQUEST_TIMEOUT", + "SERVER_BUSY", + "STALE_STATUS_UPDATE", + "PORT_IN_USE", + "BRIDGE_INVALID_ENV", + "BRIDGE_START_FAILED", + "BRIDGE_START_TIMEOUT", + "BRIDGE_READY_PARSE_FAILED", + "BRIDGE_PROTOCOL_UNSUPPORTED", + "BRIDGE_PAGE_RENDER_FAILED", + "BRIDGE_REQUEST_TIMEOUT", + "BRIDGE_REQUEST_MISMATCH", + "AGENT_BRIDGE_DISABLED", + "CAPTURE_BUSY", + "CAPTURE_TIMEOUT", + "EXTENSION_NOT_CONNECTED", + "BROWSER_OPEN_FAILED", + "BRIDGE_TOKEN_CANNOT_READ_PROFILE", + "PRIVATE_NETWORK_TARGET_BLOCKED", + "TARGET_DNS_LOOKUP_FAILED", + "BRIDGE_SELF_TARGET_BLOCKED", + "FINAL_URL_BLOCKED", + "ACTIVE_TAB_UNAVAILABLE", + "ACTIVE_TAB_MISMATCH", + "INCOGNITO_NOT_SUPPORTED", + "TARGET_LOAD_TIMEOUT", + "TARGET_LOAD_FAILED", + "TARGET_INJECTION_FAILED", + "TARGET_TAB_CLOSED", + "BRIDGE_TAB_CLOSED", + "TARGET_NAVIGATED_AWAY", + "SERVICE_WORKER_RESTARTED", + "BRIDGE_TRANSPORT_DISCONNECTED", + "PROFILE_TRANSPORT_FAILED", + "PROFILE_CHUNK_MISSING", + "PROFILE_HASH_MISMATCH", + "PROFILE_TOO_LARGE", + "RATE_LIMITED", + "NONCE_REUSED", + "CAPTURE_ALREADY_COMPLETED", + "CAPTURE_RESULT_EXPIRED", + "NOT_SUPPORTED", +} + +SENSITIVE_DETAIL_KEY = re.compile(r"authorization|cookie|token|nonce|secret", re.I) +ID_PATTERN = re.compile(r"\b(?:spbt?_|cap_|s_|n_|xfer_|shot_)[A-Za-z0-9_-]{8,}\b") +URL_PATTERN = re.compile(r"https?://[^\s\"')\]}]+") +SENSITIVE_PATH_WORD_PATTERN = re.compile(r"^(?:token|secret|session|auth|authorization|signature|password|cookie|passcode)$", re.I) +SENSITIVE_PATH_SHORT_TOKEN_PATTERN = re.compile(r"(?:^|[-_.])(?:key|pass)(?:$|[-_.])", re.I) +SENSITIVE_PATH_COMPOUND_PATTERN = re.compile( + r"^(?:(?:api|access|private|public|secret|session|auth)[-_.]?(?:key|pass|token|secret|signature|code|id)|(?:key|pass)[-_.]?(?:token|secret|signature|code|id)|(?:reset|verify|access|auth|session|csrf|xsrf)[-_.]?(?:token|code|secret|key|signature))$", + re.I, +) +SENSITIVE_PATH_CAMEL_PATTERN = re.compile( + r"^(?:apiKey|privateKey|publicKey|accessToken|refreshToken|sessionId|secretToken|authToken|csrfToken|xsrfToken)$", + re.I, +) +HIGH_ENTROPY_PATH_SEGMENT_PATTERN = re.compile(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z0-9_-]{24,}$") +MAX_ERROR_TEXT_LENGTH = 512 +MAX_ERROR_DETAIL_DEPTH = 4 +MAX_ERROR_DETAIL_KEYS = 50 +MAX_ERROR_DETAIL_ARRAY_ITEMS = 20 + +ID_PATTERNS = { + "apiToken": re.compile(r"^spb_[A-Za-z0-9_-]{43}$"), + "bridgeToken": re.compile(r"^spbt_[A-Za-z0-9_-]{43}$"), + "captureId": re.compile(r"^cap_[A-Za-z0-9_-]{22}$"), + "sessionId": re.compile(r"^s_[A-Za-z0-9_-]{22}$"), + "nonce": re.compile(r"^n_[A-Za-z0-9_-]{22}$"), + "screenshotDownloadId": re.compile(r"^shot_[A-Za-z0-9_-]{43}$"), + "profileTransferId": re.compile(r"^xfer_[A-Za-z0-9_-]{22}$"), + "cspNonce": re.compile(r"^[A-Za-z0-9_-]{22}$"), +} + + +def random_id(prefix, size): + return prefix + base64.urlsafe_b64encode(secrets.token_bytes(size)).decode("ascii").rstrip("=") + + +def new_api_token(): + return random_id("spb_", 32) + + +def new_bridge_token(): + return random_id("spbt_", 32) + + +def new_capture_id(): + return random_id("cap_", 16) + + +def new_session_id(): + return random_id("s_", 16) + + +def new_nonce(): + return random_id("n_", 16) + + +def new_screenshot_download_id(): + return random_id("shot_", 32) + + +def new_csp_nonce(): + return base64.urlsafe_b64encode(secrets.token_bytes(16)).decode("ascii").rstrip("=") + + +def valid_id(kind, value): + pattern = ID_PATTERNS.get(kind) + return isinstance(value, str) and pattern is not None and bool(pattern.match(value)) + + +def is_known_bridge_error_code(value): + return isinstance(value, str) and value in BRIDGE_ERROR_CODES + + +def safe_equal(left, right): + if not isinstance(left, str) or not isinstance(right, str): + return False + left_bytes = left.encode("utf-8") + right_bytes = right.encode("utf-8") + comparison_length = max(len(left_bytes), len(right_bytes)) + padded_left = left_bytes.ljust(comparison_length, b"\0") + padded_right = right_bytes.ljust(comparison_length, b"\0") + return hmac.compare_digest(padded_left, padded_right) and len(left_bytes) == len(right_bytes) + + +def html_escape_script_json(value): + return ( + json.dumps(value, separators=(",", ":"), ensure_ascii=False) + .replace("<", "\\u003c") + .replace(">", "\\u003e") + .replace("&", "\\u0026") + .replace("\u2028", "\\u2028") + .replace("\u2029", "\\u2029") + ) + + +def is_sensitive_path_segment(segment): + text = str(segment or "") + return ( + bool(SENSITIVE_PATH_WORD_PATTERN.search(text)) + or bool(SENSITIVE_PATH_SHORT_TOKEN_PATTERN.search(text)) + or bool(SENSITIVE_PATH_COMPOUND_PATTERN.search(text)) + or bool(SENSITIVE_PATH_CAMEL_PATTERN.search(text)) + or bool(re.match(r"^[0-9a-f]{16,}$", text, re.I)) + or bool(HIGH_ENTROPY_PATH_SEGMENT_PATTERN.match(text)) + or "=" in text + ) + + +def redact_pathname(pathname): + return "/".join("[redacted]" if segment and is_sensitive_path_segment(segment) else segment for segment in str(pathname or "").split("/")) + + +def redact_url(value): + from urllib.parse import urlparse, urlunparse + + parsed = urlparse(str(value or "")) + host = parsed.hostname or "" + if ":" in host and not host.startswith("["): + host = f"[{host}]" + netloc = f"{host}:{parsed.port}" if parsed.port else host + query = "[redacted]" if parsed.query else "" + return urlunparse((parsed.scheme, netloc, redact_pathname(parsed.path), parsed.params, query, "")) + + +def redact_error_text(value): + text = str(value or "") + text = URL_PATTERN.sub(lambda match: redact_url(match.group(0)) or "[redacted-url]", text) + text = ID_PATTERN.sub("[redacted-id]", text) + return text[:MAX_ERROR_TEXT_LENGTH] + + +def sanitize_error_value(key, value, depth=0): + if SENSITIVE_DETAIL_KEY.search(str(key or "")): + return "[redacted]" + if isinstance(value, str): + return redact_error_text(value) + if value is None or isinstance(value, (bool, int, float)): + return value + if depth >= MAX_ERROR_DETAIL_DEPTH: + return "[redacted-object]" + if isinstance(value, list): + return [sanitize_error_value("", item, depth + 1) for item in value[:MAX_ERROR_DETAIL_ARRAY_ITEMS]] + if isinstance(value, dict): + sanitized = {} + for child_key, child in list(value.items())[:MAX_ERROR_DETAIL_KEYS]: + sanitized_key = redact_error_text(child_key)[:64] or "field" + sanitized[sanitized_key] = sanitize_error_value(child_key, child, depth + 1) + return sanitized + return redact_error_text(value) + + +def sanitize_bridge_error(error): + source = error if isinstance(error, dict) else {} + raw_code = source.get("code") if isinstance(source.get("code"), str) else "" + code = raw_code if is_known_bridge_error_code(raw_code) else "INVALID_REQUEST" + message = redact_error_text(source.get("message") or code or "Capture status failed.") or "Capture status failed." + details = sanitize_error_value("details", source.get("details") or {}) + return {"code": code, "message": message, "details": details} + + +def error_body(code, message, details=None): + return {"error": {"code": code, "message": message, "details": details or {}}} + + +def json_bytes(value): + return json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode("utf-8") diff --git a/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/security.py b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/security.py new file mode 100644 index 00000000..2784c5e4 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/security.py @@ -0,0 +1,121 @@ +from urllib.parse import urlparse + +from .protocol import safe_equal, valid_id + + +SENSITIVE_DUPLICATE_HEADERS = {"Host", "Authorization", "Content-Type", "Content-Length"} +BRIDGE_QUERY_KINDS = { + "session": "sessionId", + "capture": "captureId", + "nonce": "nonce", +} + + +def parse_bridge_query(parsed_query): + parts = parsed_query.split("&") if parsed_query else [] + if len(parts) != 3: + return None + values = {} + for part in parts: + if not part or part.count("=") != 1: + return None + name, value = part.split("=", 1) + kind = BRIDGE_QUERY_KINDS.get(name) + if not kind or name in values or not valid_id(kind, value): + return None + values[name] = value + return values if set(values.keys()) == set(BRIDGE_QUERY_KINDS.keys()) else None + + +def valid_bridge_query(parsed_query): + return parse_bridge_query(parsed_query) is not None + + +def bridge_query_value(parsed_query, name): + return (parse_bridge_query(parsed_query) or {}).get(name, "") + + +def bad_shell_error(handler): + for name in SENSITIVE_DUPLICATE_HEADERS: + if len(handler.headers.get_all(name, [])) > 1: + return 400, "INVALID_REQUEST", "Ambiguous request headers are not allowed." + content_length = handler.headers.get("Content-Length") + if content_length is not None and (not content_length.isdecimal()): + return 400, "INVALID_REQUEST", "Content-Length is invalid." + if handler.headers.get("Host") != urlparse(handler.server.store.base_url).netloc: + return 400, "INVALID_REQUEST", "Host is not allowed." + path = handler.path + if not path.startswith("/") or path.startswith("//"): + return 400, "INVALID_REQUEST", "Only origin-form request targets are allowed." + raw_path, _, raw_query = path.partition("?") + if any(value in path.lower() for value in ("%2e", "%2f", "%5c")) or "\\" in path: + return 400, "INVALID_REQUEST", "Encoded path separators or dot segments are not allowed." + if raw_path != "/" and any(segment in {"", ".", ".."} for segment in raw_path.split("/")[1:]): + return 400, "INVALID_REQUEST", "Ambiguous path segments are not allowed." + if raw_query and raw_path != "/bridge": + return 400, "INVALID_REQUEST", "Query string is not allowed for this endpoint." + content_encoding = handler.headers.get("Content-Encoding") + if content_encoding and content_encoding.lower() != "identity": + return 415, "UNSUPPORTED_MEDIA_TYPE", "Content-Encoding is not supported." + transfer_encoding = handler.headers.get("Transfer-Encoding") + if transfer_encoding and handler.headers.get("Content-Length"): + return 400, "INVALID_REQUEST", "Content-Length and Transfer-Encoding cannot be combined." + if transfer_encoding: + return 400, "UNSUPPORTED_TRANSFER_ENCODING", "Transfer-Encoding is not supported." + return None + + +def cross_origin_error(handler): + base_url = handler.server.store.base_url + origin = handler.headers.get("Origin") + if origin and origin != base_url: + return 403, "ORIGIN_NOT_ALLOWED", "Origin is not allowed." + if referer := handler.headers.get("Referer"): + parsed = urlparse(referer) + if not parsed.scheme or not parsed.netloc or f"{parsed.scheme}://{parsed.netloc}" != base_url: + return 403, "ORIGIN_NOT_ALLOWED", "Referer is not allowed." + if handler.headers.get("Sec-Fetch-Site") not in {None, "same-origin", "none"}: + return 403, "ORIGIN_NOT_ALLOWED", "Sec-Fetch-Site is not allowed." + return None + + +def bearer_token(handler): + value = handler.headers.get("Authorization", "") + return value.removeprefix("Bearer ") if value.startswith("Bearer ") else "" + + +def auth_api(handler): + token = bearer_token(handler) + if not token: + handler.fail(401, "UNAUTHORIZED", "Bearer token is required.") + return False + if not safe_equal(token, handler.server.api_token): + handler.fail(403, "FORBIDDEN", "Token is not allowed for this endpoint.") + return False + return True + + +def auth_capture(handler, capture, scope): + token = bearer_token(handler) + if not token: + handler.fail(401, "UNAUTHORIZED", "Bearer token is required.") + return None + if scope in {"api", "status", "download"} and safe_equal(token, handler.server.api_token): + return "api" + if scope in {"bridge", "status", "download"} and safe_equal(token, capture["bridgeToken"]): + return "bridge" + handler.fail(403, "FORBIDDEN", "Token is not allowed for this endpoint.") + return None + + +def rate_limited(handler, token, bucket_name, now_window): + key = f"{token}:{bucket_name}" + with handler.server.rate_lock: + bucket = handler.server.rate_buckets.get(key) + if bucket is None or bucket["window"] != now_window: + handler.server.rate_buckets[key] = {"window": now_window, "count": 1} + return False + if bucket["count"] >= handler.server.rate_limits[bucket_name]: + return True + bucket["count"] += 1 + return False diff --git a/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/server_factory.py b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/server_factory.py new file mode 100644 index 00000000..26f45610 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/server_factory.py @@ -0,0 +1,50 @@ +import threading +import time +import os +from http.server import ThreadingHTTPServer + +from .capture_store import CaptureStore +from .http_server import BridgeHandler +from .open_browser import open_browser, parse_open_config +from .protocol import new_api_token + + +DEFAULT_CREATE_LIMIT_PER_MINUTE = 10 +DEFAULT_QUERY_LIMIT_PER_MINUTE = 120 +DEFAULT_MAX_OPEN_CONNECTIONS = 20 +DEFAULT_REQUEST_TIMEOUT_SECONDS = 35 + + +class BridgeServer(ThreadingHTTPServer): + allow_reuse_address = False + request_queue_size = DEFAULT_MAX_OPEN_CONNECTIONS + + +def create_server(port=0, now=time.time, rate_limits=None, max_open_connections=DEFAULT_MAX_OPEN_CONNECTIONS, env=None, result_ttl_seconds=None): + active_env = os.environ if env is None else env + open_config_ok, open_config_code, open_config_message = parse_open_config(active_env) + if not open_config_ok: + error = ValueError(open_config_message) + error.code = open_config_code + raise error + if not isinstance(max_open_connections, int) or max_open_connections <= 0: + max_open_connections = DEFAULT_MAX_OPEN_CONNECTIONS + api_token = new_api_token() + BridgeServer.request_queue_size = max_open_connections + server = BridgeServer(("127.0.0.1", port), BridgeHandler) + base_url = f"http://127.0.0.1:{server.server_address[1]}" + server.max_open_connections = max_open_connections + server.active_connections = 0 + server.connection_lock = threading.Lock() + server.api_token = api_token + ttl_seconds = result_ttl_seconds if result_ttl_seconds is not None else None + store_args = [base_url, now, lambda url: open_browser(url, active_env)] + server.store = CaptureStore(*store_args, result_ttl_seconds=ttl_seconds) if ttl_seconds is not None else CaptureStore(*store_args) + server.rate_limits = { + "create": (rate_limits or {}).get("createLimitPerMinute", DEFAULT_CREATE_LIMIT_PER_MINUTE), + "query": (rate_limits or {}).get("queryLimitPerMinute", DEFAULT_QUERY_LIMIT_PER_MINUTE), + } + server.rate_buckets = {} + server.rate_lock = threading.Lock() + server.timeout = DEFAULT_REQUEST_TIMEOUT_SECONDS + return server, {"baseUrl": base_url, "healthUrl": f"{base_url}/health", "apiToken": api_token} diff --git a/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/status.py b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/status.py new file mode 100644 index 00000000..389ce148 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/status.py @@ -0,0 +1,88 @@ +from .profile_response import screenshot_payload_for_capture +from .profile_summary import profile_preview_summary +from .protocol import PROTOCOL_VERSION, is_known_bridge_error_code, redact_url +from .url_policy import is_strict_int + +FINAL_STATES = {"completed", "failed", "cancelled", "expired"} +PLUGIN_WRITABLE_STATUSES = {"waiting_extension", "running", "cancelled", "failed"} +STATUS_PHASES = [ + "bridge_connected", + "request_loaded", + "target_opening", + "target_loaded", + "detecting_tech", + "profiling_experience", + "posting_profile", + "cleanup", +] +PHASE_ORDER = {phase: index for index, phase in enumerate(STATUS_PHASES)} +def screenshot_preview(capture): + payload = screenshot_payload_for_capture(capture) + screenshot = (((capture.get("profile") or {}).get("visualProfile") or {}).get("screenshot") or {}) + if not payload: + return None + return { + "downloadUrl": capture.get("screenshotUrl"), + "mimeType": payload["mimeType"], + "byteLength": len(payload["data"]), + "scope": screenshot.get("scope"), + } + + +def public_preview(capture): + preview = {} + target_url = redact_url(capture.get("finalUrl") or (capture.get("request") or {}).get("url")) + if target_url: + preview["targetUrl"] = target_url + screenshot = screenshot_preview(capture) if capture["status"] == "completed" else None + if screenshot: + preview["screenshot"] = screenshot + summary = profile_preview_summary(capture, screenshot) + if summary: + preview.update(summary) + return preview + + +def public_status(capture): + status = {"id": capture["id"], "status": capture["status"]} + if capture.get("phase"): + status["phase"] = capture["phase"] + if capture.get("error"): + status["error"] = capture["error"] + if capture.get("profileDownloadReadyAt"): + status["profileDownloadReady"] = True + preview = public_preview(capture) + if preview: + status["preview"] = preview + return status + + +def validate_status_update(capture, body): + if capture["status"] in FINAL_STATES: + return False, "STALE_STATUS_UPDATE", "Capture is already terminal." + if ( + body.get("captureId") != capture["id"] + or body.get("sessionId") != capture["sessionId"] + or body.get("nonce") != capture["nonce"] + or body.get("protocolVersion") != PROTOCOL_VERSION + ): + return False, "INVALID_REQUEST", "Capture status identity is invalid." + if body.get("status") not in PLUGIN_WRITABLE_STATUSES or body.get("phase") not in PHASE_ORDER: + return False, "INVALID_REQUEST", "Capture status or phase is invalid." + if body["status"] == "cancelled" and capture["status"] != "cancel_requested": + return False, "STALE_STATUS_UPDATE", "Capture cancellation was not requested." + if capture["status"] == "cancel_requested" and body["status"] != "cancelled": + return False, "STALE_STATUS_UPDATE", "Capture cancellation is already requested." + if body["status"] == "failed": + error = body.get("error") + if not isinstance(error, dict) or not (error.get("code") and error.get("message")): + return False, "INVALID_REQUEST", "Failed status requires a structured error." + if not is_known_bridge_error_code(error["code"]): + return False, "INVALID_REQUEST", "Failed status error code is invalid." + if body["status"] == "cancelled" and body["phase"] != "cleanup": + return False, "INVALID_REQUEST", "Cancelled status must use cleanup phase." + if not is_strict_int(body.get("sequence")) or body["sequence"] <= capture["sequence"]: + return False, "STALE_STATUS_UPDATE", "Capture status sequence is stale." + if PHASE_ORDER[body["phase"]] < PHASE_ORDER.get(capture.get("phase"), -1): + return False, "STALE_STATUS_UPDATE", "Capture phase cannot move backwards." + return True, None, None diff --git a/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/url_policy.py b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/url_policy.py new file mode 100644 index 00000000..554338c9 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/url_policy.py @@ -0,0 +1,290 @@ +import ipaddress +import re +import socket +from concurrent.futures import ThreadPoolExecutor, TimeoutError +from urllib.parse import urlparse, urlunparse + +from .protocol import PROTOCOL_VERSION + + +REQUEST_KEYS = {"url", "mode", "waitMs", "include", "viewports", "options"} +OPTION_KEYS = { + "forceRefresh", "captureScreenshotMetadata", "captureScreenshot", "keepTabOpen", + "allowPrivateNetworkTarget", "targetMode", "maxResourceUrls", +} +BOOLEAN_OPTION_KEYS = {"forceRefresh", "captureScreenshotMetadata", "captureScreenshot", "keepTabOpen", "allowPrivateNetworkTarget"} +INCLUDE_ORDER = ["tech", "visual", "layout", "components", "interaction", "ux", "assets"] +TARGET_MODES = {"reuse_or_new_tab", "new_tab", "active_tab"} +DNS_LOOKUP_TIMEOUT_SECONDS = 2.0 +_DNS_EXECUTOR = ThreadPoolExecutor(max_workers=4, thread_name_prefix="stackprism-dns") +PRIVATE_IP_NETWORKS = tuple( + ipaddress.ip_network(network) + for network in ( + "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", + "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", + "192.88.99.0/24", "192.168.0.0/16", "198.18.0.0/15", "198.51.100.0/24", + "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4", "255.255.255.255/32", + "::/128", "::1/128", "64:ff9b:1::/48", "100::/64", "2001::/23", "2001:db8::/32", + "2002::/16", "3fff::/20", "fc00::/7", "fe80::/10", "ff00::/8", + ) +) +PROXY_RESERVED_IP_NETWORKS = tuple(ipaddress.ip_network(network) for network in ("198.18.0.0/15",)) +PUBLIC_IP_EXCEPTIONS = tuple( + ipaddress.ip_network(network) + for network in ( + "192.0.0.9/32", + "192.0.0.10/32", + "2001:1::1/128", + "2001:1::2/128", + "2001:3::/32", + "2001:4:112::/48", + "2001:20::/28", + "2001:30::/28", + ) +) + + +def is_strict_int(value): + return isinstance(value, int) and not isinstance(value, bool) + + +def parse_ip_address(hostname): + host = (hostname or "").strip("[]") + try: + address = ipaddress.ip_address(host) + except ValueError: + try: + address = ipaddress.ip_address(socket.inet_aton(host)) + except OSError: + return None + if getattr(address, "ipv4_mapped", None): + address = address.ipv4_mapped + elif address.version == 6 and address.packed.startswith(b"\x00" * 12) and int(address) > 0xFFFF: + address = ipaddress.ip_address(address.packed[-4:]) + return address + + +def is_private_host(hostname): + if (hostname or "").strip("[]").lower() == "localhost": + return True + address = parse_ip_address(hostname) + if address is None: + return False + return any(address in network for network in PRIVATE_IP_NETWORKS) and not any(address in network for network in PUBLIC_IP_EXCEPTIONS) + + +def is_proxy_reserved_host(hostname): + address = parse_ip_address(hostname) + if address is None: + return False + return any(address in network for network in PROXY_RESERVED_IP_NETWORKS) + + +def is_ip_literal(hostname): + return parse_ip_address(hostname) is not None + + +def default_resolve_hostname(hostname): + future = _DNS_EXECUTOR.submit(socket.getaddrinfo, hostname, None, 0, socket.SOCK_STREAM) + try: + return [item[4][0] for item in future.result(timeout=DNS_LOOKUP_TIMEOUT_SECONDS)] + except TimeoutError as exc: + future.cancel() + raise TimeoutError("DNS lookup timed out.") from exc + + +def validate_dns_policy(hostname, allow_private_network_target, resolver): + if allow_private_network_target or is_ip_literal(hostname): + return None, None + try: + addresses = resolver(hostname) + except Exception: + return "TARGET_DNS_LOOKUP_FAILED", {"reason": "dns_lookup_failed"} + if not isinstance(addresses, list) or not addresses: + return "TARGET_DNS_LOOKUP_FAILED", {"reason": "dns_lookup_failed"} + if any(is_private_host(str(address)) and not is_proxy_reserved_host(str(address)) for address in addresses): + return "PRIVATE_NETWORK_TARGET_BLOCKED", {"reason": "private_network_address"} + return None, None + + +def valid_viewports(viewports): + if not isinstance(viewports, list) or len(viewports) > 3: + return False + for viewport in viewports: + if not isinstance(viewport, dict) or not set(viewport.keys()).issubset({"name", "width", "height", "deviceScaleFactor"}): + return False + name = viewport.get("name") + if name is not None and (not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,32}", name)): + return False + if not is_strict_int(viewport.get("width")) or not 320 <= viewport["width"] <= 3840: + return False + if not is_strict_int(viewport.get("height")) or not 320 <= viewport["height"] <= 2160: + return False + scale = viewport.get("deviceScaleFactor") + if isinstance(scale, bool) or not isinstance(scale, (int, float)) or not 1 <= scale <= 4: + return False + return True + + +def normalize_options(options): + if not isinstance(options, dict) or not set(options.keys()).issubset(OPTION_KEYS): + return None, "Unknown capture option field." + for key in BOOLEAN_OPTION_KEYS: + if key in options and not isinstance(options[key], bool): + return None, "Capture options are invalid." + target_mode = options.get("targetMode", "reuse_or_new_tab") + max_resource_urls = options.get("maxResourceUrls", 300) + if target_mode not in TARGET_MODES: + return None, "Capture targetMode is invalid." + if not is_strict_int(max_resource_urls) or not 0 <= max_resource_urls <= 1000: + return None, "Capture maxResourceUrls is invalid." + normalized = {key: options.get(key) is True for key in BOOLEAN_OPTION_KEYS} + return {**normalized, "targetMode": target_mode, "maxResourceUrls": max_resource_urls}, None + + +def invalid_request(message): + return None, "INVALID_REQUEST", None, message + + +def parse_capture_url(value): + try: + parsed = urlparse(str(value).strip()) + parsed.port + except Exception: + return None + if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password: + return None + return parsed + + +def normalized_netloc(parsed): + host = (parsed.hostname or "").lower() + if ":" in host and not host.startswith("["): + host = f"[{host}]" + default_port = (parsed.scheme == "http" and parsed.port == 80) or (parsed.scheme == "https" and parsed.port == 443) + return host if parsed.port is None or default_port else f"{host}:{parsed.port}" + + +def normalized_capture_url(parsed): + return urlunparse((parsed.scheme.lower(), normalized_netloc(parsed), parsed.path or "/", "", parsed.query, "")) + + +def effective_port(parsed): + if parsed.port is not None: + return parsed.port + return 80 if parsed.scheme == "http" else 443 if parsed.scheme == "https" else None + + +def is_bridge_loopback_alias(hostname, bridge_hostname): + host = (hostname or "").strip("[]").lower() + bridge_host = (bridge_hostname or "").strip("[]").lower() + if host == bridge_host: + return True + if bridge_host != "127.0.0.1": + return False + if host in {"localhost", "::1", "0:0:0:0:0:0:0:1"}: + return True + address = parse_ip_address(host) + return bool(address and str(address) == "127.0.0.1") + + +def is_bridge_origin(parsed, bridge_origin): + bridge = urlparse(bridge_origin) + return ( + parsed.scheme.lower() == bridge.scheme.lower() + and effective_port(parsed) == effective_port(bridge) + and is_bridge_loopback_alias(parsed.hostname, bridge.hostname) + ) + + +def validate_capture_policy(parsed, bridge_origin, options, resolver): + if is_bridge_origin(parsed, bridge_origin): + return "BRIDGE_SELF_TARGET_BLOCKED", None, "Bridge origin cannot be captured." + if is_private_host(parsed.hostname or "") and options["allowPrivateNetworkTarget"] is not True: + return "PRIVATE_NETWORK_TARGET_BLOCKED", {"reason": "private_network_address"}, "Private network targets are disabled." + dns_code, dns_details = validate_dns_policy(parsed.hostname or "", options["allowPrivateNetworkTarget"] is True, resolver) + if dns_code == "TARGET_DNS_LOOKUP_FAILED": + return dns_code, dns_details, "Target hostname could not be resolved." + if dns_code: + return dns_code, dns_details, "Private network targets are disabled." + return None, None, None + + +def normalize_capture_request(body, bridge_origin, resolver=default_resolve_hostname): + if not isinstance(body, dict) or not set(body.keys()).issubset(REQUEST_KEYS): + return invalid_request("Unknown capture request field.") + if body.get("mode") != "experience": + return invalid_request("Capture mode is invalid.") + url_value = str(body.get("url", "")).strip() + if not 1 <= len(url_value) <= 4096: + return invalid_request("Capture url is invalid.") + parsed = parse_capture_url(url_value) + if parsed is None: + return invalid_request("Capture url is invalid.") + include = body.get("include") + wait_ms = body["waitMs"] if "waitMs" in body else 3000 + viewports = body["viewports"] if "viewports" in body else [] + options_input = body["options"] if "options" in body else {} + options, option_message = normalize_options(options_input) + if not isinstance(include, list) or not include or any(item not in INCLUDE_ORDER for item in include): + return invalid_request("Capture include is invalid.") + if not is_strict_int(wait_ms) or not 0 <= wait_ms <= 30000: + return invalid_request("Capture waitMs is invalid.") + if not valid_viewports(viewports): + return invalid_request("Capture viewports are invalid.") + if options is None: + return invalid_request(option_message) + policy_code, policy_details, policy_message = validate_capture_policy(parsed, bridge_origin, options, resolver) + if policy_code: + return None, policy_code, policy_details, policy_message + return { + "url": normalized_capture_url(parsed), + "mode": "experience", + "waitMs": wait_ms, + "include": [item for item in INCLUDE_ORDER if item in include], + "viewports": viewports, + "options": options, + "protocolVersion": PROTOCOL_VERSION, + }, None, None, None + + +def validate_final_url(value, bridge_origin, request): + final_request = { + "url": value, + "mode": request["mode"], + "waitMs": request.get("waitMs", 3000), + "include": request.get("include", []), + "viewports": request.get("viewports", []), + "options": {**request.get("options", {}), "allowPrivateNetworkTarget": request.get("options", {}).get("allowPrivateNetworkTarget") is True}, + } + normalized, code, details, _message = normalize_capture_request(final_request, bridge_origin) + if normalized: + return normalized["url"], None, None + reason = "dns_lookup_failed" if code == "TARGET_DNS_LOOKUP_FAILED" else "invalid_final_url" + return None, "FINAL_URL_BLOCKED", details or {"reason": reason} + + +def validate_target_network_address(value, request, from_cache=False, final_url=None): + if request.get("options", {}).get("allowPrivateNetworkTarget") is True: + return None, None + if value is None: + return None, None + if not isinstance(value, str): + return "INVALID_REQUEST", {"reason": "invalid_network_address"} + address = value.strip().strip("[]") + if not address: + return None, None + try: + ipaddress.ip_address(address) + except ValueError: + return "INVALID_REQUEST", {"reason": "invalid_network_address"} + try: + parsed_final_url = urlparse(final_url or request.get("url", "")) + except Exception: + parsed_final_url = None + final_hostname = parsed_final_url.hostname if parsed_final_url else "" + if is_private_host(address) and not ( + is_proxy_reserved_host(address) and final_hostname and not is_ip_literal(final_hostname) and not is_private_host(final_hostname) + ): + return "FINAL_URL_BLOCKED", {"reason": "private_network_address"} + return None, None diff --git a/build-scripts/build-injected.mjs b/build-scripts/build-injected.mjs index 36ce4028..5edc0b0d 100644 --- a/build-scripts/build-injected.mjs +++ b/build-scripts/build-injected.mjs @@ -6,9 +6,19 @@ import { fileURLToPath } from 'node:url' const __dirname = fileURLToPath(new URL('.', import.meta.url)) const root = resolve(__dirname, '..') -const entries = ['page-detector', 'page-source-search'] +const allEntries = ['page-detector', 'page-source-search', 'experience-profiler'] +const requestedEntries = String(process.env.INJECTED_ENTRIES || '') + .split(',') + .map(entry => entry.trim()) + .filter(Boolean) +const entries = requestedEntries.length ? requestedEntries : allEntries +const invalidEntries = entries.filter(entry => !allEntries.includes(entry)) +if (invalidEntries.length) { + console.error(`[build-injected] invalid INJECTED_ENTRIES: ${invalidEntries.join(', ')}`) + process.exit(1) +} -const outDir = resolve(root, 'public/injected') +const outDir = resolve(root, process.env.INJECTED_OUT_DIR || 'public/injected') rmSync(outDir, { recursive: true, force: true }) mkdirSync(outDir, { recursive: true }) @@ -17,8 +27,8 @@ for (const entry of entries) { const result = spawnSync('pnpm', ['exec', 'vite', 'build', '--config', 'vite.injected.config.ts'], { cwd: root, stdio: 'inherit', - env: { ...process.env, INJECTED_ENTRY: entry }, - shell: true + shell: process.platform === 'win32', + env: { ...process.env, INJECTED_ENTRY: entry, INJECTED_OUT_DIR: outDir } }) if (result.status !== 0) { process.exit(result.status ?? 1) diff --git a/build-scripts/package-firefox.mjs b/build-scripts/package-firefox.mjs index e77c62a7..e2e1ab3f 100644 --- a/build-scripts/package-firefox.mjs +++ b/build-scripts/package-firefox.mjs @@ -1,88 +1,243 @@ -import { cpSync, rmSync, readFileSync, writeFileSync, mkdirSync, existsSync, createWriteStream } from 'node:fs' -import { resolve, dirname } from 'node:path' -import { fileURLToPath } from 'node:url' +import { cpSync, rmSync, readFileSync, writeFileSync, mkdirSync, existsSync, createWriteStream, readdirSync, statSync } from 'node:fs' +import { basename, resolve, dirname, relative } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' import { createRequire } from 'node:module' import archiver from 'archiver' const require = createRequire(import.meta.url) const esbuild = require('esbuild') +const defaultRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const agentOnlySourceFiles = [ + 'stackprism-bridge.mjs', + 'stackprism_bridge.py', + 'capture-site.mjs', + 'capture-site-args.mjs', + 'capture-runtime.mjs', + 'capture-screenshot-artifact.mjs', + 'capture-store.mjs', + 'http-handlers.mjs', + 'http-server.mjs', + 'open-browser.mjs', + 'protocol.mjs', + 'security.mjs', + 'url-policy.mjs' +] +const agentOnlySourceFileSet = new Set(agentOnlySourceFiles) +const agentOnlyPathPatterns = [/(?:^|\/)agent-skill(?:\/|$)/, /(?:^|\/)docs\/superpowers(?:\/|$)/, /(?:^|\/)tests(?:\/|$)/] +const disallowedPackagePatterns = [...agentOnlyPathPatterns, /(?:^|\/)__pycache__(?:\/|$)/, /\.py[co]?$/] + +export async function packageFirefox({ root = defaultRoot, logger = console } = {}) { + const paths = firefoxPackagePaths(root) + copyDist(paths) + await bundleBackground(paths, logger) + await bundleContentScripts(paths, logger) + const manifest = writeFirefoxManifest(paths, logger) + assertFirefoxPackageHygiene(paths, manifest) + const xpiPath = await writeXpi({ root, firefoxDir: paths.firefoxDir, manifest, logger }) + return { firefoxDir: paths.firefoxDir, manifestPath: paths.manifestPath, xpiPath } +} -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') -const distDir = resolve(root, 'dist') -const firefoxDir = resolve(root, 'dist-firefox') +function pathBasename(value) { + return basename(String(value || '').replaceAll('\\', '/')) +} -if (!existsSync(distDir)) { - console.error('[package-firefox] dist/ not found, run `pnpm build` first') - process.exit(1) +function isAgentOnlySourcePath(path) { + return agentOnlySourceFileSet.has(pathBasename(path)) } -rmSync(firefoxDir, { recursive: true, force: true }) -cpSync(distDir, firefoxDir, { recursive: true }) +function firefoxPackagePaths(root) { + const firefoxDir = resolve(root, 'dist-firefox') + return { + distDir: resolve(root, 'dist'), + firefoxDir, + manifestPath: resolve(firefoxDir, 'manifest.json') + } +} -// --- Bundle background script as IIFE --- -// CRXJS outputs background as ES modules with code-split shared chunks. -// Firefox background scripts don't support ES modules, so we rebundle -// the entry point into a single IIFE via esbuild. +function copyDist({ distDir, firefoxDir }) { + if (!existsSync(distDir)) { + throw new Error('[package-firefox] dist/ not found, run `pnpm build` first') + } -const loaderPath = resolve(firefoxDir, 'service-worker-loader.js') -const loaderCode = readFileSync(loaderPath, 'utf8') -const entryMatch = loaderCode.match(/import\s+'\.\/(assets\/[^']+)'/) -if (!entryMatch) { - console.error('[package-firefox] could not resolve service-worker-loader entry') - process.exit(1) + rmSync(firefoxDir, { recursive: true, force: true }) + cpSync(distDir, firefoxDir, { recursive: true }) } -const entryPath = resolve(firefoxDir, entryMatch[1]) -const backgroundPath = resolve(firefoxDir, 'background.js') +async function bundleBackground({ firefoxDir }, logger) { + // CRXJS outputs background as ES modules with code-split shared chunks. + // Firefox background scripts do not support ES modules, so rebundle one IIFE. + const loaderPath = resolve(firefoxDir, 'service-worker-loader.js') + const loaderCode = readFileSync(loaderPath, 'utf8') + const entryMatch = loaderCode.match(/import\s+'\.\/(assets\/[^']+)'/) + if (!entryMatch) { + throw new Error('[package-firefox] could not resolve service-worker-loader entry') + } + + const entryPath = resolve(firefoxDir, entryMatch[1]) + const backgroundPath = resolve(firefoxDir, 'background.js') -await esbuild.build({ - entryPoints: [entryPath], - bundle: true, - format: 'iife', - outfile: backgroundPath, - target: 'es2022', - platform: 'browser', - logLevel: 'warning' -}) + await esbuild.build({ + entryPoints: [entryPath], + bundle: true, + format: 'iife', + outfile: backgroundPath, + target: 'es2022', + platform: 'browser', + logLevel: 'warning' + }) -console.log('[package-firefox] bundled background.js as IIFE') + logger.log('[package-firefox] bundled background.js as IIFE') +} -// --- Transform manifest.json --- +async function bundleContentScripts({ firefoxDir, manifestPath }, logger) { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + const contentScripts = Array.isArray(manifest.content_scripts) ? manifest.content_scripts : [] + const rewritten = new Map() -const manifestPath = resolve(firefoxDir, 'manifest.json') -const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + for (const script of contentScripts) { + if (!Array.isArray(script.js)) continue + script.js = await Promise.all(script.js.map(file => bundleContentScriptFile({ firefoxDir, file, rewritten }))) + } -if (manifest.background?.service_worker) { - manifest.background = { scripts: ['background.js'] } + if (!rewritten.size) return + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)) + logger.log(`[package-firefox] bundled ${rewritten.size} content script loader(s) as Firefox IIFE files`) } -manifest.browser_specific_settings = { - gecko: { - id: 'stackprism@setube.github.io', - strict_min_version: '128.0' +async function bundleContentScriptFile({ firefoxDir, file, rewritten }) { + if (rewritten.has(file)) return rewritten.get(file) + const loaderPath = resolve(firefoxDir, file) + if (!existsSync(loaderPath)) throw new Error(`[package-firefox] content script file not found: ${file}`) + + const loaderCode = readFileSync(loaderPath, 'utf8') + const entry = resolveContentScriptLoaderEntry(loaderCode) + if (!entry) return file + + const outputFile = firefoxContentScriptOutputFile(file) + mkdirSync(resolve(firefoxDir, dirname(outputFile)), { recursive: true }) + await esbuild.build({ + entryPoints: [resolve(firefoxDir, entry)], + bundle: true, + format: 'iife', + outfile: resolve(firefoxDir, outputFile), + target: 'es2022', + platform: 'browser', + logLevel: 'warning' + }) + rewritten.set(file, outputFile) + return outputFile +} + +function resolveContentScriptLoaderEntry(loaderCode) { + const match = loaderCode.match(/chrome\.runtime\.getURL\(\s*["']([^"']+)["']\s*\)/) + return match?.[1]?.replace(/^\/+/, '') || null +} + +function firefoxContentScriptOutputFile(file) { + const name = basename(file) + .replace(/(?:\.ts)?-loader(?:-[^.]+)?\.js$/i, '') + .replace(/[^A-Za-z0-9._-]+/g, '-') + return `firefox/${name || 'content-script'}.js` +} + +function writeFirefoxManifest({ manifestPath }, logger) { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + + if (manifest.background?.service_worker) { + manifest.background = { scripts: ['background.js'] } } + + if (Array.isArray(manifest.web_accessible_resources)) { + manifest.web_accessible_resources = manifest.web_accessible_resources.map(resource => { + const { use_dynamic_url: _useDynamicUrl, ...rest } = resource + return rest + }) + } + + manifest.browser_specific_settings = { + gecko: { + id: 'stackprism@setube.github.io', + strict_min_version: '128.0' + } + } + + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)) + logger.log('[package-firefox] manifest.json transformed') + return manifest } -writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)) -console.log('[package-firefox] manifest.json transformed') +function assertFirefoxPackageHygiene({ firefoxDir }, manifest) { + const failures = [] + + if (Object.prototype.hasOwnProperty.call(manifest, 'externally_connectable')) { + failures.push('dist-firefox/manifest.json must not expose externally_connectable') + } + + for (const resource of manifest.web_accessible_resources || []) { + for (const path of resource.resources || []) { + if ( + agentOnlyPathPatterns.some(pattern => pattern.test(path)) || + isAgentOnlySourcePath(path) || + path.includes('experience-profiler.iife.js') + ) { + failures.push(`web_accessible_resources exposes agent-only path: ${path}`) + } + } + } -// --- Package .xpi --- + for (const file of walkFiles(firefoxDir)) { + if (disallowedPackagePatterns.some(pattern => pattern.test(file)) || isAgentOnlySourcePath(file)) { + failures.push(`dist-firefox contains agent-only or test artifact: ${file}`) + } + } -const releaseDir = resolve(root, 'release') -if (!existsSync(releaseDir)) mkdirSync(releaseDir) + if (failures.length) { + throw new Error(`[package-firefox] Firefox artifact hygiene failed:\n${failures.map(failure => `- ${failure}`).join('\n')}`) + } +} -const version = manifest.version -const xpiName = `stackprism-v${version}.xpi` -const xpiPath = resolve(releaseDir, xpiName) +function walkFiles(root, dir = root) { + const files = [] + for (const entry of readdirSync(dir)) { + const fullPath = resolve(dir, entry) + if (statSync(fullPath).isDirectory()) { + files.push(...walkFiles(root, fullPath)) + continue + } + files.push(relative(root, fullPath).replaceAll('\\', '/')) + } + return files +} -await new Promise((ok, reject) => { - const output = createWriteStream(xpiPath) - const archive = archiver('zip', { zlib: { level: 9 } }) - output.on('close', ok) - archive.on('error', reject) - archive.pipe(output) - archive.glob('**', { cwd: firefoxDir, dot: true }) - archive.finalize() -}) +async function writeXpi({ root, firefoxDir, manifest, logger }) { + const releaseDir = resolve(root, 'release') + if (!existsSync(releaseDir)) mkdirSync(releaseDir) + + const xpiName = `stackprism-v${manifest.version}.xpi` + const xpiPath = resolve(releaseDir, xpiName) + + await new Promise((ok, reject) => { + const output = createWriteStream(xpiPath) + const archive = archiver('zip', { zlib: { level: 9 } }) + output.on('close', ok) + archive.on('error', error => { + output.destroy() + reject(error) + }) + archive.pipe(output) + archive.glob('**', { cwd: firefoxDir, dot: true }) + archive.finalize() + }) + + logger.log(`[package-firefox] created release/${xpiName}`) + return xpiPath +} -console.log(`[package-firefox] created release/${xpiName}`) +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + await packageFirefox() + } catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exit(1) + } +} diff --git a/build-scripts/sync-docs-assets.mjs b/build-scripts/sync-docs-assets.mjs index 8354001f..02482fdd 100644 --- a/build-scripts/sync-docs-assets.mjs +++ b/build-scripts/sync-docs-assets.mjs @@ -10,5 +10,5 @@ for (const [src, dst] of targets) { const dstPath = resolve(root, dst) mkdirSync(dirname(dstPath), { recursive: true }) copyFileSync(srcPath, dstPath) - console.log(`synced ${src} → ${dst}`) + console.log(`synced ${src} -> ${dst}`) } diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index f37c15f0..9298be46 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -49,6 +49,7 @@ export default defineConfig({ items: [ { text: '概述', link: '/dev/' }, { text: '架构概览', link: '/dev/architecture' }, + { text: 'Agent Bridge', link: '/dev/agent-bridge' }, { text: '规则文件格式', link: '/dev/rule-format' }, { text: '检测流程', link: '/dev/detection-flow' }, { text: '贡献规则', link: '/dev/contribute-rules' }, diff --git a/docs/config/categories.md b/docs/config/categories.md index 6912ba37..1fb84089 100644 --- a/docs/config/categories.md +++ b/docs/config/categories.md @@ -4,31 +4,71 @@ ## 全部分类 -| 分类 | 包含示例 | -| ----------------- | ------------------------------------------------------------------------- | -| 前端框架 | React、Vue、Angular、Next.js、Nuxt、Gatsby、Remix、SvelteKit、Astro | -| UI / CSS 框架 | Tailwind CSS、Bootstrap、Material UI、Ant Design、Element Plus、Chakra UI | -| 构建运行时 | Webpack、Vite、Rollup、Parcel、esbuild、SWC、Turbopack、Bun | -| CDN / 托管 | Cloudflare、Akamai、Fastly、AWS CloudFront、Vercel、Netlify、jsDelivr | -| 后端框架 | Django、Flask、Rails、Laravel、Express、Koa、Spring、ASP.NET | -| 网站程序 | WordPress、Drupal、Discuz!、Typecho、ZBlog、phpBB、MediaWiki | -| 主题 / 模板 | WordPress 主题、Drupal 主题、CMS 模板路径反推 | -| 开发语言 / 运行时 | PHP、Node.js、Python、Ruby、Java、Go、Rust | -| 统计 / 分析 | Google Analytics、百度统计、友盟、Plausible、Umami | -| 第三方登录 | Google 登录、GitHub OAuth、微信登录、Auth0、Clerk | -| 支付系统 | Stripe、PayPal、支付宝、微信支付、银联 | -| 广告营销 | Google Ads、Facebook Pixel、TikTok Pixel | -| SaaS 服务 | Sentry、Mixpanel、Intercom、Crisp、HubSpot | -| AI / 大模型 | Open WebUI、Dify、Flowise、Gradio、ComfyUI | -| 探针 / 监控 | New Relic、Datadog、Pingdom、Hotjar | -| RSS / 订阅 | RSS、Atom、JSON Feed | -| WordPress 插件 | 4500+ 个具名插件 | -| Drupal 模块 | 4000+ 个具名模块 | -| 安全与协议 | HTTPS、CSP、Service Worker | -| 其他库 | 自定义规则默认归类、未明确分类的兜底 | -| ... 等共 23 类 | - -完整列表:`src/utils/category-order.ts` 的 `CATEGORY_ORDER` 数组。 +| 分类 | 包含示例 | +| --- | --- | +| 前端框架 | React、Vue、Angular、Next.js、Nuxt、Gatsby、Remix、SvelteKit、Astro | +| UI / CSS 框架 | Tailwind CSS、Bootstrap、Material UI、Ant Design、Element Plus、Chakra UI | +| 前端库 | jQuery、Axios、D3、Three.js、Chart.js | +| 构建与运行时 | Webpack、Vite、Rollup、Parcel、esbuild、SWC、Turbopack、Bun | +| CDN / 托管 | Cloudflare、Akamai、Fastly、AWS CloudFront、Vercel、Netlify、jsDelivr | +| Web 服务器 | Nginx、Apache、Caddy、IIS、lighttpd | +| 后端 / 服务器框架 | Django、Flask、Rails、Laravel、Express、Koa、Spring、ASP.NET | +| 开发语言 / 运行时 | PHP、Node.js、Python、Ruby、Java、Go、Rust | +| 数据基础设施 | Kibana、Elasticsearch、Redis、MongoDB、PostgreSQL 管理面板 | +| 对象存储 / 文件存储 | AWS S3、MinIO、Cloudflare R2、七牛云、阿里云 OSS | +| DevOps / 研发效能 | Jenkins、GitLab CI/CD、SonarQube、Sentry、LaunchDarkly | +| 开发者工具 / 代码托管 | GitHub、GitLab、Bitbucket、CodeSandbox、StackBlitz | +| 低代码 / 自动化 / 内部工具 | Retool、Appsmith、n8n、Zapier、Make | +| 网站程序 | WordPress、Drupal、Discuz!、Typecho、ZBlog、phpBB、MediaWiki | +| CMS / 电商平台 | Shopify、Magento、PrestaShop、Liferay Portal | +| Headless CMS | Contentful、Sanity、Strapi、Storyblok | +| 主题 / 模板 | WordPress 主题、Drupal 主题、CMS 模板路径反推 | +| 网站源码线索 | WordPress 插件、Drupal 模块、源码路径和资源命名线索 | +| 探针 / 监控 | New Relic、Datadog、Pingdom、Hotjar、Grafana | +| 状态页 / 可用性监控 | Statuspage、Better Stack、UptimeRobot | +| RSS / 订阅 | RSS、Atom、JSON Feed | +| SaaS / 第三方服务 | Intercom、Crisp、HubSpot、Zendesk、Airtable | +| AI / 大模型 | Open WebUI、Dify、Flowise、Gradio、ComfyUI | +| 第三方登录 / OAuth | Google 登录、GitHub OAuth、微信登录、Auth0、Clerk | +| 支付系统 | Stripe、PayPal、支付宝、微信支付、银联 | +| 订阅计费 / 税务发票 | Chargebee、Paddle、Recurly、TaxJar | +| 电子签名 / 合同 | DocuSign、Dropbox Sign、PandaDoc | +| KYC / 反欺诈风控 | Stripe Identity、Persona、Sift、Riskified | +| 会员积分 / 推荐返利 | ReferralCandy、Smile.io、Yotpo Loyalty | +| 招聘 / ATS | Greenhouse、Lever、Workable | +| 预约排程 | Calendly、Acuity Scheduling、Cal.com | +| 活动 / 票务 | Eventbrite、Ticket Tailor、Luma | +| 物流追踪 / 退货售后 | AfterShip、Shippo、Narvar | +| 广告 / 营销 | Google Ads、Facebook Pixel、TikTok Pixel | +| 统计 / 分析 | Google Analytics、百度统计、友盟、Plausible、Umami | +| 分析与标签 | Google Tag Manager、Segment、Tealium | +| 站内搜索 / 个性化推荐 | Algolia、Elastic Site Search、Constructor.io | +| 评论 / 社区嵌入 | Disqus、Giscus、Discourse | +| 评价 / UGC | Trustpilot、Yotpo Reviews、Bazaarvoice | +| 产品引导 / 用户反馈 | Pendo、Userpilot、Canny、UserVoice | +| 表单 / 问卷 | Typeform、Jotform、Formspree | +| 隐私合规 / Cookie 同意 | OneTrust、Cookiebot、Iubenda | +| 无障碍辅助 | UserWay、accessiBe、EqualWeb | +| Web Push / 消息推送 | OneSignal、Firebase Cloud Messaging、PushEngage | +| 短信 / 通信 API | Twilio、Vonage、MessageBird | +| 电话 / 呼叫追踪 | CallRail、Aircall、Twilio Voice | +| 邮箱验证 / 邮件校验 API | ZeroBounce、NeverBounce、Mailboxlayer | +| 地址验证 / 地理编码 | Google Maps Platform、Mapbox、Loqate | +| IP 地理位置 / IP 情报 | ipinfo、ipstack、MaxMind、ipdata | +| 金融数据 / 汇率 API | Open Exchange Rates、Currencylayer、Plaid | +| 天气 / 气象数据 | OpenWeather、WeatherAPI、Tomorrow.io | +| 翻译 / 本地化 API | Lokalise、Phrase、Crowdin | +| OCR / 文档智能 API | Veryfi OCR、Mindee、Google Document AI | +| 文档生成 / 截图 API | ApiFlash、Urlbox、Bannerbear | +| 媒体托管 / 图片处理 | Cloudinary、imgix、Mux | +| 实时音视频 / 视频 SDK | Agora、Twilio Video、Daily | +| Web3 钱包 / 链上基础设施 | WalletConnect、Alchemy、thirdweb | +| 代码示例 / 在线 IDE 嵌入 | CodePen、StackBlitz、CodeSandbox | +| 安全与协议 | HTTPS、CSP、Service Worker | +| 其他库 | 自定义规则默认归类、未明确分类的兜底 | +| ... 等共 60 类 | 完整列表见 `src/utils/category-order.ts` | + +完整列表:`src/utils/category-order.ts` 的 `CATEGORY_ORDER` 数组。该列表覆盖内置规则已使用的分类,并保留运行时内置检测和自定义规则的兜底分类。 ## 全开 / 全关 @@ -37,8 +77,8 @@ ## 关闭分类的常见用途 - **只看技术栈,不看插件**:关「WordPress 插件」「Drupal 模块」,避免插件名把列表撑得太长 -- **只看后端不看前端**:关掉前端框架 / UI 框架 / 构建运行时 -- **过滤通用 SaaS**:关「广告营销」「统计 / 分析」聚焦核心技术栈 +- **只看后端不看前端**:关掉「前端框架」「UI / CSS 框架」「构建与运行时」 +- **过滤通用 SaaS**:关「广告 / 营销」「SaaS / 第三方服务」「统计 / 分析」聚焦核心技术栈 ## 实现细节 diff --git a/docs/config/custom-css.md b/docs/config/custom-css.md index c9b78a7b..3efd796c 100644 --- a/docs/config/custom-css.md +++ b/docs/config/custom-css.md @@ -51,7 +51,7 @@ body { - 设置页(options page) - 使用说明页(help page) -不会影响普通网页 —— 这段 CSS 只在扩展自己的 UI 范围内生效。 +不会影响普通网页 - 这段 CSS 只在扩展自己的 UI 范围内生效。 ## 调试技巧 diff --git a/docs/config/custom-rules.md b/docs/config/custom-rules.md index e643e95c..c2135aa5 100644 --- a/docs/config/custom-rules.md +++ b/docs/config/custom-rules.md @@ -7,7 +7,7 @@ | 字段 | 必填 | 说明 | | ------------------- | ---- | ---------------------------------------------------------------------------------------------------------- | | **技术名称** | 是 | 弹窗里显示的名字,比如 `MyCMS` | -| **分类** | | 选一个内置分类(23 个)或自己输入新分类。默认「其他库」。Select 组件支持自由输入,分类列表里没有的也能写。 | +| **分类** | | 选一个内置分类(60 个)或自己输入新分类。默认「其他库」。Select 组件支持自由输入,分类列表里没有的也能写。 | | **类型说明** | | 给自己看的注释,比如 `企业 CMS`、`第三方支付`。会显示在 evidence 前缀,可以为空。 | | **置信度** | | 高 / 中 / 低,默认「中」 | | **匹配方式** | | 关键词 / 正则表达式,默认「正则表达式」 | @@ -40,7 +40,7 @@ 显示在页面底部,每条带: - 标题:规则的技术名称 -- 元信息:分类 · 类型 · 置信度 · 匹配方式 · N 条 patterns +- 元信息:分类 / 类型 / 置信度 / 匹配方式 / N 条 patterns - 操作:「编辑」「删除」两个按钮(删除按钮 hover 变红) ## 验证规则 diff --git a/docs/config/index.md b/docs/config/index.md index 1ba40ff2..43fce77e 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -2,17 +2,20 @@ 设置页可以通过弹窗顶部的「设置」按钮打开,或者在 `chrome://extensions/` 找到 StackPrism 卡片点「详情 → 扩展程序选项」。 -设置页分五块: +设置页分六块: -- [识别开关](./categories.md) — 23 个分类的启停(关掉后该分类的技术不再显示在弹窗里) -- [禁用指定技术](./disabled-technologies.md) — 用名字精确屏蔽某些技术(无视分类) -- [自定义弹窗样式](./custom-css.md) — 写一段 CSS 覆盖弹窗 / 设置页样式 -- [自定义规则](./custom-rules.md) — 用表单或 JSON 添加自己的识别规则 -- [规则 JSON 导入导出](./json-export.md) — 在多浏览器 / 多设备同步规则集合 +- [识别开关](./categories.md) - 60 个分类的启停(关掉后该分类的技术不再显示在弹窗里) +- Agent Bridge - 启用后允许本机 Agent Bridge 读取当前浏览器可观测的技术与体验摘要;可人工确认放开所有网络目标 +- [禁用指定技术](./disabled-technologies.md) - 用名字精确屏蔽某些技术(无视分类) +- [自定义弹窗样式](./custom-css.md) - 写一段 CSS 覆盖弹窗 / 设置页样式 +- [自定义规则](./custom-rules.md) - 用表单或 JSON 添加自己的识别规则 +- [规则 JSON 导入导出](./json-export.md) - 在多浏览器 / 多设备同步规则集合 ## 配置存储位置 -所有配置存在 `chrome.storage.sync`,会随你登录的 Google / Edge 账号跨设备同步。Key 是 `stackPrismSettings`。如果不想同步,可以关闭浏览器自己的同步功能。 +识别开关、禁用列表、自定义样式和自定义规则存在 `chrome.storage.sync`,会随你登录的 Google / Edge 账号跨设备同步。Key 是 `stackPrismSettings`。如果不想同步,可以关闭浏览器自己的同步功能。 + +Agent Bridge 启用状态和“允许所有网络目标”高风险开关是例外:它们只存在当前浏览器 profile 的 `chrome.storage.local`,不会随 Chrome sync 同步到其他设备、浏览器或 profile。换环境后需要重新显式开启。 `chrome.storage.sync` 的容量上限是 100KB,单个 key 不超 8KB。这意味着: diff --git a/docs/dev/agent-bridge.md b/docs/dev/agent-bridge.md new file mode 100644 index 00000000..47596cfc --- /dev/null +++ b/docs/dev/agent-bridge.md @@ -0,0 +1,147 @@ +# Agent Bridge + +Agent Bridge 让本机 AI Agent 在用户已安装并显式启用 StackPrism 扩展后,通过 `127.0.0.1` 本地 HTTP bridge 获取 `stackprism.site_experience_profile.v1`。 + +## 数据流 + +1. Agent 启动 `agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs`,读取 stdout 中唯一 ready JSON。 +2. Agent 用 `apiToken` 调用 `POST /v1/captures` 创建采集任务。 +3. bridge 打开 `/bridge?session=...&capture=...&nonce=...`。 +4. `src/content/agent-bridge-client.ts` 只在 `http://127.0.0.1/*` 的 bridge 页面运行,读取 DOM config,向 background 发送 `AGENT_BRIDGE_HELLO`。 +5. background 校验 `chrome.storage.local` 中的 `agentBridgeEnabled` 后,打开或复用目标 tab,运行技术识别和 experience profiler。 +6. background 将 profile 分片发送回 bridge content script,content script 再同源 POST 给本地 bridge。 +7. Agent 轮询 status,并在 completed 后用 `apiToken` 读取 profile。 + +## 用户门禁 + +`agentBridgeEnabled` 是本机浏览器 profile 级 opt-in,只从 `chrome.storage.local` 生效。即使旧 `chrome.storage.sync` 中存在同名字段,也不得自动开启 Agent Bridge。 + +`agentBridgeAllowAllNetworkTargets` 是同样只存在当前浏览器 profile 的高风险开关,默认关闭。用户在设置页保存开启时必须人工确认;开启后,扩展侧会允许 Agent Bridge 继续采集本机、私网、保留地址以及 DNS/proxy 映射到私网的目标。该开关不放开 `http:` / `https:` 之外的协议、不允许采集当前 bridge server 自身,也不改变本地 bridge 进程的创建阶段策略;repo-local helper 仍需显式传入 `--allow-private-network` 或 request option 才会在创建阶段接受私网目标。 + +发布到 Chrome Web Store 或 Edge Add-ons 前,默认值必须保持 `false`,除非维护者完成隐私披露、用户文档和发布说明更新。 + +发布前 disclosure 必须覆盖: + +- Agent Bridge 默认关闭,只能由用户在扩展设置中显式启用。 +- 启用后,扩展会把浏览器侧可观测的技术栈与体验摘要发送到用户本机 `127.0.0.1` bridge,供本机 Agent 读取。 +- StackPrism 不接收远程上传,不采集 Cookie、Authorization、localStorage/sessionStorage 明文或完整私密页面文本。 +- 第一版信任用户启动的本机 bridge 进程,不声称抵御同机恶意进程或同一浏览器 profile 中其他恶意扩展。 + +## 信任边界 + +- 本版本信任用户或 Agent 启动的本机 bridge 进程。 +- `127.0.0.1`、nonce、bridge 页面 meta 和 `bridgeToken` 只能绑定一次 capture,不能证明本机进程一定没有被同机恶意进程伪造。 +- DOM 中的 `bridgeToken` 不是对同浏览器 profile 中其他扩展保密的秘密。 +- 默认不采集 cookie、Authorization、localStorage/sessionStorage 明文、完整敏感 query 或页面全文。 +- Agent Bridge 不是浏览器级 SSRF 防火墙。private-network 校验用于拒绝创建 capture、停止采集和阻止 profile 交付,不保证导航前零网络触达。 +- “允许所有网络目标”只应在用户确认本机 Agent、bridge 进程和当前浏览器 profile 可信时短时开启;开启后 private-network fail-closed 保护不再作为扩展侧二次门禁生效。 + +## 本地脚本 + +以下 `agent-skill/...` 路径均以 StackPrism 仓库根目录为当前工作目录。Agent 若从其他目录启动,必须先切到 ``,或把脚本路径解析为绝对路径后再调用。bridge 脚本是 repo-local 工具,不是扩展发布产物,也不是全局命令。 + +JavaScript bridge: + +```bash +cd +node agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs +``` + +Python fallback: + +```bash +cd +python3 agent-skill/stackprism-site-experience/scripts/stackprism_bridge.py +``` + +Python fallback 基于标准库 HTTP server,定位是 Node 不可用时的兼容路径。长时间批量采集、重复压力测试或需要更可靠连接上限控制时优先使用 JavaScript bridge;如果 Python fallback 在本机连接堆积下超时,应停止子进程、重新启动 bridge 并重试,不复用半完成 capture。 + +JavaScript bridge 与 Python fallback 的 bridge 页面 CSS 和客户端脚本必须保持字节级一致。修改 `agent-skill/stackprism-site-experience/scripts/bridge/bridge-page-assets.mjs` 时,必须同步更新 `agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/bridge_page_assets.py`,并保留 `tests/stackprism_bridge_py.test.mjs` 中的资产一致性测试通过。 + +测试环境可设置 `STACKPRISM_BRIDGE_NO_OPEN=1`,此时不会自动打开浏览器,但仍会返回 `bridgeUrl`。 + +Agent 只读取 stdout 的第一条 ready JSON line,并应在 10 秒内完成解析。超时按 `BRIDGE_START_TIMEOUT`,非 JSON stdout 按 `BRIDGE_READY_PARSE_FAILED`,`protocolVersion` 不匹配按 `BRIDGE_PROTOCOL_UNSUPPORTED` 处理;这些失败都必须停止 bridge 子进程并等待退出。 + +大型页面 profile 通过分片传回 bridge 页面。若采集中出现 `BRIDGE_TRANSPORT_DISCONNECTED`、`PROFILE_TRANSPORT_FAILED`、`PROFILE_CHUNK_MISSING` 或 `CAPTURE_TIMEOUT`,Agent 应将本次 capture 视为失败,停止当前 bridge 子进程后重启,并用 helper 的 `--include` 传入更小的范围或用 `--max-resource-urls` 降低资源 URL 上限后重试一次;不得从部分分片拼出“降级成功”的 profile。 + +如果扩展安装在非默认浏览器或非默认用户 profile,设置 `STACKPRISM_BROWSER_OPEN_COMMAND` 指向平台 opener 或对应 Chrome 内核浏览器可执行文件,并把 opener/profile 参数放入 `STACKPRISM_BROWSER_OPEN_ARGS_JSON` 字符串数组。bridge URL 始终由脚本作为最后一个独立 argv 追加,不要写入环境变量或 shell 命令。 + +`STACKPRISM_BROWSER_OPEN_COMMAND` 只放可执行文件或平台 opener,不能把命令参数拼进同一个字符串。示例:使用 `STACKPRISM_BROWSER_OPEN_COMMAND=open` 和 `STACKPRISM_BROWSER_OPEN_ARGS_JSON='["-a","Google Chrome"]'`,不要写成 `STACKPRISM_BROWSER_OPEN_COMMAND='open -a Google Chrome'`。profile 参数同样放在 args JSON 中,bridge URL 不需要也不允许由调用方追加。 + +跨平台 browser open 口径: + +- macOS 默认使用 `open`;若系统默认浏览器不是安装 StackPrism 的 Chrome,可设置 `STACKPRISM_BROWSER_OPEN_COMMAND=open` 和 `STACKPRISM_BROWSER_OPEN_ARGS_JSON='["-a","Google Chrome"]'`。若还需要指定 Chrome profile,应把 command 改成 Chrome 可执行文件路径,并把 `--profile-directory=...` 放进 args JSON。 +- Windows 默认使用 command `rundll32.exe`,并由脚本内置 `url.dll,FileProtocolHandler` 参数;若需要指定 Chrome 或 Edge,使用完整 `.exe` 路径作为 `STACKPRISM_BROWSER_OPEN_COMMAND`,profile 参数放进 args JSON。 +- Linux 默认使用 `xdg-open`;若需要指定 Chrome/Chromium,使用 `google-chrome`、`chromium` 或绝对路径作为 `STACKPRISM_BROWSER_OPEN_COMMAND`,profile 参数放进 args JSON。 + +## 发布产物 Hygiene + +`dist/` 只应包含扩展运行所需文件。发布前必须确认: + +- `dist/manifest.json` 不包含 `externally_connectable`。 +- `dist/` 不包含 `agent-skill/`、`docs/superpowers/`、`tests/`、Python 源文件、Python 字节码或本地 bridge server 源脚本。 +- `experience-profiler.iife.js` 默认不放入 `web_accessible_resources`。 + +## Profile Schema 口径 + +Agent Bridge 输出 schema 为 `stackprism.site_experience_profile.v1`。当前 profile 至少按以下口径消费: + +- `target`: 规范化目标、最终 URL、标题、`language`、viewport 摘要和 capture scope。`language` 来自页面 `documentElement.lang` 或 body `lang`,为空时保持空字符串,不推断用户身份或地区。 +- `browserContext`: user agent、扩展版本、采集时间、bridge protocol version、请求的 viewport 和扩展 capabilities。 +- `techProfile`: 现有 StackPrism 技术识别结果和实现参考说明。 +- `visualProfile`: 颜色、字体、间距、形状、阴影、密度、主题和响应式视觉摘要。`options.captureScreenshot = true` 且 `include` 包含 `visual` 时,扩展会用截图 data URL 把当前可见视口交给本机 bridge;bridge 保存 profile 时必须剥离 `dataUrl`,只保留临时内存截图资产和 `screenshot.downloadUrl`、`note`、生命周期字段。`capture-site.mjs` 会在 bridge 仍存活时下载截图,并把 `downloadUrl` 重写为本地 `file://` URL 与 `localPath`。 +- `layoutProfile`: landmarks、hero、grid、sticky、above-fold 和截图 metadata。`captureScreenshotMetadata = false` 时不得输出 bounding box、above-fold 细节或几何截图 metadata。 +- `componentProfile`: button、link、form、card、navigation、overlay 和 data display 模式。 +- `interactionProfile`: 仅记录 passive 可观察的 hover/focus/transition/animation/loading/scroll 线索,不点击、不提交表单、不主动打开隐藏菜单。 +- `uxProfile`: 一阶 UX 字段包含 `pagePurpose`、`primaryUserPath`、`informationHierarchy`、`ctaStrategy`、`trustSignals`、`navigationDepth`、`contentGrouping`、`frictionPoints` 和有限 `textSamples`。这些字段只来自 DOM 结构和短标签摘要,必须先脱敏 token-like 值、email、手机号、长数字和敏感 query。 +- `assetProfile`: script、style、resource domain、image/font hint、manifest、favicon 和资源 URL 脱敏摘要。 +- `evidence`、`limitations`: 记录来源覆盖、截断、未请求 section、不可访问 frame 或 shadow root 等边界。 +- `agentGuidance`: 给下游 Agent 的实现建议。当前包含摘要、优先级、注意事项和 `recreationPlan`。`recreationPlan` 把 profile 转成复刻执行层:`implementationOrder`、`designTokens`、`layoutBlueprint`、`componentInventory`、`interactionChecklist`、`uxChecklist`、`assetHints` 和 `verificationChecklist`。这些字段只引用已脱敏的 profile 内容,不能把缺失字段理解为目标站点不存在对应结构。 + +下游 Agent 不得把 profile 当作页面完整拷贝。它是浏览器可观察事实和实现参考,不是后端私有实现或用户账号内容。截图像素只在 `captureScreenshot = true` 时显式采集,属于未做逐像素脱敏的可选视觉证据,不应用于登录态或私密页面。下载的 Profile JSON 是纯 JSON,不包含注释或截图 base64;如需查看实际视觉效果,Agent 应按 `visualProfile.screenshot.downloadUrl` 下载或打开图片。直接 bridge URL 只在本机 bridge 进程存活且 completed result TTL 未过期时有效;TTL 过期时 bridge 必须同时清理 profile 和临时内存截图资产。helper 写出的本地 `file://` 图片在文件被移动或删除前有效。用户在 bridge 页面手动下载的截图文件由浏览器下载目录管理,插件不会自动删除该文件。 + +完成采集后,本地 bridge 页面会展示受限结果工作台:目标网址、截图预览、截图放大预览、下载截图、复制截图、复制 Markdown 摘要和分组 profile 内容卡片。该页面只能用 `bridgeToken` 读取 `GET /v1/captures/{id}` 的 status preview;preview 中的 `copyText` 和 `contentSummary` 由 bridge server 从已完成 profile 生成,并会脱敏 URL query、token-like id、email、手机号和 token 字段。页面不得使用 `bridgeToken` 读取 raw `/profile`,不得把 raw profile、截图 data URL、`apiToken`、`bridgeToken`、nonce 或完整敏感文本放进“一键复制全部信息”。复制截图依赖浏览器 Clipboard API,失败时必须显示错误,不得伪装成功。复制到剪切板或用户下载后的截图由浏览器/操作系统管理,不属于 StackPrism 自动清理范围。 + +## Browser Smoke 场景 + +默认 smoke 命令: + +```bash +STACKPRISM_BROWSER_SMOKE_CDP_PORT=9661 node tests/agent-bridge-browser-smoke.mjs +``` + +当前默认成功路径使用本地 `tests/fixtures/site-experience-fixture.html`,并在 capture request 中显式设置 `allowPrivateNetworkTarget = true`。默认路径不再依赖 `https://example.com`,因为外部公网目标会受到当前网络、代理、DNS 和站点可用性影响。公网域名经本机代理或 TUN fake-IP 解析/连接到 `198.18.0.0/15` 时,默认策略允许采集;直接私网 IP、`localhost`、RFC1918、link-local、真实内网和其他 special-use 地址仍 fail closed,除非显式设置 `allowPrivateNetworkTarget = true`。 + +smoke 结果按三类理解: + +- 默认 fixture 成功路径:证明扩展加载、opt-in、bridge handshake、target capture、profile transfer、profile endpoint、隐私脱敏和 cleanup 主链路。 +- 显式 public complex target:例如 `STACKPRISM_BROWSER_SMOKE_SCENARIO=public-complex-target STACKPRISM_BROWSER_SMOKE_TARGET_URL=https://www.wikipedia.org/ ...`。如果 resolver 或 Chrome network evidence 返回 `198.18.*`,默认策略会按本机代理/TUN fake-IP 场景允许该公网 hostname;这不等价于允许直接私网或真实内网目标。 +- private-network policy 场景:`private-target-blocked`、DNS/private final URL、bridge self-target 等场景证明 fail-closed 策略,不是浏览器级 SSRF 防火墙,也不保证导航前零网络触达。 + +## Live Gate 边界 + +以下 gate 不能仅凭本机单测或 fixture smoke 标记完成: + +- Chrome Web Store / Edge Add-ons 真实发布、升级链路和审核后台 disclosure 接受状态。 +- 运行中 capture 的 Chrome service worker 自然 idle eviction 精确触发。本机已有 fail-closed cleanup 证据,但当前 Chrome 行为未稳定触发该 live 分支。 +- incognito bridge 或 target tab 的精确 `INCOGNITO_NOT_SUPPORTED` live metadata 分支。当前单元测试覆盖该分支,CDP/`--incognito` probes 在本机表现为 `EXTENSION_NOT_CONNECTED` fail-closed skip。 +- 多网络、多 DNS、多目标站点的长时资源压力矩阵。 + +发布 workflow 会在打包前检查 Agent Bridge 是否出现在 `dist/manifest.json` 的 loopback content script 或 loopback web accessible resource 中。若出现,则必须通过 workflow_dispatch 输入 `agent_bridge_disclosure_confirmed=true`,或在 GitHub Release 正文中包含已勾选的 `- [x] Agent Bridge disclosure confirmed`;否则工作流失败。该门禁只能防止未确认披露就上传 release 资产,不能替代 Chrome Web Store / Edge Add-ons 后台的真实审核和发布状态。 + +## 验证命令 + +```bash +pnpm run build:injected +pnpm run test:unit +pnpm run lint +pnpm run typecheck +pnpm run docs:build +pnpm run check:links +node build-scripts/package-firefox.mjs +node --check agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs +python3 -m py_compile agent-skill/stackprism-site-experience/scripts/stackprism_bridge.py agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/*.py +git diff --check +``` + +`pnpm run typecheck` 已包含 `vue-tsc --noEmit` 和 `pnpm run build`,因此会同时刷新 Chrome `dist/` 构建产物。`node build-scripts/package-firefox.mjs` 依赖已有 `dist/`,用于验证 Firefox manifest 转换、background rebundle、content script bundling、agent-only 产物卫生检查和 XPI 产物边界。验证后应清理或忽略 `dist/`、`dist-firefox/`、`release/`、`public/injected/`、`docs/.vitepress/dist/`、`docs/public/icon.svg` 与 Python `__pycache__`,不要把这些本地产物纳入提交。 diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index d30255d4..3de2a32c 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -104,16 +104,16 @@ Chrome 扩展有四种执行环境,StackPrism 全部用上: 8 种消息全部在 `src/types/messages.ts` 用 discriminated union 定义类型,所有调用走 `src/utils/messaging.ts` 的 `sendMessage()` wrapper。 -| 消息 | 方向 | 用途 | -| ----------------------------- | ------------- | ------------------------------------ | -| `GET_HEADER_DATA` | popup → bg | 拉取响应头记录 | -| `GET_POPUP_RESULT` | popup → bg | 拉取轻量缓存(弹窗主显示) | -| `GET_POPUP_RAW_RESULT` | popup → bg | 拉取完整 raw(原始线索 / 纠错反馈) | -| `GET_TECH_LINK` | popup → bg | 兜底查询某技术的官网链接 | -| `START_BACKGROUND_DETECTION` | popup → bg | 「刷新」按钮触发主动检测 | -| `GET_WORDPRESS_THEME_DETAILS` | bg internal | 抓取主题 style.css header | -| `DYNAMIC_PAGE_SNAPSHOT` | content → bg | content script 持续上报动态快照 | -| `PAGE_DETECTION_RESULT` | injected → bg | page-detector 注入完返回结果 | +| 消息 | 方向 | 用途 | +| ----------------------------- | ------------- | ----------------------------------- | +| `GET_HEADER_DATA` | popup → bg | 拉取响应头记录 | +| `GET_POPUP_RESULT` | popup → bg | 拉取轻量缓存(弹窗主显示) | +| `GET_POPUP_RAW_RESULT` | popup → bg | 拉取完整 raw(原始线索 / 纠错反馈) | +| `GET_TECH_LINK` | popup → bg | 兜底查询某技术的官网链接 | +| `START_BACKGROUND_DETECTION` | popup → bg | 「刷新」按钮触发主动检测 | +| `GET_WORDPRESS_THEME_DETAILS` | bg internal | 抓取主题 style.css header | +| `DYNAMIC_PAGE_SNAPSHOT` | content → bg | content script 持续上报动态快照 | +| `PAGE_DETECTION_RESULT` | injected → bg | page-detector 注入完返回结果 | ## 注入脚本的双轨问题 diff --git a/docs/dev/detection-flow.md b/docs/dev/detection-flow.md index 3f135313..a64b5bfc 100644 --- a/docs/dev/detection-flow.md +++ b/docs/dev/detection-flow.md @@ -103,6 +103,49 @@ const runActivePageDetection = async tabId => { } ``` +## Agent Bridge 采集流程 + +Agent Bridge 不复用弹窗按钮作为触发入口。它由本机 bridge 页面上的专用 content script 发起,background 在校验本机 opt-in、bridge 页面身份和 capture request 后,临时接管目标 tab 完成一次 site experience profile 采集。 + +```text +Agent 启动本机 bridge 脚本 + ↓ +POST /v1/captures 创建 capture,得到一次性 bridgeUrl + ↓ +浏览器打开 http://127.0.0.1:{port}/bridge?... 页面 + ↓ +agent-bridge-client.ts 校验 /bridge path、meta、session、capture、nonce、protocolVersion + ↓ +AGENT_BRIDGE_HELLO 发给 background + ↓ +background 校验 chrome.storage.local 中 agentBridgeEnabled 为 true + ↓ +bridge content script 读取 /v1/captures/{id}/request + ↓ +START_AGENT_CAPTURE 交给 background/agent-capture.ts + ↓ +打开或复用目标 tab,等待主 frame load 完成 + ↓ +先把 finalUrl 写回 bridge,由 bridge server 执行最终 URL 策略校验 + ↓ +finalUrl 通过后才运行技术检测和 experience-profiler + ↓ +profile 分片发回 bridge content script + ↓ +bridge content script 校验 chunk、sha256、session/capture/nonce 后同源 POST profile + ↓ +Agent 用 apiToken 读取 /v1/captures/{id}/profile +``` + +这个流程的关键边界: + +- `agentBridgeEnabled` 只从 `chrome.storage.local` 读取,sync 旧字段不能自动开启。 +- bridge tab、`/bridge` 页面和 `/v1/captures/*` 请求不写入普通 `tab-store`、popup 缓存、badge 或 dynamic snapshot。 +- bridge content script 不持有 `apiToken`;background 不持久化 `bridgeToken`。 +- `target_loaded` 的 final URL 被 bridge 接受前,不注入主动检测脚本或 experience profiler。 +- profile 回传走 bridge content script 同源 POST,不由 background 直接跨 origin fetch localhost。 +- 未完成 capture 的 deadline、tab ownership 和 cleanup 锚点写入 `chrome.storage.session`;service worker 重启后只能 fail closed,不伪造完成。 + ## 动态采集节流 content-observer 端: diff --git a/docs/dev/index.md b/docs/dev/index.md index f27d88f2..f4b03946 100644 --- a/docs/dev/index.md +++ b/docs/dev/index.md @@ -7,6 +7,7 @@ - [架构概览](./architecture.md) — 项目目录结构、各模块职责、数据流 - [规则文件格式](./rule-format.md) — `public/rules/` 下 JSON 怎么组织,nested groups + defaults 继承 - [检测流程](./detection-flow.md) — 从 webRequest / 页面注入到弹窗渲染走过哪些环节 +- [Agent Bridge](./agent-bridge.md) — 本地 loopback bridge、扩展握手、profile 回传和信任边界 - [贡献规则](./contribute-rules.md) — 怎么往内置规则集合加新技术 - [构建与发布](./release.md) — 本地构建、打包、签 crx、发布工作流 diff --git a/docs/dev/release.md b/docs/dev/release.md index 14b8cef8..56a0134c 100644 --- a/docs/dev/release.md +++ b/docs/dev/release.md @@ -63,17 +63,25 @@ git push origin main 1. 在 GitHub UI 发布一个 release(`release: published` 事件) 2. 在 Actions 页面手动跑 workflow_dispatch(可选传 release_tag input) + - 如果本版本包含 Agent Bridge,必须同时勾选 `agent_bridge_disclosure_confirmed` 工作流做的事: 1. checkout + 安装 pnpm + Node 20 2. `pnpm install --frozen-lockfile` -3. `pnpm run build` -4. 从 `dist/manifest.json` 读 version,校验与 release tag 一致 -5. 把 `dist/` 整个 zip 成 `stackprism-v{ver}.zip` + sha256 -6. **如果配置了 secret `EXTENSION_PRIVATE_KEY`**,再用 `npx crx3` 签名出 `stackprism-v{ver}.crx` + sha256;否则跳过 crx 仅传 zip -7. `gh release upload --clobber` 把所有产物上传到 release tag -8. `actions/upload-artifact` 同时备一份 artifact +3. `pnpm run lint` +4. `pnpm run build:injected` +5. `pnpm run test:unit` +6. `pnpm run typecheck`,该脚本会执行 `vue-tsc --noEmit` 并刷新 `dist/` +7. `pnpm run docs:build` +8. `pnpm run build`,发布前再次刷新扩展产物 +9. 校验 `dist/` 发布边界:`manifest.json` 不含 `externally_connectable`,`web_accessible_resources` 不暴露 agent-only 入口或 profiler,`dist/` 不包含 `agent-skill/`、`docs/superpowers/`、`tests/`、Python 源文件/字节码、repo-local JS bridge helper 源文件或本地 bridge server 入口 +10. 如果 `dist/manifest.json` 包含 Agent Bridge 的 `http://127.0.0.1/*` content script 或 web accessible resource,校验商店隐私披露确认:workflow_dispatch 必须勾选 `agent_bridge_disclosure_confirmed`,release 事件的 release note 必须包含 `- [x] Agent Bridge disclosure confirmed` +11. 从 `dist/manifest.json` 读 version,校验与 release tag 一致 +12. 把 `dist/` 整个 zip 成 `stackprism-v{ver}.zip` + sha256 +13. **如果配置了 secret `EXTENSION_PRIVATE_KEY`**,再用 `npx crx3` 签名出 `stackprism-v{ver}.crx` + sha256;否则跳过 crx 仅传 zip +14. `gh release upload --clobber` 把所有产物上传到 release tag +15. `actions/upload-artifact` 同时备一份 artifact ## 发布说明 @@ -86,6 +94,14 @@ git push origin main 发布说明只写用户或维护者关心的变化,不写本地跑了哪些格式化、类型检查、lint 或构建命令。 +如果本版本包含 Agent Bridge,release note 必须显式包含下面这行;工作流会在打包前读取 release body,没有勾选就失败: + +```markdown +- [x] Agent Bridge disclosure confirmed +``` + +这行只能在维护者已经更新 Chrome Web Store / Edge Add-ons 后台隐私披露、数据用途说明、用户可见说明和 release note 后勾选。它不是代码测试结果,也不能代替商店后台的实际审核状态。 + ## CRX 签名密钥 第一次发布前需要生成一个 RSA 私钥,并把它配置到 GitHub repository secrets。 @@ -120,6 +136,8 @@ openssl genrsa -out extension.pem 2048 - [ ] `pnpm run lint` 通过 - [ ] `pnpm run build` 通过 - [ ] 在 chrome 里加载 `dist/` 手动测试关键路径(弹窗打开、识别一个站点、刷新、复制完整技术栈报告、设置页加规则) +- [ ] 如果本轮涉及 Agent Bridge,运行 bridge smoke test,并确认 `dist/` 不包含 `agent-skill/`、`docs/superpowers/`、`tests/`、Python 源文件/字节码、repo-local JS bridge helper 源文件或本地 bridge server 入口 +- [ ] 如果发布 Agent Bridge,Chrome Web Store / Edge Add-ons 的隐私披露、数据用途说明、用户可见说明和 release note 已同步更新,明确 profile 会发送到用户本机 loopback bridge 供本地 Agent 读取,但不会发送到 StackPrism 远程服务器;随后 workflow_dispatch 勾选 `agent_bridge_disclosure_confirmed`,或在 release note 中加入 `- [x] Agent Bridge disclosure confirmed` - [ ] 把 `package.json` 的 version bump - [ ] git commit + push - [ ] 如果版本符合 release 节点,在 GitHub UI 发布 release(tag 为 `v{version}`,与 package.json 对齐) @@ -132,3 +150,7 @@ openssl genrsa -out extension.pem 2048 1. 把 `dist/` 文件夹打成 zip(同工作流的 zip 产物) 2. [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole) → 上传新版本 3. 填变更说明,等审核(通常 1-3 工作日) + +若本版本包含 Agent Bridge,还必须在 Chrome Web Store 和 Edge Add-ons 后台同步隐私披露与数据用途说明:该能力默认关闭,用户显式启用后会把浏览器侧可观测的技术栈与体验摘要发送到用户本机 `127.0.0.1` bridge,供本机 Agent 读取;StackPrism 不接收远程上传。 + +工作流只能检查维护者是否做了发布确认,不能自动验证 Chrome Web Store 或 Edge Add-ons 后台是否已经接受披露。商店后台状态仍以人工登录 dashboard 后看到的实际审核和发布状态为准。 diff --git a/docs/guide/basic-usage.md b/docs/guide/basic-usage.md index 45062316..b8214b5e 100644 --- a/docs/guide/basic-usage.md +++ b/docs/guide/basic-usage.md @@ -28,11 +28,11 @@ ## 分类过滤栏 ```text -[ 重点 5 全部 22 ] [ 选择分类 ▾ ] +[ 重点 5 全部 22 ] [ 选择分类 ] ``` - 左侧是 segment 切换器,「重点」只列高置信度结果,「全部」列全部 -- 右侧是分类下拉,23 个分类(前端框架 / UI 框架 / CDN / 网站程序 / ...),选具体某个分类只显示该分类的结果。每个选项后面带计数(如「网站程序 · 4」) +- 右侧是分类下拉,60 个分类(前端框架 / UI / CSS 框架 / CDN / 托管 / 网站程序 / ...),选具体某个分类只显示该分类的结果。每个选项后面带计数(如「网站程序: 4」) ## 技术列表 @@ -69,3 +69,15 @@ React [高置信度] 弹窗整体高度锁定 600px,只有**技术列表区域**自己滚动。顶部工具栏、概览、分类过滤栏始终可见。 列表向下滚超过约 240px 时右下角浮出一个圆形按钮(向上箭头),点击平滑滚回顶部。 + +## Agent Bridge + +Agent Bridge 是面向本机 AI Agent 的可选能力。启用后,本机 Agent 可以通过 `127.0.0.1` bridge 读取当前浏览器可观测的技术栈、视觉、布局、组件、交互和资源摘要,用于生成相似体验的实现方案。 + +该能力默认关闭,需要在设置页显式开启。启用状态只保存在当前浏览器 profile 的本机 `chrome.storage.local`,不会随 Chrome sync 同步到其他设备或其他 profile;换设备、换浏览器 profile 或重装扩展后需要重新开启。 + +如果需要采集本机开发站点、内网、保留地址,或当前 DNS/proxy 会把公网域名映射到私网地址,可以在设置页人工确认开启“允许所有网络目标”。该开关仅放开 Agent Bridge 网络目标门禁,不改变 `http:` / `https:` 协议限制,也不允许采集 bridge 页面自身。 + +Agent Bridge 使用 passive capture,不会点击页面、提交表单、登录账号或执行破坏性操作。`viewports` 只写入 profile 请求上下文,不是 CDP 移动仿真或真实手机截图。它不会要求您点击插件按钮、复制 JSON 或下载文件,但本版本信任您本机启动的 bridge 进程,不防同机恶意进程或同浏览器 profile 中其他恶意扩展。 + +repo-local skill 脚本路径以仓库根目录为基准,Agent 从其他目录启动时应先切到 `` 或使用绝对脚本路径。如果 StackPrism 安装在非默认浏览器或非默认 profile,本机 Agent 需要设置 `STACKPRISM_BROWSER_OPEN_COMMAND` 指向平台 opener 或对应 Chrome 内核浏览器,并通过 `STACKPRISM_BROWSER_OPEN_ARGS_JSON` JSON 字符串数组传入 opener/profile 参数;不要把参数拼进 command 字符串,bridge URL 永远由脚本作为最后一个参数追加。macOS 可用 `open` 加 `["-a","Google Chrome"]` 指定 Chrome;Windows 应使用目标浏览器 `.exe` 路径;Linux 可用 `xdg-open` 或 `google-chrome`/`chromium` 可执行文件。未启用时会返回 `AGENT_BRIDGE_DISABLED`,打开到未安装扩展的浏览器或 profile 时通常会返回 `EXTENSION_NOT_CONNECTED`,这些都是需要用户处理的配置错误,不应由 Agent 静默重试或降级。 diff --git a/docs/reviews/CR-AGENT-BRIDGE-COMPLETION-AUDIT-2026-05-24.md b/docs/reviews/CR-AGENT-BRIDGE-COMPLETION-AUDIT-2026-05-24.md new file mode 100644 index 00000000..e7d7a570 --- /dev/null +++ b/docs/reviews/CR-AGENT-BRIDGE-COMPLETION-AUDIT-2026-05-24.md @@ -0,0 +1,417 @@ +# CR-AGENT-BRIDGE-COMPLETION-AUDIT-2026-05-24 + +## Scope + +本审计围绕 `docs/superpowers/plans/2026-05-21-stackprism-agent-bridge-plan.md`,检查当前 Agent Bridge 功能代码是否达到计划目标、实现是否完善、严谨、安全。 + +审计对象包括: + +- 本地 JS bridge 和 Python fallback HTTP/API 实现。 +- MV3 扩展端 opt-in、bridge handshake、background capture 编排、profile transfer、目标 tab 生命周期处理。 +- experience profiler、site experience profile builder、隐私脱敏和 limitations 输出。 +- repo-local skill、用户/开发/隐私/发布文档、release workflow 和 `dist/` 发布边界。 +- 单元测试、Chrome for Testing browser smoke、构建、文档构建、格式和外部 CodeRabbit review 结果。 + +## Verdict + +当前实现已经覆盖 Agent Bridge 第一版的主链路,并且主链路在本地自动化与真实 Chrome for Testing smoke 中通过:用户 opt-in、bridge token 一次性渲染、目标采集、profile 分片传输、敏感信息脱敏、取消/关闭/reload/worker stop 等关键路径都有证据。 + +但不能把整份计划判定为“所有目标均已完成”。计划中 Task 10 的 live E2E 矩阵仍有部分要求只有单元测试或文档风险说明覆盖,缺少真实浏览器/商店发布侧证据。当前结论是:核心功能可用且安全边界基本严谨;完整计划仍有未实证项,不能标记全量完成。商店披露风险已被发布 workflow 升级为打包前人工确认硬门禁,但外部 dashboard 的审核和 rollout 状态仍不能由 worktree 自动证明。 + +## Proven By Current Evidence + +- Opt-in gate:`agentBridgeEnabled` 只从 `chrome.storage.local` 生效;legacy sync `agentBridgeEnabled: true` 在 smoke 中被忽略,未开启时返回 `AGENT_BRIDGE_DISABLED`,不产生 profile,且本地 probe target 的请求数为 0,证明未开启时不会打开或抓取目标 URL。读取 `chrome.storage.local` 失败时现在 fail closed 为 disabled,不会退回 sync 或默认开启。运行中关闭本机 `agentBridgeEnabled` 已由单元测试和独立 Chrome smoke 覆盖:capture fail closed 为 `AGENT_BRIDGE_DISABLED`,profile endpoint 返回 409,插件创建的目标 tab 被清理;单元测试还覆盖该关闭动作不会被普通 badge refresh 延迟。 +- Bridge API:JS/Python 均有 Bearer token、Host 校验、Origin/Referer/Sec-Fetch 校验、no-store/nosniff/no-referrer、一次性 bridge token render、hostile query/fragment 和 terminal error message 不反射、failed status error payload 脱敏、failed status 未知错误码拒绝、status/profile/control scope、documented identifier fixture validation、browser-open invalid args capture-time contract、bridge URL single-argv spawn boundary、completed control 返回 stop 命令、rate limit、terminal DELETE guard、同一 capture 并发 profile POST 序列化和 strict request-shell 拒绝测试;request-shell 覆盖 missing Host、wrong host/port、IPv6 host、absolute-form、`CONNECT` authority-form、encoded slash/backslash、duplicate bridge query、duplicate sensitive headers、unsupported transfer/content encoding 和 invalid content length;missing Host 回归测试先复现 JS 返回 Node 默认空 `400`,修复后 JS/Python 均返回统一 JSON `400 / INVALID_REQUEST`;authority-form 回归测试先复现 JS 空响应和 Python 标准库 `501` HTML,修复后两端都返回统一 JSON `400 / INVALID_REQUEST`,不会进入业务 routing;status/request/control/profile 端点响应头有 no-store/nosniff 契约测试,profile 响应另有 no-referrer 契约测试;`GET /request` 响应 shape 现在由 JS/Python 测试锁定为只含 `captureId`、`sessionId`、`nonce`、`protocolVersion` 和 `request`,content-side request envelope validator 也拒绝未知 key,避免 token、profile body 或 callback URL 进入扩展启动路径;`/bridge` HTML 响应有 no-store/no-referrer/nosniff、DENY frame、COOP、minimal Permissions-Policy、无 `unsafe-inline` CSP 和 script/style nonce 一致性契约测试;JS/Python 单元测试现在还覆盖真实 `bridgeUrl` 在跨站 `Referer` 和 `Sec-Fetch-Site: cross-site` 下返回 `403 / ORIGIN_NOT_ALLOWED`、响应不含 `spbt_`,且同 URL 后续正常顶层打开仍可首次渲染 token,证明跨站探测不会消耗一次性 token。Chrome smoke 追加覆盖攻击页 iframe 嵌入尝试:父 DOM 和可访问 frame 文档均不含 `spbt_`,随后顶层首次 `/bridge` 渲染仍返回 200 且包含一次性 `bridgeToken`,证明 iframe 尝试没有触发 token render/claim。`host-validation` smoke 又在真实 JS bridge 进程上用原始 TCP 请求覆盖 `/health`、`/bridge` 和 Bearer status endpoint:正确 `127.0.0.1:{port}` Host 可用,`localhost:{port}`、错误端口和 `[::1]:{port}` 被拒绝为 `INVALID_REQUEST`,错误 Host 的 `/bridge` 不泄露或消耗 bridge token。`response-headers-cors` smoke 在真实 JS bridge 进程上补充覆盖 capture status GET、request GET、control GET、status POST 和 profile GET 响应头,验证 JSON 端点返回 no-store/nosniff,profile 返回 `Referrer-Policy: no-referrer`,`OPTIONS /v1/captures` 不返回 `Access-Control-Allow-*`,带跨站 Origin、Referer 或 `Sec-Fetch-Site: cross-site` 的敏感请求均返回 `403 / ORIGIN_NOT_ALLOWED`,且 target probe request count 保持 0。 +- Capture concurrency:JS/Python bridge 单元测试和扩展编排单元测试覆盖 `CAPTURE_BUSY`;独立 Chrome smoke 进一步证明真实扩展运行中已有一个 slow-fixture capture 持有目标 tab 时,第二个 capture 返回 `429 / CAPTURE_BUSY`,取消第一个 capture 后 profile endpoint 返回 409 且 owned target tab 不再可见。 +- Target policy:URL schema、credential、fragment、query-preserving match、private/DNS policy、bridge self-target、final URL blocking 均有单元测试;fixture DNS 使用假 resolver,并新增 `198.18.0.12` benchmark-address、IPv4/IPv6 special-use address、以及 Python `ipaddress` public exception fixtures 锁定 JS/Python policy parity。扩展编排测试还覆盖 `TARGET_INJECTION_FAILED` 会携带有界且脱敏的 `details.reason`。Chrome smoke 进一步覆盖未开启 `allowPrivateNetworkTarget` 时本机私网初始 URL 在创建阶段被拒绝为 `PRIVATE_NETWORK_TARGET_BLOCKED / private_network_address` 且不触达目标 server,也覆盖当前 resolver 将 `stackprism-browser-smoke.invalid` 映射到 `198.18.0.12` 时创建阶段 fail-closed,还覆盖本机允许初始 URL 经 302 重定向到 bridge origin 后,被真实扩展按最终 URL 策略拒绝为 `FINAL_URL_BLOCKED / invalid_final_url`。新增 `dns-lookup-failed` smoke 用真实 JS bridge 生产 resolver 证明 oversized-label hostname 返回 `ENOTFOUND` 时创建阶段拒绝为 `TARGET_DNS_LOOKUP_FAILED / dns_lookup_failed`,且 `activeCapturesAfterReject = 0`。新增 final-private-url smoke 使用公网 IP 字面量初始 URL 经本机 HTTP proxy 真实跳转到 `127.0.0.1` final URL,确认 bridge 在 `target_loaded` 后拒绝为 `FINAL_URL_BLOCKED / private_network_address`、profile endpoint 为 409、owned target tab 已清理;final-dns-policy smoke 则使用同样的公网 IP 初始 URL 经 proxy 真实跳转到 `stackprism-browser-smoke.invalid` final URL,确认当前 resolver 分支在 `target_loaded` 后拒绝为 `FINAL_URL_BLOCKED / private_network_address`;final-dns-lookup-failed smoke 使用同样公网 IP 初始 URL 经 proxy 跳转到 oversized-label hostname final URL,确认 bridge 在 `target_loaded` 后拒绝为 `FINAL_URL_BLOCKED / dns_lookup_failed`、profile endpoint 为 409、owned target tab 已清理;target-navigated-away smoke 在 `target_loaded` 后通过扩展 API 将目标 tab 导航到同源不同路径,确认 Agent 端返回 `TARGET_NAVIGATED_AWAY`、profile endpoint 为 409、owned target tab 已清理;target-load-failed smoke 用真实 Chrome 访问会主动断连的本机 fixture,确认 main-frame load failure 返回 `TARGET_LOAD_FAILED`、profile endpoint 为 409、owned target tab 已清理;target-load-timeout smoke 用真实慢响应 fixture 证明目标页加载超过 capture 上限时 Agent 端返回 `TARGET_LOAD_TIMEOUT`,不会退化成浏览器错误页 profile 或泛化 `CAPTURE_TIMEOUT`。真实 Chrome 下也覆盖 `reuse_or_new_tab` 对同 origin/path 但 query 不同的既有 tab 不复用、`active_tab` 对 query 不同的前一 active tab 返回 `ACTIVE_TAB_MISMATCH`。 +- Extension orchestration:background 校验 sender tab/window/url/session/capture/nonce,普通消息隔离 bridge tab;incognito bridge URL 也按 bridge tab 处理,普通 runtime message 会在读取检测缓存前被拒绝;Agent Bridge 页判定现在要求 `127.0.0.1/bridge` 同时具备严格的 `session`、`capture`、`nonce` query 形态,普通本机 `/bridge` 页面不再被排除在常规检测之外;已登记 bridge session 的 `/v1/captures/*` API tab 不能通过普通 runtime、popup/options 或普通后台检测入口读取或触发普通检测缓存;`/v1/captures/*` 的 `webRequest` 和 `webNavigation` 跳过逻辑绑定到 bridge tab 或已登记 bridge session origin,普通本地同路径 API 不会被误跳过;`AGENT_BRIDGE_HELLO` 和 `START_AGENT_CAPTURE` 缺少 required capabilities 时在 background 验证阶段返回 `NOT_SUPPORTED` 和缺失能力名,且不会解析或打开目标 tab;`START_AGENT_CAPTURE` 含 `bridgeToken`、callback URL 或 profile wrapper 时会在 background 校验阶段显式返回 forbidden `INVALID_REQUEST`,不会解析目标 tab 或创建 capture state;本轮新增 RED/GREEN 覆盖 unknown top-level start payload 早期拒绝后仍残留 bridge session 的缺口,修复后目标解析前的 request validation、opt-in disabled、busy、incognito 和 target-resolution failure 均复用同一 bridge-session cleanup path;bridge content client 在 request envelope 绑定信息与页面 config 不一致时同源 POST `failed / BRIDGE_REQUEST_MISMATCH`,且不会向 background 发送 `AGENT_BRIDGE_HELLO` 或 `START_AGENT_CAPTURE`;同一测试还断言 content client 读取 request 时带 Bearer token,status POST 同时带 `Authorization: Bearer {bridgeToken}` 和 `Content-Type: application/json`;content bridge config 的 JSON 解析失败和非整数 `protocolVersion` 均归一为 `INVALID_REQUEST`;content observer 主动注入不再依赖 manifest 第一个 content script,而是按文件名定位普通 observer,Agent capture 路径在 observer 注入失败时显式失败为 `TARGET_INJECTION_FAILED`;profile 走 bridge content script 同源 POST,不直接由 background 跨 origin fetch localhost。 +- Profile transfer:Port handshake、384 KiB chunk、ACK、sha256、缺片、非法 payload、mismatch、断连和大 profile multi-chunk 均有自动化或 browser smoke 证据;本轮新增 RED/GREEN 锁定 BEGIN/COMPLETE hash 绑定,content 侧现在保存 BEGIN 宣告的 `sha256`,并要求 COMPLETE metadata 与实际 bytes 都匹配该原始 hash,不能在完成阶段改写 hash 后提交 profile;本轮还补强 BEGIN metadata gate,非 64 位小写 hex `sha256`、与 `byteLength / 384 KiB` 不匹配的 `chunkCount`、超过 8 MiB profile 上限的声明都会在分配 transfer state 前失败;`include: ["tech"]` 的真实浏览器 smoke 证明未请求的 experience sections 不产生 profiler metadata,不返回 visual/layout/components/interaction/ux/assets 数据,并带对应 `section_not_requested` limitations。 +- Privacy:profile builder 和 profiler 对 URL hash/query、headers、文本摘要、截图元数据、token-like 字段做脱敏;嵌入在文本或 CSS 片段中的 `http(s)` URL 也会脱敏 query value 并移除 fragment;本轮新增 RED/GREEN 覆盖外部 profile 输入的对象键名和 `limitations` 文本,修复后敏感键名会脱敏并处理键名碰撞,externally supplied limitations 也走同一 redaction path;续审又补强 camelCase/compound token-like field names,`apiToken`、`sessionId`、`secretKey`、`bridgeToken` 这类字段名和值现在会在 profile builder 和 injected profiler 两侧同口径脱敏;background log sanitizer 现在覆盖普通错误、污染的 `Error.name`、cleanup warning 详情、Agent Bridge router 内部异常和注入失败 `details.reason`;Agent Bridge router 的异常 catch 路径对 bridge 响应只返回通用 `INVALID_REQUEST / Agent Bridge request failed.`,不会把 raw exception 中的 bridge URL query、nonce 或 token-like id 回传给本机 bridge;本地 JS/Python bridge 在接受扩展侧 failed status 时会清洗 error message/details 中的 URL query、fragment、token、nonce 和 authorization 字段后再进入 `publicStatus`;browser smoke 断言 profile 未包含 cookie、authorization、set-cookie、`token=secret` 或 `#frag` marker。 +- Settings fallback logging:`loadDetectorSettings()` 在 `chrome.storage.local` 不可用时仍保留 sync settings fallback 和 local opt-in fail-closed 行为,但 warning 详情现在通过 `sanitizeLogDetails`,不再把 raw `Error` 直接写入 background console。 +- Lifecycle:browser smoke 覆盖 target tab 关闭、bridge tab 关闭、Agent 取消、运行中关闭本机 Agent Bridge opt-in、通过 `chrome://extensions` 真实关闭浏览器扩展、通过 `chrome://extensions` reload unpacked 扩展、持久化 deadline 过期后的事件入口 reconciliation、service worker CDP target stop、`chrome.runtime.reload()` 中断、service worker 自然 idle eviction 后由 bridge 页面唤醒并完成 capture,以及单独 destructive smoke 覆盖 `chrome.storage.session.clear()` 后 reload;这些路径均未产生 fake profile,并验证 owned target tab 清理或不可见。 +- Cleanup observability:owned target tab 删除失败不再被 `cleanupTarget` 静默吞掉;失败会通过 `reportCleanupFailure('cleanupTarget', errorName)` 记录,且 warning details 会先经过统一日志脱敏,避免污染的错误名泄漏 bridge URL query、nonce 或 token-like id;failure 和 cancel 编排路径继续清理 `agent-capture-state` 与 bridge session。直接清理路径也会记录 `removeAgentCaptureState` / `clearBridgeSession` 失败,避免一个 cleanup 异常隐藏后续清理动作。 +- Release boundary:`dist/manifest.json` 无 `externally_connectable`;manifest source contract 固定扩展权限集合、content script 顺序/匹配范围和单一 `web_accessible_resources` 暴露面;`web_accessible_resources` 未暴露 `agent-skill`、bridge 脚本、repo-local JS bridge helper 源文件或 `experience-profiler.iife.js`;`dist/` 未包含 Python/test/agent-only 产物、local bridge server 入口或 repo-local JS bridge helper 源文件。发布 workflow 还会在检测到 Agent Bridge loopback content script 或 loopback web accessible resource 时要求维护者确认商店隐私披露:workflow_dispatch 必须设置 `agent_bridge_disclosure_confirmed=true`,release 事件必须在 release note 中包含 `- [x] Agent Bridge disclosure confirmed`,否则打包前失败。 +- Documentation:README、用户指南、配置指南、开发文档、隐私文档和 release 文档均写明默认关闭、本机 profile 级 opt-in、loopback trust boundary、同浏览器扩展 trust boundary、private-network 非浏览器级防火墙;`docs/config/index.md` 已把 Agent Bridge 从 sync 配置总述中拆出为 `chrome.storage.local` 例外。 +- Code quality:新增 `tests/agent-bridge-complexity.test.mjs` 固化 repo-local bridge helper 源文件 300 行预算;该测试先在 Python fallback `http_server.py: 326` 处失败,拆分通用 HTTP handler helper 后通过。本轮并发 profile POST 修复后又先复现 `http-server.mjs` 超过预算,随后把路由鉴权、profile read/write 和 commit helper 收敛到 handler 模块,当前 JS/Python bridge helper 均低于 300 行。 +- Storage boundary:新增 `tests/agent-capture-orchestration.test.mjs` 契约测试,覆盖 bridge session、agent capture state 和 active-tab tracker 的 `chrome.storage.session` 写入路径不会调用 `setAccessLevel`,不会把 `TRUSTED_AND_UNTRUSTED_CONTEXTS` 或 token 写入 session storage。 +- Startup env gate:JS `createBridgeServer({ env })` / `openBrowser()` 和 Python fallback `create_server(..., env=...)` / `open_browser()` 现在都会校验 browser-open 环境;`STACKPRISM_BROWSER_OPEN_COMMAND` 或 `STACKPRISM_BROWSER_OPEN_ARGS_JSON` 原始值含 NUL,或 `STACKPRISM_BROWSER_OPEN_ARGS_JSON` 解析后参数字符串含 NUL 时,直接拒绝为 `BRIDGE_INVALID_ENV`,不会绑定 server、生成 ready token 或进入 spawn。CLI 入口会在非法 `STACKPRISM_BRIDGE_PORT` 时先失败为 `BRIDGE_INVALID_ENV`,在配置端口已占用时先失败为 `PORT_IN_USE`,JS/Python 测试均锁定这些路径不会向 stdout 输出 ready JSON 或 API token material。非法 JSON、非数组和非字符串元素形式的 `STACKPRISM_BROWSER_OPEN_ARGS_JSON` 现在由 JS/Python capture API 契约测试锁定为 `BROWSER_OPEN_FAILED / invalid_open_args`,且 helper 级测试证明包含 `?`、`&`、空格、引号和 shell 元字符的 bridge URL 会作为最后一个独立 argv 传给 fake command。 +- Browser/profile routing:Chrome smoke 现在同时覆盖错误 profile 和正确 profile。`wrong-profile-extension-missing` 场景把 bridge 自动打开到未加载 StackPrism 的临时 Chrome profile,capture 最终失败为 `EXTENSION_NOT_CONNECTED`,profile endpoint 为 409,目标 probe 请求数为 0;主 smoke 中的 custom browser-open 场景则通过 `STACKPRISM_BROWSER_OPEN_COMMAND` / `STACKPRISM_BROWSER_OPEN_ARGS_JSON` 打开已加载扩展的 Chrome for Testing profile,并完成本地 fixture capture。 + +## Findings + +### P1 - Plan Completion Remains Unproven For Several Live E2E Items + +`docs/superpowers/plans/2026-05-21-stackprism-agent-bridge-plan.md` 的 Task 10 要求覆盖一组真实浏览器和生命周期场景。当前 `docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md` 已明确记录剩余风险,包括 incognito live behavior 已按计划跳过规则保留单元覆盖和原因说明、Chrome Web Store / Edge Add-ons update rollout、idle-driven service worker eviction、更广 live 资源压力矩阵,以及更广 DNS/private-network live matrix。`chrome.storage.session.clear()` 后 reload、运行中关闭本机 Agent Bridge opt-in、通过 `chrome://extensions` 真实关闭浏览器扩展、通过 `chrome://extensions` reload unpacked 扩展、当前 resolver rewrite 到 `198.18.0.12`,以及 JS/Python 对特殊用途地址段的离线 parity 子场景已在 2026-05-25 补充证据,不再只依赖泛泛单元覆盖;但这不能替代商店/签名包更新 rollout 或完整 DNS/private-network live matrix 验证。 + +这些项不能用单元测试或“未发现问题”证明完成。发布前若要宣称整份计划完成,需要补 live 场景或在计划中明确降级为当前第一版不覆盖项。 + +### P2 - External Publishing Gate Cannot Be Proven From Worktree Alone + +代码和文档已经加入 Chrome Web Store / Edge Add-ons 发布前隐私披露要求,并且发布 workflow 现在会在 Agent Bridge 进入 `dist/manifest.json` 后要求维护者做打包前披露确认:workflow_dispatch 需要 `agent_bridge_disclosure_confirmed=true`,release 事件需要勾选 `- [x] Agent Bridge disclosure confirmed`。这能阻止未确认披露就上传 release 资产,但商店后台隐私披露、数据用途说明、审核接受和 rollout 仍属于外部状态,当前 worktree 无法证明已经完成。发布 Agent Bridge 版本前仍需要人工核验 dashboard。 + +### P2 - Some HTTP Resource Exhaustion Requirements Are Only Partially Exercised + +JS/Python bridge 已配置连接数、headers/request/keep-alive timeout,并测试了 request shell、oversized body、Python DNS timeout、慢 request body、慢 request headers 和配置级 active connection limit。JS bridge 已有显式 body/header timeout 关闭逻辑;本轮又补强 request body 超限契约,RED 先证明 JS `readJson()` 停止继续消费超限 body 但没有把关闭连接意图传给 HTTP 层,修复后 413 结果携带 `close: true` 并由响应层返回 `Connection: close`。Python fallback 已有 `close_connection = True` 的同口径测试。Chrome smoke 现在也覆盖了真实扩展运行中的首个并发门禁:已有 slow-fixture capture 时第二个 capture 返回 `429 / CAPTURE_BUSY`。新增 `sequential-capture-pressure` smoke 又覆盖同一 bridge/Chrome profile 下 4 轮连续真实 capture,每轮完成后 `/health.activeCaptures = 0` 且 owned target tab 已清理。新增 `connection-pressure` smoke 用真实 CLI bridge 默认配置持有 20 条半开 HTTP 连接,确认第 21 个 `/health` 请求被 `ECONNRESET` 而不是进入业务 routing,释放 1 条连接后 `/health` 恢复 200。新增 `resource-timeouts` smoke 用真实 CLI bridge 默认配置确认慢 header 在约 5 秒返回 `408 Request Timeout` 且不进入 `/health` 业务 body,随后 `/health` 恢复 200;慢 body `POST /v1/captures` 在约 10 秒返回 `408 Request Timeout`、不创建 capture,随后 `/health` 恢复 200 且 `activeCaptures = 0`;keep-alive `/health` 连接收到初始 200 后在默认 idle 窗口后被服务端关闭,随后新 `/health` 请求恢复 200。但计划中的更长时间负载、更广真实浏览器并发压力和完整资源耗尽矩阵仍没有完整 live 证据,因此不能宣称资源耗尽矩阵完全验证。 + +### P3 - CodeRabbit Valid Low-Risk Findings Were Addressed + +本轮 `coderabbit review --prompt-only -t uncommitted` 返回 5 条: + +- 已修复:`src/utils/site-experience-guidance.ts` 对外部 profile label 和 limitations 进入 `agentGuidance.summary` 前新增脱敏、控制字符清理和长度限制,并新增回归测试。 +- 已修复:移除 `src/background/tab-store.ts` 中无意义的 `clearAgentCaptureTabSession` wrapper,`src/background/agent-capture-target.ts` 直接调用 `clearTabSession`。 +- 误报:`tests/stackprism-bridge.test.mjs` 当前只有一个 `rawHttp` 定义。 +- 已补强:`src/background/agent-capture-target.ts` 不再吞掉 owned target tab 删除失败;`src/background/agent-capture.ts` 直接清理调用点统一记录 `cleanupTarget`、`removeAgentCaptureState` 和 `clearBridgeSession` 失败后继续后续清理,新增回归测试覆盖 failure、cancel 和 state removal failure 路径。 + +### P3 - 2026-05-25 CodeRabbit Follow-Up Valid Findings Were Addressed + +续跑 `cr review --prompt-only -t uncommitted` 返回 25 条。逐条核验后,已修复仍有效的问题: + +- content bridge config 非法 JSON 统一映射为 `INVALID_REQUEST`,避免 `SyntaxError` 泄漏到不一致路径。 +- content 侧收到 successful hello 但缺少必需 capability 时,现在在同源 failed status 中保留 `details.missingCapability`,并在打开 profile transfer port 或发送 `START_AGENT_CAPTURE` 前停止。 +- JS bridge 与 Python fallback 的 status phase monotonicity 不再只限 `running`,`waiting_extension` 等状态也不能倒退 phase。 +- JS bridge 对同一 capture 的 status 更新增加 per-capture lock,避免并发相同 sequence 同时通过 check-then-update。 +- 扩展侧 `START_AGENT_CAPTURE` 增加 start lock,避免并发 start 同时通过 active-capture 检查并打开多个 target。 +- `startAgentCapture` / `cancelAgentCapture` 在 reconciliation 前先检查 `chrome.storage.session` capability,缺失时 fail closed 为 `NOT_SUPPORTED / storageSession`。 +- `getAgentCaptureState()` 验证 session storage 中的状态对象,损坏或类型错位时清理对应 state/index 并返回 `null`。 +- profile transfer port disconnect cleanup、background runner rejection、content observer 注入失败和普通 background detection 失败均有脱敏日志,不再空 catch。 +- Python fallback `max_open_connections` 做正整数归一化;`captureKey()` 对分隔符做显式拒绝;browser smoke harness 在 ready JSON 解析失败时终止 bridge 子进程并清理 CDP page target。 + +核验后跳过的建议:`content-injector` 和 `message-router` 的两条已被当前 diff 覆盖;JS bridge `CaptureStore({ baseUrl: "" })` 在 `listen()` resolve 前不可被 API 请求触达,且创建 capture 前会设置 `store.baseUrl`;`CaptureStore.create()` 当前是同步 check/set/openBrowser 流程,`openBrowser()` 使用 `spawnSync`,不存在异步 TOCTOU 窗口;Python body `Content-Type` 建议接受额外参数与计划中仅允许缺省或 `utf-8` charset 的约束不一致;cleanup errors 继续记录并尝试后续清理,不上抛覆盖原始 fail-closed 终态。 + +第二轮 `cr review --prompt-only -t uncommitted` 收敛为 2 条。已修复有效项:`experience-profiler` 的 `boundingBoxes[].selector` 现在保留触发匹配的 selector,而不是退化为 tagName,并新增静态回归测试。跳过项:`tests/stackprism_bridge_py.test.mjs` 只有一个 `rawHttp` 定义;另一个同名 helper 位于 `tests/stackprism-bridge.test.mjs`,属于不同模块作用域。 + +第三轮 `cr review --prompt-only -t uncommitted` 返回 5 条,均已核验。已修复有效项:Python fallback 非 UTF-8 JSON body 现在归一为 `400 INVALID_JSON`;content bridge config 的非数字 `protocolVersion` 现在 fail fast 为 `INVALID_REQUEST`;smoke harness 的 CDP WebSocket close/error 会 reject pending request,extension reload 触发失败会进入返回结果而不是空 catch;profile transfer port disconnect cleanup 改为由 onDisconnect 调用方记录脱敏上下文。 + +第四轮 `cr review --prompt-only -t uncommitted` 返回 4 条。已修复有效项:Python fallback claim bridge token 时间戳改用 `CaptureStore.now()` 注入时钟;新建目标 tab 如果返回 incognito 会先清理该 tab 再返回 `INCOGNITO_NOT_SUPPORTED`;bridge session lock 清理从异步 `finally()` 回调改为同步检查删除,避免误删更新的 lock。跳过项:`experience-profiler` selector metadata 建议是 stale/误报,当前代码和测试都已验证保留 matched selector。 + +### P3 - 2026-05-25 Local Continuation Audit Findings Were Addressed + +本轮本地继续审计又补强 4 个边界: + +- `loadAgentBridgeEnabled()` 在 `chrome.storage.local.get` 抛错时返回 disabled,避免本地 opt-in 读取失败后进入不确定状态。 +- `content` 侧 bridge config 不再接受字符串形式的 `protocolVersion`,非法 JSON 和非整数版本统一 fail fast 为 `INVALID_REQUEST`。 +- 普通检测路径不再把任意 `http://127.0.0.1:/bridge` 页面视为 Agent Bridge 页面;只有带严格 `session`、`capture`、`nonce` query 的桥页才触发桥页隔离。 +- `site-experience-redaction` 会脱敏任意文本/CSS 片段里的嵌入式 URL query value 并移除 fragment,避免 profile 文本字段绕过 URL 字段脱敏。 +- Agent Bridge router 的 `AGENT_BRIDGE_HELLO`、`START_AGENT_CAPTURE` 和 `AGENT_CAPTURE_CONTROL` 异常兜底不再把 `String(error)` 写入 bridge 响应;内部异常进入统一后台脱敏日志,bridge 侧只收到通用错误。新增回归测试先构造包含 `token=secret`、`nonce=`、`/bridge?` 和 `spb_...` 的 storage 异常,再断言响应和日志均不包含这些敏感片段。 +- JS/Python bridge 底层 server factory 现在也执行 browser-open env 预检,避免绕过 CLI 入口直接创建 server 时先生成 token 或绑定端口。新增测试先证明 factory 接受 raw NUL 或 JSON escaped NUL 的 browser-open env,修复后分别返回 JS `BRIDGE_INVALID_ENV` throw 和 Python `ValueError.code = BRIDGE_INVALID_ENV`。 +- JS `openBrowser()` 直接调用路径现在也先执行同一 parsed browser-open args NUL 预检,避免 helper 被复用时把 `["bad\\u0000arg"]` 送进 `spawnSync` 后降级成 `spawn_failed`;Python `open_browser()` helper 同步保留该断言。 +- Browser smoke harness 现在用 `stopBridge()` 统一停止 JS bridge 子进程:先等待 SIGTERM/SIGKILL 后的进程退出,再轮询 `/health` 确认原端口不再响应。所有 smoke 场景 finally 都改为等待该清理路径,避免 E2E 结束后遗留本地 bridge 服务。 +- Browser smoke stdout 摘要不再输出 `apiTokenPrefix` 或任何 token 前缀;所有场景只保留 `apiTokenPresent` 布尔值,证明 ready JSON 已包含 token 字段但不泄露 token 片段。新增 `tests/agent-bridge-browser-smoke-output.test.mjs` 静态契约,禁止 smoke 输出和 harness helper 重新引入 `apiTokenPrefix`、`bridgeTokenPrefix` 或 `tokenPrefix`。 + +本轮还尝试 `cr review --prompt-only -t uncommitted`;CLI 进入 limited/free 行为并长时间停在 `summarizing`,没有返回 findings,进程被终止,因此不作为有效外部审计证据。 + +### P3 - Python Fallback Helper File Length Contract Was Tightened + +本轮复杂度扫描发现 `agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/http_server.py` 超过计划中的 Python bridge helper 300 行预算。新增 `tests/agent-bridge-complexity.test.mjs` 后先观察到 RED:`http_server.py: 326`。随后将通用 JSON 响应、错误响应、鉴权、限流和 JSON body 读取 helper 提取到 `http_handler_base.py`,保留 `BridgeHandler` 路由职责、`DEFAULT_REQUEST_TIMEOUT_SECONDS` 模块级 patch 面和现有 Python fallback 行为。回归后 `http_server.py` 为 279 行,新增 helper 为 55 行,复杂度契约测试和 Python fallback 行为测试均通过。 + +## Task 10 Coverage Matrix + +Task 10 当前共有 78 个 checklist 项。下面按验收主题归并当前证据强度;该矩阵用于避免把局部通过误写成整份计划完成。 + +| Theme | Plan Items | Current Evidence | Status | +| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| 基础命令门禁 | build:injected、test:unit、lint、prettier、typecheck、build、docs:build、JS/Python 语法检查、JS/Python bridge 专项测试 | 审计记录已有本地执行结果;本轮新增运行中本机 opt-in 关闭、cleanup observability、复杂度、storage access-level、非 bridge loopback 零副作用、request envelope mismatch 后不启动 capture、content client request/status header contract、START_AGENT_CAPTURE forbidden sensitive fields fail-fast、Python preflight parity、JS/Python SIGTERM listener 关闭契约、污染 Error-name cleanup-log 脱敏契约、注入失败脱敏详情契约、content observer 注入失败和 observer 文件缺失显式终态契约、failed status error payload 脱敏与未知错误码拒绝契约、incognito bridge-tab 普通消息隔离契约、bridge API request isolation 契约、registered bridge API ordinary-message isolation 契约、manifest source contract、release workflow dist hygiene 契约、detector settings warning redaction 契约、extension lifecycle wake recovery 契约、AGENT_BRIDGE_HELLO storageSession 缺失诊断和 START_AGENT_CAPTURE required capabilities 缺失 fail-closed 契约;续审新增 content config JSON 错误、content hello capability missing detail、并发 start、并发 status、损坏 storage state、storageSession 缺失入口、非 running phase 回退和 profiler selector metadata 契约;最终本地续审新增 strict bridge URL 判定、local opt-in 读取失败 fail-closed、整数 `protocolVersion`、嵌入 URL 脱敏、router 异常脱敏、底层 factory env gate、CLI 非法端口和端口占用 ready gate、cross-site `/bridge` token render gate、profile transfer hash-binding、BEGIN metadata gate、request envelope strict shape、start rejection cleanup、compound token-like redaction、keep-alive idle smoke evidence contract、rate-limit smoke evidence contract、JS/Python `/profile` endpoint rate-limit 单元契约,以及 content client 在 `chrome.extension.inIncognitoContext === true` 时先返回 `INCOGNITO_NOT_SUPPORTED` 且不读取 `/request`、不发送 `AGENT_BRIDGE_HELLO` 或 `START_AGENT_CAPTURE` 的单元契约、background response-started API 缺失时 network observer 保持 inactive、JS/Python `targetNetworkAddress` 非 IP 字面量拒绝契约、profile language/UX 字段契约、fixture-backed default smoke 契约、bridge 页面截图弹窗焦点陷阱和 Settings 确认弹窗焦点恢复契约后,最新 `pnpm run test:unit` 为 248 tests passed | Proven locally | +| Bridge API semantics and rate limiting | create/status/profile/control API、auth scope、query/create rate limit、busy ordering、no silent queue | JS/Python 单元测试覆盖低阈值 create 和 query rate limit;新增 `rate-limit` live smoke 使用真实 CLI JS bridge 默认 production 阈值,确认默认 create limit 10:第 1 次 create 返回 200,第 2 到第 10 次在已有 active capture 下返回 `429 / CAPTURE_BUSY`,第 11 次 create 在 body 解析和队列前返回 `429 / RATE_LIMITED`;默认 query limit 120:同一 capture 的 120 次 authenticated status reads 返回 200,第 121 次返回 `429 / RATE_LIMITED`;新增 `profile-rate-limit` live smoke 使用独立真实 CLI JS bridge 确认默认 query limit 也覆盖 `/profile`:120 次 profile reads 到达 profile endpoint 并返回未完成 capture 的 `409 / INVALID_REQUEST`,第 121 次返回 `429 / RATE_LIMITED`;target request count 0,cleanup DELETE 200,bridge stderr 为空 | Proven by tests plus live smoke | +| Bridge 启动与环境错误 | ready JSON、port 占用、非法端口、NUL browser env、非法 browser args | JS/Python 专项测试覆盖 `PORT_IN_USE`、`BRIDGE_INVALID_ENV`、`BROWSER_OPEN_FAILED`,并检查非法端口和端口占用路径不会向 stdout 伪造 ready JSON 或泄漏 API token material | Proven by automated tests | +| 主链路 smoke | 加载 `dist/`、启动 JS bridge、创建 capture、轮询 profile、真实 fixture、真实复杂站点、非默认浏览器/profile 路由 | Chrome for Testing smoke 覆盖本地 fixture、custom browser-open、profile schema 和错误 profile fail-closed 路径;新增 wrong-profile smoke 覆盖 bridge 自动打开到未安装扩展的临时 Chrome profile 时返回 `EXTENSION_NOT_CONNECTED` 且不触达目标 probe,主 smoke 的 custom-open 路径覆盖指定正确 Chrome profile 后可握手并完成 fixture capture;报告记录浏览器版本与 dist 路径。2026-05-26 新增 `public-complex-target` smoke 捕获 `https://www.wikipedia.org/`,当前 resolver 输出 `198.18.0.19`,场景显式使用 `allowPrivateNetworkTarget = true`,profile HTTP 200、schema/capture id/final URL 匹配、visual/layout/component profile keys 非空、无截图 payload、无 checked privacy leak markers、bridge stderr 为空;默认 smoke 已切换为 fixture-backed local capture;`https://example.com` 只作为历史环境问题记录,当前不再作为默认成功路径 | Proven locally with explicit DNS-proxy caveat | +| 终态、并发和取消语义 | terminal DELETE、running DELETE、cancel control、completed control、late status、target/bridge tab close、maxConcurrentCaptures | JS/Python 测试覆盖终态 DELETE、completed control 返回 `cancel`、stale update、`CAPTURE_BUSY`、extension connect timeout 后 `/bridge` 不渲染 token,以及 completed result TTL 过期后 profile endpoint 和 `/bridge` 均返回 `CAPTURE_RESULT_EXPIRED` 且不渲染 bridge token;Chrome smoke 覆盖 running DELETE、owned target cleanup、target tab close、bridge tab close,以及运行中 slow capture 下第二个 capture 返回 `429 / CAPTURE_BUSY`;新增 sequential pressure smoke 在同一 bridge/Chrome profile 连续完成 4 轮真实 capture,逐轮断言 profile HTTP 200、`/health.activeCaptures = 0`、owned target tab 不再可见;新增 `result-expiry-bridge-page` smoke 在真实 Chrome fixture capture 完成后推进 in-process JS bridge 时钟,验证 profile endpoint 返回 `410 / CAPTURE_RESULT_EXPIRED`,原 `/bridge` URL 返回 410 且不含 bridge token material;单元测试覆盖 owned target cleanup 删除失败和 state/session cleanup 删除失败时可观测且不阻断后续清理 | Proven by tests plus smoke | +| HTTP resource pressure | maxOpenConnections、headers/body/keep-alive timeout、慢 body/header、超大 body、资源耗尽不进入业务 routing | JS/Python 单元测试覆盖默认 resource policy、慢 body/header timeout、configured active connection limit、超大 body、request shell 和 SIGTERM/stdin EOF shutdown;本轮新增 JS reader RED/GREEN,确认 request body 超限后停止继续消费并返回 `close: true`,响应层会带 `Connection: close`,Python fallback 已有 `close_connection = true` 契约;新增 `connection-pressure` smoke 使用真实 CLI JS bridge 默认配置持有 20 条半开 HTTP 连接,确认第 21 个 `/health` 请求被 `ECONNRESET` 而不是进入业务 routing,释放 1 条连接后 `/health` 恢复 200,bridge stderr 为空;新增 `resource-timeouts` smoke 使用真实 CLI JS bridge 默认配置确认慢 header 在约 5 秒返回 `408 Request Timeout` 且不进入 `/health` 业务 body,随后 `/health` 恢复 200;慢 body `POST /v1/captures` 在约 10 秒返回 `408 Request Timeout`、不创建 capture,随后 `/health` 恢复 200 且 `activeCaptures = 0`;keep-alive `/health` 连接收到初始 200 后在默认 idle 窗口后被服务端关闭,随后新 `/health` 请求恢复 200,bridge stderr 为空;新增 `request-shell-rejections` smoke 使用真实 JS bridge 拒绝 19 类原始 request-shell / ambiguous header 请求,目标 probe request count 0,拒绝后 `/health` 恢复 200,cleanup DELETE 200,bridge stderr 为空 | Targeted live evidence plus tests | +| Service worker / extension lifecycle | service worker target close、runtime reload、storage session clear、deadline reconciliation、extension update/user disable、idle-driven worker eviction、browser-managed update lifecycle | Chrome smoke 覆盖 CDP service worker target close、`chrome.runtime.reload()`、本机 opt-in 关闭、通过 `chrome://extensions` 真实关闭浏览器扩展、通过 `chrome://extensions` reload unpacked 扩展、`chrome.storage.session.clear()` + reload、service worker 自然 idle eviction 后由 bridge 页面唤醒并完成 capture,以及持久化 deadline 过期后的事件入口 reconciliation。2026-05-26 复核发现旧 expired-deadline smoke 在 slow target 下可能仍处于 `target_opening`,该阶段契约应为 `TARGET_LOAD_TIMEOUT`;已修正 harness 改为普通 fixture + `waitMs: 30000`,等待持久化 state 达到 `target_loaded` 后再回拨 `deadlineAt`,端口 9648 真实 Chrome rerun 返回 `CAPTURE_TIMEOUT`、profile 409、owned target tab 清理。unit tests 覆盖恢复/reconciliation,并固定 `onInstalled` lifecycle wake 会重新注入 observer、触发未完成 Agent capture fail-closed recovery、清理 owned target;两次运行中 idle best-effort probe 均未让 Chrome 在 active capture 中自然移除 service worker,最新端口 9650 仍显示 worker visible after 75s,随后 capture 以 `TARGET_LOAD_TIMEOUT` fail closed 并清理目标;Chrome Web Store / Edge Add-ons update rollout 和运行中 idle eviction 仍缺少真实浏览器触发证据 | Partially proven; live gaps remain | +| Target policy and target failures | final URL blocked、private network、DNS fail closed、redirect/final URL、target load fail/timeout/navigation away/injection fail、active tab/reuse query matching、incognito | JS/Python bridge policy tests和 background orchestration tests 覆盖这些错误码与清理语义;policy tests 使用同一 fixture 锁定 `198.18.0.12`、IPv4/IPv6 special-use address、Python `ipaddress` public exception 和 IPv4-mapped IPv6 行为;扩展编排测试覆盖注入失败返回 `TARGET_INJECTION_FAILED` 且 `details.reason` 不泄漏敏感 query、fragment、nonce 或 token-like id,也覆盖 content observer 注入抛错或 manifest 缺失 observer 文件时不会被普通检测静默吞掉;Chrome smoke 覆盖本地 fixture、private target opt-in、未开启 `allowPrivateNetworkTarget` 时私网初始 URL 创建阶段拒绝且目标 request count 为 0、current resolver rewrite 到 `198.18.0.12` 时创建阶段拒绝、真实 resolver `ENOTFOUND` 时创建阶段拒绝为 `TARGET_DNS_LOOKUP_FAILED / dns_lookup_failed` 且 active captures 归零、`bridge-self-target-blocked` 确认当前 bridge origin 和 `localhost` alias 初始目标均返回 `BRIDGE_SELF_TARGET_BLOCKED` 且 active captures 归零、允许初始 URL 经 302 重定向到 bridge origin 后被拒绝为 `FINAL_URL_BLOCKED / invalid_final_url`、公网 IP 字面量初始 URL 经本机 HTTP proxy 真实重定向到 `127.0.0.1` final URL 后被拒绝为 `FINAL_URL_BLOCKED / private_network_address`、公网 IP 字面量初始 URL 经 proxy 真实重定向到 `stackprism-browser-smoke.invalid` final URL 后按当前 resolver 拒绝为 `FINAL_URL_BLOCKED / private_network_address`、公网 IP 字面量初始 URL 经 proxy 真实重定向到 oversized-label hostname final URL 后按当前 resolver 拒绝为 `FINAL_URL_BLOCKED / dns_lookup_failed`、`target-url-validation` 真实 CLI smoke 覆盖 unsupported protocol、credential URL、loopback private target 和 bridge self-target 均在创建阶段 fail closed,且 `activeCaptures = 0`、target request count 0、bridge stderr 为空、`target_loaded` 后目标 tab 导航离开时拒绝为 `TARGET_NAVIGATED_AWAY`、目标 main-frame load failure 拒绝为 `TARGET_LOAD_FAILED`、目标 load timeout 拒绝为 `TARGET_LOAD_TIMEOUT`,以及真实浏览器中的 `reuse_or_new_tab` / `active_tab` query 边界;新增 `active-tab-unavailable` smoke 在新鲜 Chrome profile 无上一张可检测 active tab 记录时返回 `ACTIVE_TAB_UNAVAILABLE`、profile 409、target request count 0 且未打开目标 URL;targetMode query live smoke 还覆盖 `keepTabOpen=true` 的 `reuse_or_new_tab` query 边界:同 origin/path 但 query 不同的既有 tab 被保留,并保留插件新建目标 tab 到场景清理阶段,smoke 输出包含 `keepTabOpenWasTrue` 与 `keptNewTargetTabId`;incognito probe 已确认临时 profile 可启用 unpacked 扩展无痕权限且同 profile 重启后保持,但 CDP incognito context 未加载扩展 content script;`--incognito` 窗口路径 probe 也未加载扩展 content script。两条 live probe 均以 `EXTENSION_NOT_CONNECTED` fail closed、profile 409、目标 request count 0 作为按计划跳过证据;content-client `chrome.extension.inIncognitoContext === true` 的精确 `INCOGNITO_NOT_SUPPORTED` 分支已由单元测试证明不读取 `/request` 且不发送 capture runtime message,但 exact live metadata path 仍受当前 Chrome for Testing 自动化限制未触发 | Mostly automated plus targeted smoke | +| Profile privacy and transfer | cookie/header/query/hash/token 脱敏、truncation limitations、大 profile 分片、缺片、ack/hash/session/nonce 错误、重复/超大 profile、并发 profile POST 一次性提交、`["tech"]` include、screenshot metadata 开关 | Unit tests 覆盖 transfer 错误矩阵、schema/limitations、metadata 开关和 JS/Python 同一 capture 并发 profile POST 只能一个成功;Chrome smoke 覆盖隐私 marker、大 profile 多片、metadata true/false 无截图像素 payload,以及 `include: ["tech"]` 时 visual/layout/components/interaction/ux/assets 均为空、带 `section_not_requested` limitation 且无 profiler metadata | Proven by tests plus smoke | +| Bridge isolation and storage boundary | bridge tab 不污染 popup/cache/badge/header/dynamic snapshot、普通 runtime message 隔离、popup/options 不能读取 bridge tab 或伪造 tabId、`chrome.storage.session` access level | Unit tests和 message-router/bridge-session contracts 覆盖普通检测排除、非 bridge loopback 页零副作用退出、sender tab 匹配、AGENT_BRIDGE_HELLO storageSession 缺失时返回 `NOT_SUPPORTED / details.missingCapability`、START_AGENT_CAPTURE required capabilities 缺失时返回 `NOT_SUPPORTED` 且不解析/打开目标 tab、START_AGENT_CAPTURE 携带 `bridgeToken`、callback URL 或 profile wrapper 时返回 forbidden `INVALID_REQUEST` 且不解析/打开目标 tab、incognito bridge URL 仍按 bridge tab 拒绝普通检测缓存读取、registered bridge API tab 不能通过普通 runtime、popup/options 或普通 background detection 读取/触发检测缓存、bridge-tab `/v1/captures/*` 的 `webRequest` header merge 与 `webNavigation` throttle reset 跳过、普通本地 `/v1/captures/*` API 仍保留 header/log evidence、token 不持久化、storage session 不可用 fail closed,以及 Agent Bridge 相关 session storage 写入不调用 `setAccessLevel` 放宽给 untrusted contexts | Proven by automated tests | +| HTTP and bridge page security | one-time token render、cross-site render、iframe embedding、host/header/request-shell/preflight 拒绝、no-store/nosniff/no-referrer、CSP nonce、XFO/COOP/Permissions-Policy、反射防护、profile 越权 | JS/Python HTTP tests 覆盖 request-shell、headers、CSP、nonce、反射和 scope;request-shell 现在明确覆盖 missing Host、absolute-form 和 `CONNECT` authority-form,JS/Python 均返回 JSON `400 / INVALID_REQUEST` 而不是空响应或标准库 HTML;Python fallback preflight `Allow` 头与 JS 行为对齐;JS/Python 均覆盖真实 `bridgeUrl` 的跨站 `Referer` 和 `Sec-Fetch-Site: cross-site` 请求在 token render 前返回 403 且不消耗一次性 token;Chrome smoke 覆盖 cross-site render、first/second render、terminal render no-token,并新增 `bridge-iframe-blocked` 真实浏览器场景,证明攻击页 iframe 尝试后未在父 DOM 或 frame 文档暴露 bridge token,也未消耗 `/bridge` 首次顶层 token render;新增 `host-validation` smoke 在真实 JS bridge 进程上覆盖 `/health`、`/bridge` 和 Bearer endpoint 的 Host 校验,错误 Host 不进入 token render 或业务鉴权路径;新增 `response-headers-cors` smoke 在真实 JS bridge 进程上覆盖 JSON endpoint 安全头、profile no-referrer、preflight 无 CORS allow header,以及 Origin/Referer/Sec-Fetch 跨站拒绝 | Proven by tests plus smoke | +| Trust boundary documentation | private-network、loopback/local process、same-profile extension trust boundary | E2E 和开发/隐私/用户文档已记录边界,明确不是浏览器级防火墙,也不防同机恶意进程或同 profile 恶意扩展 | Documented | +| Release and cleanup hygiene | bridge 进程退出、`__pycache__` 清理、`dist/` hygiene、manifest 无 `externally_connectable`、WAR 不暴露 agent-only 路径、diff whitespace/status | JS/Python bridge 子进程已有 stdin EOF 和 SIGTERM listener 关闭测试;browser smoke harness 现在在每个场景 finally 中等待 bridge 子进程退出并确认 `/health` 端口关闭,`tech-only` live smoke 已验证该清理路径;manifest source contract 固定允许权限集合、普通 observer 与 bridge content script 的匹配范围/顺序、以及单一 WAR 资源集合;release workflow 现在显式拒绝 `web_accessible_resources` 或 `dist/` 中出现 repo-local JS bridge helper 源文件、本地 bridge server 入口、Python fallback、agent skill、tests、`docs/superpowers` 和 `__pycache__`,并拒绝嵌套在 asset 路径下的 agent-only 目录;新增测试会实际执行 workflow inline hygiene 脚本,覆盖 release gate 顺序、clean dist 通过、`externally_connectable`、污染 dist/WAR、嵌套 agent-only 目录失败;计划中的 commit 未执行 | Local hygiene staged; commit pending | +| External publishing | Chrome Web Store / Edge Add-ons privacy disclosure、release note、商店后台数据用途 | 只能由外部商店后台状态证明,当前 worktree 无法证明 | Not provable from worktree | + +## Verification Run + +本轮重新执行并通过: + +- `pnpm run test:unit`:163 tests passed,包含新增的扩展 `onInstalled` lifecycle wake recovery 测试、运行中本机 Agent Bridge opt-in 关闭 fail-closed 测试、关闭动作不被 badge refresh 延迟的入口测试、owned target cleanup 删除失败和 state/session cleanup 删除失败可观测且不隐藏后续清理动作的回归测试、污染 `Error.name` 不泄漏 bridge URL query、nonce 或 token-like id 的 cleanup warning 脱敏测试、注入失败返回脱敏 `details.reason` 的编排测试、content observer 注入失败和 observer 文件缺失显式返回 `TARGET_INJECTION_FAILED` 的编排测试、AGENT_BRIDGE_HELLO 在 storageSession 缺失时返回 `NOT_SUPPORTED / details.missingCapability` 的握手测试、request endpoint 返回错误绑定信息时 bridge content client 同源 POST `BRIDGE_REQUEST_MISMATCH` 且不发送 `AGENT_BRIDGE_HELLO` 或 `START_AGENT_CAPTURE` 的握手测试、START_AGENT_CAPTURE required capabilities 缺失时返回 `NOT_SUPPORTED` 且不解析或打开目标 tab 的编排测试、START_AGENT_CAPTURE 含 `bridgeToken`、callback URL 或 profile wrapper 时返回 forbidden `INVALID_REQUEST` 且不解析或打开目标 tab 的编排测试、failed status error payload 不泄漏 apiToken, bridgeToken, nonce, URL query 或 fragment 且未知错误码不会终态化 capture 的 JS/Python bridge 契约测试、incognito bridge-tab 普通消息隔离测试、bridge API request isolation 测试、registered bridge API tab 普通 runtime/popup/background detection 隔离测试、非 bridge loopback 页零副作用退出契约测试、Python fallback preflight `Allow` 头 parity 断言、JS/Python bridge SIGTERM 后关闭 listener 的子进程测试、Agent Bridge helper 300 行复杂度契约测试、storage session access-level 不放宽给 untrusted contexts 的契约测试、manifest source contract、release workflow dist hygiene 契约测试,以及 detector settings warning redaction 契约测试。续审新增覆盖坏 bridge config JSON、非 running phase 回退、同一 capture 并发 status 序列化、并发 start 串行化、损坏 session storage state 清理、storageSession 缺失入口和 profiler selector metadata。 +- `node --test --test-timeout=60000 tests/agent-bridge-manifest.test.mjs`:2 tests passed,固定允许的扩展权限集合、`host_permissions`、普通 observer 与 Agent Bridge content script 的顺序、匹配范围和 `run_at`,并固定单一 `web_accessible_resources` 资源集合,防止 `externally_connectable`、`localhost` bridge 匹配、agent-only profiler、repo-local bridge helper、Python fallback 或测试路径进入 manifest 暴露面。 +- `node --test --test-timeout=60000 tests/release-workflow.test.mjs`:RED 阶段先失败于 release workflow 只阻止顶层 bridge 入口脚本,没有显式阻止 `capture-store.mjs` 等 repo-local JS bridge helper 源文件进入 `dist/`;抽取 `agentOnlySourceFiles` 并复用于 `web_accessible_resources` 和 `dist/` 扫描后,4 tests passed。2026-05-25 续审把该文件扩展到 7 tests passed,新增覆盖 release gate 顺序必须在打包前执行、`externally_connectable` manifest 被拒绝、以及 `dist/assets/agent-skill/` 这类嵌套 agent-only 目录被拒绝。 +- `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs`:RED 阶段先失败于 `TARGET_INJECTION_FAILED` 没有 `details.reason`;修复后 37 tests passed,并覆盖含敏感 URL query、nonce、fragment 和 token-like id 的 `chrome.scripting.executeScript` 错误不会进入状态 payload。 +- `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs`:RED 阶段先失败于 incognito bridge URL 未被 bridge-tab helper 识别,导致普通 `GET_POPUP_RESULT` 消息没有在读取检测缓存前被 bridge sender guard 拒绝;移除 helper 中的 incognito 排除后 38 tests passed。 +- `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs`:RED 阶段先失败于 bridge-tab `/v1/captures/*` navigation commit 仍进入普通 `webNavigation` detection path;修复后 40 tests passed。当前实现只在 bridge tab 或已登记 bridge session origin 匹配时跳过 `/v1/captures/*`,普通本地同路径 API 仍会保留 header evidence 与普通 navigation log。 +- `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs`:RED 阶段先失败于已登记 bridge API tab 仍可通过普通 `GET_POPUP_RESULT`、popup/options `GET_HEADER_DATA` 或 `START_BACKGROUND_DETECTION` 读取/触发普通检测路径;修复后 43 tests passed。当前 message-router 在普通消息分支里同时使用 session-aware sender guard 和 popup target guard,`START_BACKGROUND_DETECTION` 也只在守卫通过后返回成功。 +- `node --test --test-timeout=60000 tests/background-logging.test.mjs`:RED 阶段先失败于污染的 `Error.name` 仍保留 `token=secret`,并且 `reportCleanupFailure` 的 `console.warn` 详情同样保留 raw bridge URL query 和 token-like id;修复后 2 tests passed,覆盖 `sanitizeLogDetails` 和 cleanup warning 两条日志路径。 +- `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs`:31 tests passed,包含 hostile query/fragment 和 terminal error message 不反射、bridge URL 只含 `session` / `capture` / `nonce` query 且不含 API/bridge token、failed status error payload 脱敏、failed status 未知错误码拒绝、strict request-shell 拒绝矩阵、status/request/control/profile 响应头契约、`/bridge` HTML header/CSP 契约、stdin EOF 和 SIGTERM listener 关闭、`completed`、`failed`、`cancelled`、`expired` 终态 DELETE 均返回 409 且不改写状态,以及扩展后的 URL policy fixture。新增 failed status 脱敏断言在 RED 阶段先失败于 API token 原样回传,随后 JS bridge 存储前清洗 error code/message/details 后通过;新增未知错误码断言先失败于 `MADE_UP_ERROR` 被接受,随后 JS bridge 在状态变更前拒绝并保持 capture 非终态。 +- `node --test --test-timeout=60000 tests/stackprism_bridge_py.test.mjs`:35 tests passed,包含同一组 bridge page 反射防护、bridge URL 只含 `session` / `capture` / `nonce` query 且不含 API/bridge token、failed status error payload 脱敏、failed status 未知错误码拒绝、strict request-shell 拒绝矩阵、preflight `Allow` 头 parity、响应头契约、`/bridge` HTML header/CSP 契约、首次 `/bridge` 渲染 token 后第二次同 URL 返回 409 且不含 `bridgeToken`、stdin EOF 和 SIGTERM listener 关闭、终态 DELETE 契约,以及同一组扩展后的 URL policy fixture。Python fallback 的 URL policy fixture 现在明确覆盖混合公网/私网 DNS 答案、resolver 异常和空解析结果都 fail closed。新增 failed status 脱敏断言在 RED 阶段先失败于 API token 原样回传,随后 Python fallback 与 JS bridge 同口径清洗后通过;新增未知错误码断言先失败于 `MADE_UP_ERROR` 被接受,随后 Python fallback 在状态变更前拒绝并保持 capture 非终态。 +- `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs`:66 tests passed,包含 shared `nonGlobalHostname`、`specialUseHostname`、`specialUseIpv6Hostname` 和 `publicSpecialUseExceptionHostname` fixtures,以及 JS/Python bridge URL query/token 暴露面断言。fixtures 使用假 resolver 返回 `198.18.0.12`、IPv4 documentation/reserved/unspecified ranges、IPv6 documentation/translation/discard/6to4/special ranges 和 Python `ipaddress` public exceptions,锁定 JS/Python 均返回一致的 `PRIVATE_NETWORK_TARGET_BLOCKED / private_network_address` 或放行语义。 +- `node --test --test-timeout=60000 tests/agent-bridge-complexity.test.mjs`:RED 阶段先失败于 Python fallback `http_server.py: 326` 超出 300 行预算;拆分后重新执行通过,1 test passed。 +- `node --test --test-timeout=60000 tests/agent-bridge-complexity.test.mjs tests/stackprism_bridge_py.test.mjs`:36 tests passed,覆盖复杂度契约和 Python fallback HTTP/API 行为回归。 +- `node --test --test-timeout=60000 tests/site-experience-profile.test.mjs`:RED 阶段先失败于 `chrome.storage.local` 不可用时 `loadDetectorSettings()` 直接把 raw `Error` 传给 `console.warn`;修复后 15 tests passed,证明 warning 详情已脱敏且 sync settings fallback 与 local opt-in fail-closed 行为仍保持。 +- `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs`:51 tests passed,新增 forbidden sensitive fields 测试先失败于 `bridgeToken`、callback URL 和 profile wrapper 被泛化为 unknown fields;校验顺序修复后,三者均返回 forbidden `INVALID_REQUEST`,不会解析或打开目标 tab,也不会创建 capture state。该文件还包含 storage session access-level 不放宽、START_AGENT_CAPTURE required capabilities 缺失时返回 `NOT_SUPPORTED` 且不解析或打开目标 tab、token 不持久化、target/bridge tab lifecycle、deadline reconciliation、active-tab query matching、incognito 拒绝、incognito target orphan-tab cleanup、incognito bridge-tab 普通消息隔离、bridge API request isolation、registered bridge API tab 普通 runtime/popup/background detection 隔离、content observer 注入失败和 observer 文件缺失显式终态、运行中本机 opt-in 关闭、extension lifecycle wake recovery、fail-closed cleanup、owned target cleanup 删除失败可观测性、cancel status post 失败可观测性,以及 state removal 失败仍继续 bridge session 清理。 +- `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs`:2026-05-25 start rejection cleanup follow-up 先以 RED 证明 unknown top-level `START_AGENT_CAPTURE` payload 返回 `INVALID_REQUEST` 后仍残留 registered bridge session;修复后 53 tests passed,覆盖目标解析前的早期拒绝会清理 bridge session,且 forbidden sensitive fields 仍不会解析或打开目标 tab,也不会创建 capture state。 +- 追加 start rejection cleanup follow-up 后重新执行 changed-file Prettier check、`pnpm run test:unit`、`pnpm run docs:build`、`git diff --check`、`git diff --cached --check` 和 repo-local `__pycache__` cleanup check:均通过;`pnpm run test:unit` 最新为 180 tests passed。 +- 追加 compound token-like redaction follow-up 后,RED 阶段先失败于 `apiToken=...`、`sessionId=...`、`secretKey=...` 和 `bridgeToken=...` 这类敏感字段名仍可出现在 profile 序列化结果;修复后 profile builder 与 injected profiler 共用更宽的 token-like key 口径。`node --test --test-timeout=60000 tests/site-experience-profile.test.mjs` 为 16 tests passed,`node --test --test-timeout=60000 tests/experience-profile-format.test.mjs tests/site-experience-profile.test.mjs tests/agent-capture-orchestration.test.mjs` 为 73 tests passed,`pnpm run test:unit` 为 180 tests passed。 +- 追加 request body over-limit close follow-up 后,RED 阶段先失败于 JS `readJson()` 超限返回缺少 `close` 标记;修复后 JS reader 在停止继续消费超限 body 时返回 `close: true`,响应层会带 `Connection: close`。`node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs` 为 37 tests passed,`node --test --test-timeout=60000 tests/stackprism_bridge_py.test.mjs` 为 38 tests passed,覆盖 JS/Python body 超限和资源策略口径。 +- `node --test --test-timeout=60000 tests/agent-bridge-handshake.test.mjs`:14 tests passed,新增覆盖 content client 在非 `/bridge` 的 loopback 页面 import 后不读取 DOM config、不 fetch、不注册 window lifecycle listener、不发送 runtime message,并覆盖 request envelope 绑定信息不一致时同源 POST `failed / BRIDGE_REQUEST_MISMATCH` 且不发送 `AGENT_BRIDGE_HELLO` 或 `START_AGENT_CAPTURE`;同一 import-time 测试断言 request GET 带 `Authorization: Bearer {bridgeToken}`,status POST 带 `Authorization: Bearer {bridgeToken}` 和 `Content-Type: application/json`;还覆盖 storageSession 缺失时 hello 返回 `NOT_SUPPORTED / details.missingCapability`。 +- 追加 content hello capability detail follow-up 后,RED 阶段先失败于 successful hello 返回缺失 `profileChunkTransport` 时,content 侧同源 failed status 只有 `NOT_SUPPORTED`,没有 `details.missingCapability`。修复后该路径会在打开 profile transfer port 或发送 `START_AGENT_CAPTURE` 前停止,并上报 `missingCapability = "profileChunkTransport"`。`node --test --test-timeout=60000 tests/agent-bridge-handshake.test.mjs` 为 17 tests passed。 +- `node --test --test-timeout=120000 tests/agent-capture-orchestration.test.mjs tests/agent-bridge-handshake.test.mjs`:62 focused extension orchestration and bridge handshake tests passed。 +- `node --test --test-timeout=120000 tests/agent-bridge-handshake.test.mjs tests/agent-capture-orchestration.test.mjs`:66 focused extension orchestration and bridge handshake tests passed;新增覆盖 Agent Bridge router 内部异常不会通过响应或后台日志泄漏 bridge URL query、nonce 或 token-like id。 +- `node --test --test-timeout=120000 tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs tests/agent-bridge-handshake.test.mjs tests/site-experience-profile.test.mjs tests/agent-capture-orchestration.test.mjs`:143 focused Agent Bridge tests passed。 +- `node --test --test-timeout=60000 tests/site-experience-profile.test.mjs tests/agent-capture-orchestration.test.mjs`:63 tests passed。 +- `node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载 `/Volumes/Work/code/stackprism-1.3.70/dist`,主链路和 lifecycle smoke 通过;disabled local opt-in 场景的 probe target 请求数为 0。 +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=cleared-storage-session node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载同一 `dist`,运行中 capture 在 `chrome.storage.session.clear()` 后 `chrome.runtime.reload()`,Agent 看到 `failed / BRIDGE_TRANSPORT_DISCONNECTED`,profile endpoint 返回 409,owned target tab 不再可见,bridge stderr 为空。 +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=local-opt-in-disabled STACKPRISM_BROWSER_SMOKE_CDP_PORT=9476 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载同一 `dist`,运行中 capture 在 `chrome.storage.local.stackPrismSettings.agentBridgeEnabled = false` 后失败为 `AGENT_BRIDGE_DISABLED`,profile endpoint 返回 409,owned target tab 不再可见,bridge stderr 为空。该证据只覆盖本机 profile 设置关闭,不覆盖浏览器级扩展禁用或更新。 +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=browser-extension-disabled STACKPRISM_BROWSER_SMOKE_CDP_PORT=9483 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载同一 `dist`,运行中 capture 通过 `chrome://extensions/?id=` 将当前 unpacked StackPrism 扩展从 enabled 切换为 disabled;Agent 看到 `failed / BRIDGE_TRANSPORT_DISCONNECTED`,profile endpoint 返回 409,owned target tab 不再可见,bridge stderr 为空。该证据覆盖临时 profile 中的浏览器级用户禁用扩展,不覆盖商店更新或浏览器托管更新生命周期。 +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=expired-deadline-reconciliation STACKPRISM_BROWSER_SMOKE_CDP_PORT=9648 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载同一 `dist`。本轮先发现旧 slow-target 夹具在 state 仍可能为 `target_opening` 时修改 deadline,导致实际返回 `TARGET_LOAD_TIMEOUT`,而该行为与实现和单元契约一致;修正后 smoke 使用普通 fixture + `waitMs: 30000`,等待 `chrome.storage.session` 持久化 state 达到 `target_loaded` 且有 target tab id 后再把 `deadlineAt` 改为过去时间,并通过创建普通 `about:blank` tab 触发 background 事件入口。修正后的 Agent 结果为 `failed / CAPTURE_TIMEOUT`,profile endpoint 返回 409,owned target tab 不再可见,bridge stderr 为空。该证据覆盖已加载目标的过期持久化 deadline reconciliation,不覆盖浏览器 idle eviction 本身。 +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=final-url-blocked STACKPRISM_BROWSER_SMOKE_CDP_PORT=9489 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载同一 `dist`,本机 `127.0.0.1` fixture 对允许的初始 target 返回一次 302 到当前 bridge origin;真实扩展在 target loaded 后按最终 URL 策略拒绝为 `failed / FINAL_URL_BLOCKED`,reason 为 `invalid_final_url`,profile endpoint 返回 409,fixture 请求数为 1,owned target tab 不再可见,bridge stderr 为空。该证据覆盖 redirect 后最终 URL 指向 bridge origin 的真实浏览器拦截,不覆盖完整 DNS/private-network live matrix。 +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=private-target-blocked STACKPRISM_BROWSER_SMOKE_CDP_PORT=9490 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载同一 `dist`,本机 `127.0.0.1` probe fixture 作为初始 capture URL 且未开启 `allowPrivateNetworkTarget` 时,bridge 在创建阶段拒绝为 `400 / PRIVATE_NETWORK_TARGET_BLOCKED`,reason 为 `private_network_address`,fixture 请求数为 0,CDP 中没有该目标页,bridge stderr 为空。该证据覆盖私网字面量初始 URL 的真实 bridge fail-closed 创建路径,不覆盖完整 DNS/private-network live matrix。 +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=dns-non-global-blocked STACKPRISM_BROWSER_SMOKE_CDP_PORT=9491 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载同一 `dist`,当前 resolver 将 `stackprism-browser-smoke.invalid` 解析为 `198.18.0.12`;bridge 在创建阶段拒绝为 `400 / PRIVATE_NETWORK_TARGET_BLOCKED`,reason 为 `private_network_address`,CDP 中没有该目标页,bridge stderr 为空。该证据覆盖当前环境中 resolver rewrite 到 benchmark address 的真实 bridge fail-closed 创建路径,不覆盖完整 DNS/private-network live matrix。 +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=dns-lookup-failed STACKPRISM_BROWSER_SMOKE_CDP_PORT=9541 node tests/agent-bridge-browser-smoke.mjs`:真实 JS bridge 使用当前生产 resolver 解析 `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com` 返回 `ENOTFOUND`;bridge 在创建阶段拒绝为 `400 / TARGET_DNS_LOOKUP_FAILED`,reason 为 `dns_lookup_failed`,`/health.activeCaptures` 仍为 0,bridge stderr 为空。该证据覆盖真实 resolver 的 DNS lookup failed 终态,不覆盖广泛 NXDOMAIN/SERVFAIL live matrix。 +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=capture-busy STACKPRISM_BROWSER_SMOKE_CDP_PORT=9492 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载同一 `dist`,第一个 slow-fixture capture 拥有真实 target tab 时第二个 capture 返回 `429 / CAPTURE_BUSY`;打开 bridge 页后 CDP `Page.getNavigationHistory` 未出现 API/bridge token material 或 `apiToken` / `bridgeToken` / `authorization` query;随后取消第一个 capture,profile endpoint 返回 409,owned target tab 不再可见,bridge stderr 为空。 +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=tech-only STACKPRISM_BROWSER_SMOKE_CDP_PORT=9493 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载同一 `dist`,本地 fixture 只请求 `include: ["tech"]` 后 capture `completed`,profile endpoint 返回 `stackprism.site_experience_profile.v1`,visual/layout/components/interaction/ux/assets 均为空,limitations 包含 6 个 `section_not_requested` 项,且没有 screenshot metadata/payload 或 privacy leak marker,bridge stderr 为空。 +- 续审重新执行 `node --test --test-timeout=120000 tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs tests/agent-capture-orchestration.test.mjs tests/agent-bridge-handshake.test.mjs tests/background-logging.test.mjs`:135 tests passed。 +- 续审重新执行 `pnpm run test:unit`:165 tests passed,新增覆盖 Python fallback 非 UTF-8 request body、content bridge config 非数字 `protocolVersion`、cancel status post 失败可观测性和 incognito target orphan-tab cleanup。 +- 续审重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=tech-only STACKPRISM_BROWSER_SMOKE_CDP_PORT=9507 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`,capture `completed`,profile endpoint HTTP 200,schema 为 `stackprism.site_experience_profile.v1`,6 个未请求 section 均为空并带 `section_not_requested` limitation,无 screenshot metadata/payload 和 privacy leak marker。 +- 续审重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=capture-busy STACKPRISM_BROWSER_SMOKE_CDP_PORT=9504 node tests/agent-bridge-browser-smoke.mjs`:第二个 capture 返回 `429 / CAPTURE_BUSY`,取消第一个 capture 后 profile endpoint 返回 409,owned target tab 不再可见,bridge stderr 为空。 +- 续审重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=private-target-blocked STACKPRISM_BROWSER_SMOKE_CDP_PORT=9505 node tests/agent-bridge-browser-smoke.mjs`:创建阶段返回 `400 / PRIVATE_NETWORK_TARGET_BLOCKED`,reason 为 `private_network_address`,fixture request count 为 0,目标页不可见。 +- 续审重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=final-url-blocked STACKPRISM_BROWSER_SMOKE_CDP_PORT=9506 node tests/agent-bridge-browser-smoke.mjs`:终态为 `failed / FINAL_URL_BLOCKED`,reason 为 `invalid_final_url`,profile endpoint 返回 409,owned target tab 不再可见。 +- 续审重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=cleared-storage-session STACKPRISM_BROWSER_SMOKE_CDP_PORT=9508 node tests/agent-bridge-browser-smoke.mjs`:运行中 capture 在 `chrome.storage.session.clear()` 后 fail closed 为 `BRIDGE_TRANSPORT_DISCONNECTED`,profile endpoint 返回 409,owned target tab 不再可见,bridge stderr 为空。 +- 续审重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=local-opt-in-disabled STACKPRISM_BROWSER_SMOKE_CDP_PORT=9509 node tests/agent-bridge-browser-smoke.mjs`:运行中关闭 local opt-in 后 fail closed 为 `AGENT_BRIDGE_DISABLED`,profile endpoint 返回 409,owned target tab 不再可见,bridge stderr 为空。 +- 最终续审重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=tech-only STACKPRISM_BROWSER_SMOKE_CDP_PORT=9510 node tests/agent-bridge-browser-smoke.mjs`:capture `completed`,profile HTTP 200,schema 为 `stackprism.site_experience_profile.v1`,6 个未请求 section 均为空并带 `section_not_requested` limitation,bridge stderr 为空。 +- 最终续审重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=cleared-storage-session STACKPRISM_BROWSER_SMOKE_CDP_PORT=9511 node tests/agent-bridge-browser-smoke.mjs`:运行中 capture 在 `chrome.storage.session.clear()` 后 fail closed 为 `BRIDGE_TRANSPORT_DISCONNECTED`,profile endpoint 返回 409,owned target tab 不再可见,bridge stderr 为空。 +- 最终续审重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=final-url-blocked STACKPRISM_BROWSER_SMOKE_CDP_PORT=9512 node tests/agent-bridge-browser-smoke.mjs`:终态为 `failed / FINAL_URL_BLOCKED`,reason 为 `invalid_final_url`,profile endpoint 返回 409,owned target tab 不再可见。 +- 续审尝试 `node tests/agent-bridge-browser-smoke.mjs` 的默认公网目标:当前环境将 `example.com` 解析到 `198.18.1.69`,bridge 按策略在创建阶段 fail closed,因此默认公网段不能作为通过证据。改用公网直连 IP `http://34.117.59.81` 和 `http://142.250.72.14/robots.txt` 时 capture 可创建,但 Chrome 主 frame 终态为 `TARGET_LOAD_FAILED`;本次不把公网目标不稳定写成 Agent Bridge 主链路失败。 +- `pnpm run lint`:通过。 +- `pnpm run typecheck`:通过,包含 `vue-tsc --noEmit` 和生产构建。 +- `pnpm run docs:build`:通过。 +- 最终本地续审重新执行 `node --test --test-timeout=120000 tests/agent-bridge-handshake.test.mjs tests/agent-capture-orchestration.test.mjs tests/site-experience-profile.test.mjs tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs`:148 tests passed。 +- 最终本地续审重新执行 `pnpm run test:unit`:165 tests passed。 +- 最终本地续审重新执行 `pnpm run lint`:通过。 +- 最终本地续审重新执行 `pnpm run typecheck`:通过,包含 `vue-tsc --noEmit` 和生产构建。 +- 最终本地续审重新执行 `pnpm run docs:build`:通过。 +- `zsh -f -c 'files=...; pnpm exec prettier --check "$files[@]"'`:当前变更中的 JS/TS/Vue/Markdown/JSON/YAML 文件均符合 Prettier;`.py` 文件未纳入 Prettier,因为本仓库没有 Python parser。 +- 最终本地续审重新执行 `git diff --check && git diff --cached --check`:通过。 +- `node --check agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs && for f in agent-skill/stackprism-site-experience/scripts/bridge/*.mjs; do node --check "$f"; done`:通过。 +- `python3 -m py_compile agent-skill/stackprism-site-experience/scripts/stackprism_bridge.py agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/http_server.py agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/http_handler_base.py`:通过。 +- `python3 -m compileall -q agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib`:通过;生成的 `__pycache__` 已清理。 +- `pnpm exec prettier --check ...`:关键 docs/test/skill/bridge 文件通过;`.py` 文件未纳入 Prettier,因为本仓库没有为 Python 配置 Prettier parser。 +- `git diff --check && git diff --cached --check`:通过。 +- 从 `.github/workflows/release-extension.yml` 提取 inline dist hygiene 脚本并在当前 `dist/` 上执行:exit code 0,无发布边界失败。 +- `dist/` hygiene scan:无 agent-only、test、Python、`__pycache__`、local bridge server 入口或 repo-local JS bridge helper 源文件产物。 +- 追加 router 异常脱敏后重新执行 `node --test --test-timeout=120000 tests/agent-bridge-handshake.test.mjs tests/agent-capture-orchestration.test.mjs`:66 tests passed。 +- 追加 router 异常脱敏后重新执行 `pnpm exec prettier --check src/background/message-router.ts tests/agent-capture-orchestration.test.mjs docs/reviews/CR-AGENT-BRIDGE-COMPLETION-AUDIT-2026-05-24.md docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md`:通过。 +- 追加 router 异常脱敏后重新执行 `pnpm run test:unit`:166 tests passed。 +- 追加 router 异常脱敏后重新执行 `pnpm run lint`:通过。 +- 追加 router 异常脱敏后重新执行 `pnpm run typecheck`:通过,包含 `vue-tsc --noEmit` 和生产构建。 +- 追加 router 异常脱敏后重新执行 `pnpm run docs:build`:通过。 +- 追加底层 factory env gate 后重新执行 `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs`:33 tests passed。 +- 追加底层 factory env gate 后重新执行 `node --test --test-timeout=60000 tests/stackprism_bridge_py.test.mjs`:37 tests passed。 +- 追加底层 factory env gate 后重新执行 `node --test --test-timeout=60000 tests/agent-bridge-complexity.test.mjs`:通过;`http-server.mjs` 收敛为 296 行,仍低于 300 行预算。 +- 追加底层 factory env gate 后重新执行 `pnpm exec prettier --check agent-skill/stackprism-site-experience/scripts/bridge/http-server.mjs tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs docs/reviews/CR-AGENT-BRIDGE-COMPLETION-AUDIT-2026-05-24.md docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md`:通过。 +- 追加底层 factory env gate 后重新执行 `node --check agent-skill/stackprism-site-experience/scripts/bridge/http-server.mjs`:通过。 +- 追加底层 factory env gate 后重新执行 `python3 -m py_compile agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/server_factory.py`:通过。 +- 追加底层 factory env gate 后重新执行 `python3 -m compileall -q agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib`:通过;生成的 `__pycache__` 已清理。 +- 追加底层 factory env gate 后重新执行 `pnpm run test:unit`:168 tests passed。 +- 追加底层 factory env gate 后重新执行 `pnpm run lint`:通过。 +- 追加底层 factory env gate 后重新执行 `pnpm run typecheck`:通过,包含 `vue-tsc --noEmit` 和生产构建。 +- 追加底层 factory env gate 后重新执行 `pnpm run docs:build`:通过。 +- 追加 direct open-browser parsed args NUL gate 后重新执行 `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs`:34 tests passed。 +- 追加 parsed browser-open args NUL gate 后重新执行 `node --test --test-timeout=60000 tests/stackprism_bridge_py.test.mjs`:37 tests passed。 +- 追加 direct open-browser parsed args NUL gate 后重新执行 `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs tests/agent-bridge-complexity.test.mjs`:72 tests passed。 +- 追加 direct open-browser parsed args NUL gate 后重新执行 `pnpm exec prettier --check agent-skill/stackprism-site-experience/scripts/bridge/open-browser.mjs agent-skill/stackprism-site-experience/scripts/bridge/http-server.mjs tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs docs/reviews/CR-AGENT-BRIDGE-COMPLETION-AUDIT-2026-05-24.md docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md`:通过。 +- 追加 direct open-browser parsed args NUL gate 后重新执行 `node --check agent-skill/stackprism-site-experience/scripts/bridge/open-browser.mjs` 和 `node --check agent-skill/stackprism-site-experience/scripts/bridge/http-server.mjs`:通过。 +- 追加 direct open-browser parsed args NUL gate 后重新执行 `python3 -m py_compile agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/open_browser.py agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/server_factory.py`:通过。 +- 追加 direct open-browser parsed args NUL gate 后重新执行 `python3 -m compileall -q agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib`:通过;生成的 `__pycache__` 已清理。 +- 追加 direct open-browser parsed args NUL gate 后重新执行 `pnpm run test:unit`:169 tests passed。 +- 追加 direct open-browser parsed args NUL gate 后重新执行 `pnpm run lint`:通过。 +- 追加 direct open-browser parsed args NUL gate 后重新执行 `pnpm run typecheck`:通过,包含 `vue-tsc --noEmit` 和生产构建。 +- 追加 direct open-browser parsed args NUL gate 后重新执行 `pnpm run docs:build`:通过。 +- 追加 browser-open invalid args/argv follow-up 后重新执行 `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs`:88 tests passed。 +- 追加 browser-open invalid args/argv follow-up 后重新执行 `pnpm exec prettier --check tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs`:通过。 +- 追加 browser smoke token-prefix redaction follow-up 后重新执行 `node --test --test-timeout=60000 tests/agent-bridge-browser-smoke-output.test.mjs`:1 test passed。 +- 追加 browser smoke token-prefix redaction follow-up 后重新执行 `node --check tests/agent-bridge-browser-smoke.mjs && node --check tests/helpers/agent-bridge-browser-smoke-harness.mjs`:通过。 +- 追加 browser smoke token-prefix redaction follow-up 后重新执行 `pnpm exec prettier --check tests/agent-bridge-browser-smoke.mjs tests/helpers/agent-bridge-browser-smoke-harness.mjs tests/agent-bridge-browser-smoke-output.test.mjs`:通过。 +- 追加 smoke bridge cleanup gate 后重新执行 `node --check tests/helpers/agent-bridge-browser-smoke-harness.mjs && node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 smoke bridge cleanup gate 后重新执行 `pnpm exec prettier --check tests/helpers/agent-bridge-browser-smoke-harness.mjs tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 smoke bridge cleanup gate 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=tech-only STACKPRISM_BROWSER_SMOKE_CDP_PORT=9520 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,capture `completed`,profile HTTP 200,schema 为 `stackprism.site_experience_profile.v1`,6 个未请求 section 均为空并带 `section_not_requested` limitation,bridge stderr 为空;该场景 finally 已通过 `stopBridge()` 等待 bridge 子进程退出并确认 `/health` 不再可达。 +- 追加 smoke bridge cleanup gate 后重新执行 `pnpm run test:unit`:169 tests passed。 +- 追加 smoke bridge cleanup gate 后重新执行 `pnpm run docs:build`:通过。 +- 追加 smoke bridge cleanup gate 后重新执行 `git diff --check && git diff --cached --check`:通过;`__pycache__` 已清理。 +- 追加 targetMode query live smoke 后重新执行 `node --check tests/helpers/agent-bridge-browser-smoke-harness.mjs && node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 targetMode query live smoke 后重新执行 `pnpm exec prettier --check tests/helpers/agent-bridge-browser-smoke-harness.mjs tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 targetMode query live smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=target-mode-query-boundaries STACKPRISM_BROWSER_SMOKE_CDP_PORT=9521 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;`reuse_or_new_tab` 对同 origin/path 但 query 不同的既有 tab 未复用并完成 capture,`active_tab` 对 query 不同的前一 active tab 返回 `failed / ACTIVE_TAB_MISMATCH`,且没有打开 mismatch 目标 URL;bridge stderr 为空。 +- 追加 targetMode keep-tab-open 输出证据后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=target-mode-query-boundaries STACKPRISM_BROWSER_SMOKE_CDP_PORT=9558 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;`reuse_or_new_tab` 对同 origin/path 但 query 不同的既有 tab 未复用并完成 capture,输出 `keepTabOpenWasTrue = true` 和 `keptNewTargetTabId`,证明 `keepTabOpen=true` 时保留插件新建目标 tab 直到场景清理;`active_tab` 对 query 不同的前一 active tab 返回 `failed / ACTIVE_TAB_MISMATCH`,且没有打开 mismatch 目标 URL;bridge stderr 为空。 +- 追加 sequential capture pressure smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=sequential-capture-pressure STACKPRISM_BROWSER_SMOKE_CDP_PORT=9522 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;同一 JS bridge 和同一 Chrome profile 连续 4 轮 capture 均 `completed / cleanup`,profile HTTP 均为 200,每轮后 `/health.activeCaptures = 0`,owned target tab 均不可见,bridge stderr 为空。 +- 追加 sequential capture pressure smoke 后重新执行 `node --check tests/helpers/agent-bridge-browser-smoke-harness.mjs && node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 sequential capture pressure smoke 后重新执行 `pnpm exec prettier --check tests/helpers/agent-bridge-browser-smoke-harness.mjs tests/agent-bridge-browser-smoke.mjs docs/reviews/CR-AGENT-BRIDGE-COMPLETION-AUDIT-2026-05-24.md docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md`:通过。 +- 追加 sequential capture pressure smoke 后重新执行 `pnpm run test:unit`:169 tests passed。 +- 追加 sequential capture pressure smoke 后重新执行 `pnpm run docs:build`:通过。 +- 追加 sequential capture pressure smoke 后重新执行 `git diff --check && git diff --cached --check`:通过;`__pycache__` 已清理。 +- 追加 final private URL smoke 后重新执行 `node --check tests/helpers/agent-bridge-browser-smoke-harness.mjs && node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 final private URL smoke 后重新执行 `pnpm exec prettier --check tests/helpers/agent-bridge-browser-smoke-harness.mjs tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 final private URL smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=final-private-url-blocked STACKPRISM_BROWSER_SMOKE_CDP_PORT=9523 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;公网 IP 字面量初始 URL 通过本机 HTTP proxy 到达 redirect fixture,再真实跳转到 `127.0.0.1` final target;bridge 在 `target_loaded` 后返回 `failed / FINAL_URL_BLOCKED`,reason 为 `private_network_address`,profile endpoint 返回 409,owned target tab 不可见,proxy request count 为 2,private final request count 为 2,bridge stderr 为空。 +- 追加 final private URL smoke 后重新执行 `pnpm run test:unit`:169 tests passed。 +- 追加 final private URL smoke 后重新执行 `pnpm run docs:build`:通过。 +- 追加 final DNS policy smoke 后重新执行 `node --check tests/helpers/agent-bridge-browser-smoke-harness.mjs && node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 final DNS policy smoke 后重新执行 `pnpm exec prettier --check tests/helpers/agent-bridge-browser-smoke-harness.mjs tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 final DNS policy smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=final-dns-policy-blocked STACKPRISM_BROWSER_SMOKE_CDP_PORT=9524 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;公网 IP 字面量初始 URL 通过本机 HTTP proxy 到达 redirect fixture,再真实跳转到 `stackprism-browser-smoke.invalid` final target;当前 resolver 将该 hostname 判定为 private/non-global,bridge 在 `target_loaded` 后返回 `failed / FINAL_URL_BLOCKED`,reason 为 `private_network_address`,profile endpoint 返回 409,owned target tab 不可见,proxy request count 为 6,final request count 为 3,bridge stderr 为空。 +- 追加 final DNS lookup failed smoke 后重新执行 `node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 final DNS lookup failed smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=final-dns-lookup-failed STACKPRISM_BROWSER_SMOKE_CDP_PORT=9542 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;公网 IP 字面量初始 URL 通过本机 HTTP proxy 到达 redirect fixture,再真实跳转到 oversized-label hostname final target;bridge 在 `target_loaded` 后返回 `failed / FINAL_URL_BLOCKED`,reason 为 `dns_lookup_failed`,profile endpoint 返回 409,owned target tab 不可见,proxy request count 为 3,final request count 为 1,bridge stderr 为空。 +- 追加 final DNS lookup failed smoke 记录后重新执行 `pnpm exec prettier --check tests/agent-bridge-browser-smoke.mjs docs/reviews/CR-AGENT-BRIDGE-COMPLETION-AUDIT-2026-05-24.md docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md`、`pnpm run docs:build`、`git diff --check` 和 `git diff --cached --check`:均通过。 +- 追加 target navigated away smoke 后重新执行 `node --check tests/helpers/agent-bridge-browser-smoke-harness.mjs && node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 target navigated away smoke 后重新执行 `pnpm exec prettier --check tests/helpers/agent-bridge-browser-smoke-harness.mjs tests/agent-bridge-browser-smoke.mjs docs/reviews/CR-AGENT-BRIDGE-COMPLETION-AUDIT-2026-05-24.md docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md`:通过。 +- 追加 target navigated away smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=target-navigated-away STACKPRISM_BROWSER_SMOKE_CDP_PORT=9525 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;capture 进入 `target_loaded` 后通过扩展 API 将 owned target tab 请求导航到同源不同路径,Agent 端返回 `failed / TARGET_NAVIGATED_AWAY` 且 `details.finalUrlChanged = true`,profile endpoint 返回 409,owned target tab 不可见,bridge stderr 为空。 +- 追加 target navigated away smoke 后重新执行 `pnpm run test:unit`:169 tests passed。 +- 追加 target navigated away smoke 后重新执行 `pnpm run docs:build`:通过。 +- 追加 target navigated away smoke 后重新执行 `git diff --check && git diff --cached --check`:通过;`__pycache__` 已清理。 +- 追加 target load failed smoke 后重新执行 `node --check tests/helpers/agent-bridge-browser-smoke-harness.mjs && node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 target load failed smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=target-load-failed STACKPRISM_BROWSER_SMOKE_CDP_PORT=9526 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;本机 load-failure fixture 被 Chrome 请求 1 次后主动断开连接,Agent 端返回 `failed / TARGET_LOAD_FAILED`,profile endpoint 返回 409,owned target tab 已关闭,bridge stderr 为空。 +- 追加 target load timeout smoke 时先复现缺陷:`STACKPRISM_BROWSER_SMOKE_SCENARIO=target-load-timeout STACKPRISM_BROWSER_SMOKE_CDP_PORT=9530 node tests/agent-bridge-browser-smoke.mjs` 和后续 9531、9532、9533 端口复跑均显示慢目标页最终被本机 bridge 泛化为 `failed / CAPTURE_TIMEOUT`,未满足计划要求的 `TARGET_LOAD_TIMEOUT`。 +- 修复后 JS/Python bridge capture store 会在当前 phase 为 `target_opening` 时把全局 deadline 映射为 `TARGET_LOAD_TIMEOUT`;扩展侧 target load wait 也预留 5 秒 bridge 回写余量,避免与全局 deadline 同点竞争。 +- 追加 target load timeout 修复后重新执行 `node --test --test-timeout=120000 tests/agent-capture-orchestration.test.mjs tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs`:124 tests passed,覆盖扩展 reconciliation、JS bridge 和 Python fallback 对 extension connect timeout、target load timeout、普通 running timeout 的区分。 +- 追加 target load timeout 修复后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=target-load-timeout STACKPRISM_BROWSER_SMOKE_CDP_PORT=9534 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;慢响应 fixture 触发目标加载超时,Agent 端返回 `failed / TARGET_LOAD_TIMEOUT`,profile endpoint 返回 409,owned target tab 已关闭,bridge stderr 为空。 +- 追加 target load timeout 修复后重新执行 `pnpm exec prettier --check ...`:当前触及的 TS/JS/MJS/Markdown 文件均符合 Prettier;Python fallback 文件通过 `python3 -m py_compile`。 +- 追加 target load timeout 修复后重新执行 `pnpm run test:unit`:170 tests passed。 +- 追加 target load timeout 修复后重新执行 `pnpm run lint`:通过。 +- 追加 target load timeout 修复后重新执行 `pnpm run typecheck`:通过,包含 `vue-tsc --noEmit` 和生产构建。 +- 追加 target load timeout 修复后重新执行 `pnpm run docs:build`:通过。 +- 追加 bridge iframe embedding smoke 后重新执行 `node --check tests/helpers/agent-bridge-browser-smoke-harness.mjs && node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 bridge iframe embedding smoke 后重新执行 `pnpm exec prettier --check tests/helpers/agent-bridge-browser-smoke-harness.mjs tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 bridge iframe embedding smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=bridge-iframe-blocked STACKPRISM_BROWSER_SMOKE_CDP_PORT=9536 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;本机攻击页通过 iframe 尝试加载真实 `bridgeUrl` 后,父 DOM 和可访问 frame 文档均不含 `spbt_`,随后顶层首次 `/bridge` 渲染仍返回 200 且包含一次性 `bridgeToken`,证明 iframe 尝试未触发 token render/claim;清理 DELETE 返回 200,bridge stderr 为空。 +- 按 Task 10 原命令口径分别执行 `pnpm exec prettier --file-info agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs`、`pnpm exec prettier --file-info agent-skill/stackprism-site-experience/scripts/bridge/http-server.mjs` 和 `pnpm exec prettier --file-info docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md`:三次均返回 `"ignored": false`,parser 分别为 `babel`、`babel`、`markdown`。 +- 按 Task 10 原命令口径重新执行 `pnpm run build:injected`:通过,生成 `public/injected/page-detector.iife.js`、`public/injected/page-source-search.iife.js` 和 `public/injected/experience-profiler.iife.js`。 +- 按 Task 10 原命令口径重新执行 `pnpm run build`:通过,包含 `build:injected` 和 Vite production build,当前 `dist/manifest.json` 与扩展产物重新生成成功。 +- 追加 wrong-profile smoke 后重新执行 `node --check tests/helpers/agent-bridge-browser-smoke-harness.mjs && node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 wrong-profile smoke 后重新执行 `pnpm exec prettier --check tests/helpers/agent-bridge-browser-smoke-harness.mjs tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 wrong-profile smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=wrong-profile-extension-missing STACKPRISM_BROWSER_SMOKE_CDP_PORT=9537 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,bridge 自动打开到未加载 StackPrism 的临时 Chrome profile;Agent 端最终返回 `failed / EXTENSION_NOT_CONNECTED`,profile endpoint 返回 409,目标 probe request count 为 0,bridge stderr 为空。 +- 追加 host validation smoke 后重新执行 `node --check tests/helpers/agent-bridge-browser-smoke-harness.mjs && node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 host validation smoke 后重新执行 `pnpm exec prettier --check tests/helpers/agent-bridge-browser-smoke-harness.mjs tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 host validation smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=host-validation STACKPRISM_BROWSER_SMOKE_CDP_PORT=9538 node tests/agent-bridge-browser-smoke.mjs`:真实 JS bridge 上原始 TCP 请求确认 `/health` 正确 Host 返回 200,`localhost:{port}`、错误端口和 `[::1]:{port}` Host 均返回 400;`/bridge` 错误 Host 返回 400 且不包含 `spbt_`,随后正确 Host 首次 `/bridge` 返回 200 且包含 bridge token;Bearer status endpoint 错误 Host 返回 400,正确 Host 返回 200,目标 probe request count 为 0,bridge stderr 为空。 +- 追加 response headers/CORS live smoke 后重新执行 `node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 response headers/CORS live smoke 后重新执行 `pnpm exec prettier --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 response headers/CORS live smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=response-headers-cors STACKPRISM_BROWSER_SMOKE_CDP_PORT=9539 node tests/agent-bridge-browser-smoke.mjs`:真实 JS bridge 上确认 capture status GET、request GET、control GET、status POST 均返回 200 且带 no-store/nosniff;profile GET 在未完成时返回 409 且带 no-store/nosniff/no-referrer;`OPTIONS /v1/captures` 返回 405 且无 `Access-Control-Allow-*`;跨站 Origin 创建、跨站 Referer status 查询和 `Sec-Fetch-Site: cross-site` status 查询均返回 403;目标 probe request count 为 0,cleanup DELETE 返回 200,bridge stderr 为空。 +- 追加 response headers/CORS live smoke 记录后重新执行 `pnpm exec prettier --check tests/helpers/agent-bridge-browser-smoke-harness.mjs tests/agent-bridge-browser-smoke.mjs docs/reviews/CR-AGENT-BRIDGE-COMPLETION-AUDIT-2026-05-24.md docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md`:通过。 +- 追加 response headers/CORS live smoke 记录后重新执行 `pnpm run docs:build`:通过。 +- 追加 response headers/CORS live smoke 记录后重新执行 `git diff --check` 和 `git diff --cached --check`:均通过。 +- 追加 connection pressure smoke 后重新执行 `node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 connection pressure smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=connection-pressure STACKPRISM_BROWSER_SMOKE_CDP_PORT=9540 node tests/agent-bridge-browser-smoke.mjs`:真实 CLI JS bridge 使用默认 `maxOpenConnections = 20`;20 条半开 `/health` 连接占满后,第 21 个 `/health` 请求返回 `ECONNRESET` 且 body 长度为 0;释放 1 条连接后 `/health` 恢复 `HTTP/1.1 200 OK`;bridge stderr 为空。 +- 追加 connection pressure smoke 记录后重新执行 `pnpm exec prettier --check tests/agent-bridge-browser-smoke.mjs docs/reviews/CR-AGENT-BRIDGE-COMPLETION-AUDIT-2026-05-24.md docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md`、`pnpm run docs:build`、`git diff --check` 和 `git diff --cached --check`:均通过。 +- 追加 DNS lookup failed smoke 后重新执行 `node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 DNS lookup failed smoke 后重新执行 `pnpm exec prettier --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 DNS lookup failed smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=dns-lookup-failed STACKPRISM_BROWSER_SMOKE_CDP_PORT=9541 node tests/agent-bridge-browser-smoke.mjs`:真实 JS bridge 使用当前生产 resolver 解析 64 字符 label hostname 返回 `ENOTFOUND`;capture 创建返回 `400 / TARGET_DNS_LOOKUP_FAILED / dns_lookup_failed`,`/health.activeCaptures` 为 0,bridge stderr 为空。 +- 追加 DNS lookup failed smoke 记录后重新执行 `pnpm exec prettier --check tests/agent-bridge-browser-smoke.mjs docs/reviews/CR-AGENT-BRIDGE-COMPLETION-AUDIT-2026-05-24.md docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md`、`pnpm run docs:build`、`git diff --check` 和 `git diff --cached --check`:均通过。 +- 追加 service worker idle wake smoke 后重新执行 `node --check tests/agent-bridge-browser-smoke.mjs` 和 `node --check tests/helpers/agent-bridge-browser-smoke-harness.mjs`:通过。 +- 追加 service worker idle wake smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=service-worker-idle-wake STACKPRISM_BROWSER_SMOKE_CDP_PORT=9544 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;设置 local opt-in 后初始 service worker target 自然 idle 31 秒后从 CDP 消失,bridge 页面唤醒新的 service worker target,fixture capture `completed`,profile HTTP 200,schema 为 `stackprism.site_experience_profile.v1`,bridge stderr 为空。 +- 追加 service worker idle wake smoke 记录后重新执行 `pnpm exec prettier --check tests/agent-bridge-browser-smoke.mjs tests/helpers/agent-bridge-browser-smoke-harness.mjs docs/reviews/CR-AGENT-BRIDGE-COMPLETION-AUDIT-2026-05-24.md docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md`、`pnpm run docs:build`、`git diff --check` 和 `git diff --cached --check`:均通过。 +- 运行中 idle 探针执行一段一次性 Node harness 脚本:Chrome for Testing 147.0.7727.15,端口 9545,slow target 90 秒;bridge page 注册 profile-transfer port 并打开目标 tab 后关闭 harness 的 service-worker CDP session,继续等待 75 秒。当前 Chrome 未自然移除 service worker target;capture 最终按 `TARGET_LOAD_TIMEOUT` fail closed,owned target tab 不可见,bridge stderr 为空。该证据说明当前实现/浏览器行为下未能 live 触发运行中 idle eviction,不能把该项标记完成。 +- 追加 browser-managed unpacked reload smoke 后重新执行 `node --check tests/agent-bridge-browser-smoke.mjs` 和 `node --check tests/helpers/agent-bridge-browser-smoke-harness.mjs`:通过。 +- 追加 browser-managed unpacked reload smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=browser-extension-reloaded STACKPRISM_BROWSER_SMOKE_CDP_PORT=9547 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;运行中 slow-fixture capture 通过 `chrome://extensions` 的 unpacked 扩展卡片 `#dev-reload-button` 触发 reload,Agent 看到 `failed / BRIDGE_TRANSPORT_DISCONNECTED`,profile endpoint 返回 409,owned target tab 不可见,bridge stderr 为空。该证据覆盖临时 unpacked 扩展的浏览器管理页 reload,不覆盖 Chrome Web Store / Edge Add-ons rollout。 +- 追加 incognito bridge probe 后重新执行 `node --check tests/agent-bridge-browser-smoke.mjs` 和 `node --check tests/helpers/agent-bridge-browser-smoke-harness.mjs`:通过。 +- 追加 incognito bridge probe 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=incognito-bridge-probe STACKPRISM_BROWSER_SMOKE_CDP_PORT=9549 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;通过 `chrome://extensions` 打开临时 profile 的 `allow-incognito` toggle 并确认启用,写入 local opt-in 后重启同一 profile,再用 CDP incognito browser context 打开 bridge URL。当前 CDP 无痕 context 未连接扩展 content script,Agent 看到 `failed / EXTENSION_NOT_CONNECTED` 而不是扩展侧 `INCOGNITO_NOT_SUPPORTED`;profile endpoint 返回 409,目标 probe request count 为 0,bridge stderr 为空。该证据只能证明当前自动化环境的 fail-closed skip,不证明精确 incognito metadata rejection live path。 +- 追加 resource timeouts smoke 后重新执行 `node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 resource timeouts smoke 后重新执行 `pnpm exec prettier --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 resource timeouts smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=resource-timeouts STACKPRISM_BROWSER_SMOKE_CDP_PORT=9561 node tests/agent-bridge-browser-smoke.mjs`:真实 CLI JS bridge 默认 resource policy 下,慢 header `/health` 在约 5 秒返回 `HTTP/1.1 408 Request Timeout` 且不返回业务 body,随后完整 `/health` 返回 200;慢 body `POST /v1/captures` 在约 10 秒返回 `HTTP/1.1 408 Request Timeout`,不创建 capture,随后 `/health` 返回 200 且 `activeCaptures = 0`;keep-alive `/health` 连接先返回 200,再因 idle timeout 被服务端关闭,随后新 `/health` 返回 200,bridge stderr 为空。 +- 追加 rate-limit smoke 后重新执行 `node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 rate-limit smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=rate-limit STACKPRISM_BROWSER_SMOKE_CDP_PORT=9562 node tests/agent-bridge-browser-smoke.mjs`:真实 CLI JS bridge 默认 production 阈值下,第 1 次 create 返回 200,第 2 到第 10 次 create 返回 `429 / CAPTURE_BUSY`,第 11 次 create 返回 `429 / RATE_LIMITED`;同一 capture 的 120 次 authenticated status reads 返回 200,第 121 次返回 `429 / RATE_LIMITED`;target request count 为 0,cleanup DELETE 200,bridge stderr 为空。 +- 追加 profile-rate-limit smoke 后重新执行 `node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 profile-rate-limit smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=profile-rate-limit STACKPRISM_BROWSER_SMOKE_CDP_PORT=9564 node tests/agent-bridge-browser-smoke.mjs`:真实 CLI JS bridge 默认 production query 阈值下,120 次 authenticated `/profile` reads 到达 profile endpoint 并返回未完成 capture 的 `409 / INVALID_REQUEST`,第 121 次 profile read 返回 `429 / RATE_LIMITED`;target request count 为 0,cleanup DELETE 200,bridge stderr 为空。 +- 追加 target-url-validation smoke 后重新执行 `node --check tests/agent-bridge-browser-smoke.mjs`:通过。 +- 追加 target-url-validation smoke 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=target-url-validation STACKPRISM_BROWSER_SMOKE_CDP_PORT=9563 node tests/agent-bridge-browser-smoke.mjs`:真实 CLI JS bridge 创建阶段拒绝 unsupported protocol 和 credential URL 为 `400 / INVALID_REQUEST`,拒绝 loopback private target 为 `400 / PRIVATE_NETWORK_TARGET_BLOCKED`,拒绝 bridge self-target 为 `400 / BRIDGE_SELF_TARGET_BLOCKED`;`/health.activeCaptures = 0`,target request count 为 0,bridge stderr 为空。 +- 追加 release workflow gate 后重新执行 `node --test --test-timeout=60000 tests/release-workflow.test.mjs`:7 tests passed,覆盖 release gate 顺序、clean fake `dist`、`externally_connectable` 拒绝、`dist/assets/capture-store.mjs` 拒绝、嵌套 `dist/assets/agent-skill/` 拒绝、以及 `web_accessible_resources` 暴露 `assets/http-server.mjs` 拒绝。 +- 追加 release workflow gate 后重新执行 `node --test --test-timeout=60000 tests/release-workflow.test.mjs tests/agent-bridge-manifest.test.mjs`:9 tests passed。 +- 追加 release workflow gate 后重新执行 `pnpm run test:unit`:173 tests passed。 +- 追加 release workflow gate 后从 `.github/workflows/release-extension.yml` 提取更新后的 inline hygiene 脚本并在当前 `dist/` 执行:exit code 0;随后 `find dist` 扫描嵌套 `agent-skill`、`docs/superpowers`、`tests`、Python artifacts、`__pycache__`、local bridge server 入口和 repo-local JS bridge helper 源文件无输出;`dist/manifest.json` 确认 `hasExternallyConnectable: false`。 +- 追加 JS CLI invalid port ready gate 后重新执行 `node --check tests/stackprism-bridge.test.mjs`:通过。 +- 追加 JS CLI invalid port ready gate 后重新执行 `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs`:35 tests passed,新增覆盖 `STACKPRISM_BRIDGE_PORT=''` 时只在 stderr 返回 `BRIDGE_INVALID_ENV`,stdout 为空且不包含 API token material。 +- 追加 JS CLI invalid port ready gate 后重新执行 `pnpm run test:unit`:174 tests passed。 +- 追加 JS CLI occupied port ready gate 后重新执行 `node --check tests/stackprism-bridge.test.mjs`:通过。 +- 追加 JS CLI occupied port ready gate 后重新执行 `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs`:36 tests passed,新增覆盖 `STACKPRISM_BRIDGE_PORT=<已占用端口>` 时只在 stderr 返回 `PORT_IN_USE`,stdout 为空且不包含 API token material。 +- 追加 JS CLI occupied port ready gate 后重新执行 `pnpm run test:unit`:175 tests passed。 +- 强化 Python fallback CLI ready gate 后重新执行 `node --test --test-timeout=60000 tests/stackprism_bridge_py.test.mjs`:37 tests passed,非法端口和端口占用路径均断言 stdout 为空,stderr 不包含 API token material。 +- 强化 Python fallback CLI ready gate 后重新执行 `pnpm run test:unit`:175 tests passed。 +- 追加 cross-site `/bridge` token render gate 后重新执行 `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs`:37 tests passed,真实 `bridgeUrl` 带攻击者 `Referer` 或 `Sec-Fetch-Site: cross-site` 时返回 403、body 不含 `spbt_`,且后续正常打开同 URL 仍能首次渲染 token。 +- 追加 cross-site `/bridge` token render gate 后重新执行 `node --test --test-timeout=60000 tests/stackprism_bridge_py.test.mjs`:38 tests passed,同步覆盖 Python fallback 的同一语义。 +- 追加 authority-form request target gate 时先观察到 RED:JS bridge 对 `CONNECT 127.0.0.1:{port} HTTP/1.1` 返回空响应,Python fallback 返回标准库默认 `501` HTML。修复后 JS `server.on("connect")` 和 Python `do_CONNECT` 均返回统一 JSON `400 / INVALID_REQUEST`,不进入业务 routing。 +- 追加 authority-form request target gate 后重新执行 `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs`:37 tests passed。 +- 追加 authority-form request target gate 后重新执行 `node --test --test-timeout=60000 tests/stackprism_bridge_py.test.mjs`:38 tests passed。 +- 追加 authority-form request target gate 后重新执行 `node --test --test-timeout=60000 tests/agent-bridge-complexity.test.mjs`:先复现 `http-server.mjs` 超出 300 行预算,压缩重复 raw socket JSON 响应 helper 后通过,`http-server.mjs` 为 299 行。 +- 追加 authority-form request target gate 后重新执行 `pnpm run test:unit`:177 tests passed。 +- 追加 authority-form request target gate 后重新执行 `pnpm run docs:build`、changed-file Prettier check、`git diff --check && git diff --cached --check`,均通过;生成的 `__pycache__` 已清理。 +- 追加 missing Host request-shell gate 时先观察到 RED:JS bridge 对 HTTP/1.1 缺失 `Host` 的 `/health` 请求返回 Node 默认空 `400`,缺少统一 error envelope。修复后缺失 Host 会进入 JS bridge shell validator 并返回 JSON `400 / INVALID_REQUEST`。 +- 追加 missing Host request-shell gate 后重新执行 `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs`:37 tests passed。 +- 追加 missing Host request-shell gate 后重新执行 `node --test --test-timeout=60000 tests/stackprism_bridge_py.test.mjs`:38 tests passed,确认 Python fallback 已保持同一错误语义。 +- 追加 profile redaction follow-up 时先观察到 RED:`tests/site-experience-profile.test.mjs` 新增敏感对象键名和外部 `limitations` 输入后失败,说明 profile values 已脱敏但 key/limitation 文本仍可能泄漏 `token=secret` / `authorization=Bearer secret` 这类片段。修复后对象键名会走 redaction 并加碰撞后缀,外部 limitations 也走 `redactText`。 +- 追加 profile redaction follow-up 后重新执行 `node --test --test-timeout=60000 tests/site-experience-profile.test.mjs`:16 tests passed。 +- 追加 profile redaction follow-up 后重新执行 `node --test --test-timeout=60000 tests/experience-profile-format.test.mjs tests/site-experience-profile.test.mjs`:20 tests passed。 +- 追加 profile redaction follow-up 后重新执行 `pnpm run test:unit`:178 tests passed。 +- 追加 profile transfer hash-binding follow-up 时先观察到 RED:content-side transfer handler 接受 BEGIN 宣告的错误 `sha256`,只用 COMPLETE 提供的 hash 校验实际 payload,导致 COMPLETE 阶段可以改写 BEGIN metadata 后成功提交 profile。修复后 transfer state 保存 BEGIN `sha256`,COMPLETE metadata 和实际 bytes 都必须匹配该原始 hash。 +- 追加 profile transfer hash-binding follow-up 后重新执行 `node --test --test-timeout=60000 tests/agent-bridge-handshake.test.mjs`:15 tests passed。 +- 追加 profile transfer hash-binding follow-up 后重新执行 `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs`:53 tests passed。 +- 追加 profile transfer BEGIN metadata gate follow-up 时先观察到 RED:content-side transfer handler 对非法 `sha256` 的 BEGIN 返回 success,并会进入 transfer state。修复后 BEGIN 阶段要求 `sha256` 是 64 位小写 hex、`chunkCount` 与 `byteLength / 384 KiB` 一致且声明大小不超过 8 MiB,非法 metadata 在缓冲 chunk 前失败为 `PROFILE_TRANSPORT_FAILED`。 +- 追加 profile transfer BEGIN metadata gate follow-up 后重新执行 `node --test --test-timeout=60000 tests/agent-bridge-handshake.test.mjs`:16 tests passed。 +- 追加 profile transfer BEGIN metadata gate follow-up 后重新执行 `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs`:53 tests passed。 +- 追加 request envelope strict-shape follow-up 时先观察到 RED:content-side `validateCaptureRequestEnvelope` 会接受包含额外 `bridgeToken` / `profileUrl` 的 request envelope,虽然当前 bridge server 不会正常返回这些字段,但这不符合计划中 `/request` 不得包含 token、profile body 或 callback URL 的合同。修复后 envelope validator 只接受 `captureId`、`sessionId`、`nonce`、`protocolVersion` 和 `request` 五个顶层字段,JS/Python bridge 测试也锁定 `/request` 响应只返回这五个字段。 +- 追加 request envelope strict-shape follow-up 后重新执行 `node --test --test-timeout=60000 tests/agent-bridge-handshake.test.mjs`:16 tests passed。 +- 追加 request envelope strict-shape follow-up 后重新执行 `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs`:75 tests passed。 +- 追加并发 profile POST follow-up 时先观察到 RED:JS bridge 同一 capture 的两个并发 /profile POST 都返回 200,违反一次性 profile 提交语义。修复后 JS bridge 在读取 body 后进入 per-capture lock 并重新检查终态,只有一个 POST 成功,另一个返回 `409 / CAPTURE_ALREADY_COMPLETED`;Python fallback 已有锁内重检,本轮补了同口径并发回归测试。 +- 追加 completed control follow-up 时先观察到 RED:JS bridge 和 Python fallback 在 `/profile` 成功提交进入 `completed` 后,`GET /control` 仍返回 `command = "continue"`。修复后两端 completed control 均返回 `command = "cancel"` 和当前 `status = "completed"`,避免终态 capture 继续暴露运行态命令语义;`node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs` 通过 77 tests。 +- 追加 Python identifier fixture follow-up 后,Python fallback 测试现在复用 `tests/fixtures/bridge-protocol-identifiers.json`,对每个 documented valid/invalid `apiToken`、`bridgeToken`、`captureId`、`sessionId`、`nonce`、`profileTransferId` 和 CSP nonce 样例执行 `valid_id()` 契约校验,补齐计划要求的固定 ASCII regex/长度矩阵证据;`node --test --test-timeout=60000 tests/stackprism_bridge_py.test.mjs` 通过 40 tests。 +- 追加 browser-open invalid args/argv follow-up 后,JS/Python capture API 测试覆盖 `STACKPRISM_BROWSER_OPEN_ARGS_JSON` 的非法 JSON、非数组和非字符串元素均返回 `500 / BROWSER_OPEN_FAILED`,且 `details.reason = invalid_open_args`;直接 helper 测试使用 fake command/record file 证明带 `?`、`&`、空格、引号和 shell 元字符的 bridge URL 作为单个 argv 追加,不经过 shell 字符串拼接。 +- 追加 browser smoke token-prefix redaction follow-up 时先观察到 smoke 输出摘要中多处 token-prefix 摘要字段,会把 API token 的固定前缀写入 stdout/报告证据。修复后摘要字段改为 `apiTokenPresent: Boolean(...)`,并删除 prefix helper;新增静态契约测试防止 smoke 输出和 E2E 报告重新记录 token 前缀值。 +- 追加 public complex-site smoke follow-up 后执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=public-complex-target STACKPRISM_BROWSER_SMOKE_TARGET_URL=https://www.wikipedia.org/ STACKPRISM_BROWSER_SMOKE_CDP_PORT=9491 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;当前 resolver 输出 `www.wikipedia.org -> 198.18.0.19`,所以该场景显式使用 `allowPrivateNetworkTarget = true`。capture 完成到 `completed / cleanup`,profile HTTP 200,schema 为 `stackprism.site_experience_profile.v1`,final URL 为 `https://www.wikipedia.org/`,visual/layout/component profile keys 非空,profile 15382 bytes,无截图 metadata、无截图 image/pixel payload、无 checked privacy leak markers,bridge stderr 为空。该证据只关闭当前 DNS-proxy 环境下的一条公网复杂站点内容捕获缺口,不替代默认 no-private-network policy 或更广 DNS/private-network live matrix。 +- 追加 incognito window bridge probe follow-up 后执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=incognito-window-bridge-probe STACKPRISM_BROWSER_SMOKE_CDP_PORT=9554 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;测试先通过 `chrome://extensions` 启用临时 profile 的 allow-incognito,再用同一 profile 加 `--incognito` 重启并打开 bridge URL。结果仍为 `failed / EXTENSION_NOT_CONNECTED`,profile endpoint 409,target probe request count 0,bridge stderr 为空。该结果与 CDP incognito context probe 一致,说明当前自动化环境仍无法把扩展 content script 带入 incognito bridge 页;它是更强的 fail-closed 跳过证据,不是精确 `INCOGNITO_NOT_SUPPORTED` live metadata 分支通过证据。 +- 追加 result expiry bridge page follow-up 后,先把 JS/Python 单元测试中“extension connect timeout 后 `/bridge` 不渲染 token”和“completed result TTL 过期后 profile endpoint 与 `/bridge` 均返回 `CAPTURE_RESULT_EXPIRED` 且不渲染 token”拆成独立断言;`node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs` 为 44 tests passed,`node --test --test-timeout=60000 tests/stackprism_bridge_py.test.mjs` 为 46 tests passed。随后执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=result-expiry-bridge-page STACKPRISM_BROWSER_SMOKE_CDP_PORT=9555 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;真实 fixture capture 完成并返回 profile HTTP 200 后,测试推进 in-process JS bridge clock 超过 `resultExpiresAt`,profile endpoint 返回 `410 / CAPTURE_RESULT_EXPIRED`,原 `/bridge` URL 返回 410、包含 `CAPTURE_RESULT_EXPIRED` 且不含 bridge token material。 +- 追加 bridge self-target smoke follow-up 后执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=bridge-self-target-blocked STACKPRISM_BROWSER_SMOKE_CDP_PORT=9556 node tests/agent-bridge-browser-smoke.mjs`:真实 JS bridge 进程启动成功;以当前 bridge origin 作为 capture 初始 URL 返回 `400 / BRIDGE_SELF_TARGET_BLOCKED`,将 host 替换为 `localhost` 的 loopback alias 同样返回 `400 / BRIDGE_SELF_TARGET_BLOCKED`,`/health.activeCaptures` 保持 0,bridge stderr 为空。 +- 追加 active-tab unavailable smoke follow-up 后执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=active-tab-unavailable STACKPRISM_BROWSER_SMOKE_CDP_PORT=9557 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;新鲜 profile 中没有上一张可检测 active tab 记录时,bridge 页面仍握手到 ready,但 capture 失败为 `ACTIVE_TAB_UNAVAILABLE`,profile endpoint 返回 409,target probe request count 为 0,且没有打开目标 URL tab。 +- 追加 active-tab unavailable smoke/report follow-up 后重新执行 `pnpm run lint`:通过。 +- 追加 active-tab unavailable smoke/report follow-up 后重新执行 `pnpm run typecheck`:通过,包含 `vue-tsc --noEmit` 和生产构建,重新生成 injected IIFE 与 `dist/manifest.json`。 +- 追加 active-tab unavailable smoke/report follow-up 后重新执行 `node --test --test-timeout=60000 tests/agent-bridge-browser-smoke-output.test.mjs`:9 tests passed。 +- 追加 active-tab unavailable smoke/report follow-up 后重新执行 `pnpm run test:unit`:207 tests passed。 +- 追加 request-shell-rejections smoke follow-up 后执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=request-shell-rejections STACKPRISM_BROWSER_SMOKE_CDP_PORT=9560 node tests/agent-bridge-browser-smoke.mjs`:真实 JS bridge 拒绝 19 类原始 request-shell / ambiguous header 请求,包含 missing Host、duplicate Host、absolute-form、authority-form、encoded slash path、encoded backslash path、dot segment path、empty segment path、unexpected `/health` query、duplicate `/bridge` query、duplicate Authorization、duplicate Content-Type、Content-Length plus Transfer-Encoding、duplicate Content-Length、invalid Content-Length、chunked body、unsupported Transfer-Encoding、unsupported Content-Encoding 和 unsupported charset;duplicate bridge query 响应不泄漏 bridge-token material,target request count 0,拒绝后 `/health` 恢复 200,cleanup DELETE 200,bridge stderr 为空。 +- 追加 keep-alive idle smoke/report follow-up 后重新执行 `node --test --test-timeout=60000 tests/agent-bridge-browser-smoke-output.test.mjs`:12 tests passed。 +- 追加 keep-alive idle smoke/report follow-up 后重新执行 `pnpm run test:unit`:208 tests passed。 +- 追加 rate-limit smoke/report follow-up 后重新执行 `node --test --test-timeout=60000 tests/agent-bridge-browser-smoke-output.test.mjs`:13 tests passed。 +- 追加 rate-limit smoke/report follow-up 后重新执行 `pnpm run lint`:通过。 +- 追加 rate-limit smoke/report follow-up 后重新执行 `pnpm run typecheck`:通过,包含 `vue-tsc --noEmit` 和生产构建,重新生成 injected IIFE 与 `dist/manifest.json`。 +- 追加 target-url-validation smoke/report follow-up 后重新执行 `node --test --test-timeout=60000 tests/agent-bridge-browser-smoke-output.test.mjs`:14 tests passed。 +- 追加 target-url-validation smoke/report follow-up 后重新执行 `pnpm run test:unit`:210 tests passed。 +- 追加 JS/Python profile endpoint rate-limit 单元覆盖后重新执行 `node --test tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs`:92 tests passed。 +- 追加 JS/Python profile endpoint rate-limit 单元覆盖后重新执行 `pnpm run test:unit`:212 tests passed。 +- 追加 content client incognito context fail-closed follow-up 后先执行 RED:`node --test --test-timeout=60000 tests/agent-bridge-handshake.test.mjs` 失败于新增用例,实际错误码为 `PROFILE_TRANSPORT_FAILED`,证明当前 client 仍先请求 `/request`。修复后同一命令通过 18 tests,新增断言 `chrome.extension.inIncognitoContext === true` 时只向 bridge POST `failed / INCOGNITO_NOT_SUPPORTED`,不读取 `/request`,不注册 runtime status listener,不发送 `AGENT_BRIDGE_HELLO` 或 `START_AGENT_CAPTURE`。 +- 追加 content client incognito context fail-closed follow-up 后重新执行 `pnpm run test:unit`:213 tests passed。 +- 追加 content client incognito context fail-closed follow-up 后重新执行 `pnpm run lint`:通过。 +- 追加 content client incognito context fail-closed follow-up 后重新执行 `pnpm run typecheck`:通过,包含 `vue-tsc --noEmit` 和生产构建,重新生成 injected IIFE 与 `dist/manifest.json`。 +- 追加 content client incognito context fail-closed follow-up 后重新执行 `STACKPRISM_BROWSER_SMOKE_SCENARIO=incognito-window-bridge-probe STACKPRISM_BROWSER_SMOKE_CDP_PORT=9612 node tests/agent-bridge-browser-smoke.mjs`:Chrome for Testing 147.0.7727.15,加载当前 `dist`;临时 profile 的 allow-incognito 已启用,但 `--incognito` 窗口仍未连接扩展 content script,结果为 `failed / EXTENSION_NOT_CONNECTED`,profile endpoint 409,target probe request count 0,bridge stderr 为空。该结果继续作为自动化环境 fail-closed skip 证据;精确 content client 无痕分支由单元测试覆盖。 +- 追加 JS/Python profile endpoint rate-limit 单元覆盖后重新执行 `git diff --check -- tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs`:通过。 +- 追加 repo hygiene follow-up 后执行 `find agent-skill docs src tests build-scripts public \( -name __pycache__ -o -name '*.pyc' -o -name '*.pyo' -o -name .DS_Store \) -print`:无输出,确认当前仓库工作路径下没有 Python 字节码缓存、`__pycache__` 或 macOS 缓存残留。 +- 追加 docs asset follow-up 后执行 `cmp -s public/icons/icon.svg docs/public/icon.svg`:退出码 0;`git check-ignore -v docs/public/icon.svg` 显示该文件由 `.gitignore` 第 8 行忽略。`docs/public/icon.svg` 是 `pnpm run docs:assets` 从 `public/icons/icon.svg` 同步生成的 VitePress 静态资源,不是未清理缓存。 +- 追加 `sync-docs-assets` ASCII follow-up 后将 `build-scripts/sync-docs-assets.mjs` 的同步日志从 Unicode 箭头改为 ASCII `->`,并重新执行 `node --check build-scripts/sync-docs-assets.mjs`、`pnpm run docs:assets`、`pnpm exec prettier --check build-scripts/sync-docs-assets.mjs` 和 `git diff --check -- build-scripts/sync-docs-assets.mjs`:均通过;`docs:assets` 输出 `synced public/icons/icon.svg -> docs/public/icon.svg`。 +- 执行本计划实现时重新运行 `pnpm run test:unit`:首次失败于 `tests/agent-bridge-browser-smoke-output.test.mjs` 中仍断言旧的 `48 个 unstaged 文件`,而当前 `git diff --name-only | wc -l` 为 49;修正该静态契约后,`node --test --test-timeout=60000 tests/agent-bridge-browser-smoke-output.test.mjs` 为 14 tests passed,随后完整 `pnpm run test:unit` 为 212 tests passed。 +- 执行本计划实现时重新运行 `pnpm run lint`:通过;重新运行 `pnpm run typecheck`:通过,包含 `vue-tsc --noEmit` 和 `pnpm build`,生产构建重新生成 injected IIFE 与 `dist/manifest.json`;重新运行 `pnpm run docs:build`:通过,`docs:assets` 输出 `synced public/icons/icon.svg -> docs/public/icon.svg`。 +- 执行本计划实现时重新运行 JS/Python bridge 专项语法检查:`node --check agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs && for f in agent-skill/stackprism-site-experience/scripts/bridge/*.mjs; do node --check "$f"; done` 通过;`python3 -m py_compile agent-skill/stackprism-site-experience/scripts/stackprism_bridge.py agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/*.py && python3 -m compileall -q agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib` 通过,随后已清理生成的 Python 缓存。 +- 执行本计划实现时重新运行聚焦 Agent Bridge 测试:`node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs tests/agent-capture-orchestration.test.mjs tests/agent-bridge-handshake.test.mjs tests/site-experience-profile.test.mjs tests/experience-profile-format.test.mjs`:182 tests passed。 +- 执行本计划实现时重新运行 release/manifest hygiene 测试:`node --test --test-timeout=60000 tests/release-workflow.test.mjs tests/agent-bridge-manifest.test.mjs`:9 tests passed。`dist/manifest.json` 检查确认 `hasExternallyConnectable: false`;`web_accessible_resources` 仅包含普通规则/observer 资源和 `http://127.0.0.1/*` loopback bridge chunks;`find dist ...` 扫描无 agent-only、test、Python、`__pycache__`、local bridge server 入口或 repo-local JS bridge helper 源文件产物。 +- 执行本计划实现时重新运行 repo/file hygiene:清理 `agent-skill docs src tests build-scripts public` 下 Python compile 缓存后,`find agent-skill docs src tests build-scripts public \( -name __pycache__ -o -name '*.pyc' -o -name '*.pyo' -o -name .DS_Store \) -print` 无输出;`cmp -s public/icons/icon.svg docs/public/icon.svg` 退出码 0;`git check-ignore -v docs/public/icon.svg` 仍显示 `.gitignore:8` 忽略该 docs 静态同步产物。 +- 执行本计划实现时曾尝试旧默认 `STACKPRISM_BROWSER_SMOKE_CDP_PORT=9601 node tests/agent-bridge-browser-smoke.mjs`:失败于 `Example capture creation failed.`。该旧默认场景使用 `https://example.com`,当前环境此前已记录该目标可能被解析到 `198.18.*` 并在创建阶段 fail closed;后续默认 smoke 已切换为 fixture-backed local capture,该旧失败只作为历史环境问题记录。 +- 执行本计划实现时重新运行关键 Chrome smoke:`STACKPRISM_BROWSER_SMOKE_SCENARIO=tech-only STACKPRISM_BROWSER_SMOKE_CDP_PORT=9602 node tests/agent-bridge-browser-smoke.mjs` 通过,Chrome for Testing 147.0.7727.15 加载当前 `dist`,profile endpoint HTTP 200,schema 为 `stackprism.site_experience_profile.v1`,6 个未请求 section 均为空并带 `section_not_requested` limitation,bridge stderr 为空。 +- 执行本计划实现时重新运行 target policy/lifecycle Chrome smoke:`private-target-blocked` on CDP 9603 返回 `400 / PRIVATE_NETWORK_TARGET_BLOCKED` 且 target request count 为 0;`final-url-blocked` on CDP 9604 返回 `failed / FINAL_URL_BLOCKED`、profile 409、owned target tab 不可见;`cleared-storage-session` on CDP 9605 返回 `failed / BRIDGE_TRANSPORT_DISCONNECTED`、profile 409、owned target tab 不可见。 +- 执行本计划实现时重新运行 opt-in/resource Chrome smoke:`local-opt-in-disabled` on CDP 9606 返回 `failed / AGENT_BRIDGE_DISABLED`、profile 409、owned target tab 不可见;`rate-limit` on CDP 9607 证明默认 create limit 第 11 次返回 `429 / RATE_LIMITED`,同一 capture 120 次 status read 后第 121 次返回 `429 / RATE_LIMITED`,target request count 为 0,cleanup DELETE 200;`resource-timeouts` on CDP 9608 证明慢 header 约 5 秒返回 `408 Request Timeout`、慢 body 约 10 秒返回 `408 Request Timeout`、keep-alive idle 被关闭,后续 `/health` 均恢复 200,`activeCapturesAfterSlowBody = 0`。 +- 执行本轮剩余本地验证矩阵时重新运行基础门禁:`pnpm run typecheck` 通过,覆盖 `vue-tsc --noEmit` 和 `pnpm build` 并刷新 `dist/manifest.json`;`pnpm run lint` 通过;`pnpm run docs:build` 通过,`docs:assets` 输出 `synced public/icons/icon.svg -> docs/public/icon.svg`;`pnpm run test:unit` 通过,213 tests passed;`node --test --test-timeout=60000 tests/release-workflow.test.mjs tests/agent-bridge-manifest.test.mjs tests/agent-bridge-browser-smoke-output.test.mjs` 通过,23 tests passed;`node --check tests/agent-bridge-browser-smoke.mjs && node --check tests/helpers/agent-bridge-browser-smoke-harness.mjs` 通过。 +- 执行本轮剩余 Chrome smoke 矩阵时在端口 9620 到 9646 顺序运行 27 个场景。首轮 26 个符合预期:browser disable/reload 为 `BRIDGE_TRANSPORT_DISCONNECTED`,service-worker idle wake `completed`,incognito 两条 probe 均 `EXTENSION_NOT_CONNECTED` 且 target request count 0,private/DNS/final URL 策略、target URL validation、target navigation/load failure/load timeout、host validation、response headers/CORS、request-shell rejections、connection pressure、resource timeouts、rate-limit/profile-rate-limit、sequential capture pressure 和 result-expiry bridge page 均按既定断言通过。唯一失败是 `expired-deadline-reconciliation` 旧夹具期望 `CAPTURE_TIMEOUT`,但实际在 `target_opening` 返回 `TARGET_LOAD_TIMEOUT`;按实现契约修正为等待 `target_loaded` 后,端口 9648 单独 rerun 通过并返回 `CAPTURE_TIMEOUT`。 +- 执行运行中 service worker idle best-effort 探针时,第一次一次性 Node 脚本观测到 75 秒后 service worker 仍可见、最终 `TARGET_LOAD_TIMEOUT` 且 target 清理,但 cleanup 脚本自身报错退出 1,不作为成功验证。修正 cleanup 后在端口 9650 重跑,命令退出 0:Chrome for Testing 147.0.7727.15,`workerStillVisibleAfter75s = true`,final status `failed / TARGET_LOAD_TIMEOUT`,target 不可见,bridge stderr 为空。该结果说明当前本机 Chrome 仍未自然触发运行中 idle eviction;可记录 fail-closed 清理证据,但不能把 running idle eviction live 标记为完成。 +- 2026-05-26 收尾时执行上游同步:`git fetch upstream` 成功更新到 `f0cc39a`,`git rebase upstream/main` 成功;`git fetch origin` 失败于 `LibreSSL SSL_connect: SSL_ERROR_SYSCALL`,因此 push 前需要重新拉取 origin。rebase 前用 `git stash push -u` 暂存本地改动,rebase 后 `git stash apply stash@{0}` 无冲突恢复。 +- 2026-05-26 rebase 后补充两个回归契约:background 在 `chrome.webRequest.onResponseStarted` 不存在时 network observer 保持 inactive;JS bridge 与 Python fallback 对浏览器上报的 `targetNetworkAddress` 要求 IP 字面量,`example.com` 这类非 IP 字符串按 `INVALID_REQUEST / invalid_network_address` fail closed。随后 `pnpm run test:unit` 通过,219 tests passed;`pnpm run lint` 通过;`pnpm run typecheck` 通过,覆盖 `vue-tsc --noEmit` 与 `pnpm build`;changed-file Prettier check、JS/Python 语法检查、`pnpm run docs:build`、`git diff --check` 和 `git diff --cached --check` 均通过。 +- 2026-05-26 本轮接手后补齐 profile language/UX 字段契约、默认 browser smoke fixture-backed 成功路径契约、计划当前状态覆盖层、Task 10 状态矩阵、`docs/dev/agent-bridge.md` profile schema / Browser Smoke / Live Gate 口径和 repo-local skill schema reference。随后 `node --test --test-timeout=60000 tests/site-experience-profile.test.mjs tests/experience-profile-format.test.mjs` 通过,21 tests passed;`node --test --test-timeout=60000 tests/agent-bridge-browser-smoke-output.test.mjs` 通过,16 tests passed;`node --check tests/agent-bridge-browser-smoke.mjs` 通过。最终主线门禁结果见下方收尾验证记录。真实默认 Chrome smoke `STACKPRISM_BROWSER_SMOKE_CDP_PORT=9661 node tests/agent-bridge-browser-smoke.mjs` 退出 0 时,Chrome for Testing 147.0.7727.15 加载当前 `dist`,默认 fixture target `completed / cleanup`,profile schema 为 `stackprism.site_experience_profile.v1`,extensionVersion 为 `1.3.73`,screenshot payload absent,privacy leak marker absent,bridge stderr 为空。 +- 2026-05-26 状态矩阵收口后执行主线验证:`pnpm run test:unit` 通过,222 tests passed;`pnpm run lint` 通过;`pnpm run typecheck` 通过,覆盖 `vue-tsc --noEmit` 与 `pnpm build` 并刷新 `dist/manifest.json`;`pnpm run docs:build` 通过;changed-file Prettier check 和 `git diff --check` 通过;release `dist` 卫生扫描未发现 repo-local skill、本地 bridge helper、Python fallback、测试目录或 Python 缓存;默认 Chrome smoke `STACKPRISM_BROWSER_SMOKE_CDP_PORT=9661 node tests/agent-bridge-browser-smoke.mjs` 退出 0,Chrome for Testing 147.0.7727.15 加载当前 `dist`,默认 target 是 local fixture `http://127.0.0.1:/fixture?token=secret#frag`,capture `completed / cleanup`,profile HTTP 200,schema 为 `stackprism.site_experience_profile.v1`,extensionVersion 为 `1.3.73`,profile size 4697 bytes,screenshot metadata/payload absent,privacy leak marker absent,bridge stderr 为空。 +- 2026-05-26 收尾清理扫描确认未发现 `.DS_Store`、日志、tmp 或构建产物残留;Python 语法检查生成的 repo-local `__pycache__` 属于临时缓存,已在最终验证后清理并复查无输出。 +- 2026-05-27 围绕“AI Agent 快速复刻网站体验”的目标补强 profile 消费层:`agentGuidance.recreationPlan` 现在输出 `implementationOrder`、`designTokens`、`layoutBlueprint`、`componentInventory`、`interactionChecklist`、`uxChecklist`、`assetHints` 和 `verificationChecklist`,并只引用已脱敏 profile 内容。`src/utils/site-experience-profile.ts` 拆出 `src/utils/site-experience-profile-sections.ts` 后从 300 行降到 118 行,新 helper 211 行,`src/utils/site-experience-guidance.ts` 当前 163 行,均低于仓库 300 行文件预算。repo-local skill、schema reference、`docs/dev/agent-bridge.md` 和计划文档已同步说明 Agent 应先读取 `agentGuidance.recreationPlan`。实现过程中默认 browser smoke 两次先暴露命名问题:`rectMetadataIncluded` 和 `layoutBlueprint.aboveFold` 会让未请求截图 metadata 的哨兵误判为 metadata present;修复后改为 `geometryIncluded` 和 `firstViewportSummary`,且 `geometryIncluded` 当前同时识别 `rect`、`boundingBox` 和 `bounds`,保持真实 `layoutProfile.aboveFold` 仍只在 `captureScreenshotMetadata = true` 时出现。 +- 2026-05-27 本轮复刻计划补强后执行验证:`node --test --test-timeout=120000 tests/site-experience-profile.test.mjs` 通过,17 tests passed;`node --test --test-timeout=120000 tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs` 通过,98 tests passed;`pnpm run test:unit` 通过,225 tests passed;`pnpm run lint` 通过;`pnpm run typecheck` 通过,覆盖 `vue-tsc --noEmit` 与 `pnpm build` 并刷新 `dist/manifest.json`;`pnpm run docs:build` 通过;`git diff --check` 通过;`find dist -maxdepth 3 ...` 发布产物快速扫描无输出,未发现 agent-only、test、Python、`__pycache__`、local bridge server 入口或 repo-local JS bridge helper 源文件产物。默认 Chrome smoke `STACKPRISM_BROWSER_SMOKE_CDP_PORT=9661 node tests/agent-bridge-browser-smoke.mjs` 最终退出 0,Chrome for Testing 147.0.7727.15 加载当前 `dist`,默认 local fixture capture `completed / cleanup`,profile HTTP 200,schema 为 `stackprism.site_experience_profile.v1`,extensionVersion 为 `1.3.73`,profile size 7577 bytes,未请求截图 metadata 时 `screenshotMetadataPresent = false`,请求 metadata 时 `screenshotMetadataPresent = true`,screenshot payload absent,privacy leak marker absent,large profile 估算 2 chunks,bridge stderr 为空。 +- 2026-05-27 bridge 页面体验与可选截图 follow-up:JS bridge 和 Python fallback 的 `/bridge` 页面从纯文本状态改为带阶段进度、边界说明和终态反馈的本机状态面板,仍保留 no-store/no-referrer/CSP nonce 和一次性 `bridgeToken` JSON config 边界。profile 增加显式 `options.captureScreenshot` 能力,默认仍不输出截图;仅当 `captureScreenshot = true` 且 `include` 包含 `visual` 时,扩展尝试用 `chrome.tabs.captureVisibleTab` 输出 `visualProfile.screenshot.dataUrl`,供支持图像输入的 Agent 可选使用。截图捕获失败或过大只记录 limitation,不伪造成成功;Skill、开发文档和计划文档已同步说明该字段未做逐像素脱敏,不应用于登录态或私密页面。 +- 2026-05-27 bridge 页面与截图 follow-up 后重新验证:`node --test --test-timeout=120000 tests/agent-capture-orchestration.test.mjs tests/site-experience-profile.test.mjs tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs` 通过,173 tests passed;`node --test --test-timeout=120000 tests/agent-bridge-browser-smoke-output.test.mjs` 通过,16 tests passed;`node --check tests/agent-bridge-browser-smoke.mjs` 通过;`pnpm run lint` 通过;`pnpm run typecheck` 通过,覆盖 `vue-tsc --noEmit` 与 `pnpm build` 并刷新 `dist/manifest.json`;`pnpm run test:unit` 通过,228 tests passed;`pnpm run docs:build` 通过;`git diff --check` 通过。新增 browser smoke 场景 `STACKPRISM_BROWSER_SMOKE_SCENARIO=visual-screenshot` 用于显式验证真实 Chrome 截图请求路径:若 `captureVisibleTab` 返回 JPEG data URL,则校验 `visualProfile.screenshot`;若 Chrome 返回 image readback failure,则必须记录 `screenshot_capture_failed` limitation 且不得伪造截图。2026-05-28 复验时 Chrome for Testing 147.0.7727.15 在该场景下返回真实 JPEG payload:`visualProfile.screenshot.dataUrl` present,`mimeType = image/jpeg`,`byteLength = 99111`,`agentGuidance.recreationPlan.visualReference.screenshotIncluded = true`;默认 browser smoke 仍断言未请求时没有 screenshot payload。 +- 2026-05-28 completed profile TTL cleanup follow-up:复核发现 JS bridge 和 Python fallback 的 completed profile 过期原本依赖后续请求触发 lazy prune;若 bridge 进程完成截图 profile 后持续空闲,内存中的 screenshot data URL 可能超过结果 TTL 才在下一次请求时清理。JS `CaptureStore` 与 Python fallback 现已增加 per-capture result expiry timer,完成 profile 后主动按 TTL 清理,并在 prune/clear 路径取消 timer;截图仍只保存在 bridge 进程内存中,不写入磁盘。验证:`node --test --test-timeout=120000 tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs` 通过,101 tests passed;`node --check agent-skill/stackprism-site-experience/scripts/bridge/capture-store.mjs && python3 -m py_compile agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/capture_store.py` 通过;`pnpm run test:unit` 通过,230 tests passed;`git diff --check` 通过。 +- 2026-05-28 bridge screenshot preview follow-up:复核发现 `/bridge` 页面下载文件名固定 `.jpg`,且 status preview 会信任 profile 中的 screenshot `mimeType` 字段;虽然当前扩展只生成 JPEG,这与 bridge preview 接受 `jpeg/png/webp` data URL 的边界不完全一致。JS bridge 和 Python fallback 现按 data URL 前缀归一化 preview `mimeType`,下载文件名也按 `image/jpeg`、`image/png`、`image/webp` 选择 `jpg`、`png`、`webp` 后缀。验证:`node --test --test-timeout=120000 tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs` 通过,103 tests passed;`node --check agent-skill/stackprism-site-experience/scripts/bridge/http-handlers.mjs && node --check agent-skill/stackprism-site-experience/scripts/bridge/bridge-page.mjs && python3 -m py_compile agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/status.py agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/bridge_page.py` 通过;`pnpm run test:unit` 通过,232 tests passed;`pnpm run docs:build` 通过;`git diff --check` 通过。 +- 2026-05-28 optional screenshot cancellation follow-up:复核发现 `captureVisibleTab` 可在重试和 tab 激活期间等待数秒;若用户在该 await 期间取消 capture,runner 可能用旧的 running state 继续写回 `posting_profile`。扩展端现在在截图 await 后、构建 profile 和写 transfer deadline 前重新读取 capture state;若状态已取消、失败或被清理,直接停止,不再发送 profile transfer。验证:`node --test --test-timeout=120000 tests/agent-capture-orchestration.test.mjs` 通过,57 tests passed;`pnpm run test:unit` 通过,233 tests passed。 + +## Current Status + +可以认为 Agent Bridge 已具备面向 AI Agent 快速复刻网站体验的本机可交付实现:本机 JS/Python bridge、扩展 handshake、capture orchestration、profile transfer、隐私脱敏、用户 opt-in、`agentGuidance.recreationPlan` 复刻执行计划、repo-local skill、文档和发布边界均已落地,并经自动化和 Chrome smoke 验证。 + +不能认为 `2026-05-21-stackprism-agent-bridge-plan.md` 全量完成。需要补齐或正式降级的主要缺口是 Task 10 中仍未 live 证明的 Chrome Web Store / Edge Add-ons update rollout、运行中 idle-driven service worker eviction、精确 incognito `INCOGNITO_NOT_SUPPORTED` live 浏览器元数据路径、更广长时间/资源耗尽矩阵、更广 DNS/private-network live matrix 和外部商店审核接受状态;当前新增的 `198.18.0.12` resolver rewrite smoke、DNS lookup failed smoke、特殊用途地址离线 parity fixtures、bridge self-target smoke、active-tab unavailable smoke、final private URL smoke、final DNS policy smoke、final DNS lookup failed smoke、target navigated away smoke、target load failed/timeout smoke、bridge iframe embedding smoke、response headers/CORS live smoke、connection pressure smoke、resource timeouts smoke、service worker idle wake smoke、browser-managed unpacked reload smoke、incognito bridge probe、incognito window bridge probe、sequential capture pressure smoke、result expiry bridge page smoke、修正后的 expired deadline reconciliation smoke 和显式 private-network override 的 `www.wikipedia.org` public complex-site smoke 只是收窄 DNS/private-network policy、bridge-origin 初始目标拒绝、active-tab 缺失 fail-closed、目标页漂移/失败、已加载目标 deadline reconciliation、bridge 页面嵌入防护、HTTP 安全响应头/CORS 拒绝、HTTP 连接压力/超时、service worker idle 后唤醒、浏览器管理页 reload、无痕自动化跳过边界、连续 capture 清理、completed result TTL 过期后的 profile/bridge token suppression,以及当前 DNS-proxy 环境下公网复杂页内容捕获风险点。运行中 idle 探针两次未能触发自然 worker eviction,最新端口 9650 仍显示 worker 75 秒后可见且最终 `TARGET_LOAD_TIMEOUT` fail closed;两条 incognito probe 仍未能触发扩展侧无痕元数据拒绝 live 分支,content client 已有 `chrome.extension.inIncognitoContext` 单元契约,但 live 缺口仍保留。商店披露确认已纳入 release workflow 硬门禁,但不是外部商店审核通过的证明。 + +历史交接基线状态:工作区干净,本分支当时已推送到 `origin/codex/agent-bridge-implementation`,HEAD `4191cad` 与远端一致。此行只记录接手前基线,不表示当前 HEAD 或远端同步状态;本轮后续优化改动必须通过当次 `git status --short --branch`、`git diff --name-only`、测试和构建结果复核,不把历史 staged/unstaged 计数或历史 HEAD 当作当前事实。 diff --git a/docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md b/docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md new file mode 100644 index 00000000..0c13d628 --- /dev/null +++ b/docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md @@ -0,0 +1,260 @@ +# CR-AGENT-BRIDGE-E2E-2026-05-22 + +## Scope + +This report records the current Agent Bridge verification state for the work driven by `docs/superpowers/plans/2026-05-21-stackprism-agent-bridge-plan.md`. + +Current implemented slice: + +- Extension-side protocol types, local opt-in, bridge content script handshake, profile transfer, profile builder, experience profiler, background capture orchestration, active-tab tracking, and fail-closed cleanup paths. +- JavaScript loopback bridge: ready JSON, `/health`, `POST /v1/captures`, one-time `/bridge` token render, cross-site `/bridge` render rejection before token claim, terminal/expired bridge page no-token responses, hostile bridge-page query/fragment and terminal error-message non-reflection, failed status error payload redaction, failed status unknown error-code rejection, request/profile/status/control endpoints, bearer profile reads, completed control stop command, status sequence/phase monotonicity, cancellation terminalization, cross-origin/preflight rejection, DNS/private-target policy, localhost alias self-target blocking, final URL blocking, repeated/oversized profile rejection, concurrent profile POST serialization, API rate limiting, resource timeout configuration, browser-open failure reporting, bridge page config identity checks, and strict request-shell rejection including absolute-form and `CONNECT` authority-form targets. +- Python fallback bridge: matching basic HTTP/API behavior, documented identifier fixture validation, failed status error payload redaction, failed status unknown error-code rejection, DNS/private/self-target policy including localhost alias blocking, final URL blocking, repeated/oversized profile rejection, concurrent profile POST serialization, completed control stop command, stale status rejection, cancellation terminalization, bounded DNS timeout, bridge page CSP/script-safe config, one-time `/bridge` token render, cross-site `/bridge` render rejection before token claim, terminal/expired bridge page no-token responses, hostile bridge-page query/fragment plus terminal error-message non-reflection, bridge page config identity checks, open-browser validation including invalid args and single-argv bridge URL handling, and strict request-shell rejection including absolute-form and `CONNECT` authority-form targets. +- Repo-local skill package, developer/user privacy documentation, release hygiene checks, and detection-flow documentation for the Agent Bridge capture path. + +This report is no longer a middle-state ledger. The current worktree has automated unit coverage plus Chrome for Testing browser smoke for the primary extension to bridge page to background capture to profile endpoint path. The current live-browser pass evidence covers the local complex fixture at `tests/fixtures/site-experience-fixture.html`, a separate public complex-site capture for `https://www.wikipedia.org/` in this DNS-proxy environment with explicit private-network override, default smoke success path is fixture-backed via the local complex fixture, local-only opt-in enforcement when legacy sync data says enabled, custom browser-open command handoff, large-profile multi-chunk transfer, target tab closure, bridge tab closure, Agent-initiated cancellation of a running capture, forced service worker target termination, service worker idle eviction before capture start followed by bridge-page wake and successful capture, extension reload interruption cleanup, browser-managed unpacked extension reload through `chrome://extensions`, a separate destructive `chrome.storage.session.clear()` plus reload fail-closed scenario, a separate running-capture local opt-in disable fail-closed scenario, a separate incognito bridge probe that fails closed without fetching the target or returning a fake profile in this CDP environment, a separate initial private target block smoke, a separate DNS resolver benchmark-address block smoke, a separate running-capture `CAPTURE_BUSY` concurrency smoke, a default active-connection pressure smoke, and a default slow header/body timeout smoke. The 2026-05-25 evidence still supersedes earlier `https://example.com` default-target wording because this environment resolved it to `198.18.1.69` and fail-closed at creation; the 2026-05-26 public complex-site pass is tracked as a separate scenario, not as proof that the default no-private-network policy accepts resolver-rewritten public hostnames. + +## Current Verification + +| Command | Exit | Result | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm run test:unit` | 0 | Fresh rerun on 2026-06-01 after Agent Bridge release disclosure gate hardening; 248 tests passed, 0 failed. Coverage includes optional screenshot cancellation during pending captureVisibleTab without profile transfer resurrection, bridge handshake, content client zero-side-effect exit on non-bridge loopback pages, content client incognito extension-context rejection with `INCOGNITO_NOT_SUPPORTED` before loading `/request` or sending `AGENT_BRIDGE_HELLO` / `START_AGENT_CAPTURE`, content client request envelope mismatch handling with `BRIDGE_REQUEST_MISMATCH` before any `AGENT_BRIDGE_HELLO` or `START_AGENT_CAPTURE` runtime message, content client request GET with Bearer auth and status POST with Bearer auth plus JSON content type, START_AGENT_CAPTURE rejection for `bridgeToken`、callback URL and profile wrapper before target resolution or capture-state creation, opt-in, strict capture request validation, no-token storage, profile chunk transfer, active-tab mode, new-tab mode, target load wait, cancellation cleanup, target tab close, target navigation-away, target main-frame load failure, sanitized target injection failure details, service worker restart fail-closed notification, extension lifecycle wake fail-closed recovery, bridge tab closure cleanup, cleared storage-session recovery no-op behavior, start-time local opt-in recheck before target resolution, running local opt-in disable cleanup before badge refresh can delay it, owned target cleanup failure observability without capture-state leakage, state removal failure logging without bridge-session leakage, incognito bridge URLs rejected before ordinary detection-cache reads, registered bridge API tabs rejected before ordinary runtime/popup/background detection access, bridge API webRequest/webNavigation skips bound to bridge tab or registered bridge session origin, ordinary local `/v1/captures/*` requests still preserving header/log evidence, ordinary local `/bridge` routes remaining detectable, report formatting, detector settings local fallback with sanitized warning details, manifest permissions/content-script/WAR contract, release workflow blocking agent-only JS bridge helper source files from `dist` artifacts and requiring Agent Bridge disclosure confirmation before packaging, JS bridge policy, Python fallback policy, JS/Python failed status error payload redaction and unknown-code rejection, JS/Python SIGTERM listener closure, Agent Bridge helper 300-line budget, Agent Bridge session storage access level not widened to untrusted contexts, hello storageSession missing diagnostics, required capabilities missing fail-closed before target resolution, raw request target dot/empty/query rejection plus wrong host/port, IPv6 host, encoded slash/backslash, duplicate bridge query, duplicate sensitive headers, unsupported transfer/content encoding and invalid content length rejection, status/request/control/profile JSON no-store/nosniff headers, profile no-referrer headers, Python preflight `Allow` header parity with JS, `/bridge` no-store/no-referrer/nosniff/COOP/Permissions-Policy headers, CSP without `unsafe-inline`, matching script/style nonce, frame-ancestors/base-uri/form-action restrictions, profiler option injection, request body failure behavior, JS/Python slow body/header timeout behavior, configured active connection limit rejection, terminal DELETE rejection for `completed`/`failed`/`cancelled`/`expired`, hostile query/fragment and terminal error-message non-reflection on bridge pages, Python DNS timeout bounds, terminal/expired bridge page token suppression, terminal profile POST semantics, active completed-profile result TTL cleanup without a later request in JS/Python bridge stores, screenshot preview MIME normalization and download-extension selection in JS/Python bridge pages, JS/Python unit coverage proving authenticated `/profile` GET uses the same query rate-limit bucket as status reads, `executeScriptResult` truncation evidence, background error log redaction including polluted `Error.name` cleanup warnings, positive/negative `captureScreenshotMetadata` behavior, bridge/target tab incognito metadata rejection, embedded text/CSS URL query/hash redaction, profile language and first-order UX field preservation, `agentGuidance.recreationPlan` redaction and recreation-plan contracts, cross-platform browser opener command resolution, bridge page screenshot modal focus-trap and Settings confirmation focus-return contracts, and browser-smoke report evidence contracts for keep-alive idle timeout, default rate-limit smoke, profile query rate-limit smoke, target URL validation smoke, response-started API unavailable network observer guard, and JS/Python rejection of non-IP browser-observed targetNetworkAddress values. | +| `STACKPRISM_BROWSER_SMOKE_SCENARIO=public-complex-target STACKPRISM_BROWSER_SMOKE_TARGET_URL=https://www.wikipedia.org/ STACKPRISM_BROWSER_SMOKE_CDP_PORT=9491 node tests/agent-bridge-browser-smoke.mjs` | 0 | Added and ran on 2026-05-26. Chrome for Testing 147.0.7727.15 loaded `/Volumes/Work/code/stackprism-1.3.70/dist`, used the JS loopback bridge, and captured `https://www.wikipedia.org/` to `completed / cleanup`. The current resolver returned `www.wikipedia.org -> 198.18.0.19`, so the scenario explicitly used `allowPrivateNetworkTarget = true`; this is a public complex-site content capture in this DNS-proxy environment, not proof that the default no-private-network policy accepts resolver-rewritten public hostnames. `/v1/captures/{id}/profile` returned HTTP 200 with `schema = "stackprism.site_experience_profile.v1"`, matching capture id, final URL `https://www.wikipedia.org/`, non-empty visual/layout/component profile keys, 15382 profile bytes, no screenshot metadata or image/pixel payload, no checked privacy leak markers, and empty bridge stderr. | +| `node tests/agent-bridge-browser-smoke.mjs` | 0 | Earlier default smoke rerun on 2026-05-25 after the state/session cleanup observability follow-up and latest `pnpm run typecheck` rebuilt `dist`: Chrome for Testing 147.0.7727.15 loaded `/Volumes/Work/code/stackprism-1.3.70/dist` and passed for the fixture-backed and lifecycle paths. Legacy sync `agentBridgeEnabled: true` was ignored when local opt-in was false and failed as `AGENT_BRIDGE_DISABLED`; a local disabled-probe target server observed `targetRequestCount = 0`, proving the target URL was not opened or fetched before local opt-in. Current passing capture evidence is fixture-backed: captures completed for `tests/fixtures/site-experience-fixture.html`, a local large-profile assets-only fixture, and a second bridge opened through `STACKPRISM_BROWSER_OPEN_COMMAND`/`STACKPRISM_BROWSER_OPEN_ARGS_JSON`; the earlier `https://example.com` public-target wording is superseded by later evidence and is not counted as current passing evidence; profiles returned HTTP 200 with `stackprism.site_experience_profile.v1`, matching capture ids, non-empty browser context, non-empty visual/layout/component keys where requested, and no privacy leak markers. With `captureScreenshotMetadata = false`, layout output stayed to non-bounding metadata. With `captureScreenshotMetadata = true`, the fixture profile contained `aboveFold` and `boundingBoxes` metadata but no screenshot/image/pixel payload. The large fixture produced a 393436-byte profile, estimated as 2 transfer chunks under the 384 KiB raw chunk limit. Target tab closure returned `TARGET_TAB_CLOSED`; bridge tab closure returned `BRIDGE_TAB_CLOSED`; deleting a running capture returned 200, reached `cancelled`, and closed the owned target tab; closing the service worker CDP target during a running capture returned `failed / BRIDGE_TRANSPORT_DISCONNECTED` and removed the owned target page from CDP targets; `chrome.runtime.reload()` during a running capture returned `failed / BRIDGE_TRANSPORT_DISCONNECTED` and the owned target page was not visible in CDP targets; cross-site `/bridge` render returned 403, first token render returned 200, second token render returned 409, terminal-first render after Agent cancellation returned 409 without token, and deleting a completed capture returned 409. | +| `STACKPRISM_BROWSER_SMOKE_SCENARIO=cleared-storage-session node tests/agent-bridge-browser-smoke.mjs` | 0 | Added and ran on 2026-05-25 as a separate destructive lifecycle smoke so it does not destabilize the default smoke's later service-worker cases. Chrome for Testing 147.0.7727.15 loaded `/Volumes/Work/code/stackprism-1.3.70/dist`; during a running slow-fixture capture, the worker cleared `chrome.storage.session` and called `chrome.runtime.reload()`. The Agent-observed status became `failed / BRIDGE_TRANSPORT_DISCONNECTED`, `/v1/captures/{id}/profile` returned 409 instead of a fake profile, the owned target tab was no longer visible, and bridge stderr tail was empty. | +| `STACKPRISM_BROWSER_SMOKE_SCENARIO=local-opt-in-disabled STACKPRISM_BROWSER_SMOKE_CDP_PORT=9476 node tests/agent-bridge-browser-smoke.mjs` | 0 | Added and reran on 2026-05-25 as a separate local opt-in lifecycle smoke after a fresh `pnpm run typecheck` rebuilt `dist`. Chrome for Testing 147.0.7727.15 loaded `/Volumes/Work/code/stackprism-1.3.70/dist`; during a running slow-fixture capture, the worker changed `chrome.storage.local.stackPrismSettings.agentBridgeEnabled` to `false`. The Agent-observed status became `failed / AGENT_BRIDGE_DISABLED`, `/v1/captures/{id}/profile` returned 409 instead of a fake profile, the owned target tab was no longer visible, and bridge stderr tail was empty. This proves the local profile setting shutdown path, not browser-managed extension disable/update. | +| `STACKPRISM_BROWSER_SMOKE_SCENARIO=browser-extension-disabled STACKPRISM_BROWSER_SMOKE_CDP_PORT=9483 node tests/agent-bridge-browser-smoke.mjs` | 0 | Added and ran on 2026-05-25 as a separate browser-level disable lifecycle smoke. Chrome for Testing 147.0.7727.15 loaded `/Volumes/Work/code/stackprism-1.3.70/dist`; during a running slow-fixture capture, the smoke opened `chrome://extensions/?id=` in the temporary profile and toggled the unpacked StackPrism extension from enabled to disabled through the browser-managed extensions UI. The Agent-observed status became `failed / BRIDGE_TRANSPORT_DISCONNECTED`, `/v1/captures/{id}/profile` returned 409 instead of a fake profile, the owned target tab was no longer visible, the bridge stderr tail was empty, and the smoke records only the extension id plus token presence boolean. This proves browser-level user disable fail-closed behavior for the temporary unpacked-extension profile, not Chrome Web Store update rollout behavior. | +| `STACKPRISM_BROWSER_SMOKE_SCENARIO=expired-deadline-reconciliation STACKPRISM_BROWSER_SMOKE_CDP_PORT=9648 node tests/agent-bridge-browser-smoke.mjs` | 0 | Reworked and reran on 2026-05-26 after finding the earlier slow-target fixture still reconciled while the persisted phase could be `target_opening`, where the product contract is `TARGET_LOAD_TIMEOUT`. The smoke now uses a normal fixture plus `waitMs: 30000`, waits until stored capture state has `phase = "target_loaded"` and a target tab id, edits `agent-capture:{id}.deadlineAt` in `chrome.storage.session` to a past timestamp, then creates a normal `about:blank` tab to trigger a background event entrypoint. Chrome for Testing 147.0.7727.15 loaded `/Volumes/Work/code/stackprism-1.3.70/dist`; the Agent-observed status became `failed / CAPTURE_TIMEOUT`, `/v1/captures/{id}/profile` returned 409 instead of a fake profile, the owned target tab was no longer visible, and bridge stderr tail was empty. This proves expired persisted deadline reconciliation for an already loaded running target, not the target-load timeout branch. | +| `STACKPRISM_BROWSER_SMOKE_SCENARIO=final-url-blocked STACKPRISM_BROWSER_SMOKE_CDP_PORT=9489 node tests/agent-bridge-browser-smoke.mjs` | 0 | Added and ran on 2026-05-25 as a separate final URL policy smoke. Chrome for Testing 147.0.7727.15 loaded `/Volumes/Work/code/stackprism-1.3.70/dist`; a local `127.0.0.1` redirect fixture returned one 302 from the allowed initial target URL to the running bridge origin. After the target loaded, the extension rejected the redirected final URL as `failed / FINAL_URL_BLOCKED` with `invalid_final_url`, `/v1/captures/{id}/profile` returned 409 instead of a fake profile, the fixture observed `requestCount = 1`, the target tab was no longer visible, and bridge stderr was empty. This proves the real-browser redirect/final URL policy for a redirect into the bridge origin, not a broad live DNS/private-network matrix. | +| `STACKPRISM_BROWSER_SMOKE_SCENARIO=private-target-blocked STACKPRISM_BROWSER_SMOKE_CDP_PORT=9490 node tests/agent-bridge-browser-smoke.mjs` | 0 | Added and ran on 2026-05-25 as a separate initial target policy smoke. Chrome for Testing 147.0.7727.15 loaded `/Volumes/Work/code/stackprism-1.3.70/dist`; a local `127.0.0.1` probe fixture was submitted as the initial capture URL without `allowPrivateNetworkTarget`. The bridge rejected capture creation as `400 / PRIVATE_NETWORK_TARGET_BLOCKED` with `private_network_address`, the fixture observed `requestCount = 0`, no target page for the blocked URL was visible in CDP targets, and bridge stderr was empty. This proves the real bridge creation path refuses a private literal initial URL before browser navigation, not a broad live DNS/private-network matrix. | +| `pnpm run lint` | 0 | Fresh rerun on 2026-05-26 after the rate-limit smoke/report follow-up; ESLint passed for `src`. | +| `pnpm run typecheck` | 0 | Fresh rerun on 2026-05-26 after the rate-limit smoke/report follow-up; `vue-tsc --noEmit` and production build passed. The build regenerated injected scripts and produced `/Volumes/Work/code/stackprism-1.3.70/dist/manifest.json`. | +| `pnpm run docs:build` | 0 | VitePress build passed on 2026-05-25 after the latest Agent Bridge review-report update. | +| `git diff --check && git diff --cached --check` | 0 | No whitespace or conflict-marker errors in the tracked or staged diff on 2026-05-26 after the upstream rebase and report/test updates. | + +Additional focused checks recorded for this slice: + +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=capture-busy STACKPRISM_BROWSER_SMOKE_CDP_PORT=9492 node tests/agent-bridge-browser-smoke.mjs`: Chrome for Testing 147.0.7727.15 loaded `/Volumes/Work/code/stackprism-1.3.70/dist`; while the first slow-fixture capture had a real extension target tab, a second capture request returned `429 / CAPTURE_BUSY`; the bridge page opener checked CDP `Page.getNavigationHistory` and found no API/bridge token material or `apiToken` / `bridgeToken` / `authorization` query in browser navigation history; then the first capture was cancelled, profile read returned 409, the owned target tab was no longer visible, and bridge stderr tail was empty. +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=tech-only STACKPRISM_BROWSER_SMOKE_CDP_PORT=9493 node tests/agent-bridge-browser-smoke.mjs`: Chrome for Testing 147.0.7727.15 loaded `/Volumes/Work/code/stackprism-1.3.70/dist`; a local fixture capture with `include: ["tech"]` completed and returned `stackprism.site_experience_profile.v1`; visual/layout/components/interaction/ux/assets profile sections were empty, limitations contained all 6 `section_not_requested` entries, screenshot metadata/payload were absent, no privacy leak marker was found, and bridge stderr tail was empty. +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=result-expiry-bridge-page STACKPRISM_BROWSER_SMOKE_CDP_PORT=9555 node tests/agent-bridge-browser-smoke.mjs`: Chrome for Testing 147.0.7727.15 loaded `/Volumes/Work/code/stackprism-1.3.70/dist`; a local fixture capture completed through the real extension and returned profile HTTP 200. The smoke then advanced an in-process JS bridge clock beyond that capture's `resultExpiresAt` and verified `GET /v1/captures/{id}/profile` returned `410 / CAPTURE_RESULT_EXPIRED`; opening the original `/bridge` URL also returned 410, contained `CAPTURE_RESULT_EXPIRED`, and did not contain bridge token material. +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=bridge-self-target-blocked STACKPRISM_BROWSER_SMOKE_CDP_PORT=9556 node tests/agent-bridge-browser-smoke.mjs`: the real JS bridge rejected a capture whose initial target URL used the current bridge origin as `400 / BRIDGE_SELF_TARGET_BLOCKED`; the same request using the `localhost` loopback alias was also rejected as `400 / BRIDGE_SELF_TARGET_BLOCKED`, `/health.activeCaptures` remained 0 after both rejects, and bridge stderr was empty. This is a focused bridge-origin self-target creation guard; final URL self-target remains covered by `final-url-blocked`. +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=active-tab-unavailable STACKPRISM_BROWSER_SMOKE_CDP_PORT=9557 node tests/agent-bridge-browser-smoke.mjs`: Chrome for Testing 147.0.7727.15 loaded `/Volumes/Work/code/stackprism-1.3.70/dist`; in a fresh profile with no prior detectable active tab record, a capture using `targetMode = "active_tab"` failed as `ACTIVE_TAB_UNAVAILABLE`, profile read returned 409, the target probe received 0 requests, no target URL tab was opened, the bridge DOM still reached ready state, and bridge stderr was empty. +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=request-shell-rejections STACKPRISM_BROWSER_SMOKE_CDP_PORT=9560 node tests/agent-bridge-browser-smoke.mjs`: the real JS bridge rejected 19 类原始 request-shell / ambiguous header cases: missing Host, duplicate Host, absolute-form, authority-form, encoded slash path, encoded backslash path, dot segment path, empty segment path, unexpected `/health` query, duplicate `/bridge` query, duplicate Authorization, duplicate Content-Type, Content-Length plus Transfer-Encoding, duplicate Content-Length, invalid Content-Length, chunked body, unsupported Transfer-Encoding, unsupported Content-Encoding, and unsupported charset. The duplicate bridge query response did not leak bridge-token material, target request count 0, `healthAfterRejections` returned `HTTP/1.1 200 OK`, cleanup DELETE returned 200, and bridge stderr was empty. +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=service-worker-idle-wake STACKPRISM_BROWSER_SMOKE_CDP_PORT=9544 node tests/agent-bridge-browser-smoke.mjs`: Chrome for Testing 147.0.7727.15 loaded `/Volumes/Work/code/stackprism-1.3.70/dist`; after local opt-in was set, the initial service worker target naturally disappeared from CDP after 31 seconds idle, the bridge page woke a new service worker target, the fixture capture completed with profile HTTP 200 and schema `stackprism.site_experience_profile.v1`, and bridge stderr was empty. +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=expired-deadline-reconciliation STACKPRISM_BROWSER_SMOKE_CDP_PORT=9648 node tests/agent-bridge-browser-smoke.mjs`: 2026-05-26 rerun passed after correcting the smoke harness to wait for persisted state `phase = "target_loaded"` before moving `deadlineAt` into the past. The prior slow-target harness expectation was wrong for `target_opening`, where the extension and unit contract correctly return `TARGET_LOAD_TIMEOUT`. The corrected run returned `failed / CAPTURE_TIMEOUT`, profile endpoint 409, target tab not visible, and empty bridge stderr. +- 2026-05-26 local remaining-matrix run executed the explicit smoke scenarios on ports 9620 through 9646 against Chrome for Testing 147.0.7727.15 and current `dist`. Passed expectations included browser disable and browser-managed reload as `BRIDGE_TRANSPORT_DISCONNECTED`, service-worker idle wake as `completed`, incognito probes as fail-closed `EXTENSION_NOT_CONNECTED` with target request count 0, private/DNS/final URL policy failures, bridge self-target creation rejection, target URL validation, target navigation/load failure/load timeout, host validation, response headers/CORS, request-shell rejections, connection pressure, resource timeouts, default create/status/profile rate limits, sequential capture pressure, and result-expiry bridge page. The only initial failure was the old `expired-deadline-reconciliation` smoke harness expectation, which was fixed and passed separately on port 9648. +- `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs`: RED first failed because `TARGET_INJECTION_FAILED` status had no `details.reason`; after adding sanitized failure details, 37 extension orchestration tests passed, including an injected `chrome.scripting.executeScript` error containing a sensitive URL query, nonce, fragment, and token-like id. +- `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs`: RED first failed because incognito bridge page URLs were not classified as bridge tabs for ordinary runtime-message isolation; after removing the incognito exclusion from the bridge-tab predicate, 38 extension orchestration tests passed, and `GET_POPUP_RESULT` from an incognito bridge sender is rejected before reading ordinary detection caches. +- `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs`: RED first failed because bridge-tab `/v1/captures/*` navigation commits still entered the ordinary `webNavigation` detection path after URL-only request skipping was tightened. The fix binds `/v1/captures/*` isolation to the bridge tab or the registered bridge-session origin, keeps ordinary local `/v1/captures/*` APIs observable, and now passes 40 Agent Capture orchestration tests. +- `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs`: RED first failed because registered bridge API tabs could still use ordinary `GET_POPUP_RESULT`, popup/options `GET_HEADER_DATA`, or `START_BACKGROUND_DETECTION` to access or trigger ordinary detection paths after session registration. The fix adds session-aware message-router sender/target guards and returns `START_BACKGROUND_DETECTION` success only after those guards pass; now passes 43 Agent Capture orchestration tests. +- `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs --test-name-pattern="observer content script|content observer injection|injection failure"`: 45 Agent Capture orchestration tests passed after making content observer active injection locate the observer by filename instead of assuming `content_scripts[0]`, after making the Agent capture path fail explicitly as `TARGET_INJECTION_FAILED` when observer injection fails, and after making a missing observer content script fail closed instead of continuing. Ordinary background detection keeps its non-blocking injection behavior. +- `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs`: RED first failed because a `START_AGENT_CAPTURE` payload with `profileChunkTransport: false` continued into target/profile-transfer orchestration and returned `BRIDGE_TRANSPORT_DISCONNECTED`. The fix rejects missing required capabilities in background validation as `NOT_SUPPORTED` with `details.missingCapability`, before target tab resolution; a later forbidden-field regression test first failed because `bridgeToken`、callback URL and profile wrapper were reported as generic unknown fields, then passed after the forbidden-field validation was moved before unknown-field validation. The file now passes 48 Agent Capture orchestration tests, including the extension lifecycle wake recovery contract. +- `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs --test-name-pattern="extension lifecycle wake"`: 48 Agent Capture orchestration tests passed after wiring `chrome.runtime.onInstalled` / `onStartup` through the same recovery helper used at service-worker module load. The new contract confirms an `onInstalled` update event still injects the content observer into detectable tabs, fails an active Agent capture as `SERVICE_WORKER_RESTARTED`, removes the owned target tab, and clears capture state. +- `node --test --test-timeout=60000 tests/agent-bridge-handshake.test.mjs --test-name-pattern="storage session"`: RED first failed because `AGENT_BRIDGE_HELLO` returned `NOT_SUPPORTED` without `details.missingCapability` when `chrome.storage.session` was unavailable. The fix uses the same missing-capability detail path as start-capture validation and now passes 14 bridge handshake/content transport tests. +- `node --test --test-timeout=60000 tests/agent-bridge-manifest.test.mjs`: 2 manifest contract tests passed after pinning the exact extension permissions, host permissions, observer content script scope/order, loopback-only bridge content script scope, and single `web_accessible_resources` block. The test rejects accidental `externally_connectable`, `localhost` bridge matches, agent-only profiler, repo-local bridge helper, Python fallback, and test path exposure. +- `node --test --test-timeout=60000 tests/background-logging.test.mjs`: RED first failed because a polluted `Error.name` in both `sanitizeLogDetails` and `reportCleanupFailure` preserved raw bridge URL query and token-like ids. After routing `Error.name` and cleanup warnings through the log sanitizer, 2 tests passed and asserted no raw `token=secret`, `nonce=`, `spb_`, or token-bearing diagnostic detail remained in serialized log output. +- `node --test --test-timeout=60000 tests/background-logging.test.mjs tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs tests/site-experience-profile.test.mjs`: 69 focused tests passed after the terminal/expired bridge page no-token fix, `executeScriptResult` profile evidence propagation, background error log redaction extraction, and `captureScreenshotMetadata=true` positive metadata retention check. +- `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs`: 31 JS bridge tests passed after adding explicit slow body/header timeout, configured active connection limit coverage, stdin EOF shutdown, SIGTERM listener shutdown, terminal DELETE guards for `completed`, `failed`, `cancelled`, and `expired` captures, bridge URL query/token exposure assertions, bridge-page hostile query/fragment plus terminal error-message non-reflection, strict request-shell cases for wrong host/port, IPv6 host, encoded slash/backslash, duplicate bridge query fields, duplicate sensitive headers, unsupported transfer/content encoding, invalid content length, status/request/control/profile response-header assertions, `/bridge` page no-store/no-referrer/nosniff/COOP/Permissions-Policy/CSP nonce assertions, and expanded private-address policy fixtures. +- `node --test --test-timeout=60000 tests/stackprism_bridge_py.test.mjs`: 35 Python fallback tests passed, including stdin EOF shutdown, SIGTERM listener shutdown, socket-timeout coverage for slow body/header requests, configured active connection limit rejection, terminal DELETE guards for `completed`, `failed`, `cancelled`, and `expired` captures, bridge URL query/token exposure assertions, bridge-page hostile query/fragment plus terminal error-message non-reflection, the same strict request-shell matrix as the JS bridge, matching preflight `Allow` header behavior, matching status/request/control/profile response-header assertions, matching `/bridge` page header/CSP nonce assertions, and the same expanded private-address policy fixtures. +- `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs`: 66 JS/Python bridge tests passed after adding shared `nonGlobalHostname`, `specialUseHostname`, `specialUseIpv6Hostname`, `publicSpecialUseExceptionHostname`, and bridge URL query/token exposure assertions. The fixtures use fake resolver results for `198.18.0.12`, IPv4 documentation/reserved/unspecified ranges, IPv6 documentation/translation/discard/6to4/special ranges, and Python `ipaddress` public exceptions. A RED run first showed JS accepted `100::1` while Python rejected it; JS now uses a table-driven `net.BlockList` policy aligned with the Python fallback behavior for these fixtures. +- `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs`: 66 JS/Python bridge tests are present after aligning terminal profile POST semantics, adding resource-timeout/active-connection coverage, locking stdin EOF and SIGTERM shutdown, locking terminal DELETE behavior, removing hostile terminal-message reflection from bridge pages, extending raw HTTP request-shell parity, locking endpoint response headers, and locking `/bridge` page header/CSP contracts; completed captures return terminal `409 / CAPTURE_ALREADY_COMPLETED` before reading an oversized repeated profile body in both bridge implementations, all four terminal states reject DELETE with `409` without state rewrite, terminal bridge pages return fixed terminal text while preserving error code/status, ambiguous raw request shells are rejected before business routing, JSON status/request/control/profile responses carry no-store/nosniff, profile responses also carry no-referrer, and `/bridge` pages carry no-store/no-referrer/nosniff/COOP/Permissions-Policy plus nonce-only script/style CSP without `unsafe-inline`. +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=dns-non-global-blocked STACKPRISM_BROWSER_SMOKE_CDP_PORT=9491 node tests/agent-bridge-browser-smoke.mjs`: Chrome for Testing 147.0.7727.15 loaded `/Volumes/Work/code/stackprism-1.3.70/dist`; the current resolver mapped `stackprism-browser-smoke.invalid` to `198.18.0.12`; capture creation was rejected as `400 / PRIVATE_NETWORK_TARGET_BLOCKED` with `private_network_address`, no target page for that URL was visible in CDP targets, and bridge stderr was empty. This proves the current production resolver path fail-closes for this benchmark-address rewrite, not a broad DNS/private-network live matrix. +- `STACKPRISM_BROWSER_SMOKE_SCENARIO=dns-lookup-failed STACKPRISM_BROWSER_SMOKE_CDP_PORT=9541 node tests/agent-bridge-browser-smoke.mjs`: the real JS bridge production resolver returned `ENOTFOUND` for `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com`; capture creation was rejected as `400 / TARGET_DNS_LOOKUP_FAILED` with `dns_lookup_failed`, `/health.activeCaptures` remained 0, and bridge stderr was empty. This proves the live DNS lookup failed branch for the current resolver path, not a broad NXDOMAIN/SERVFAIL matrix. +- `node --test --test-timeout=60000 tests/agent-bridge-handshake.test.mjs`: 14 bridge handshake/content transport tests passed after removing raw content-side error logging, replacing profile transfer fallback ack messages with fixed non-sensitive summaries, adding the non-bridge loopback zero-side-effect contract, and requiring hello `NOT_SUPPORTED` responses to include `details.missingCapability = "storageSession"` when `chrome.storage.session` is unavailable. +- `node --test --test-timeout=120000 tests/agent-capture-orchestration.test.mjs tests/agent-bridge-handshake.test.mjs`: 62 focused extension orchestration and bridge handshake tests are present after the incognito bridge-tab metadata, cleared-storage-session recovery, start-time opt-in recheck, running local opt-in disable fail-closed handling, extension lifecycle wake recovery, owned target cleanup failure observability, state/session cleanup failure observability, non-bridge loopback zero-side-effect, request envelope mismatch isolation, storage access-level contract, incognito bridge-tab ordinary message isolation, bridge API request isolation, and registered bridge API ordinary runtime/popup/background detection isolation follow-ups. Browser smoke is intentionally run as `node tests/agent-bridge-browser-smoke.mjs` rather than in parallel with these tests because the large-profile live capture uses the real 60-second capture deadline. +- `node --test --test-timeout=120000 tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs tests/agent-bridge-handshake.test.mjs tests/site-experience-profile.test.mjs tests/agent-capture-orchestration.test.mjs`: 143 focused Agent Bridge tests are present after the final request-body, DNS timeout, localhost self-target, bridge config identity, truncation limitations, file-split, incognito metadata, storage-session recovery, opt-in recheck, running local opt-in disable handling, extension lifecycle wake recovery, owned target cleanup failure observability, state/session cleanup failure observability, non-bridge loopback zero-side-effect, request envelope mismatch isolation, storage access-level contract, incognito bridge-tab ordinary message isolation, bridge API request isolation, registered bridge API ordinary message isolation, JS/Python SIGTERM shutdown, terminal DELETE guard, hostile bridge-page non-reflection, Python preflight `Allow` parity, and request-shell parity follow-ups. +- `node --test --test-timeout=60000 tests/release-workflow.test.mjs`: RED first failed because the release workflow dist hygiene script only blocked the Agent Bridge entry scripts and did not explicitly block repo-local JS bridge helper source filenames such as `capture-store.mjs`; after extracting an `agentOnlySourceFiles` list shared by `web_accessible_resources` and `dist/` scans, 4 executable release workflow contract tests passed. The 2026-05-25 continuation expanded this to 7 tests: workflow gate order before packaging, clean fake `dist` pass, `externally_connectable` rejection, polluted `dist/assets/capture-store.mjs` rejection, nested `dist/assets/agent-skill/` rejection, and `web_accessible_resources` exposure of `assets/http-server.mjs` rejection. +- `node --test --test-timeout=60000 tests/site-experience-profile.test.mjs`: RED first failed because `loadDetectorSettings()` logged a raw `Error` object when `chrome.storage.local` was unavailable. The warning now routes the error through `sanitizeLogDetails`, preserves sync settings and local opt-in fail-closed behavior, and 15 site/profile contract tests passed. +- Current focused test composition: 13 `tests/*.test.mjs` files and 212 Node tests. The current suite includes browser-smoke report evidence contracts, JS/Python bridge contracts, bridge handshake tests, Agent Capture orchestration tests, and supporting manifest/release/profile/logging/complexity/report-format contracts. +- `python3 -m py_compile agent-skill/stackprism-site-experience/scripts/stackprism_bridge.py && python3 -m compileall -q agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib`: Python entry and helper modules compile after the Python bridge page terminal-token fix. +- `pnpm exec prettier --check docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md tests/agent-bridge-browser-smoke.mjs tests/site-experience-profile.test.mjs src/background/logging.ts tests/background-logging.test.mjs`: all matched files use Prettier style after the latest report and test updates. +- `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs tests/site-experience-profile.test.mjs tests/agent-capture-orchestration.test.mjs tests/agent-bridge-handshake.test.mjs`: 69 focused tests passed after the stdin lifecycle, request strictness, bridge status page, and unrequested-section fixes. +- `sourcery review agent-skill/stackprism-site-experience/scripts/stackprism_bridge.py agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib tests/stackprism_bridge_py.test.mjs --no-summary`: no Python fallback findings after the 2026-05-24 cleanup. +- `cr review --prompt-only -t uncommitted`: 2026-05-25 limited/free CLI run completed with 25 findings. Valid findings were fixed for malformed config JSON, phase monotonicity outside `running`, JS status-update serialization, extension start serialization, storageSession preflight ordering, corrupted stored state cleanup, explicit failure logging, Python max connection normalization, and smoke harness cleanup. Findings skipped after verification were either already covered by current diff, not reachable before bridge listen, contradicted the plan's strict content-type contract, or would mask the original fail-closed terminal state by propagating cleanup errors. A later 2026-05-25 attempt entered limited/free CLI behavior and stalled at `summarizing`; it produced no findings and was terminated, so it is not counted as review evidence. +- `node -e` manifest inspection after the fresh `pnpm run typecheck` build confirmed `dist/manifest.json` has no `externally_connectable`, does not expose `injected/experience-profiler.iife.js`, and limits CRXJS-generated Agent Bridge content chunks to the `http://127.0.0.1/*` web-accessible-resource match. +- Extracted the inline dist hygiene script from `.github/workflows/release-extension.yml` and ran it against the current `dist/`; exit code 0 with no release boundary failures. +- Release workflow dist hygiene executable contract: extracted inline workflow script passes a clean fake `dist`, rejects `externally_connectable`, rejects repo-local JS bridge helper source files in both `dist/` and `web_accessible_resources`, and rejects nested agent-only directories such as `dist/assets/agent-skill/`. +- `find dist \( -path 'dist/agent-skill*' -o -path 'dist/docs/superpowers*' -o -path 'dist/tests*' -o -name '*.py' -o -name '*.pyc' -o -name '__pycache__' -o -name 'stackprism-bridge.mjs' -o -name 'stackprism_bridge.py' -o -name 'capture-store.mjs' -o -name 'http-handlers.mjs' -o -name 'http-server.mjs' -o -name 'open-browser.mjs' -o -name 'protocol.mjs' -o -name 'security.mjs' -o -name 'url-policy.mjs' \) -print`: no forbidden release-package paths, Python artifacts, or repo-local bridge helper source files found under `dist/`. + +## Browser Smoke + +Latest successful smoke: + +- Browser: Chrome for Testing 147.0.7727.15 from the local Playwright cache. +- Extension load directory: `/Volumes/Work/code/stackprism-1.3.70/dist`. +- Extension version: `1.3.73`. +- Latest default rerun: `STACKPRISM_BROWSER_SMOKE_CDP_PORT=9661 node tests/agent-bridge-browser-smoke.mjs` exited 0 on 2026-05-26 after switching the default target to the local fixture. +- Bridge script: `agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs` with `STACKPRISM_BRIDGE_NO_OPEN=1`; ready JSON was observed only as sanitized fields (`baseUrl`, `protocolVersion = 1`, `apiTokenPresent = true`). +- Opt-in gate: with `chrome.storage.sync.stackPrismSettings.agentBridgeEnabled = true` but local `agentBridgeEnabled = false`, bridge handshake still failed as `AGENT_BRIDGE_DISABLED`, produced no profile, and a local probe target received `0` requests; after explicitly writing local opt-in, capture completed. A separate running-capture smoke then changed local `agentBridgeEnabled` back to `false`; the capture failed as `AGENT_BRIDGE_DISABLED`, the profile endpoint returned 409, and the owned target tab was no longer visible. +- Capture targets counted as current passing evidence: a local HTTP server serving `tests/fixtures/site-experience-fixture.html`, a local large-profile fixture, a custom-open bridge capture using the same fixture with `allowPrivateNetworkTarget = true`, and a separate `public-complex-target` smoke for `https://www.wikipedia.org/`. +- Default smoke success path: fixture-backed local HTTP fixture capture with `allowPrivateNetworkTarget = true`, `completed / cleanup`, HTTP 200 profile reads, profile size 4697 bytes for the default fixture path, and no dependence on resolver behavior for `https://example.com`. +- Result: the enabled profile captures reached `completed / cleanup`; `/v1/captures/{id}/profile` returned HTTP 200 and `schema = "stackprism.site_experience_profile.v1"`. +- Public target status: counted only through the explicit `public-complex-target` scenario. In this environment `www.wikipedia.org` resolved to `198.18.0.19`, so the smoke used `allowPrivateNetworkTarget = true` and records `privateNetworkOverrideUsed = true`; the former default `https://example.com` path remains unsuitable because it resolves to `198.18.1.69` and correctly fail-closes when private-network targets are not explicitly allowed. +- Public complex-site assertions: final URL was `https://www.wikipedia.org/`, capture id matched, user agent was present, visual/layout/component profile keys were non-empty, profile size was 15382 bytes, screenshot metadata and screenshot image/pixel payload were absent, checked privacy leak markers were absent, and bridge stderr was empty. +- Complex fixture assertions: final URL redacted the sensitive query value, capture id matched, user agent was present, and visual/layout/component sections were non-empty. +- Screenshot metadata assertions: the false branch returned no bounding metadata; the true branch returned `aboveFold` and `boundingBoxes` metadata and still returned no screenshot image, base64, or pixel payload. +- Large-profile assertion: the local large assets-only fixture produced a 393436-byte profile and crossed the 384 KiB raw transfer chunk boundary, so the live Chrome path now exercises multi-chunk profile transfer instead of relying only on unit tests. +- Custom browser-open assertion: a second bridge ran without `STACKPRISM_BRIDGE_NO_OPEN`; `STACKPRISM_BROWSER_OPEN_COMMAND` pointed to the current Node executable, `STACKPRISM_BROWSER_OPEN_ARGS_JSON` passed a module helper as argv, and the helper opened the nonce-scoped bridge URL in the already loaded Chrome for Testing profile through CDP. The capture completed and returned the expected profile, proving the custom browser command success path without shell string interpolation. +- Privacy assertion: neither profile contained the checked leak markers for cookie, authorization, set-cookie, `token=secret`, or `#frag`. +- Bridge page guard: cross-site `/bridge` render with attacker `Referer` returned 403 without `bridgeToken`; first normal render returned 200 with token; second render of the same URL returned 409 without token; a never-rendered capture cancelled by the Agent reached `cancelled`, and its first `/bridge` render returned 409 without token. +- Terminal DELETE guard: deleting terminal `completed`, `failed`, `cancelled`, and `expired` captures returns 409 and preserves the terminal status in both JS bridge and Python fallback tests; the live Chrome smoke also confirms deleting a completed capture returns 409. +- Lifecycle guards: closing the target tab during a slow local fixture capture returned `failed / TARGET_TAB_CLOSED`; closing the bridge tab during a slow local fixture capture returned `failed / BRIDGE_TAB_CLOSED`; deleting a running slow-fixture capture returned 200, reached `cancelled`, and `chrome.tabs.get(targetTabId)` confirmed the owned target tab no longer existed; changing local `agentBridgeEnabled` to `false` during a running slow-fixture capture returned `failed / AGENT_BRIDGE_DISABLED`, returned 409 from the profile endpoint, and removed the owned target tab from visible CDP targets; closing the service worker CDP target during a running capture returned `failed / BRIDGE_TRANSPORT_DISCONNECTED` and CDP no longer listed the owned target page; calling `chrome.runtime.reload()` during a running capture returned `failed / BRIDGE_TRANSPORT_DISCONNECTED` and CDP no longer listed the owned target page. +- Bridge DOM marker: `document.documentElement.dataset.stackprismAgentBridgeClient = "ready"`. +- Bridge stderr tail was empty after redaction. +- 2026-05-23 rerun caught and fixed one content-side regression: terminal `failed` status updates from the bridge page must be serialized with `phase = "cleanup"`. Before the fix, disabled local opt-in posted `failed / bridge_connected`, the bridge server rejected it, and the capture later expired as `EXTENSION_NOT_CONNECTED`. The rerun after rebuilding `dist` returned `failed / AGENT_BRIDGE_DISABLED`. +- 2026-05-23 final optimization pass fixed the JS bridge stdin EOF shutdown path, added bridge page status polling for JS and Python bridge pages, tightened extension-side request validation, and aligned unrequested `tech`/`assets` profile sections with the plan. The post-build smoke rerun still completed the disabled gate, public target, local fixture target, bridge token guard, and target/bridge tab closure scenarios. +- 2026-05-24 final audit follow-up fixed JS bridge cancellation authorization so `DELETE /v1/captures/{id}` requires `apiToken`, and aligned JS final URL blocked failures with Python fallback by exposing terminal phase `cleanup` instead of retaining `target_loaded`. +- 2026-05-24 verification refresh fixed localhost alias bridge self-target blocking for JS and Python, hardened bridge page config identity checks against URL query fields, and propagated profiler truncation evidence into profile limitations. The browser smoke rerun still completed the disabled gate, public target, local fixture target, bridge token guard, and target/bridge tab closure scenarios. +- 2026-05-24 completion audit follow-up hardened JS and Python request target parsing for dot segments, empty path segments, and query strings on non-bridge endpoints. It also changed the profiler path so `captureScreenshotMetadata = false` is passed before injection and bounding boxes, above-fold data, and component rects are not collected at the source. +- 2026-05-24 CodeRabbit follow-up made failure cleanup resilient so profile-transfer cleanup or target cleanup cannot prevent `agent-capture-state` removal, replaced a hardcoded status protocol version with `bridgeProtocolVersion`, and wrapped `__SP_RULES__` cleanup in the same deadline guard used by agent detection. +- 2026-05-24 cancellation smoke follow-up added a live browser assertion for Agent-initiated `DELETE` on a running capture. The bridge server returned 200, the bridge content script picked up cancel control polling, background cleaned up the owned target tab, and the Agent observed `cancelled`. +- 2026-05-24 completion audit hardening blocked the last known DOM-readable token window for expired or already-terminal captures in both JS and Python bridge pages, preserved `evidence.truncation.executeScriptResult` for Agent consumers, and moved background error logging through a tested redaction helper that strips bridge URLs, ids, token-like fields, and Error message/stack content. +- 2026-05-24 screenshot metadata follow-up added a positive profile-builder assertion and a live Chrome smoke assertion that `captureScreenshotMetadata = true` keeps above-fold, bounding box, and component rect metadata while still avoiding screenshot image or pixel fields. +- 2026-05-24 terminal-render smoke follow-up added a live Chrome assertion for a never-rendered capture that becomes terminal before bridge page load. The first `/bridge` request after terminalization returned 409 without `bridgeToken`; expired terminal rendering remains covered by JS/Python automated bridge tests. +- 2026-05-24 large-profile smoke follow-up added a local large resource fixture and proved the real Chrome capture/profile path can return a profile larger than one 384 KiB transfer chunk while preserving redacted target URLs and no screenshot payload. +- 2026-05-24 custom-open smoke follow-up added a live `STACKPRISM_BROWSER_OPEN_COMMAND` success path using an argv-array Node helper to open the bridge URL in the same Chrome for Testing profile, then completed a fixture capture through the automatically opened bridge page. +- 2026-05-24 service-worker-stop smoke follow-up added a live CDP service worker target termination during a running capture. The Agent-observed bridge status failed closed as `BRIDGE_TRANSPORT_DISCONNECTED`, and the owned slow target page was cleaned up instead of producing a fake profile. +- 2026-05-24 extension-reload smoke follow-up added a live `chrome.runtime.reload()` interruption during a running capture. The Agent-observed bridge status failed closed as `BRIDGE_TRANSPORT_DISCONNECTED`, and the owned slow target page was cleaned up instead of producing a fake profile. +- 2026-05-24 disabled-opt-in smoke follow-up changed the disabled target to a local probe server and asserted `targetRequestCount = 0`, proving the fail-closed opt-in path did not open or fetch the target URL before local opt-in. +- 2026-05-24 JS/Python bridge parity follow-up fixed Python fallback profile POST ordering so already-terminal captures reject repeated profile submissions as terminal `409` before reading large request bodies, matching JS bridge semantics and avoiding an oversized body from masking the terminal state. +- 2026-05-24 content log redaction follow-up replaced raw `console.error(..., error)` output in the bridge content script with an error-code-only summary and replaced profile transfer fallback ack messages derived from `String(error)` with fixed non-sensitive text. +- 2026-05-24 bridge page reflection follow-up added JS/Python tests for malicious query/fragment input and terminal internal error messages. Both bridge pages now avoid reflecting hostile fragments, script-like payloads, bridge tokens, or raw internal error messages; terminal responses preserve error code/status and use fixed terminal text. +- 2026-05-24 request-shell parity follow-up expanded the JS and Python raw HTTP tests for wrong host ports, IPv6 host headers, encoded slash/backslash request targets, duplicate `/bridge` query fields, duplicate `Content-Length`, duplicate sensitive headers, unsupported transfer/content encoding, and invalid content length. These requests now reject before business routing in both bridge implementations. +- 2026-05-24 response-header contract follow-up added JS/Python assertions that capture status, request, control, profile POST, bridge-token profile rejection, and API profile reads return JSON with `Cache-Control: no-store` and `X-Content-Type-Options: nosniff`; profile responses additionally return `Referrer-Policy: no-referrer`. +- 2026-05-24 bridge page header contract follow-up added JS/Python assertions that `/bridge` HTML responses include `Cache-Control: no-store`, `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Cross-Origin-Opener-Policy: same-origin`, minimal `Permissions-Policy`, CSP `default-src 'none'`, `frame-ancestors 'none'`, `base-uri 'none'`, `form-action 'none'`, no `unsafe-inline`, and matching script/style nonces. +- 2026-05-25 cleared storage-session smoke follow-up added a separate browser smoke scenario for `chrome.storage.session.clear()` followed by `chrome.runtime.reload()` during a running capture. The Agent-observed status failed closed as `BRIDGE_TRANSPORT_DISCONNECTED`, the profile endpoint returned 409 instead of a fake profile, and the owned slow target page was not visible afterward. +- 2026-05-25 local opt-in disable smoke follow-up added a separate browser smoke scenario for changing `chrome.storage.local.stackPrismSettings.agentBridgeEnabled` to `false` during a running capture. The Agent-observed status failed closed as `AGENT_BRIDGE_DISABLED`, the profile endpoint returned 409 instead of a fake profile, and the owned slow target page was not visible afterward. This does not prove browser-managed extension disable/update behavior. +- 2026-05-25 cleanup observability follow-up made owned target tab removal failures visible through `reportCleanupFailure('cleanupTarget', errorName)` while preserving fail-closed `agent-capture-state` and bridge-session cleanup for failure and cancel paths. +- 2026-05-25 state/session cleanup observability follow-up made direct cancellation cleanup record `removeAgentCaptureState` failures and still continue to `clearBridgeSession`, preventing one cleanup failure from hiding the next cleanup action. +- 2026-05-25 non-bridge loopback contract follow-up added an import-time content client test proving that ordinary `127.0.0.1` pages that are not `/bridge` return before reading bridge DOM config, calling bridge HTTP, registering window lifecycle listeners, or sending runtime messages. +- 2026-05-25 Python preflight parity follow-up added an assertion that fallback `OPTIONS /v1/captures` returns the same `Allow: GET, POST, DELETE` method contract as the JS bridge while still omitting `Access-Control-Allow-*` headers. +- 2026-05-25 bridge signal shutdown follow-up added JS and Python bridge subprocess tests that send SIGTERM after `/health` succeeds, wait for exit code 0, and verify the previous health endpoint is no longer reachable. +- 2026-05-25 capture busy smoke follow-up added a separate browser smoke scenario where a slow real capture owns a target tab, a second capture is rejected as `429 / CAPTURE_BUSY`, and cancelling the first capture leaves no fake profile or visible owned target tab. +- 2026-05-25 cleanup log redaction follow-up made polluted `Error.name` values go through the same background log sanitizer and made `reportCleanupFailure` sanitize its warning details before `console.warn`, preventing bridge URL query, nonce, and token-like ids from leaking through cleanup diagnostics. +- 2026-05-25 target injection failure detail follow-up made `TARGET_INJECTION_FAILED` preserve a bounded, sanitized `details.reason`; the regression test first failed on missing details, then passed with no raw sensitive query, nonce, fragment, or token-like id in the status payload. +- 2026-05-25 incognito bridge-tab isolation follow-up removed the incognito exclusion from the bridge-tab predicate so incognito bridge URLs are still excluded from ordinary tab events and ordinary runtime messages cannot read detection caches. +- 2026-05-25 bridge API request isolation follow-up changed `/v1/captures/*` detection skipping from a URL-only loopback match to bridge tab or registered bridge-session-origin matching. This prevents ordinary local apps with `/v1/captures/*` APIs from losing header evidence while still keeping bridge-tab `webRequest` and `webNavigation` events out of ordinary tab caches and throttle resets. +- 2026-05-25 registered bridge API ordinary-message isolation follow-up made message-router reject registered bridge API tabs before ordinary runtime cache reads, popup/options target reads, or ordinary background detection starts. +- 2026-05-25 release workflow hygiene follow-up made the release job reject repo-local JS bridge helper source filenames from `web_accessible_resources` and `dist/` artifacts, not only the top-level bridge entry scripts. +- 2026-05-25 detector settings warning redaction follow-up made `chrome.storage.local` read-failure warnings use `sanitizeLogDetails` instead of logging the raw caught error, preventing polluted error names or messages from exposing bridge URL query, nonce, or token-like ids while keeping sync settings fallback behavior. +- 2026-05-25 bridge failed-status redaction follow-up made JS bridge and Python fallback sanitize extension-posted failed status `error` payloads before storing or returning `publicStatus`. The regression tests first failed on raw API token exposure, then passed with apiToken, bridgeToken, nonce, URL query, and fragment removed from error message/details while preserving the structured error code. +- 2026-05-25 bridge failed-status error-code follow-up made JS bridge and Python fallback reject extension-posted failed status errors whose `error.code` is outside the first-version Agent Bridge error-code contract. The regression tests first failed because `MADE_UP_ERROR` terminalized the capture, then passed with `400 INVALID_REQUEST` and the capture left non-terminal so a valid failed status can still be submitted. +- 2026-05-25 required-capabilities follow-up made background-side `START_AGENT_CAPTURE` validation reject missing first-version capabilities as `NOT_SUPPORTED` before resolving or opening a target tab. The regression test first failed on `BRIDGE_TRANSPORT_DISCONNECTED`, then passed with `details.missingCapability = "profileChunkTransport"` and no capture state. +- 2026-05-25 forbidden start-payload follow-up made background-side `START_AGENT_CAPTURE` validation explicitly reject `bridgeToken`, callback URL, and profile wrapper fields as forbidden `INVALID_REQUEST` before target tab resolution or capture-state creation. The regression test first failed because these fields were consumed by the generic unknown-field guard. +- 2026-05-25 Python bridge render-once parity follow-up added a Python fallback assertion that the first `/bridge` response renders a valid `bridgeToken`, while the second request for the same URL returns `409 / INVALID_REQUEST` and does not include an `spbt_...` token in the JSON body. +- 2026-05-25 Python DNS policy parity follow-up extended the Python fallback URL policy fixture test to cover mixed public/private DNS answers, resolver exceptions, and empty resolver results as fail-closed outcomes, matching the JS bridge fixture coverage. +- 2026-05-25 hello missing-capability diagnostics follow-up made `AGENT_BRIDGE_HELLO` return `details.missingCapability = "storageSession"` when the extension lacks `chrome.storage.session`, matching the first-version capability contract instead of returning only `NOT_SUPPORTED`. +- 2026-05-25 request envelope mismatch follow-up added an import-time content client test proving a mismatched request endpoint response posts `failed / BRIDGE_REQUEST_MISMATCH` and sends no `AGENT_BRIDGE_HELLO` or `START_AGENT_CAPTURE` runtime message to background. The same test now records the actual content-side bridge HTTP calls and asserts request GET uses `Authorization: Bearer {bridgeToken}` while status POST uses both Bearer auth and `Content-Type: application/json`. +- 2026-05-25 content observer injection follow-up made active observer injection find the ordinary observer by filename instead of relying on manifest ordering, and made Agent capture report observer injection failure or missing observer file as `TARGET_INJECTION_FAILED` rather than continuing after a swallowed injection error. +- 2026-05-25 manifest contract follow-up pinned the exact permissions, content script scopes/order, and web-accessible-resource surface in `tests/agent-bridge-manifest.test.mjs`, so unrelated permission growth or agent-only resource exposure fails in local unit tests before release packaging. +- 2026-05-25 bridge navigation-history follow-up made JS/Python bridge tests assert bridge URLs only contain `session`、`capture` 和 `nonce` query keys, and made the browser smoke bridge-page opener fail if CDP navigation history contains API/bridge token material or `apiToken` / `bridgeToken` / `authorization` query fields. +- 2026-05-25 tech-only smoke follow-up added a live Chrome for Testing scenario for `include: ["tech"]`, proving the extension path does not emit profiler metadata or unrequested visual/layout/components/interaction/ux/assets data when those sections are not requested. +- 2026-05-25 continuation CodeRabbit follow-up fixed malformed bridge config JSON normalization, JS/Python non-running phase regression checks, JS bridge per-capture status-update serialization, extension start serialization, storageSession preflight ordering, corrupted agent-capture session state validation/removal, profile-transfer disconnect logging, background runner rejection logging, Python max-open-connections normalization, capture-key separator rejection, and smoke harness bridge/CDP cleanup. +- 2026-05-25 continuation CodeRabbit rerun returned 2 findings. The valid profiler finding was fixed so `boundingBoxes[].selector` preserves the matched selector instead of replacing it with tagName; the Python `rawHttp` duplicate finding was a false positive because the other helper is in a separate JS test module. +- 2026-05-25 continuation CodeRabbit follow-up returned 5 more findings and all valid items were fixed: Python fallback non-UTF-8 request body now returns `INVALID_JSON`; content bridge config rejects non-numeric `protocolVersion`; CDP WebSocket pending requests reject on close/error; extension reload smoke records reload trigger failures; profile-transfer disconnect cleanup is surfaced through the caller. A later rerun returned 4 minor findings; valid fixes switched Python bridge-token claim time to the injected store clock, cleaned up incognito target tabs created by `chrome.tabs.create`, and made bridge-session lock cleanup synchronous. The remaining profiler selector finding was stale because current code and tests already preserve matched selectors. +- 2026-05-25 continuation verification reran `pnpm run test:unit` with 165 tests passed, `pnpm run lint`, `pnpm run typecheck`, `pnpm run docs:build`, JS/Python syntax checks, and `git diff --check && git diff --cached --check`. Focused Agent Bridge tests passed 135 cases. +- 2026-05-25 continuation browser smoke reran `tech-only`, `capture-busy`, `private-target-blocked`, `final-url-blocked`, `cleared-storage-session`, and `local-opt-in-disabled` against Chrome for Testing 147.0.7727.15 and current `/Volumes/Work/code/stackprism-1.3.70/dist`; all six scenarios passed with bridge stderr empty. Default `https://example.com` remains unsuitable in this environment because resolver output is `198.18.1.69`; direct public IP attempts created captures but Chrome reported `TARGET_LOAD_FAILED`, so those attempts are not counted as public complex-site pass evidence. +- 2026-05-26 public complex-site smoke added and ran `STACKPRISM_BROWSER_SMOKE_SCENARIO=public-complex-target STACKPRISM_BROWSER_SMOKE_TARGET_URL=https://www.wikipedia.org/ STACKPRISM_BROWSER_SMOKE_CDP_PORT=9491 node tests/agent-bridge-browser-smoke.mjs`; Chrome for Testing 147.0.7727.15 loaded the current `dist`, resolver output was `www.wikipedia.org -> 198.18.0.19`, the scenario explicitly used `allowPrivateNetworkTarget = true`, capture completed with HTTP 200 profile schema `stackprism.site_experience_profile.v1`, final URL `https://www.wikipedia.org/`, non-empty visual/layout/component keys, no screenshot metadata or screenshot payload, no checked privacy leak markers, and empty bridge stderr. This closes the narrow public complex-site content-capture evidence gap for the current DNS-proxy environment, but it does not broaden the DNS/private-network policy matrix. +- 2026-05-25 final continuation smoke reran `tech-only`, `cleared-storage-session`, and `final-url-blocked` on ports 9510, 9511, and 9512 after the CDP WebSocket cleanup and incognito-target cleanup fixes. All three passed against Chrome for Testing 147.0.7727.15 and current `/Volumes/Work/code/stackprism-1.3.70/dist`; bridge stderr remained empty. +- 2026-05-25 final local continuation audit fixed four additional boundaries: `chrome.storage.local` opt-in read failures now fail closed as disabled; content bridge config rejects non-integer `protocolVersion`; ordinary local `/bridge` pages remain detectable unless strict Agent Bridge query identifiers are present; embedded `http(s)` URLs inside profile text/CSS strings are query-redacted and hash-stripped. +- 2026-05-25 final local verification after formatting reran focused Agent Bridge tests (`tests/agent-bridge-handshake.test.mjs`, `tests/agent-capture-orchestration.test.mjs`, `tests/site-experience-profile.test.mjs`, `tests/stackprism-bridge.test.mjs`, `tests/stackprism_bridge_py.test.mjs`) with 148 tests passed, then reran `pnpm run test:unit` with 165 tests passed, `pnpm run lint`, `pnpm run typecheck`, `pnpm run docs:build`, changed-file Prettier check, and `git diff --check && git diff --cached --check`; all passed. +- 2026-05-25 router exception redaction follow-up made Agent Bridge background router catch paths return only a generic `INVALID_REQUEST / Agent Bridge request failed.` response for unexpected `AGENT_BRIDGE_HELLO`、`START_AGENT_CAPTURE` 和 `AGENT_CAPTURE_CONTROL` exceptions, while logging through the background sanitizer. The regression test injects a storage exception containing `token=secret`, `nonce=`, `/bridge?`, and an `spb_...` token-like id, then verifies neither the bridge response nor `console.error` output contains those sensitive fragments. `node --test --test-timeout=120000 tests/agent-bridge-handshake.test.mjs tests/agent-capture-orchestration.test.mjs` passed with 66 tests. +- 2026-05-25 router exception verification reran changed-file Prettier check, `pnpm run test:unit` with 166 tests passed, `pnpm run lint`, `pnpm run typecheck` including production build, and `pnpm run docs:build`; all passed. +- 2026-05-25 startup env gate follow-up made JS `createBridgeServer({ env })` and Python fallback `create_server(..., env=...)` reject NUL-bearing `STACKPRISM_BROWSER_OPEN_COMMAND` / `STACKPRISM_BROWSER_OPEN_ARGS_JSON` before server bind or token creation, matching the CLI startup contract. The Python factory now passes the same validated env through to `open_browser`. Focused reruns passed: `tests/stackprism-bridge.test.mjs` 33 tests and `tests/stackprism_bridge_py.test.mjs` 37 tests. +- 2026-05-25 startup env gate verification reran the complexity contract after compressing `http-server.mjs` back to 296 lines, changed-file Prettier check, JS syntax check, Python `py_compile`, Python `compileall` with `__pycache__` cleanup, `pnpm run test:unit` with 168 tests passed, `pnpm run lint`, `pnpm run typecheck` including production build, and `pnpm run docs:build`; all passed. +- 2026-05-25 parsed browser-open args NUL follow-up extended the startup env gate to JSON-parsed `STACKPRISM_BROWSER_OPEN_ARGS_JSON` values. A string such as `["bad\\u0000arg"]` is now rejected as `BRIDGE_INVALID_ENV` before JS/Python server factories can bind a port or create a ready token; invalid JSON and non-array shapes still follow the existing `BROWSER_OPEN_FAILED / invalid_open_args` capture-time path. Focused reruns passed: `tests/stackprism-bridge.test.mjs` 33 tests and `tests/stackprism_bridge_py.test.mjs` 37 tests. +- 2026-05-25 direct open-browser helper parsed args NUL follow-up made JS `openBrowser()` call the same env parser before `spawnSync`, so direct helper reuse returns `BRIDGE_INVALID_ENV` instead of degrading to `spawn_failed`; Python `open_browser()` helper keeps the same parsed-NUL assertion. Focused reruns passed: `tests/stackprism-bridge.test.mjs` 34 tests and `tests/stackprism_bridge_py.test.mjs` 37 tests. +- 2026-05-25 direct open-browser helper verification reran focused JS/Python bridge plus complexity tests with 72 tests passed, changed-file Prettier check, JS syntax check, Python `py_compile`, Python `compileall` with `__pycache__` cleanup, `pnpm run test:unit` with 169 tests passed, `pnpm run lint`, `pnpm run typecheck` including production build, and `pnpm run docs:build`; all passed. +- 2026-05-25 smoke cleanup follow-up changed the browser smoke harness to stop each JS bridge with `stopBridge()`, wait for the child process to exit, and confirm the prior `/health` endpoint is no longer reachable before cleanup returns. A rerun of `STACKPRISM_BROWSER_SMOKE_SCENARIO=tech-only STACKPRISM_BROWSER_SMOKE_CDP_PORT=9520 node tests/agent-bridge-browser-smoke.mjs` passed against Chrome for Testing 147.0.7727.15 and current `dist`; because all scenario finally blocks now await `stopBridge()`, the pass also proves no JS bridge listener remained on that scenario's port. +- 2026-05-25 targetMode query live smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=target-mode-query-boundaries`. The rerun on port 9521 passed against Chrome for Testing 147.0.7727.15 and current `dist`: with an existing tab at the same origin/path but a different query, `reuse_or_new_tab` opened a separate target tab and completed; because that capture used `keepTabOpen=true`, the smoke now records `keepTabOpenWasTrue = true` and `keptNewTargetTabId` before scenario cleanup. With a previous active tab at the same origin/path but different query, `active_tab` failed as `ACTIVE_TAB_MISMATCH` and did not open the requested target URL. +- 2026-05-25 sequential capture pressure smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=sequential-capture-pressure`. The rerun on port 9522 passed against Chrome for Testing 147.0.7727.15 and current `dist`: one JS bridge and one Chrome profile completed four consecutive fixture captures, each ending in `completed / cleanup`; every profile read returned HTTP 200, `/health.activeCaptures` returned 0 after each round, no owned target tab remained visible, and bridge stderr was empty. +- 2026-05-25 final private URL smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=final-private-url-blocked`. The rerun on port 9523 passed against Chrome for Testing 147.0.7727.15 and current `dist`: a public-IP literal initial target was served through a local HTTP proxy and redirected to a `127.0.0.1` final URL; the bridge rejected the `target_loaded` update as `FINAL_URL_BLOCKED / private_network_address`, the profile endpoint returned 409, the owned target tab was not visible afterward, both proxy and private final targets were reached, and bridge stderr was empty. +- 2026-05-25 final DNS policy smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=final-dns-policy-blocked`. The rerun on port 9524 passed against Chrome for Testing 147.0.7727.15 and current `dist`: a public-IP literal initial target was served through a local HTTP proxy and redirected to `stackprism-browser-smoke.invalid`; the current resolver classified that final hostname as private/non-global, so the bridge rejected the `target_loaded` update as `FINAL_URL_BLOCKED / private_network_address`, the profile endpoint returned 409, the owned target tab was not visible afterward, both proxy and final target requests were observed, and bridge stderr was empty. +- 2026-05-25 final DNS lookup failed smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=final-dns-lookup-failed`. The rerun on port 9542 passed against Chrome for Testing 147.0.7727.15 and current `dist`: a public-IP literal initial target was served through a local HTTP proxy and redirected to an oversized-label hostname; the bridge rejected the `target_loaded` update as `FINAL_URL_BLOCKED / dns_lookup_failed`, the profile endpoint returned 409, the owned target tab was not visible afterward, both proxy and final target requests were observed, and bridge stderr was empty. +- 2026-05-25 DNS lookup failed smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=dns-lookup-failed`. The rerun on port 9541 passed against the real JS bridge process: the current production resolver returned `ENOTFOUND` for a 64-character label hostname, capture creation returned `400 / TARGET_DNS_LOOKUP_FAILED / dns_lookup_failed`, `/health.activeCaptures` stayed 0 after rejection, and bridge stderr was empty. +- 2026-05-25 target navigated away smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=target-navigated-away`. The rerun on port 9525 passed against Chrome for Testing 147.0.7727.15 and current `dist`: after capture reached `target_loaded`, the harness requested an owned target tab navigation to the same origin but a different path; the Agent-observed status failed as `TARGET_NAVIGATED_AWAY` with `details.finalUrlChanged = true`, the profile endpoint returned 409, the owned target tab was not visible afterward, and bridge stderr was empty. +- 2026-05-25 target load failed smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=target-load-failed`. The rerun on port 9526 passed against Chrome for Testing 147.0.7727.15 and current `dist`: a local fixture accepted the target navigation and then closed the socket, Chrome emitted a main-frame load failure, the Agent-observed status failed as `TARGET_LOAD_FAILED`, the profile endpoint returned 409 instead of a browser error-page profile, the owned target tab was closed, and bridge stderr was empty. +- 2026-05-25 target load timeout smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=target-load-timeout`. Initial reruns on ports 9530, 9531, 9532, and 9533 exposed a real gap: slow target loading was observed by the Agent as generic `CAPTURE_TIMEOUT` because the bridge server deadline won when the MV3 worker did not produce a target-specific timeout first. The fix maps JS/Python bridge timeouts in `target_opening` phase to `TARGET_LOAD_TIMEOUT` and gives extension-side target load waiting a 5 second bridge reporting margin. The rerun on port 9534 passed against Chrome for Testing 147.0.7727.15 and current `dist`: a slow local fixture exceeded the target load window, the Agent-observed status failed as `TARGET_LOAD_TIMEOUT`, the profile endpoint returned 409, the owned target tab was closed, and bridge stderr was empty. +- 2026-05-25 bridge iframe smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=bridge-iframe-blocked`. The rerun on port 9536 passed against Chrome for Testing 147.0.7727.15 and current `dist`: a local attacker page attempted to embed a real `bridgeUrl` in an iframe, the attacker parent DOM and accessible frame document did not contain `spbt_`, the subsequent top-level first `/bridge` render still returned 200 with the one-time `bridgeToken`, cleanup DELETE returned 200, and bridge stderr was empty. +- 2026-05-25 Task 10 command-mouth verification reran the three required one-file `prettier --file-info` commands. `agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs`, `agent-skill/stackprism-site-experience/scripts/bridge/http-server.mjs`, and `docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md` all returned `"ignored": false`. +- 2026-05-25 Task 10 build verification reran `pnpm run build:injected` and standalone `pnpm run build`. Both exited 0; `build:injected` regenerated all three injected IIFE files, and the Vite production build regenerated the current `dist/` extension output. +- 2026-05-25 wrong-profile smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=wrong-profile-extension-missing`. The rerun on port 9537 passed against Chrome for Testing 147.0.7727.15 and current `dist`: the bridge auto-open helper opened the nonce-scoped bridge URL in a temporary Chrome profile without StackPrism loaded, the capture failed as `EXTENSION_NOT_CONNECTED`, the profile endpoint returned 409, the target probe server observed `targetRequestCount = 0`, and bridge stderr was empty. Together with the earlier custom-open success path, this proves the non-default browser/profile routing guidance has both fail and success live evidence. +- 2026-05-25 host validation smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=host-validation`. The rerun on port 9538 passed against the real JS bridge process using raw TCP requests: `/health` with the exact `127.0.0.1:{port}` Host returned 200, while `localhost:{port}`, wrong port, and `[::1]:{port}` returned 400 `INVALID_REQUEST`; `/bridge` with wrong Host returned 400 without `spbt_`, the subsequent correct-host first `/bridge` render returned 200 with `spbt_`, the Bearer status endpoint rejected wrong Host before business auth and accepted correct Host, the target probe count stayed 0, and bridge stderr was empty. +- 2026-05-25 response headers/CORS live smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=response-headers-cors`. The rerun on port 9539 passed against the real JS bridge process: capture status GET, request GET, control GET, and status POST returned 200 with `Cache-Control: no-store` and `X-Content-Type-Options: nosniff`; profile GET returned 409 with the same JSON security headers plus `Referrer-Policy: no-referrer`; `OPTIONS /v1/captures` returned 405 without `Access-Control-Allow-*`; cross-site Origin capture creation, cross-site Referer status query, and `Sec-Fetch-Site: cross-site` status query returned 403 `ORIGIN_NOT_ALLOWED`; the target probe count stayed 0, cleanup DELETE returned 200, and bridge stderr was empty. +- 2026-05-25 connection pressure smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=connection-pressure`. The rerun on port 9540 passed against the real CLI JS bridge with default `maxOpenConnections = 20`: twenty half-open `/health` connections filled the active connection limit, the next `/health` request was reset with `ECONNRESET` and no body instead of reaching business routing, releasing one held connection allowed `/health` to recover with `HTTP/1.1 200 OK`, and bridge stderr was empty. +- 2026-05-25 service worker idle wake smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=service-worker-idle-wake`. The rerun on port 9544 passed against Chrome for Testing 147.0.7727.15 and current `dist`: after setting local opt-in, the initial service worker target naturally disappeared from CDP after 31 seconds of idle, the bridge page woke a new service worker target, the fixture capture completed with profile HTTP 200 and `stackprism.site_experience_profile.v1`, and bridge stderr was empty. +- 2026-05-25 running-capture idle probe used a one-off slow-target capture on port 9545. After the bridge page registered the profile-transfer port and the target tab was opened, the harness closed its service-worker CDP session and waited 75 seconds. Chrome for Testing 147.0.7727.15 did not naturally remove the service worker target during that active capture window; the capture instead failed cleanly as `TARGET_LOAD_TIMEOUT`, the owned target page was no longer visible, and bridge stderr was empty. This is negative evidence for the live trigger, not proof that running-capture idle eviction is covered. +- 2026-05-26 running-capture idle probe repeated the same best-effort shape on port 9650 after fixing cleanup in the one-off script. After 75 seconds, Chrome for Testing 147.0.7727.15 still showed the service worker target as visible during the active capture window; the capture failed closed as `TARGET_LOAD_TIMEOUT`, the owned target was no longer visible, and bridge stderr was empty. This confirms the current local Chrome run still does not naturally trigger running-capture idle eviction, while the fail-closed cleanup path remains observable. +- 2026-05-25 browser-managed unpacked reload smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=browser-extension-reloaded`. The rerun on port 9547 passed against Chrome for Testing 147.0.7727.15 and current `dist`: during a running slow-fixture capture, the smoke clicked the unpacked extension card's `#dev-reload-button` in `chrome://extensions`, the Agent-observed bridge status failed as `BRIDGE_TRANSPORT_DISCONNECTED`, the profile endpoint returned 409 instead of a fake profile, the owned target page was no longer visible, and bridge stderr was empty. This proves browser-managed reload for the unpacked extension in the temporary profile, not Chrome Web Store or Edge Add-ons rollout behavior. +- 2026-05-25 incognito bridge probe follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=incognito-bridge-probe`. The rerun on port 9549 passed against Chrome for Testing 147.0.7727.15 and current `dist`: the smoke enabled the temporary profile's `allow-incognito` extension toggle through `chrome://extensions`, confirmed the setting was enabled and restart-required, wrote local Agent Bridge opt-in before restart, then reopened the same profile and created a CDP incognito browser context for the bridge URL. In this environment the CDP incognito context did not connect the extension content script, so the Agent-observed status failed as `EXTENSION_NOT_CONNECTED` rather than reaching the extension-side `INCOGNITO_NOT_SUPPORTED` branch; the profile endpoint returned 409, the target probe request count stayed 0, and bridge stderr was empty. This is valid fail-closed skip evidence for the plan's incognito live-test rule, not proof that the exact live incognito metadata rejection path ran. +- 2026-05-26 incognito window bridge probe follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=incognito-window-bridge-probe`. The rerun on port 9554 passed against Chrome for Testing 147.0.7727.15 and current `dist`: the smoke enabled the same temporary profile's `allow-incognito` extension toggle through `chrome://extensions`, restarted Chrome with `--incognito`, opened the bridge URL in that window, and again failed closed as `EXTENSION_NOT_CONNECTED` before the target probe was fetched. The profile endpoint returned 409, target request count stayed 0, and bridge stderr was empty. This second browser path strengthens the environment skip evidence, but still does not prove the exact extension-side `INCOGNITO_NOT_SUPPORTED` live metadata branch. +- 2026-05-26 content incognito context follow-up added a RED/GREEN content-client contract for `chrome.extension.inIncognitoContext === true`: before the fix the bridge client attempted `/request` and reported `PROFILE_TRANSPORT_FAILED`; after the fix `node --test --test-timeout=60000 tests/agent-bridge-handshake.test.mjs` passed with 18 tests and verifies the client posts `failed / INCOGNITO_NOT_SUPPORTED` before loading `/request`, registering runtime status listeners, sending `AGENT_BRIDGE_HELLO`, or sending `START_AGENT_CAPTURE`. +- 2026-05-26 content incognito context follow-up reran `STACKPRISM_BROWSER_SMOKE_SCENARIO=incognito-window-bridge-probe STACKPRISM_BROWSER_SMOKE_CDP_PORT=9612 node tests/agent-bridge-browser-smoke.mjs` after a fresh `pnpm run typecheck` build. Chrome for Testing 147.0.7727.15 loaded current `dist`; the environment still did not connect the extension content script in the `--incognito` bridge window, so the live result remained `failed / EXTENSION_NOT_CONNECTED`, profile endpoint 409, target request count 0, and bridge stderr empty. This preserves the live skip evidence while the exact content-client incognito branch is now covered by unit test. +- 2026-05-25 resource timeouts smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=resource-timeouts`. The latest rerun on port 9561 passed against the real JS bridge process with default resource policy: a partial slow header request to `/health` returned `HTTP/1.1 408 Request Timeout` after about 5 seconds and did not include the `/health` business body; a subsequent complete `/health` request returned `HTTP/1.1 200 OK`; a partial slow body `POST /v1/captures` returned `HTTP/1.1 408 Request Timeout` after about 10 seconds and did not create a `cap_` capture; a subsequent `/health` returned 200 with `"activeCaptures":0`; a keep-alive `/health` connection received an initial 200 response and was closed by the server after the default idle window; a subsequent `/health` request returned 200; bridge stderr was empty. +- 2026-05-26 rate-limit smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=rate-limit`. The rerun on port 9562 passed against the real CLI JS bridge process with default production thresholds: the first capture creation returned 200, create attempts 2 through 10 failed as `429 / CAPTURE_BUSY`, the 11th create request returned `429 / RATE_LIMITED`, 120 authenticated status reads returned 200, the 121st status read returned `429 / RATE_LIMITED`, target request count was 0, cleanup DELETE returned 200, and bridge stderr was empty. +- 2026-05-26 profile rate-limit smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=profile-rate-limit`. The rerun on port 9564 passed against the real CLI JS bridge process with default production query threshold: 120 authenticated `/profile` reads reached the profile endpoint and returned `409 / INVALID_REQUEST` because the capture was not completed, the 121st profile read returned `429 / RATE_LIMITED`, target request count was 0, cleanup DELETE returned 200, and bridge stderr was empty. +- 2026-05-26 target URL validation smoke follow-up added `STACKPRISM_BROWSER_SMOKE_SCENARIO=target-url-validation`. The rerun on port 9563 passed against the real CLI JS bridge process: unsupported protocol and credential URL capture creation returned `400 / INVALID_REQUEST`, loopback private target returned `400 / PRIVATE_NETWORK_TARGET_BLOCKED`, bridge self-target returned `400 / BRIDGE_SELF_TARGET_BLOCKED`, `/health.activeCaptures = 0`, target request count was 0, and bridge stderr was empty. This is creation-stage target policy evidence, not a full DNS/private-network live matrix. +- 2026-05-25 release workflow gate follow-up tightened `.github/workflows/release-extension.yml` so agent-only path patterns are rejected at any `dist/` depth, not only top-level package paths. The focused rerun `node --test --test-timeout=60000 tests/release-workflow.test.mjs tests/agent-bridge-manifest.test.mjs` passed with 9 tests, covering manifest exposure contracts plus release workflow gate order, `externally_connectable` rejection, nested `dist/assets/agent-skill/` rejection, repo-local JS bridge helper rejection, and web-accessible agent-only resource rejection. +- 2026-05-25 release workflow gate follow-up then reran `pnpm run test:unit`: 173 tests passed, 0 failed. +- 2026-05-25 release workflow gate follow-up extracted the updated inline hygiene script from `.github/workflows/release-extension.yml` and ran it against the current `dist/`: exit code 0. A separate `find dist` scan for nested `agent-skill`, `docs/superpowers`, `tests`, Python artifacts, `__pycache__`, local bridge server entrypoints, and repo-local JS bridge helper source files produced no output. `dist/manifest.json` reported `hasExternallyConnectable: false`. +- 2026-05-25 JS CLI invalid port ready gate follow-up added a regression test for `STACKPRISM_BRIDGE_PORT=''`. The rerun `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs` passed with 35 tests; the new case verifies the CLI exits non-zero with `BRIDGE_INVALID_ENV`, writes no stdout ready JSON, and does not emit an API token material. The follow-up reran `pnpm run test:unit`: 174 tests passed, 0 failed. +- 2026-05-25 JS CLI occupied port ready gate follow-up added a regression test for `STACKPRISM_BRIDGE_PORT=<已占用端口>`. The rerun `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs` passed with 36 tests; the new case verifies the CLI exits non-zero with `PORT_IN_USE`, writes no stdout ready JSON, and does not emit an API token material. The follow-up reran `pnpm run test:unit`: 175 tests passed, 0 failed. +- 2026-05-25 Python fallback CLI ready gate follow-up strengthened the existing invalid-port and occupied-port assertions. The rerun `node --test --test-timeout=60000 tests/stackprism_bridge_py.test.mjs` passed with 37 tests; the port-gate case now verifies both `BRIDGE_INVALID_ENV` and `PORT_IN_USE` write no stdout ready JSON and do not emit an API token material. The follow-up reran `pnpm run test:unit`: 175 tests passed, 0 failed. +- 2026-05-25 cross-site bridge render gate follow-up added JS/Python regression tests for real `bridgeUrl` requests with attacker `Referer` and `Sec-Fetch-Site: cross-site`. The reruns passed with 37 JS bridge tests and 38 Python fallback tests; both implementations return `403 / ORIGIN_NOT_ALLOWED` without `spbt_` in the body, and the same `bridgeUrl` can still be opened once afterward to render the bridge token, proving cross-site probes do not consume the one-time token. +- 2026-05-25 authority-form request target follow-up first observed RED behavior: JS bridge returned an empty response for `CONNECT 127.0.0.1:{port} HTTP/1.1`, and Python fallback returned the standard-library `501` HTML response. After adding explicit JS `connect` handling and Python `do_CONNECT`, the reruns passed with 37 JS bridge tests and 38 Python fallback tests; both implementations now return JSON `400 / INVALID_REQUEST` before business routing for authority-form request targets. +- 2026-05-25 authority-form verification reran the Agent Bridge helper complexity contract after compressing `http-server.mjs` back under the 300-line budget, reran `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs`, and reran `pnpm run test:unit`; the final unit run passed 177 tests, 0 failed. `pnpm run docs:build`, changed-file Prettier check, `git diff --check && git diff --cached --check`, and the repo-local `__pycache__` cleanup check also passed. +- 2026-05-25 missing Host gate follow-up added explicit JS/Python raw HTTP/1.1 requests without a `Host` header. The JS RED run returned Node's default empty `400` response without the bridge error envelope; after setting the JS server to let missing-Host requests enter the bridge shell validator, the focused rerun passed with 37 JS bridge tests. Python fallback already returned JSON `400 / INVALID_REQUEST`, and its focused rerun passed with 38 tests. +- 2026-05-25 profile redaction follow-up added a RED regression test for sensitive object keys and externally supplied `limitations` inside profile input. The failing case showed that values were redacted but keys such as `token=secret` and limitation strings could still survive serialization. The fix now redacts object keys with collision-safe suffixes and runs external limitations through the same text redaction path. The focused rerun `node --test --test-timeout=60000 tests/site-experience-profile.test.mjs` passed with 16 tests, the adjacent profile/profiler rerun passed with 20 tests, and `pnpm run test:unit` passed with 178 tests. +- 2026-05-25 token-like redaction follow-up extended the same RED case to camelCase and compound sensitive field names such as `apiToken=...`, `sessionId=...`, `secretKey=...`, and `bridgeToken=...`. The fix updates both profile-builder redaction and injected profiler text redaction to treat any field name containing token/session/auth/key/signature/password/cookie style substrings as sensitive. The focused rerun `node --test --test-timeout=60000 tests/site-experience-profile.test.mjs` passed with 16 tests, the adjacent profiler/orchestration rerun passed with 73 tests, and `pnpm run test:unit` passed with 180 tests. +- 2026-05-25 request body over-limit close follow-up added a RED regression check for the JS bridge body reader. The failing case showed `readJson()` stopped consuming after the body exceeded the configured limit but did not return a close marker for the HTTP layer. The fix now returns `close: true` for the 413 over-limit result, so the existing response helper emits `Connection: close`. The focused rerun `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs` passed with 37 tests, and the Python fallback parity rerun `node --test --test-timeout=60000 tests/stackprism_bridge_py.test.mjs` passed with 38 tests. +- 2026-05-25 profile transfer hash-binding follow-up added a RED regression test for BEGIN/COMPLETE metadata mismatch: content-side transfer handling accepted a COMPLETE message with the real payload hash even when BEGIN announced a different `sha256`. The fix stores BEGIN `sha256` in transfer state and requires COMPLETE metadata plus computed bytes to match that original hash. The focused rerun `node --test --test-timeout=60000 tests/agent-bridge-handshake.test.mjs` passed with 15 tests, and `tests/agent-capture-orchestration.test.mjs` passed with 53 tests. +- 2026-05-25 profile transfer BEGIN metadata follow-up added a RED regression test for invalid transfer metadata being accepted before buffering chunks: non-hex `sha256`, chunk count that does not match `byteLength / 384 KiB`, and payload size above the 8 MiB profile limit. The fix rejects invalid BEGIN metadata before allocating transfer state. The focused rerun `node --test --test-timeout=60000 tests/agent-bridge-handshake.test.mjs` passed with 16 tests, and `tests/agent-capture-orchestration.test.mjs` passed with 53 tests. +- 2026-05-25 request envelope strict-shape follow-up added a RED regression test for content-side `GET /request` envelope validation: the bridge content client accepted extra `bridgeToken` / `profileUrl` fields even though the API contract says the request response must not contain tokens, profile body, or callback URL. The fix makes `validateCaptureRequestEnvelope` reject unknown envelope keys, and JS/Python bridge tests now lock `/request` response keys to exactly `captureId`, `sessionId`, `nonce`, `protocolVersion`, and `request`. The focused reruns passed: `node --test --test-timeout=60000 tests/agent-bridge-handshake.test.mjs` with 16 tests, and `tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs` with 75 tests. +- 2026-05-25 start rejection cleanup follow-up added a RED regression test for early `START_AGENT_CAPTURE` validation failures leaving a registered bridge session after rejecting unknown top-level fields. The fix clears the bridge session on early start rejection before target resolution, and reuses the same cleanup path for request validation, opt-in disabled, busy, incognito, and target-resolution failures. The focused rerun `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs` passed with 53 tests. +- 2026-05-25 content hello capability detail follow-up added a RED regression test for a defensive content-side path where `AGENT_BRIDGE_HELLO` returns success but omits a required capability. The failing case posted `NOT_SUPPORTED` without `details.missingCapability`. The fix now reports the missing capability name before opening the profile transfer port or sending `START_AGENT_CAPTURE`. The focused rerun `node --test --test-timeout=60000 tests/agent-bridge-handshake.test.mjs` passed with 17 tests. +- 2026-05-25 concurrent profile POST follow-up added a RED regression test for JS bridge once-only profile submission. The failing case posted the same capture profile concurrently and received two successful 200 responses. The fix rechecks terminal state inside the per-capture lock after body parsing, so one request completes and the loser returns `409 / CAPTURE_ALREADY_COMPLETED`. Python fallback already used the same lock-after-parse pattern; this turn added a parity regression test. +- 2026-05-25 completed control follow-up added a RED regression test for terminal control semantics. The failing case showed JS bridge and Python fallback still returned `command = "continue"` after a successful profile POST had moved the capture to `completed`; both now return `command = "cancel"` with `status = "completed"`. The focused rerun `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs` passed with 77 tests. +- 2026-05-26 Python identifier fixture follow-up added the missing `tests/fixtures/bridge-protocol-identifiers.json` contract coverage to `tests/stackprism_bridge_py.test.mjs`. The Python fallback now verifies every documented valid and invalid `apiToken`、`bridgeToken`、`captureId`、`sessionId`、`nonce`、`profileTransferId` and CSP nonce fixture against `valid_id()`, matching the JS bridge fixture-driven contract. The focused rerun `node --test --test-timeout=60000 tests/stackprism_bridge_py.test.mjs` passed with 40 tests. +- 2026-05-26 browser-open invalid args/argv follow-up added JS/Python capture API coverage for invalid `STACKPRISM_BROWSER_OPEN_ARGS_JSON`: invalid JSON, non-array values, and non-string elements now all return `500 / BROWSER_OPEN_FAILED` with `details.reason = invalid_open_args`, preserving the plan distinction from startup NUL failures. The same follow-up added direct helper tests with a fake command to prove a bridge URL containing `?`, `&`, spaces, quotes, and shell metacharacters is appended as one final argv instead of being shell-split or interpolated. The focused rerun `node --test --test-timeout=60000 tests/stackprism-bridge.test.mjs tests/stackprism_bridge_py.test.mjs` passed with 88 tests, and changed-file Prettier check passed. +- 2026-05-26 browser smoke token-prefix redaction follow-up removed `apiTokenPrefix` from smoke stdout summaries and replaced it with `apiTokenPresent: true/false`, so smoke logs and copied report evidence no longer include the fixed token prefix. A new static contract test `tests/agent-bridge-browser-smoke-output.test.mjs` rejects future `apiTokenPrefix`、`bridgeTokenPrefix` or `tokenPrefix` output helpers. Focused checks passed: `node --test --test-timeout=60000 tests/agent-bridge-browser-smoke-output.test.mjs`, `node --check tests/agent-bridge-browser-smoke.mjs`, `node --check tests/helpers/agent-bridge-browser-smoke-harness.mjs`, and changed-file Prettier check. +- 2026-05-25 start rejection cleanup verification reran changed-file Prettier check, `pnpm run test:unit` with 180 tests passed, `pnpm run docs:build`, `git diff --check`, `git diff --cached --check`, and the repo-local `__pycache__` cleanup check; all passed. + +Environment note: + +- Local Google Chrome rejected command-line unpacked extension loading with `--load-extension is not allowed in Google Chrome, ignoring.` and `--disable-extensions-except is not allowed in Google Chrome, ignoring.` This was an environment limitation, not a StackPrism runtime failure. Chrome for Testing was used for repeatable automation. + +## Dist Hygiene + +Observed after the latest production build: + +- `dist/manifest.json` has no `externally_connectable`. +- `dist/` scan found no `agent-skill/`, `docs/superpowers/`, `tests/`, Python source, Python bytecode, `__pycache__`, local bridge server entrypoints, or repo-local JS bridge helper source files. +- `web_accessible_resources` does not expose `injected/experience-profiler.iife.js`. +- Manifest source contract now pins the exact allowed permissions, ordinary observer and loopback bridge content script boundaries, and the single allowed web-accessible-resource block. +- CRXJS-generated Agent Bridge content script chunks appear only under the `http://127.0.0.1/*` loopback match. The release boundary remains fail-closed for `injected/experience-profiler.iife.js`, repo-local skill files, local bridge server entry points, repo-local JS bridge helper source files, Python artifacts, tests, docs/superpowers, and `externally_connectable`. + +## Review State + +Local automated checks listed above passed. + +External review status: + +- Claude final read-only rerun reported `NO BLOCKERS` after the 2026-05-23 Python fallback refactor, and a later scoped rerun reported no blockers for content-side terminal status phase normalization. Earlier valid findings were fixed: Python chunked body handling, smoke browser discovery, `visibilitychange` false `BRIDGE_TAB_CLOSED`, terminal control-poll cleanup, full error-code recognition, smoke timeout clarity, Python fallback missing-capture DELETE `404`, `safe_equal` truncation, origin-level bridge self-target parity, and ThreadingHTTPServer lock scope. +- Gemini final read-only rerun reported `NO BLOCKERS` after the 2026-05-23 Python fallback lock-scope refactor, and a later scoped rerun reported `NO BLOCKERS` for content-side terminal status phase normalization. Earlier valid findings were fixed: `profileTransferDeadlineAt` recovery reconciliation, Python final URL self-target parity, Python lock critical-section scope, public terminal phase consistency, final URL requirement handling, and omitted-phase terminal status serialization. +- Sourcery scoped rerun on the changed Python fallback files initially reported non-blocking quality issues in `body.py` and `url_policy.py`; both were fixed, and the final rerun reported no findings. +- CodeRabbit limited/free CLI review produced valid findings that were fixed: bridge control polling cleanup, request envelope validation, detection timeout boundary, duplicate detection flow, omitted CSS rule counting, Python `open_browser()` validation and spawn details, smoke browser discovery, redundant profile JSON parse, detector settings local fallback, capture-state removal concurrency, JS HTML escaping simplification, Agent capture main-header fallback/bundle license scheduling, Python fallback missing-capture DELETE `404`, Python rate/status/profile locking, background/content silent error logging, deadline validation, message-router cancel promise propagation, content `waitMs` validation, Python public status parity, profile chunk length error-code parity, fail-closed cleanup state removal, shared status protocol constants, and deadline-bounded `__SP_RULES__` cleanup. The last completed limited/free CLI review left no unaddressed blocking findings after review; a later attempt did not complete findings output and is not counted as evidence. + +Release and privacy wording review: + +- `README.md`, `docs/guide/basic-usage.md`, `docs/config/index.md`, `docs/dev/agent-bridge.md`, `docs/dev/release.md`, and `PRIVACY.md` now consistently state that Agent Bridge is opt-in, local-profile scoped, disabled by default, limited to `127.0.0.1` loopback transfer, not a remote upload to StackPrism, and not intended to collect Cookie, Authorization, localStorage/sessionStorage plaintext, or complete sensitive text. +- `.github/workflows/release-extension.yml` now runs lint, unit tests, typecheck/build, docs build, and a `dist/` boundary check before uploading release assets. The boundary check fails if `dist/manifest.json` exposes `externally_connectable`, if bridge-only paths are exposed as web accessible resources, or if agent skill, tests, Python source, bytecode, repo-local JS bridge helper source files, or other agent-only artifacts enter `dist/`, including nested agent-only directories under bundled asset paths. The workflow also fails before packaging when Agent Bridge appears in loopback content scripts or loopback web accessible resources unless maintainers confirm store disclosure with `agent_bridge_disclosure_confirmed=true` for workflow_dispatch or a checked `- [x] Agent Bridge disclosure confirmed` release-note line. + +## Remaining Risks + +- Real browser destructive lifecycle cases are still not fully exercised live for Chrome Web Store / Edge Add-ons update rollout and running-capture idle-driven service worker eviction. Unit tests cover the extension-side fail-closed handlers for these paths, including Port disconnect, active-tab stale/update paths, service worker restart fail-closed notification, extension lifecycle wake recovery from `onInstalled`, cleared storage-session recovery no-op behavior, start-time opt-in recheck before target resolution when settings were disabled after bridge registration, running local opt-in disable cleanup before badge refresh can delay it, owned target cleanup failure observability without leaving capture state, and state/session cleanup failure observability without hiding later cleanup actions. Chrome for Testing smoke now covers target tab closure, target main-frame load failure, target load timeout, target navigated away after `target_loaded`, bridge tab closure, Agent-initiated cancellation cleanup of an owned target tab, running local opt-in disable fail-closed cleanup, browser-level user disable through `chrome://extensions`, browser-managed unpacked extension reload through `chrome://extensions`, persisted deadline reconciliation through a subsequent tab event after `target_loaded`, forced service worker target termination via CDP, service worker natural idle before capture start followed by bridge-page wake and successful capture, extension reload interruption via `chrome.runtime.reload()`, and a separate destructive `chrome.storage.session.clear()` plus reload fail-closed scenario. Two best-effort running-capture idle probes did not trigger natural worker eviction within 75 seconds while the bridge profile-transfer port was registered; the latest port 9650 probe still saw the worker visible and then failed closed as `TARGET_LOAD_TIMEOUT` with target cleanup. This remains a live coverage gap rather than an achieved requirement. +- JS bridge and Python fallback cover the current local HTTP policy matrix, including slow body/header timeout behavior, oversized body fail-fast with connection close, configured active connection limit rejection, and low-threshold API rate limiting. Chrome smoke now covers the first live concurrency guard for running capture plus `CAPTURE_BUSY`; JS bridge smoke covers default active connection pressure with 20 held sockets plus recovery after release, default slow header/body timeout behavior with post-timeout `/health` recovery and `activeCaptures = 0`, default keep-alive idle close with subsequent `/health` recovery, and default API rate limits for create/status reads. The suite still does not exhaustively test long-duration load, broader real-browser concurrency pressure, or every resource exhaustion case from the original plan. +- Real browser DNS/private-network behavior is covered through fixture-backed JS/Python policy tests, local fixture smoke, a live initial private literal target block smoke, a live resolver rewrite to `198.18.0.12` block smoke, a live DNS lookup failed smoke, a live redirect-to-bridge-origin final URL block smoke, a live public-IP-to-private-final redirect smoke, a live public-IP-to-DNS-policy-final redirect smoke, a live public-IP-to-DNS-lookup-failed final redirect smoke, a real CLI target URL validation smoke for unsupported protocol/credential/loopback/self-target creation rejection, and one explicit public complex-site smoke for `https://www.wikipedia.org/` using `allowPrivateNetworkTarget = true` because this resolver maps it to `198.18.0.19`. This is not a broad live network matrix. +- Incognito live metadata rejection is still not fully run in Chrome for Testing smoke. The `incognito-bridge-probe` scenario confirmed the temporary profile's unpacked extension can be enabled for incognito and survives same-profile restart, but the CDP-created incognito context did not load the extension content script and therefore failed as `EXTENSION_NOT_CONNECTED` before the background could observe `sender.tab.incognito`. The `incognito-window-bridge-probe` scenario then reopened the same profile with `--incognito` and reached the same fail-closed `EXTENSION_NOT_CONNECTED` outcome. Both probes returned profile 409, target request count 0, and empty bridge stderr. Per the plan's skip rule, unit coverage is retained for tab metadata handling, bridge isolation, and content-client incognito context handling: target tab incognito and bridge tab incognito both return `INCOGNITO_NOT_SUPPORTED` without proceeding across normal/incognito windows, incognito bridge URLs are still rejected before ordinary detection-cache reads, and `chrome.extension.inIncognitoContext === true` in the bridge content client now posts `INCOGNITO_NOT_SUPPORTED` before loading `/request` or sending runtime capture messages. +- Terminal bridge page token suppression is covered by Chrome smoke for a cancelled never-rendered capture. Completed-result expiry is also covered by `result-expiry-bridge-page` live Chrome smoke: after a real fixture capture completed, the in-process JS bridge clock was advanced beyond `resultExpiresAt`, the profile endpoint returned `410 / CAPTURE_RESULT_EXPIRED`, and the original `/bridge` URL returned 410 without bridge token material. +- The plan's broad E2E matrix still has items covered only by unit tests or not covered live in this checkout: exact live incognito `INCOGNITO_NOT_SUPPORTED` browser metadata rejection, Chrome Web Store / Edge Add-ons update rollout, running-capture idle-driven service worker eviction, and broader long-duration/resource/DNS-private-network matrices. Current evidence is sufficient for the implemented slice report, not for marking every plan checkbox complete. +- Repo-side privacy, release, README, and user-guide wording now document the local trust boundary and same-browser-extension trust boundary. The release workflow now blocks Agent Bridge packaging until a maintainer confirms the Chrome Web Store / Edge Add-ons privacy disclosure and release-note update. The remaining release risk is still outside this worktree: dashboard disclosure acceptance, review outcome, and rollout state must be checked in the store dashboards. +- The current checkout was rebased onto `upstream/main` at `f0cc39a` on 2026-05-26 after `git fetch upstream` succeeded; `git fetch origin` failed with `LibreSSL SSL_connect: SSL_ERROR_SYSCALL` and should be retried before any push. The worktree still has a split staged/unstaged state: `git diff --cached --name-only` lists 52 staged files, `git diff --name-only` lists 39 unstaged files, and `git ls-files --others --exclude-standard` lists 4 untracked Agent Bridge files. The plan's commit step has not been performed; commit readiness still requires final staged/unstaged diff review and deciding whether to stage the continuation fixes with the existing Agent Bridge implementation. diff --git a/docs/reviews/CR-AGENT-BRIDGE-FIREFOX-E2E-2026-06-09.md b/docs/reviews/CR-AGENT-BRIDGE-FIREFOX-E2E-2026-06-09.md new file mode 100644 index 00000000..75c65090 --- /dev/null +++ b/docs/reviews/CR-AGENT-BRIDGE-FIREFOX-E2E-2026-06-09.md @@ -0,0 +1,88 @@ +# CR-AGENT-BRIDGE-FIREFOX-E2E-2026-06-09 + +## Scope + +This report records the Firefox Agent Bridge compatibility fix and the live Firefox E2E verification performed on 2026-06-09. + +The verified change set covers Firefox fallback paths for: + +- Agent Bridge client injection. +- Content observer injection. +- Page detector injection in the MAIN world. +- Experience profiler injection. + +## Verdict + +Firefox Agent Bridge E2E passed against Firefox 151.0.3. + +The live run covered extension settings, Firefox data collection permission UI, Agent Bridge opt-in, allow-all-network-targets opt-in for the controlled local target, real extension popup opening, bridge capture orchestration, profile generation, and technology detection. + +Firefox `chrome.scripting.executeScript({ files })` is not reliable for the affected extension assets in this environment. The implementation keeps file injection as the primary path and uses explicit inline `func` fallbacks when file injection fails. The experience profiler fallback marks the profile with `firefox_inline_experience_profile` in `limitations`. + +## Live Firefox Evidence + +| Item | Result | +| ------------------- | --------------------------------------------------------------- | +| Firefox version | `Mozilla Firefox 151.0.3` | +| E2E command | `python3 /tmp/stackprism_firefox_full_e2e.py` | +| Capture status | `completed` | +| Capture phase | `cleanup` | +| Result file | `/tmp/stackprism-firefox-e2e/bridge-result.json` | +| Profile file | `/tmp/stackprism-firefox-e2e/bridge-profile.json` | +| Profile schema | `stackprism.site_experience_profile.v1` | +| Profile size | `10255` bytes | +| Settings screenshot | `/tmp/stackprism-firefox-e2e/settings-agent-bridge-enabled.png` | +| Popup screenshot | `/tmp/stackprism-firefox-e2e/action-popup.png` | + +Detected technologies in the generated profile: + +- React +- Vue +- Python http.server +- Express +- Node.js +- PHP +- JavaScript +- Discourse +- WordPress + +Profile limitations included: + +- `passive_interaction_only` +- `firefox_inline_experience_profile` +- `viewport_emulation_unsupported` +- `screenshot_metadata_not_requested` +- `screenshot_image_not_requested` +- `interaction_section_not_requested` +- `ux_section_not_requested` +- `assets_section_not_requested` + +## Verification Commands + +| Command | Exit | Result | +| ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---: | ----------------------------------------------------------------- | +| `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs` | 0 | 98 tests passed | +| `node --test --test-timeout=60000 tests/agent-bridge-handshake.test.mjs tests/agent-bridge-manifest.test.mjs tests/experience-profile-format.test.mjs` | 0 | 32 tests passed | +| `node --test --test-timeout=60000 tests/agent-capture-orchestration.test.mjs tests/experience-profile-format.test.mjs` | 0 | 107 tests passed | +| `pnpm run test:unit` | 0 | 332 tests passed | +| `pnpm run lint` | 0 | Passed | +| `pnpm run typecheck` | 0 | Passed; includes `vue-tsc --noEmit` and `pnpm build` | +| `pnpm run docs:build` | 0 | Passed | +| `python3 -m py_compile agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/*.py` | 0 | Passed | +| `pnpm run build:firefox` | 0 | Passed; generated `release/stackprism-v1.3.74.xpi` before cleanup | +| `git diff --check` | 0 | Passed | +| `git diff --cached --check` | 0 | Passed | + +Build artifacts generated by the verification were removed after validation: + +- `dist` +- `dist-firefox` +- `public/injected` +- `docs/.vitepress/dist` +- `release/stackprism-v1.3.74.xpi` + +Existing release artifacts were left untouched. + +## Residual Risk + +This run proves the local Firefox E2E path for an unpacked/test extension environment. It does not prove Firefox Add-ons store review, signed package rollout, or behavior in unrelated user profiles. diff --git a/docs/superpowers/plans/2026-05-21-stackprism-agent-bridge-plan.md b/docs/superpowers/plans/2026-05-21-stackprism-agent-bridge-plan.md new file mode 100644 index 00000000..fea398ee --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-stackprism-agent-bridge-plan.md @@ -0,0 +1,1397 @@ +# StackPrism Agent Bridge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans for new implementation work. This document now contains archived historical tasks plus the current status matrix; open work is tracked by explicit current-status rows, not unchecked historical checklist syntax. + +**Goal:** 构建一个不依赖 MCP、不需要用户手动复制/下载的 StackPrism Agent Bridge:用户安装浏览器插件后,AI Agent 通过 Skill 内 JS/PY 脚本启动本地 HTTP bridge,自动驱动插件采集目标网站的技术、视觉、UI/UX、交互与资源信息,并读取包含 `agentGuidance.recreationPlan` 的 `stackprism.site_experience_profile.v1`,用于快速复刻相似网站体验。 + +**Architecture:** 插件仍是浏览器事实采集器,Skill 只是 Agent 的使用说明和脚本载体。本地 bridge 脚本绑定 `127.0.0.1`,提供 HTTP API 和本地 bridge 页面;插件在该页面注入 content script 完成握手,再由 background 打开/复用目标 tab、运行检测和体验采集脚本,最后通过 bridge content script 同源 POST profile 与状态。Agent 只访问本地 HTTP API,不直接调用扩展内部接口。 + +**Tech Stack:** Chrome/Edge Manifest V3, Vite 5, Vue 3, TypeScript, pnpm, Node.js `.mjs` bridge script, Python `http.server` fallback bridge script, Chrome content scripts, `chrome.runtime.sendMessage`, `chrome.tabs`, `chrome.scripting`, `chrome.storage.session`. + +--- + +## 当前实现状态 + +- 可用第一版已落地:扩展端 opt-in、bridge tab/session 隔离、capture orchestration、profile transfer、JS bridge、Python fallback、repo-local skill、release hygiene 和主要自动化契约均已实现。 +- 不能按原计划标记为全量完成:原计划仍有外部发布和 live browser gate,不能仅凭本机单元测试或 fixture smoke 关闭全部 gate。 +- 本机已收敛:本轮补齐 profile 语言字段、UX 一阶分类白名单、`agentGuidance.recreationPlan` 复刻执行计划、默认 browser smoke fixture-backed 成功路径、Task 10 状态矩阵,以及开发文档和审计文档的 gate 口径。 +- 外部或未触发 gate:Chrome Web Store / Edge Add-ons 更新链路和商店后台实际接受状态、运行中 idle-driven service worker eviction、精确 incognito `INCOGNITO_NOT_SUPPORTED` live 浏览器元数据路径、更广 DNS/private-network 和长时资源压力矩阵仍需保留为未完成边界;商店披露已被发布 workflow 升级为打包前人工确认硬门禁,但不能由 worktree 自动证明后台审核状态。 +- 下方 Task 1 到 Task 9 保留为历史实施计划,已从 checkbox 改为 `Historical item:`,不再作为当前完成状态来源;当前状态以本节、Task 10 状态矩阵和 `docs/reviews/CR-AGENT-BRIDGE-*.md` 为准。 + +## 总目标 + +让 AI Agent 在用户已安装 StackPrism 插件的普通 Chrome 内核浏览器中,无需用户复制、下载或点击插件按钮,即可通过本地 HTTP 接口获得目标网站的 Site Experience Profile。Profile 必须把浏览器可观测事实转成 Agent 可直接执行的复刻计划,用于快速实现相似视觉效果、UI/UX 体验、交互行为和必要的技术选型参考。 + +## 明确不做 + +- 不做 MCP server。 +- 不做独立 CLI 程序、npm global bin、系统服务或守护进程。 +- 不做 Native Messaging companion 第一版。 +- 不要求用户手动点击插件、复制剪贴板或下载 JSON。 +- 不承诺复刻后端私有实现,只输出浏览器侧可观测事实与可推断建议。 +- 不采集 cookie、Authorization、完整敏感响应头、localStorage/sessionStorage 明文值。 +- 不把 loopback bridge 宣称为本机恶意进程隔离机制。第一版信任用户启动的本地 bridge 进程;若同机恶意进程能在 `127.0.0.1` 上伪造兼容 bridge 页面和 API,扩展侧无法仅凭页面 meta 与协议字段证明其真实来源。安全文档和 E2E 报告必须明确该本机信任边界,不能把 `bridgeToken` 描述为抵御本机恶意进程的秘密。 +- 不把 bridge 页面 DOM 中的 `bridgeToken` 宣称为抵御同浏览器恶意扩展的秘密。第一版不防已安装的其他扩展读取 `http://127.0.0.1/*` 页面 DOM、观察 bridge URL 或干扰同一浏览器 profile;若要覆盖该威胁模型,必须另起任务评估用户显式授权、Native Messaging、扩展间隔离策略或专用浏览器 profile。 + +## 用户与 Agent 使用流程 + +1. 用户安装 StackPrism 插件。 +2. Agent 根据 Skill 运行本地脚本: + - JS: `node agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs` + - Python fallback: `python3 agent-skill/stackprism-site-experience/scripts/stackprism_bridge.py` +3. 脚本向 stdout 输出一行机器可解析 JSON line;普通日志写 stderr,避免 Agent 解析失败。Agent 后续调用本地 API 时必须带 `Authorization: Bearer {apiToken}`。 + + ```json + { + "event": "stackprism-bridge-ready", + "service": "stackprism-agent-bridge", + "version": "0.1.0", + "protocolVersion": 1, + "baseUrl": "http://127.0.0.1:17370", + "healthUrl": "http://127.0.0.1:17370/health", + "apiToken": "spb_xxx" + } + ``` + + - ready JSON 只能在 HTTP server 已成功绑定、endpoint handler 已就绪且 `apiToken` 已生成后输出;启动失败不得输出 ready JSON。 + - stdout 在 ready 前出现非 JSON、缺字段或多余日志时,Agent 必须按 `BRIDGE_READY_PARSE_FAILED` 失败处理并停止子进程。 + - ready JSON 的 `protocolVersion` 必须等于 Agent 支持的版本;不匹配时 Agent 必须按 `BRIDGE_PROTOCOL_UNSUPPORTED` 失败处理并停止子进程。 + - Agent 读取 ready JSON 的默认超时为 10 秒;超时按 `BRIDGE_START_TIMEOUT` 失败处理,必须 kill 子进程并等待退出。 + - 启动前必须校验环境变量:`STACKPRISM_BRIDGE_PORT` 未设置时使用随机端口;一旦设置则必须是 `1..65535` 的十进制整数。非法端口或包含 NUL 字符的 browser open 配置必须以非零退出码结束,在 stderr 输出脱敏结构化错误 `BRIDGE_INVALID_ENV`,且不得输出 ready JSON 或生成 token。`STACKPRISM_BROWSER_OPEN_ARGS_JSON` 的非法 JSON/非数组/非字符串元素保留为 capture 创建后的 `BROWSER_OPEN_FAILED`,避免与启动失败混淆。 + - 指定 `STACKPRISM_BRIDGE_PORT` 且端口被占用时,bridge 必须以非零退出码结束,在 stderr 输出脱敏结构化错误 `PORT_IN_USE`,并且不得把 `apiToken`、`bridgeToken` 或完整 URL query 写入 stderr。 + +4. Agent 请求 `POST /v1/captures`,传入目标 URL、等待时间、视口配置和采集范围。 +5. bridge 脚本自动打开浏览器访问本地 bridge 页面:`http://127.0.0.1:{port}/bridge?session={sessionId}&capture={captureId}&nonce={nonce}`;自动打开失败时返回 `BROWSER_OPEN_FAILED`,由 Agent 处理,不要求用户手动复制 URL。 + - 测试环境设置 `STACKPRISM_BRIDGE_NO_OPEN=1` 时不尝试打开浏览器,也不返回 `BROWSER_OPEN_FAILED`;capture 保持 `queued`,测试可直接请求 `bridgeUrl`。 +6. StackPrism bridge content script 在 bridge 页面上握手,使用 `bridgeToken` 拉取 `GET /v1/captures/{id}/request`,再把经过校验的 capture request 传给 background 接管采集。 +7. 插件打开/复用目标 tab,运行现有检测链路和新增体验采集链路。 +8. background 把 profile 通过分片传输交给 bridge content script,由 bridge content script 在 bridge 页面同源上下文重组、校验并 POST 到 `POST /v1/captures/{id}/profile`;第一版不依赖 background 直接跨 origin `fetch` localhost,也不得把最大 8 MB profile 作为单条扩展消息发送。 +9. Agent 读取 `GET /v1/captures/{id}/profile`,按 Skill 指南生成 UI/UX 实现方案。 +10. Agent 优先读取 `agentGuidance.recreationPlan`,把 `implementationOrder`、`designTokens`、`layoutBlueprint`、`componentInventory`、`interactionChecklist`、`uxChecklist`、`assetHints` 和 `verificationChecklist` 映射到目标项目实现与验证步骤。 + +## 用户可见门禁 + +- Agent Bridge 必须有用户可见的持久设置 `agentBridgeEnabled`,写入 `chrome.storage.local` 作为本机 profile 级 opt-in,并进入运行时 `DetectorSettings` 归一化流程;设置页必须明确该开关会允许本地 Agent Bridge 读取当前浏览器可观测的页面技术与体验摘要并交给用户本机 loopback bridge。 +- Agent Bridge 允许一个独立的高风险设置 `agentBridgeAllowAllNetworkTargets`,同样只写入 `chrome.storage.local`,默认 `false`,且只有在 `agentBridgeEnabled = true` 时生效。设置页保存开启时必须要求用户人工确认;开启后扩展侧可以放行本机、私网、保留地址和 DNS/proxy 映射到私网的目标,但不得放开非 `http:` / `https:` 协议、bridge self-target、token/session 校验或本地 bridge server 的创建阶段默认 URL policy。 +- 对 Chrome Web Store / Edge Add-ons 发布包,`agentBridgeEnabled` 默认必须为 `false`,除非发布前完成商店隐私披露、用户文档和发布说明更新,并由维护者显式记录改为默认开启的理由。开发/E2E 可以通过测试设置显式开启,但不得把测试设置写成生产默认。历史上如果 sync 里曾出现同名字段,也必须被视为旧数据并忽略,不得自动开启。 +- bridge content script 在读取 DOM config 后、向 background 发起 `START_AGENT_CAPTURE` 前,必须先通过 `AGENT_BRIDGE_HELLO` 让 background 校验 `agentBridgeEnabled`。未开启时 capture 失败为 `AGENT_BRIDGE_DISABLED`,不得打开目标 tab、不得运行检测、不得读取或回传 profile。 +- 该门禁不是每次 capture 的交互确认;它是一次性用户可见 opt-in。启用后仍保持“不要求用户点击插件按钮、复制或下载 JSON”的 Agent 使用体验。 + +## 本地 HTTP API + +除 `GET /health` 与 `GET /bridge` 外,所有 API 必须带 Bearer token。token 分两类: + +```http +Authorization: Bearer {token} +``` + +- `apiToken`:脚本启动时输出给 Agent,只能由 Agent 用来创建、查询、取消 capture 和读取最终 profile。 +- `bridgeToken`:每次 capture 生成一次,只嵌入对应 bridge 页面,只能用于插件读取 capture request、回写 profile 和更新本次 bridge 页面状态。 +- `bridgeToken` 可以读取同一 capture 的状态和 control,用于 bridge 页面渲染和插件取消轮询;不能创建新的 capture,不能读取 profile,不能读取其他 capture,不能列出历史任务。 +- `apiToken` 的有效期绑定到当前 bridge 子进程。bridge server 退出、stdin EOF、SIGINT、SIGTERM 或测试清理后,必须关闭监听、清空内存 capture store 并丢弃 `apiToken`;第一版不支持跨进程恢复、token refresh 或持久 token。若 Agent 需要继续采集,必须重新启动 bridge 并读取新的 ready JSON。 + +Response contract: + +- 所有 JSON endpoint 必须返回 `Content-Type: application/json; charset=utf-8`。 +- 所有包含 capture 状态、request、control、profile 或 token 相关错误的 endpoint 必须返回 `Cache-Control: no-store` 和 `X-Content-Type-Options: nosniff`;profile endpoint 还必须返回 `Referrer-Policy: no-referrer`,避免本地浏览器或中间层缓存敏感采集结果。 +- 成功响应使用各 endpoint 已定义的业务 body,不再额外包一层 `ok`,避免 Agent 读取 profile 时多一层不稳定结构。 +- 失败响应必须统一为: + ```json + { + "error": { + "code": "INVALID_REQUEST", + "message": "Human readable error.", + "details": {} + } + } + ``` +- `details` 只能放可审计、已脱敏的字段名、限制值和当前状态,不得包含 token、完整请求头、完整 URL query 或 profile 片段。 +- JS bridge 和 Python fallback 必须使用同一套 HTTP status code、`error.code` 和 `message` 语义;`tests/stackprism_bridge_py.test.mjs` 必须抽样校验错误响应与 JS bridge 一致。 +- 未知路径返回 `404 NOT_FOUND`;不支持的方法返回 `405 METHOD_NOT_ALLOWED` 并带 `Allow` 头;缺少或格式错误的 Bearer token 返回 `401 UNAUTHORIZED`;token scope 不匹配返回 `403 FORBIDDEN`。 +- 带 body 的 JSON endpoint 必须要求 `Content-Type: application/json`,可接受的唯一 charset 是缺省或 `utf-8`;缺失、非 JSON content type 或非 UTF-8 charset 返回 `415 UNSUPPORTED_MEDIA_TYPE`;JSON body 必须按 UTF-8 解码,非法 UTF-8 或 JSON 解析失败返回 `400 INVALID_JSON`。 +- 第一版不支持浏览器跨站调用 bridge API。`OPTIONS` preflight 必须返回 `405 METHOD_NOT_ALLOWED` 或等效拒绝,且不得返回 `Access-Control-Allow-Origin`、`Access-Control-Allow-Headers` 或 `Access-Control-Allow-Credentials`;所有 API 响应默认不设置 CORS 允许头。恶意网页即使能发起 no-cors/simple request,也不能设置 Bearer token,且非 JSON content type 会被拒绝。 +- 对会修改 capture 或读取敏感状态的 endpoint,若请求带 `Origin`,必须与当前 bridge origin 精确一致;若带 `Referer`,只允许同 origin;若带 `Sec-Fetch-Site` 且值不是 `same-origin` 或 `none`,必须返回 `403 ORIGIN_NOT_ALLOWED`。Agent/curl 等非浏览器客户端通常不带这些头,不能因此被拒绝。日志不得记录完整 `Referer` query。 +- HTTP request target 只接受 origin-form path,例如 `/v1/captures/{id}`;拒绝 absolute-form、authority-form 或无法按当前 bridge origin 解析的 request target,避免代理式请求和路径解析差异。路由参数和 `/bridge` query 中的 `capture`、`session`、`nonce` 必须按固定 ASCII regex 和长度校验,拒绝 percent-encoded slash/backslash、空 segment、`..`、未知 query 字段和重复 query 字段。 +- bridge server 的 stderr 日志必须脱敏,不能打印 `Authorization` header、`apiToken`、`bridgeToken`、完整 query string 或 profile body。 + +Protocol identifier contract: + +- 所有协议标识符必须只使用 ASCII,不接受 Unicode、空白、percent-encoded 形式、URL-safe 字符集之外的字符或大小写宽松匹配。路由参数和 query 值在业务校验前不得做“解码后再尝试接受”的兼容处理。 +- `apiToken`: `^spb_[A-Za-z0-9_-]{43}$`,总长度 47;由 32 bytes 安全随机数 base64url no-padding 编码得到。 +- `bridgeToken`: `^spbt_[A-Za-z0-9_-]{43}$`,总长度 48;由 32 bytes 安全随机数 base64url no-padding 编码得到。 +- `captureId`: `^cap_[A-Za-z0-9_-]{22}$`,总长度 26;由 16 bytes 安全随机数 base64url no-padding 编码得到。capture id 不是秘密,但仍不得由时间戳、递增计数器或 `Math.random()` / `random.random()` 派生。 +- `sessionId`: `^s_[A-Za-z0-9_-]{22}$`,总长度 24;由 16 bytes 安全随机数 base64url no-padding 编码得到。 +- `nonce`: `^n_[A-Za-z0-9_-]{22}$`,总长度 24;由 16 bytes 安全随机数 base64url no-padding 编码得到,仅用于本次 capture bridge URL 和 profile 一次性提交状态。 +- `profileTransferId`: `^xfer_[A-Za-z0-9_-]{22}$`,总长度 27;由 16 bytes 安全随机数 base64url no-padding 编码得到。 +- `cspNonce`: `^[A-Za-z0-9_-]{22}$`,总长度 22;由 16 bytes 安全随机数 base64url no-padding 编码得到,并且只用于本次 `/bridge` HTML 响应的 CSP header 与 HTML nonce attribute。 +- 文档中的 `spb_xxx`、`spbt_xxx`、`cap_20260521_abcdef`、`s_xxx` 和 `n_xxx` 是脱敏占位,不是可被测试接受的合法协议样例。实现和测试必须使用 `tests/fixtures/bridge-protocol-identifiers.json` 中的合法/非法样例校验 JS bridge、Python fallback 和插件侧解析语义一致。 + +Capture request validation: + +- `url` 必须是字符串,trim 后长度 `1..4096`;必须能按 WHATWG URL 解析,协议只允许 `http:` 或 `https:`,不得包含 username/password credential,fragment 在归一化时丢弃。 +- `mode` 第一版只接受 `"experience"`。 +- `waitMs` 必须是整数,范围 `0..30000`;缺省值 `3000`。 +- `include` 必须是非空数组,元素只能来自 `tech`、`visual`、`layout`、`components`、`interaction`、`ux`、`assets`;重复项按固定顺序归一化。未包含的 profile section 必须返回空对象并在 `limitations` 记录 `section_not_requested`,不得运行对应重型采集后再静默丢弃。 +- `viewports` 第一版最多接受 3 项;每项 `name` 可选,必须是 ASCII 字母、数字、`-`、`_`,长度 `1..32`;`width` 范围 `320..3840`,`height` 范围 `320..2160`,`deviceScaleFactor` 范围 `1..4`。由于第一版不新增 `chrome.windows` 权限,这些值只写入 profile 请求上下文和 limitations,不得宣称真实移动仿真。 +- `options.forceRefresh`、`options.captureScreenshotMetadata`、`options.keepTabOpen`、`options.allowPrivateNetworkTarget` 必须是 boolean;缺省分别为 `false`、`false`、`false`、`false`。 +- `options.captureScreenshot` 是可选 boolean,缺省为 `false`。只有显式为 `true` 且 `include` 包含 `visual` 时,插件才尝试用 `chrome.tabs.captureVisibleTab` 采集当前可见视口 JPEG data URL;失败时必须返回 limitation,不得伪造截图。bridge 接收 profile 后必须把截图 `dataUrl` 剥离为临时内存截图资产,不得把 base64 保存在可下载 Profile JSON 中;用户手动下载的截图文件由浏览器下载目录管理,不属于插件自动清理范围。 +- `options.targetMode` 只能是 `"reuse_or_new_tab"`、`"new_tab"` 或 `"active_tab"`;缺省为 `"reuse_or_new_tab"`。 +- `options.maxResourceUrls` 范围 `0..1000`,缺省 `300`。 +- 未定义的顶层字段或 `options` 字段必须返回 `400 INVALID_REQUEST`,不得忽略;未来协议扩展必须通过 `protocolVersion` 或显式 capability 协商。 +- 违反请求 schema 或范围限制时返回 `400 INVALID_REQUEST`,不得创建 capture,不得打开浏览器。 + +### `GET /health` + +返回 bridge 脚本状态。 + +```json +{ + "ok": true, + "service": "stackprism-agent-bridge", + "version": "0.1.0", + "protocolVersion": 1, + "bound": "127.0.0.1", + "activeCaptures": 0 +} +``` + +### `POST /v1/captures` + +创建一次采集任务。 + +```json +{ + "url": "https://target.example", + "mode": "experience", + "waitMs": 3000, + "viewports": [ + { "name": "desktop", "width": 1440, "height": 900, "deviceScaleFactor": 1 }, + { "name": "mobile", "width": 390, "height": 844, "deviceScaleFactor": 2 } + ], + "include": ["tech", "visual", "layout", "components", "interaction", "ux", "assets"], + "options": { + "forceRefresh": true, + "captureScreenshotMetadata": true, + "captureScreenshot": false, + "maxResourceUrls": 300, + "targetMode": "reuse_or_new_tab", + "keepTabOpen": false, + "allowPrivateNetworkTarget": false + } +} +``` + +返回: + +```json +{ + "id": "cap_20260521_abcdef", + "status": "queued", + "bridgeUrl": "http://127.0.0.1:17370/bridge?session=s_xxx&capture=cap_20260521_abcdef&nonce=n_xxx", + "profileUrl": "http://127.0.0.1:17370/v1/captures/cap_20260521_abcdef/profile" +} +``` + +Concurrency policy: + +- 第一版默认 `maxConcurrentCaptures = 1`。 +- 有进行中的 capture 时,新的 `POST /v1/captures` 返回 `429`,错误码 `CAPTURE_BUSY`,避免多个浏览器 tab/window 并行采集互相覆盖状态。 +- `queued` 仅表示任务已创建且等待 bridge 页面握手;第一版不实现 FIFO 队列。 +- `queued` 或 `waiting_extension` 超过 30 秒仍未收到 bridge content script 握手时,bridge server 必须把 capture 标记为 `failed`,错误码 `EXTENSION_NOT_CONNECTED`;这覆盖默认浏览器未安装插件的场景。 +- `EXTENSION_NOT_CONNECTED` 也覆盖浏览器打开到了错误 Chrome/Edge 用户 profile 的场景。bridge server 不得尝试枚举或判断用户本机浏览器 profile;Skill 文档和 E2E 报告必须提示:如果 StackPrism 安装在非默认浏览器或非默认用户 profile,Agent 必须用 `STACKPRISM_BROWSER_OPEN_COMMAND` 和 `STACKPRISM_BROWSER_OPEN_ARGS_JSON` 精确指定对应浏览器可执行文件和 profile 参数,否则只能从该错误、stderr 脱敏摘要和浏览器可见窗口判断。 +- 非终态 capture 的全局运行上限为 60 秒;超过后 bridge server 必须标记为 `failed`,错误码 `CAPTURE_TIMEOUT`,并让 control endpoint 返回 `cancel`,避免 Agent 永久轮询。 +- `cancel_requested` 超过 10 秒仍未收到插件确认时,bridge server 必须转为 `cancelled`,记录 `details.reason = "cancel_timeout"`,并让插件后续 late status 不得覆盖该终态。 +- `completed` profile 默认只在内存保留 10 分钟;超过 TTL 后 bridge server 必须把 capture 状态转为 `expired`,清除 profile body 和临时 screenshot asset,并让 `GET /v1/captures/{id}/profile` 返回 `410` 和错误码 `CAPTURE_RESULT_EXPIRED`,避免长期保留采集数据。bridge 进程退出也必须释放全部内存结果。 + +Target policy: + +- `targetMode = "reuse_or_new_tab"`:只能复用归一化后与目标 URL 完全一致的现有 tab(忽略 fragment,保留 query 参与比较),否则新建后台 tab;不得因为 origin+path 相同但 query 不同就复用,避免采集到同一路径下不同筛选、会话、预览或业务状态的页面。 +- `targetMode = "new_tab"`:始终新建 tab,必须使用 `chrome.tabs.create({ active: false })`,不得抢焦点。 +- `targetMode = "active_tab"`:第一版只允许复用 bridge 页面打开前由插件记录的同窗口上一张非 bridge active tab;该 tab 的 URL 必须与目标 URL 归一化后一致(忽略 fragment,保留 query 参与比较),否则返回 `ACTIVE_TAB_MISMATCH`。如果插件无法确定 bridge 打开前的 active tab,返回 `ACTIVE_TAB_UNAVAILABLE`。不得为了满足 active_tab 模式主动切换焦点或把 bridge 页面当前 tab 当目标页。 +- `keepTabOpen = false` 时,插件必须关闭自己创建的目标 tab;不得关闭用户原本打开的 tab。 +- Agent Bridge 第一版只支持普通浏览器 profile,不支持 incognito/split-incognito 上下文。bridge tab 或目标 tab 的 `incognito` 为 true 时必须失败为 `INCOGNITO_NOT_SUPPORTED`,不得尝试跨普通窗口与隐身窗口传递 capture 状态。 +- `allowPrivateNetworkTarget = false` 时拒绝目标 URL 指向 loopback、link-local、private IPv4/IPv6 网段,降低本地接口被误用风险;用户确需分析本地开发站点时必须显式开启。 +- `agentBridgeAllowAllNetworkTargets = true` 是用户在扩展设置页确认过的 profile 级高风险覆盖,只作用于扩展侧 target-loaded 网络证据门禁;repo-local JS bridge 与 Python fallback 在创建 capture 时仍以 request option / CLI 参数显式控制 private-network policy。 +- private network 判断不能只看 URL 字面量;bridge server 必须对 hostname 做 DNS 解析,拒绝解析到 loopback、link-local、private IPv4/IPv6 网段的目标,覆盖 `dev.local`、自定义 hosts 和 bridge resolver 可见的私网解析结果。 +- Private-network 防护边界必须写清楚:bridge server 的 DNS 预检和 `target_loaded` final URL 校验只能阻止创建 capture、继续采集和交付 profile,不能保证浏览器在导航过程中绝不会向私网地址发出一次请求;DNS rebinding、浏览器解析器差异或服务端重定向可能在 final URL 校验前已经产生网络触达。第一版不得把该能力宣传为浏览器级 SSRF 防火墙;若验收要求是“零私网触达”,必须另起任务评估 CDP/proxy/Native Messaging 或扩展网络拦截方案。 +- URL policy 必须拆成可测试纯函数,接收标准化 URL、当前 bridge origin、`allowPrivateNetworkTarget` 和可注入 resolver 返回值;单元测试只能使用 fixture 驱动的假 resolver,不得依赖本机 hosts、VPN、DNS 缓存或外网解析结果。 +- DNS resolver 必须 fail closed:解析超时、NXDOMAIN、SERVFAIL、空结果或混合结果中任一地址落入 loopback/link-local/private 网段时,初始 URL 的 `POST /v1/captures` 返回 `400 TARGET_DNS_LOOKUP_FAILED` 或 `400 PRIVATE_NETWORK_TARGET_BLOCKED`;final URL 统一失败为 `409 FINAL_URL_BLOCKED`,并在脱敏 `details.reason` 中标记 `dns_lookup_failed` 或 `private_network_address`。 +- 生产 DNS 解析必须有独立超时,例如 2 秒;超时不得阻塞 capture 创建、status 回写或 Agent 轮询。 +- 即使 `allowPrivateNetworkTarget = true`,也必须拒绝目标 URL 指向当前 bridge server origin,错误码 `BRIDGE_SELF_TARGET_BLOCKED`,避免把 `/bridge` 页面和 `bridgeToken` 当作目标站点采集。 +- 目标 URL 必须是 `http:` 或 `https:`,不得包含 username/password credential,默认丢弃 fragment,并归一化后写入 profile。 +- URL 归一化规则必须在 JS bridge、Python fallback 和插件侧保持一致:protocol/hostname 小写、默认端口折叠、fragment 丢弃、pathname 保留尾斜杠语义、query 在内存匹配和 final URL 关系校验中保留,但在日志、profile 展示和报告中按资源 URL 脱敏规则处理。 +- `targetMode = "reuse_or_new_tab"` 和 `active_tab` 的匹配规则必须使用归一化后的完整 URL(不含 hash,包含 query)。若目标 URL path 为空,按 `/` 处理。实现和测试必须覆盖默认端口、大小写 host、fragment 丢弃、query 完全相同可复用、query 不同必须新建 tab 或返回 `ACTIVE_TAB_MISMATCH` 的情况。若未来要支持 path-only 复用,必须新增显式 option、用户文档和测试,第一版不得静默引入。 +- 如果目标站点重定向到不支持协议、credential URL、字面量私网地址,或 bridge server 对最终 URL hostname 的 DNS 校验失败,capture 必须失败为 `FINAL_URL_BLOCKED`,不得返回 profile。第一版无法阻止服务端重定向发生,但必须在运行主动检测和 experience profiler 前先上报 final URL 给 bridge 做策略确认;bridge 拒绝时不得继续采集。 +- 即使目标 URL 是 `http:` 或 `https:`,`chrome.scripting.executeScript` 仍可能因浏览器限制页、Chrome Web Store、企业策略、host permission 缺失、tab detached 或扩展上下文失效而失败。agent capture 必须把注入失败显式映射为 `TARGET_INJECTION_FAILED`,记录脱敏 `details.reason`,停止采集并清理自己创建的目标 tab;不得把注入失败吞成空 profile、`TARGET_LOAD_TIMEOUT` 或普通检测缺失。 +- profile 中所有资源 URL 默认丢弃 hash,并对 query string 做 allowlist 或整体脱敏;不得输出包含 `token`、`key`、`signature`、`session`、`auth` 等敏感参数的完整 URL。 + +Rate limit policy: + +- bridge server 对 `apiToken` 维度执行基础限流,例如 `POST /v1/captures` 每分钟最多 10 次,状态/profile 查询每分钟最多 120 次。 +- 插件回写 profile 每个 capture 只允许一次成功提交;重复 nonce 或重复完成提交返回 `NONCE_REUSED` 或 `CAPTURE_ALREADY_COMPLETED`。 +- 限流错误返回 `429` 和结构化错误码 `RATE_LIMITED`,不得静默排队。 + +HTTP resource policy: + +- bridge server 必须限制单进程打开连接数,例如 `maxOpenConnections = 20`;超出时直接关闭新连接或返回 `503 SERVER_BUSY`。 +- 所有带 body 的 endpoint 必须在读取时逐块累计字节数,超过该 endpoint 的限制后立即关闭该请求连接,不得等完整 body 读完。普通 JSON body 超限返回 `413 REQUEST_TOO_LARGE`;profile body 使用独立上限并返回 `PROFILE_TOO_LARGE`。 +- 请求头读取、body 读取和 keep-alive 必须有超时:建议 headers timeout 5 秒、body read timeout 10 秒、keep-alive timeout 2 秒;超时返回 `408 REQUEST_TIMEOUT` 或关闭连接并记录脱敏日志。 +- 必须拒绝歧义或可被 request smuggling 利用的请求头组合:重复 `Host`、`Authorization`、`Content-Type` 或 `Content-Length`,非法 `Content-Length`,同时出现 `Content-Length` 与 `Transfer-Encoding`,不以 `chunked` 结尾的 `Transfer-Encoding`,以及任何非 `identity` 的 `Content-Encoding`。错误统一返回结构化 `400 INVALID_REQUEST` 或 `415 UNSUPPORTED_MEDIA_TYPE`,JS/Python 语义必须一致。 +- `Transfer-Encoding: chunked` 必须被明确支持并按累计字节数限流;如果 Python fallback 无法用标准库可靠支持 chunked body,必须返回 `400 UNSUPPORTED_TRANSFER_ENCODING`,JS bridge 也用同样语义保持一致。 +- bridge server 必须在 SIGINT、SIGTERM、测试进程退出和 stdin EOF 时关闭 HTTP server、清理 timer、清理 active capture 状态并退出,避免 Agent 运行后遗留本地服务。 +- Skill 脚本示例必须用 `try/finally` 或等效流程停止 bridge 子进程;不能只启动 server 后让 Agent 自行遗留后台进程。 + +### `GET /v1/captures/{id}` + +返回状态。Agent 使用 `apiToken` 读取任意当前内存中的 capture;bridge 页面和插件只能用对应 capture 的 `bridgeToken` 读取同一 capture 的状态。状态枚举: + +- `queued` +- `waiting_extension` +- `running` +- `cancel_requested` +- `cancelled` +- `completed` +- `failed` +- `expired` + +失败响应必须包含明确错误: + +```json +{ + "id": "cap_20260521_abcdef", + "status": "failed", + "error": { + "code": "EXTENSION_NOT_CONNECTED", + "message": "StackPrism extension did not connect to the bridge page within 30 seconds." + } +} +``` + +### `GET /v1/captures/{id}/profile` + +采集完成后返回 `stackprism.site_experience_profile.v1`,只能使用 `apiToken` 读取。未完成时返回 `409` 和当前状态;使用 `bridgeToken` 访问必须返回 `403` 和错误码 `BRIDGE_TOKEN_CANNOT_READ_PROFILE`。 + +### `GET /v1/captures/{id}/request` + +仅插件读取。返回本次 capture 的原始请求和当前 nonce。response body 必须包含 `captureId`、`sessionId`、`nonce`、`protocolVersion` 和 `request`,不得包含 `apiToken`、`bridgeToken`、profile body 或 callback URL。bridge 必须校验 Bearer `bridgeToken`;插件侧必须校验返回的 `captureId`、`sessionId`、`nonce` 和 `protocolVersion` 与 bridge 页面 config 完全一致,不一致时同源 POST `failed` 和 `BRIDGE_REQUEST_MISMATCH`,不得向 background 发送 `START_AGENT_CAPTURE`。 + +### `GET /v1/captures/{id}/control` + +仅插件读取。bridge content script 在 capture 运行中定期轮询,用于发现 Agent 取消或任务过期。 + +```json +{ + "id": "cap_20260521_abcdef", + "command": "continue", + "status": "running" +} +``` + +当 Agent 调用 `DELETE /v1/captures/{id}` 后,返回 `command = "cancel"`;background 必须停止采集并清理自己创建的目标 tab。 +当 capture 已进入 `cancel_requested`、`failed` 或 `expired`,control endpoint 也必须返回 `command = "cancel"`,确保插件及时停止并清理目标 tab;`completed` 后插件不应继续轮询 control,若收到请求只能返回当前终态,不得重新发起采集。 + +### `DELETE /v1/captures/{id}` + +取消任务。只有 `queued`、`waiting_extension`、`running` 可以转为 `cancel_requested`,不能立即删除内存状态,因为 bridge content script 仍需要通过 `GET /v1/captures/{id}/control` 读取 `cancel` 命令。插件确认取消或超时后再转为 `cancelled` 并清理自己创建的目标 tab。`completed`、`failed`、`cancelled`、`expired` 等终态调用 `DELETE` 必须返回 `409` 和当前终态,不能重新进入 `cancel_requested`,避免误删审计结果或改写失败原因。 + +### `POST /v1/captures/{id}/status` + +仅插件回调用。bridge content script 把 background 的阶段状态和结构化错误同源 POST 回 bridge,供 Agent 通过 `GET /v1/captures/{id}` 观察。 + +```json +{ + "id": "cap_20260521_abcdef", + "status": "running", + "phase": "target_loaded", + "sequence": 3 +} +``` + +允许的插件写入状态:`waiting_extension`、`running`、`cancelled`、`failed`。`completed` 只能由 profile 回写成功后产生。 + +Status phase contract: + +- `phase` 只能是:`bridge_connected`、`request_loaded`、`target_opening`、`target_loaded`、`detecting_tech`、`profiling_experience`、`posting_profile`、`cleanup`。 +- `sequence` 是 bridge content script 为同一 capture 的每次 status POST 生成的递增整数,从 1 开始。bridge server 只接受大于当前 `sequence` 的非终态 status。 +- `running` phase 必须按上方列表顺序单调前进;重复、倒序 sequence 或倒退 phase 返回 `409 STALE_STATUS_UPDATE`,不得覆盖较新的 phase。 +- 终态 `cancelled`、`completed`、`failed`、`expired` 一旦写入后不可被插件 status 覆盖;再次写入终态必须返回当前终态和结构化错误,不得把 late message 当成功。 +- `failed` status body 必须包含 `error.code` 和 `error.message`;`details` 遵循统一脱敏规则。 + +当插件写入 `status = "running"` 且 `phase = "target_loaded"` 时,body 必须包含目标 tab 的 `finalUrl`。bridge server 必须对该 final URL 执行协议、credential、当前 bridge origin、自捕获和 DNS/private-network 校验;校验失败时该 status 请求返回 `409` 和 `FINAL_URL_BLOCKED` 或 `BRIDGE_SELF_TARGET_BLOCKED`,background 必须停止采集、清理自己创建的目标 tab,并不得注入主动检测或 experience profiler。 + +### `POST /v1/captures/{id}/profile` + +仅插件回调用。必须校验: + +- 请求来自 loopback。 +- `Authorization: Bearer {bridgeToken}` 匹配当前 capture。 +- body 是原始 `SiteExperienceProfile` JSON,必须包含 `schema = "stackprism.site_experience_profile.v1"`,且 body 内 `captureId` 与 path 一致;不得额外包 `{ "profile": ... }`,避免 `GET /profile` 与 `POST /profile` 使用两种形状。 +- bridge server 使用 capture 内部关联的 nonce 状态做一次性提交校验;nonce 不写入 profile body,避免最终 profile 暴露 bridge URL 中的 nonce。 +- nonce 未过期且未重复用于最终 profile 提交;多次 status 更新和 control 轮询不得消耗 nonce。 + +## Bridge 页面契约 + +`GET /bridge` 返回一个极小 HTML 页面,职责只有四项: + +- 暴露 session/capture/nonce 给 StackPrism bridge content script。 +- 展示连接状态,便于用户看到“等待插件 / 采集中 / 已完成 / 失败”,但不要求用户操作。 +- 阻止被普通 StackPrism 检测管道当作目标站点处理。 +- 通过同源 API 轮询 capture 状态并渲染状态;不得把状态存在 query string 或 localStorage/sessionStorage。 + +Token handling: + +- `apiToken` 不放在 URL 或 bridge 页面,避免进入浏览器历史、页面源码、日志和 referrer。 +- bridge 页面由本地脚本直接渲染,在 HTML 内嵌只供 bridge content script 读取的一次性 `bridgeToken`;它不是 Agent 使用的 `apiToken`。 +- bridge 页面自身的内联脚本若需要渲染状态,只允许使用 `bridgeToken` 读取同一 capture 的 `GET /v1/captures/{id}`;不得读取 profile。 +- capture 完成后,bridge 页面可以把同一 status preview 渲染为结果工作台:目标网址、截图预览、截图放大预览、下载截图、复制截图、复制由 profile 生成的 Markdown 摘要,以及下方分组 profile 内容卡片。该工作台只能使用服务端生成的 `preview.contentSummary` 和 `preview.copyText`,不得在页面端读取或重组 raw profile。 +- `preview.copyText` 必须是受限 Agent 摘要,不得包含 raw profile、截图 data URL、`apiToken`、`bridgeToken`、nonce、Authorization、完整敏感文本或未脱敏 URL query。生成时至少要脱敏 URL query、token-like id、email、手机号和 token/secret 字段。 +- 复制截图依赖浏览器 Clipboard API。失败时页面必须明确显示错误,不得静默降级为“已复制”,也不得把截图 data URL 复制成文本伪装成功。用户下载或复制后的截图由浏览器/操作系统管理,不属于 bridge profile TTL 自动清理范围。 +- `bridgeToken` 以 DOM 可读 JSON script 形式存在是为了适配 content script isolated world,不得被描述为对其他已安装扩展保密。安全说明必须明确:同浏览器 profile 中拥有 loopback 页面访问能力的恶意扩展、DevTools 用户或浏览器自动化工具属于本地受信边界之外,第一版只防普通网页跨站访问、错误 Host、错误 token、重复 token render 和 profile 越权读取。 +- `/bridge` 响应必须使用 `Content-Type: text/html; charset=utf-8`。 +- `/bridge` 渲染 `bridgeToken` 前必须先执行 Host、request target、query schema 和来源导航校验。若请求带跨站 `Referer` 或 `Sec-Fetch-Site: cross-site`,必须返回 `403 ORIGIN_NOT_ALLOWED` 且不渲染 token;由系统浏览器打开、地址栏打开或同源刷新产生的无来源头、`Sec-Fetch-Site: none` 或 `same-origin` 请求可以继续校验 session/capture/nonce。 +- `/bridge` 必须先校验 query 中的 `session`、`capture`、`nonce` 与内存 capture 匹配,且 capture 仍未过期;校验失败时返回 404/410,不渲染 `bridgeToken`。 +- `/bridge` 只能在 bridge token 尚未被 claim 前渲染 `bridgeToken`;一旦插件用该 `bridgeToken` 成功读取 request 或写入 `waiting_extension`,bridge server 必须把 token 标记为 claimed。之后浏览器历史里的同一 `/bridge?...nonce=...` 再次打开时只能渲染无 token 状态页或返回 409,不能再次泄露 `bridgeToken`。 +- `/bridge` 的成功 HTML 首次渲染也必须记录 `bridgeTokenRenderedAt`;同一 `session/capture/nonce` 的第二次 `/bridge` 请求即使尚未被 content script claim,也只能返回无 token 状态页或 409,不能再次渲染 `bridgeToken`。第一版不支持刷新恢复,宁可显式失败,也不能允许复制/刷新 bridge URL 获得第二份 token。 +- 第一版不支持 bridge 页面刷新后自动恢复同一 capture;刷新导致 content script 断连时按 `BRIDGE_TRANSPORT_DISCONNECTED` 或 `EXTENSION_NOT_CONNECTED` 暴露失败,不做静默重连。 +- `bridgeToken` 必须放在 DOM 可读的 JSON script 中,不能只写入页面 JS 变量,因为 content script isolated world 不能可靠读取页面 JS 全局变量。 +- 所有 ` +``` + +插件侧必须在 `src/utils/page-support.ts` 或等效位置把含该 path/origin/session 的 bridge 页面排除出普通 `content-observer` 检测、badge 更新和 popup 缓存,避免本地 bridge 自身污染技术栈结果。 + +Background listener isolation: + +- `src/background/index.ts` 的 `chrome.tabs.onUpdated`、`chrome.webNavigation.onCommitted` 和 `chrome.webRequest.onHeadersReceived` 也必须识别 bridge tab/request,并立即 return 或清理 bridge tab session;不能只依赖 content script return。 +- bridge 页面主请求、`/v1/captures/*` status/control/profile 请求、bridge tab 内的同源 fetch 都不得写入 `tab-store`、`popup-cache`、badge、dynamic snapshot 或 header records。 +- `src/background/message-router.ts` 的普通 runtime message 入口也必须识别 bridge tab:来自 bridge tab 的 `PAGE_DETECTION_RESULT`、`DYNAMIC_PAGE_SNAPSHOT`、`START_BACKGROUND_DETECTION`、`GET_POPUP_RESULT`、`GET_POPUP_RAW_RESULT` 和 `GET_HEADER_DATA` 不能写入或读取普通站点缓存;对带 `tabId` 的普通消息必须区分 sender 类型:content script sender 必须满足 `sender.tab.id === tabId`,popup/options 等扩展页面 sender 没有 `sender.tab` 时只能读取当前用户选择的普通 tab 且不能读取 bridge tab,避免 bridge tab 或任意 content script 用 message body 里的 `tabId` 污染其他标签页。 +- background、content script 和 bridge server 的日志都必须脱敏;不得打印 `apiToken`、`bridgeToken`、nonce、完整 bridge URL query、Authorization header、profile body、或目标 URL 中的敏感 query。现有 `console.log(... url ...)` 类路径触碰 bridge URL 时必须改为记录 origin/path 或 redacted URL。 +- 判断 bridge request 不能依赖 token;必须使用持久化的 `bridgeTabId`、`bridgeOrigin`、`/bridge` path、`stackprism-agent-bridge` meta 对应的 session/capture 关系,或等效的 bridge-tab registry。 +- `webRequest` 中如果 `details.tabId` 对应 bridge tab,必须跳过 `buildHeaderRecord` 和 `saveTabDataAndBadge`,避免把本地 bridge server 的响应头记录成目标站点技术证据。 + +Bridge content script guard: + +- manifest 的 content script match 会覆盖所有 loopback path,因此 `src/content/agent-bridge-client.ts` 必须在读取 token 或访问 DOM 详情前先校验 path 为 `/bridge`、存在 ``,且 query 参数包含 `session`、`capture`、`nonce`。 +- 对非 bridge 的 localhost/127.0.0.1 页面必须立即 return,不发送消息、不读取页面文本、不发起网络请求。 + +Capability and protocol contract: + +- bridge 页面 config 中的 `protocolVersion` 必须等于插件编译时的 `bridgeProtocolVersion`。不匹配时,bridge content script 必须同源 POST `failed` 和 `BRIDGE_PROTOCOL_UNSUPPORTED`,不得向 background 发送 `START_AGENT_CAPTURE`。 +- background 对 `AGENT_BRIDGE_HELLO` 的响应必须包含 `extensionVersion`、`protocolVersion` 和稳定 capabilities 对象:`agentBridge`、`siteExperienceProfileV1`、`profileChunkTransport`、`bridgeContentPost`、`storageSession`、`experienceProfiler`、`rawProfile`、`viewportMetadata`、`visualScreenshot`。 +- 第一版必需 capability 是 `agentBridge`、`siteExperienceProfileV1`、`profileChunkTransport`、`bridgeContentPost`、`storageSession` 和 `experienceProfiler`。任一缺失或为 false 时,capture 必须失败为 `NOT_SUPPORTED`,并在脱敏 `details.missingCapability` 中记录字段名。 +- `chrome.storage.session` 不可用时不得退回普通内存状态;必须显式失败为 `NOT_SUPPORTED`,避免 service worker 重启后丢失 capture 所有权。 +- `chrome.storage.session` 的 access level 必须保持默认 trusted-only,或显式设置为 `TRUSTED_CONTEXTS`;不得调用 `chrome.storage.session.setAccessLevel({ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" })`。content script 不得直接读取 `agent-capture-state`、active-tab tracker 或普通 tab cache,只能通过已校验的 runtime message/Port 与 background 交互。 +- 最终 profile 的 `browserContext.extensionCapabilities` 必须直接来自握手时的 capabilities 快照,不能在 profile builder 中重新猜测。 + +Loopback host checks: + +- bridge server 必须对所有 endpoint 校验 `Host` 头,包括 `/health` 和 `/bridge`。第一版只绑定 `127.0.0.1`,默认只生成 `http://127.0.0.1:{port}` bridge URL。允许的 Host 为 `127.0.0.1:{port}`;若实现明确支持 `localhost`,必须确认实际连接仍落在 loopback,并在测试中覆盖。不得声称支持 `[::1]`,除非同时实现 IPv6 loopback 绑定和 content script match。 +- 插件发回 profile 时必须使用原始 bridge origin,不能跟随 profile body 里的 callback URL。 +- bridge server 默认不返回宽松 CORS 头;第一版 profile 回传首选 bridge content script 的同源 `fetch`。如果后续保留 background 直连 fallback,必须只允许当前扩展 origin,不能使用 `Access-Control-Allow-Origin: *`。 +- bridge server 必须拒绝 `OPTIONS` preflight 且不返回 `Access-Control-Allow-*`,避免本地网页跨站驱动 Agent Bridge API。 +- 本机 loopback 不是强身份边界:`127.0.0.1:{port}`、`/bridge` path、meta 标记、`session/capture/nonce` 和 `bridgeToken` 只能把本次 capture 绑定到同一 bridge 页面和同一 bridge server 状态,不能证明该 server 一定由 StackPrism Skill 启动。第一版依赖“本机用户启动的 bridge 进程可信”作为部署前提;若未来要防本机恶意进程,必须另起任务设计扩展侧显式授权、Native Messaging、操作系统级 broker 或等效机制。 + +## Profile 回传传输路径 + +为避开 MV3 background 到 loopback 的 CORS、preflight 和浏览器差异,第一版固定使用以下传输路径: + +1. `src/content/agent-bridge-client.ts` 在 bridge 页面保存 `bridgeToken`、`bridgeOrigin`、`captureId` 和 `nonce`。 +2. bridge content script 用同源 `POST /v1/captures/{id}/status` 写入 `waiting_extension`,再把 capture request 发给 background。 +3. background 运行期间通过 `chrome.tabs.sendMessage` 或 `runtime.Port` 把阶段状态、结构化错误和最终 profile 分片发回对应 bridge 页面 content script。 +4. bridge content script 使用同源 `POST /v1/captures/{id}/status` 更新阶段状态,使用同源 `fetch('/v1/captures/{id}/profile')` POST profile,并带 `Authorization: Bearer {bridgeToken}`。 +5. bridge content script 轮询 `GET /v1/captures/{id}/control`;收到 `cancel` 后通知 background 停止采集并清理目标 tab。 +6. content script 把 status/profile POST 结果回报 background,background 再更新内部 capture 状态。 + +background 必须在 `AGENT_BRIDGE_HELLO` 时记录 `sender.tab.id`、`sender.tab.windowId`、bridge origin、captureId、session 和 nonce;后续所有 agent bridge 消息必须同时匹配该 bridge tab id 与 bridge URL。background 不应直接读取或持久化 `bridgeToken`。如果 service worker 重启导致 bridge content script 断连,capture 必须失败为 `BRIDGE_TRANSPORT_DISCONNECTED`,不得伪造完成。 + +Profile chunk transport contract: + +- background 不得用单条 `chrome.tabs.sendMessage` 或 `runtime.Port.postMessage` 发送完整 profile。最终 profile 必须先序列化为 UTF-8 JSON bytes,再按固定上限分片。 +- 单片 raw payload 上限为 `384 * 1024` bytes;base64 编码和消息 envelope 后的单条扩展消息目标上限为 512 KiB,避免在 MV3 扩展消息序列化链路上失败。 +- 每次 profile 传输必须包含 `profileTransferId`、`captureId`、`sessionId`、`nonce`、`chunkIndex`、`chunkCount`、`byteLength`、`chunkByteLength`、`sha256` 和 `payloadBase64`。`sha256` 计算对象是完整 UTF-8 JSON bytes,不是 base64 字符串。 +- 消息顺序为 `AGENT_PROFILE_TRANSFER_BEGIN`、一个或多个 `AGENT_PROFILE_TRANSFER_CHUNK`、`AGENT_PROFILE_TRANSFER_COMPLETE`;bridge content script 必须逐片 ack,background 只有收到上一片 ack 后才发送下一片。 +- bridge content script 必须先校验 transfer message 的 `captureId`、`sessionId` 和 `nonce` 与本页 bridge config 完全一致,再按 `profileTransferId` 建立内存缓冲。校验失败必须拒绝该 transfer 并写入 `PROFILE_TRANSPORT_FAILED`。 +- bridge content script 必须校验 `payloadBase64` 可解码为 bytes、chunk 数量、chunkIndex 连续性、累计 byteLength 和完整 sha256;校验通过后把 UTF-8 bytes 解析为原始 `SiteExperienceProfile` JSON,再 POST profile endpoint。缺片或超时写入 `PROFILE_CHUNK_MISSING`,hash 不匹配写入 `PROFILE_HASH_MISMATCH`,ack 超时、reassembly 失败、base64/UTF-8/JSON decode 失败或 POST 前传输异常写入 `PROFILE_TRANSPORT_FAILED`。 +- profile transfer 超时时间建议 10 秒;终态后必须清理内存缓冲。profile chunks、完整 profile JSON、`bridgeToken` 和 `apiToken` 都不得写入 `chrome.storage.session`。 +- content script POST profile 成功或失败后必须把结果 ack 给 background;background 不得在只完成扩展内部分片传输时把 capture 视为 completed。 + +Port disconnect reporting: + +- bridge content script 使用 `runtime.Port` 时必须监听 `port.onDisconnect`。 +- 如果断开时 capture 仍未进入 `completed`、`failed` 或 `cancelled`,bridge content script 必须用自身持有的 `bridgeToken` 同源 `POST /v1/captures/{id}/status`,写入 `failed` 和 `BRIDGE_TRANSPORT_DISCONNECTED`;POST 失败时在 bridge 页面显示失败状态,不能静默等待。 +- service worker 启动恢复未完成 capture 时,background 必须根据持久化的 `bridgeTabId` 用 `chrome.tabs.sendMessage` 通知 bridge content script 上报 `SERVICE_WORKER_RESTARTED` 或 `BRIDGE_TRANSPORT_DISCONNECTED`;如果 bridge tab 已不存在,必须清理自己创建的 target tab,Agent 侧最终通过 bridge server 的 timeout/expired 状态观察失败。 +- 浏览器完全退出、扩展 reload/update、用户禁用扩展或 `chrome.storage.session` 被清空后,第一版不尝试恢复未完成 capture;恢复入口只能 fail closed:清理 `createdByCapture` 目标 tab、清理残留 session state,并让 Agent 侧通过 bridge timeout/expired 或下一次状态查询看到结构化失败。文档不得把 `chrome.storage.session` 描述成跨浏览器重启持久化机制。 +- MV3 service worker 可能在 capture 中途挂起或重启,不能把 background 内存 `setTimeout` 当作唯一超时、取消或清理门禁。background 必须把 `startedAt`、`deadlineAt`、`cancelDeadlineAt`、`profileTransferDeadlineAt` 或等效绝对时间写入 `chrome.storage.session`,在每次事件、Port 重连、message、tab 更新和 service worker 模块初始化时比较当前时间并 fail closed。bridge server 的 capture timeout/control 是 Agent 可观察的权威超时源;扩展侧 timer 只作为活跃 worker 期间的快速清理。第一版不新增 `chrome.alarms` 权限;若实现选择用 alarms,必须同步更新 manifest、权限测试、隐私文档和验收报告。 + +Persisted extension state: + +`src/background/agent-capture-state.ts` 写入 `chrome.storage.session` 的最小字段必须包括: + +```json +{ + "captureId": "cap_20260521_abcdef", + "sessionId": "s_xxx", + "nonce": "n_xxx", + "bridgeOrigin": "http://127.0.0.1:17370", + "bridgeUrl": "http://127.0.0.1:17370/bridge?session=s_xxx&capture=cap_20260521_abcdef&nonce=n_xxx", + "bridgeTabId": 101, + "bridgeWindowId": 7, + "targetTabId": 102, + "targetWindowId": 7, + "targetUrl": "https://target.example/", + "finalUrl": "https://target.example/", + "targetMode": "reuse_or_new_tab", + "createdByCapture": true, + "keepTabOpen": false, + "phase": "target_loaded", + "status": "running", + "startedAt": "2026-05-22T12:00:00.000Z", + "updatedAt": "2026-05-22T12:00:03.000Z", + "error": null +} +``` + +这些字段用于 service worker 重启后判断该通知哪个 bridge tab、该清理哪个 target tab、哪些 tab 是插件自己创建的,以及最终应向 Agent 暴露哪个结构化失败状态;不得把 `bridgeToken` 或 `apiToken` 写入 `chrome.storage.session`。 +`agent-capture-state`、active-tab tracker 和普通 tab cache 的 key 必须使用不同前缀,并通过集中 helper 列出/清理;capture 终态、bridge tab 关闭、扩展启动恢复失败和 E2E 清理阶段都必须删除对应 capture state,避免后续 capture 误读旧 tab ownership。 + +## Profile Schema + +顶层结构: + +```json +{ + "schema": "stackprism.site_experience_profile.v1", + "captureId": "cap_20260521_abcdef", + "generatedAt": "2026-05-22T12:00:00.000Z", + "target": {}, + "browserContext": {}, + "techProfile": {}, + "visualProfile": {}, + "layoutProfile": {}, + "componentProfile": {}, + "interactionProfile": {}, + "uxProfile": {}, + "assetProfile": {}, + "evidence": {}, + "limitations": [], + "agentGuidance": {} +} +``` + +### `target` + +- `url` +- `finalUrl` +- `loadError`: only when the browser reports a main-frame load failure; contains sanitized extension error code/category, not the full failing URL query. +- `origin` +- `title` +- `language`: from page language attributes when available; empty string when unknown, not inferred from account or locale data. +- `viewportProfiles` +- `captureScope`: `current_page`, `target_url`, or `same_origin_flow` + +### `browserContext` + +- `userAgent` +- `extensionVersion` +- `capturedAt` +- `waitMs` +- `viewports` +- `pageSupported` +- `loginState`: only `unknown`, `likely_authenticated`, `likely_public`; do not expose account data. +- `viewportMode`: `current_viewport`, `window_size_approximation`, or `unsupported`. +- `bridgeProtocolVersion` +- `extensionCapabilities`: copied from `AGENT_BRIDGE_HELLO` response, including `agentBridge`, `siteExperienceProfileV1`, `profileChunkTransport`, `bridgeContentPost`, `storageSession`, `experienceProfiler`, `rawProfile`, `viewportMetadata`, `visualScreenshot`. + +Viewport rule: + +- 普通 Chrome 扩展不能像 CDP 那样真实模拟移动设备、DPR、触控和 user agent。 +- 第一版 `viewports` 只表示“希望采集的窗口尺寸或当前视口摘要”,不是移动设备仿真。 +- 如果无法安全调整窗口尺寸,插件必须返回 `viewportMode = "current_viewport"` 并在 `limitations` 说明未做移动视口采集。 +- 默认不输出截图图像;`captureScreenshotMetadata` 仅表示采集视口尺寸、关键元素 bounding box 和 above-fold 摘要。`captureScreenshotMetadata = false` 时不得采集或输出 bounding box / above-fold 细节,只保留基础 viewport 上下文和 limitation。`captureScreenshot = true` 是单独显式能力,扩展端只在传给本机 bridge 的内部 profile 中携带当前可见视口 JPEG data URL;bridge 保存后必须把它转为临时截图下载资产,下载给 Agent 的 Profile JSON 只包含 `downloadUrl` 和生命周期说明。 +- 如果需要调整窗口尺寸,必须记录原窗口尺寸并在采集后恢复;没有 `chrome.windows` 权限时不能假装已调整。 +- 第一版不新增 `chrome.windows` 权限;除非后续任务明确更新 manifest 和隐私文档,否则统一返回 `viewportMode = "current_viewport"`,不尝试调整窗口尺寸。 + +### `techProfile` + +基于现有 StackPrism 检测结果: + +- `technologies`: category, name, version, confidence, sources, evidence, url +- `primaryFrontend` +- `uiFramework` +- `buildRuntime` +- `cmsOrSiteProgram` +- `serverHints` +- `thirdPartyServices` +- `confidenceSummary` +- `implementationNotes`: 说明技术是“复刻参考”,不是必须照搬。 + +### `visualProfile` + +新增体验采集脚本输出: + +- `colorTokens`: dominant backgrounds, text colors, accent colors, border colors, CSS variables +- `typography`: font families, body size, heading scale, line heights, font weights +- `spacing`: common gaps, section padding, card padding +- `shape`: border radius scale, button radius, card radius, input radius +- `elevation`: box shadows, backdrop filters, border styles +- `density`: compact, balanced, spacious +- `themeMode`: light, dark, mixed, system-dependent + +### `layoutProfile` + +- `landmarks`: header, nav, main, footer, aside +- `hero`: presence, height, content alignment, media usage +- `gridSystems`: card grid, column count, max content width +- `responsiveBehavior`: desktop/mobile differences +- `stickyElements`: sticky header, fixed CTA, floating controls +- `aboveFold`: main visual hierarchy and first viewport summary + +### `componentProfile` + +采集常见 UI 单元: + +- buttons: count, variants, size, radius, hover/focus evidence +- links: inline/nav/button-like +- forms: inputs, selects, search bars, validation hints +- cards: count, media placement, action areas +- navigation: top nav, side nav, breadcrumbs, tabs +- overlays: modal, drawer, popover, tooltip candidates +- dataDisplay: table, list, stats, badges + +### `interactionProfile` + +- `hoverPatterns` +- `focusPatterns` +- `transitions`: duration, easing, properties +- `animations`: names, durations, iteration count +- `scrollBehavior` +- `loadingIndicators` +- `interactiveControls` + +Interaction rule: + +- 第一版默认 passive capture,不点击、不提交表单、不触发可能改变业务状态的控件。 +- hover/focus 只能来自可读 CSS selector、transition token、ARIA/state 属性和当前 DOM 状态;不得伪造“已验证 hover 效果”。 +- modal/drawer/dropdown 只记录当前可见或 DOM 中可观察的结构;不主动打开隐藏菜单。 + +### `uxProfile` + +基于 DOM 结构和可见文本摘要,不做隐私内容搬运: + +- `pagePurpose`: inferred category, such as marketing, SaaS dashboard, docs, ecommerce, form flow +- `primaryUserPath` +- `informationHierarchy` +- `ctaStrategy` +- `trustSignals` +- `navigationDepth` +- `contentGrouping` +- `frictionPoints`: only observable UX risks, not speculative private intent +- `textSamples`: bounded short labels or summaries only; no full visible text or private account content + +Text privacy rule: + +- 默认不输出完整可见文本。 +- CTA、导航、表单标签最多输出短标签摘要,并先脱敏 email、手机号、长数字 ID、货币金额、疑似姓名字段。 +- 对登录态页面,优先输出 role/category/count/length,而不是具体内容。 + +Frame and shadow DOM rule: + +- 同源 iframe 可以采集摘要,但必须标记 frame URL。 +- 跨源 iframe、closed shadow root 和不可访问 CSSStyleSheet 只能记录存在性与边界框,不得声明内部 UI 已完整采集。 + +### `assetProfile` + +- `scripts` +- `stylesheets` +- `resourceDomains` +- `imageDomains` +- `fontUrls` +- `manifest` +- `themeAssetUrls` +- `favicon` +- `cdnHints` +- `redactionPolicy`: 说明资源 URL 已丢弃 hash、敏感 query 参数已脱敏。 + +### `evidence` + +证据要可追溯但脱敏: + +- `highConfidence` +- `mediumConfidence` +- `lowConfidence` +- `rawCounts` +- `sourceCoverage`: headers, page, dynamic, bundle, visual, interaction +- `truncation`: per-field truncation flags and omitted counts, such as `resourceUrls`, `textSamples`, `componentSamples`, `cssRules`. + +### `limitations` + +必须明确: + +- 后端隐藏响应头时不可推断真实服务端技术。 +- 低置信兜底脚本名不应作为硬依赖。 +- 未交互到的流程不会出现在 profile 中。 +- 跨域样式表可能无法读取完整 CSS rules,只能读取 computed style。 +- 登录态页面只输出结构和体验摘要,不输出敏感用户数据。 + +### `agentGuidance` + +直接给 Agent 的执行建议: + +- 优先复刻视觉层级、交互反馈、布局密度和信息结构。 +- 技术栈用于选择等效实现,不要求与原站完全相同。 +- 高置信证据可作为实现约束;低置信证据只能作为候选。 +- 如果目标项目已有技术栈,优先用目标项目技术栈实现相同体验。 +- 完成后用桌面/移动截图、DOM 几何、交互 smoke test 验证。 +- 如果采集结果因上限被截断,必须在 `evidence.truncation` 和 `limitations` 同时说明,Agent 不得把缺失字段理解为目标站点不存在该结构。 + +`agentGuidance.recreationPlan` 是给复刻任务的结构化执行层,必须只引用已脱敏 profile 内容: + +- `objective`: 明确目标是基于浏览器可观测证据复刻相似网站体验,不是复制后端私有实现或用户私密内容。 +- `implementationOrder`: 从技术栈映射、design token、布局蓝图、组件优先级、交互状态到验证的执行顺序。 +- `designTokens`: 从 `visualProfile` 提取颜色、字体、字号、行高、间距、圆角和阴影候选。 +- `layoutBlueprint`: 从 `layoutProfile` 与 `uxProfile` 提取 landmarks、first-viewport summary、内容分组、信息层级、`viewportMode` 和响应式边界说明;不得在 `captureScreenshotMetadata = false` 时额外输出 `aboveFold`、`boundingBoxes`、`boundingBox`、`bounds` 或 `rect` 这类截图/几何元数据字段名。 +- `componentInventory`: 从 `componentProfile.counts` 和 samples 生成组件数量、优先类型、样本数量和是否包含 rect metadata。 +- `interactionChecklist`: 从 passive interaction 证据提取 transition、animation、sticky/fixed、focus/hover hints,并标注第一版不主动点击。 +- `uxChecklist`: 汇总 `pagePurpose`、`primaryUserPath`、`ctaStrategy`、`trustSignals` 和 `frictionPoints`。 +- `assetHints`: 汇总资源域、CDN hint、script/style 数量、image/font hint 和资源 URL 数量;不得复制 signed URL 或敏感 query。 +- `verificationChecklist`: 要求下游实现后做截图构图、DOM 几何、响应式适配、hover/focus/sticky/loading/scroll smoke 和 limitations 复核。 + +## 文件结构规划 + +### Skill 包 + +- Create: `agent-skill/stackprism-site-experience/SKILL.md` + - Agent 触发条件、运行 bridge 脚本、读取 profile、转成实现计划的工作流。 +- Create: `agent-skill/stackprism-site-experience/README.md` + - 说明 repo-local Skill 不会自动进入 Codex 全局 skill registry;提供复制/软链接到 `$CODEX_HOME/skills` 或直接按路径运行脚本的方式。 +- Create: `agent-skill/stackprism-site-experience/agents/openai.yaml` + - 可选 metadata 与发布辅助文件;必须声明不会让 Codex 自动发现 repo-local Skill。 +- Create: `agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs` + - 无全局安装、无 npm bin;作为薄 CLI 入口启动 loopback HTTP server,不承载完整业务实现。 +- Create: `agent-skill/stackprism-site-experience/scripts/bridge/*.mjs` +- JS bridge helper 模块,至少拆分 protocol/error response、capture store/timers、URL policy/DNS、HTTP routing/body limits、browser open/redaction;每个文件遵守 300 行上限,函数遵守 50 行上限。 +- Create: `agent-skill/stackprism-site-experience/scripts/stackprism_bridge.py` + - Python fallback 薄 CLI 入口,使用标准库 `http.server`,提供与 JS bridge 一致的 HTTP API;若某能力确实无法等价实现,必须返回结构化 `NOT_SUPPORTED` 并在 Skill 中标记 Python 为受限 fallback。 +- Create: `agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/*.py` +- Python fallback helper 模块,按与 JS bridge 对齐的职责拆分 protocol/error response、capture store/timers、URL policy/DNS、HTTP routing/body limits、browser open/redaction;每个文件遵守 300 行上限,函数遵守 50 行上限。 +- Modify: `.gitignore` + - 当前根规则 `scripts/` 会吞掉 `agent-skill/.../scripts/`;必须在 Task 6 创建首个 Skill 脚本前加入 `!agent-skill/`、`!agent-skill/**/`、`!agent-skill/**/scripts/` 和 `!agent-skill/**/scripts/**` 例外。还必须忽略 `__pycache__/` 和 `*.py[cod]`,并确保这些忽略规则在脚本 unignore 之后仍生效,避免 `py_compile` / `compileall` 留下未跟踪字节码。 +- Create: `tests/fixtures/bridge-url-policy-cases.json` + - JS/Python bridge 共用的 URL、DNS 解析结果、私网网段、credential、fragment、URL 归一化、tab 匹配和 final URL 校验用例;测试必须把这些 DNS 结果注入 URL policy helper,避免两套脚本语义漂移或依赖真实 DNS。 +- Create: `agent-skill/stackprism-site-experience/references/site-experience-profile-schema.md` + - profile schema 字段说明与消费规则。 +- Create: `agent-skill/stackprism-site-experience/references/agent-consumption-guide.md` + - Agent 如何从 profile 生成 UI/UX 实现策略。 +- Modify: `.prettierignore` + - 明确不忽略 `agent-skill/**/scripts/**`,避免后续新增宽泛 `scripts/` 规则时让 Skill 脚本脱离格式化和审查。 + +### 插件类型与协议 + +- Create: `src/types/agent-bridge.ts` + - Capture request/status/profile/error 类型。 +- Modify: `package.json` + - 给 `test:unit` 增加 Node test runner 超时,例如 `node --test --test-timeout=60000 tests/*.test.mjs`,避免 bridge 子进程或 HTTP 测试卡死阻塞 CI。 +- Create: `src/utils/site-experience-profile.ts` + - profile builder、脱敏、字段归一化、schema version 常量。 +- Modify: `src/background/headers.ts` + - 扩展现有响应头脱敏边界,除了 `set-cookie` 外也防御 `cookie`、`authorization`、`proxy-authorization` 和 token-like header 值进入 `allHeaders`、profile 或 evidence。 +- Modify: `src/types/settings.ts` + - 增加 `agentBridgeEnabled: boolean`,默认 `false`,并把该字段纳入 `DetectorSettings`。 +- Modify: `src/utils/constants.ts` + - 增加 `AGENT_BRIDGE_ENABLED_STORAGE_KEY`,用于 local-only bridge opt-in。 +- Modify: `src/background/detector-settings.ts` + - 从 `chrome.storage.local` 读取并合并 `agentBridgeEnabled`,忽略 sync payload 中的同名旧字段;local opt-in 作为唯一生效来源。 +- Modify: `src/utils/normalize-settings.ts` + - 归一化同步过来的 detector settings,缺省为 `false`;不得因为旧设置对象缺字段而默认开启 Agent Bridge,也不得因为旧 sync payload 中携带同名字段而自动开启。 +- Modify: `src/ui/settings/Settings.vue` + - 增加 Agent Bridge 启用开关和本机信任边界说明;保存后写入 `chrome.storage.local`,并在重置时清除 local opt-in。 +- Create: `tests/helpers/load-ts-module.mjs` + - 测试侧统一转译 TypeScript,并处理 `@/` alias 到 `src/`。若被测 TS 有运行时 import,helper 必须编译到系统临时目录或仓库已忽略的 `tmp/compiled-tests/` 后再用 file URL import,不能用 data URL 直接加载带相对 import 的输出。 +- Create: `tests/fixtures/bridge-protocol-identifiers.json` + - JS bridge、Python fallback 和插件侧共用的协议标识符 fixture,覆盖 `apiToken`、`bridgeToken`、`captureId`、`sessionId`、`nonce`、`profileTransferId` 和 `cspNonce` 的合法/非法样例、regex、长度和前缀。 +- Test: `tests/site-experience-profile.test.mjs` + - 校验 schema、脱敏、低置信标记、字段稳定性。 + +### 插件 bridge 接入 + +- Create: `src/content/agent-bridge-client.ts` + - 仅在 loopback bridge 页面运行;解析 token/session,向 background 发握手消息,并承担 bridge 页面同源 POST profile 的 transport。 +- Modify: `src/manifest.config.ts` + - 增加仅匹配 `http://127.0.0.1/*` 的 bridge content script;只有在 bridge server 同步支持并测试 `localhost` Host 后才加入 `http://localhost/*`。 +- Modify: `src/background/content-injector.ts` + - 不能继续假设 `chrome.runtime.getManifest().content_scripts?.[0]` 一定是 `content-observer.ts`;必须按文件名查找或显式常量指定 observer 文件。 +- Modify: `src/utils/page-support.ts` + - 排除 StackPrism bridge 页面,避免普通检测和 badge 被本地 bridge 污染。 +- Modify: `src/content/content-observer.ts` + - 在脚本入口识别 bridge 页面并立即 return,避免动态快照发送到 background。 +- Modify: `src/types/messages.ts` + - 增加 `AGENT_BRIDGE_HELLO`、`START_AGENT_CAPTURE`、`AGENT_CAPTURE_STATUS`、`AGENT_CAPTURE_CONTROL`、`AGENT_PROFILE_TRANSFER_BEGIN`、`AGENT_PROFILE_TRANSFER_CHUNK`、`AGENT_PROFILE_TRANSFER_COMPLETE`、`AGENT_PROFILE_TRANSFER_ACK`。 + - `START_AGENT_CAPTURE` payload 只包含 `captureId`、`sessionId`、`nonce`、`bridgeOrigin`、`request` 和 `capabilities`,不得包含 `bridgeToken`;`AGENT_PROFILE_TRANSFER_*` payload 只包含 transfer metadata 和 `payloadBase64`,不得包含 `bridgeToken` 或 profile wrapper。 +- Modify: `src/background/message-router.ts` + - 接收 bridge hello、capture start、status/control/profile transport 消息。 + +### 插件采集编排 + +- Create: `src/background/agent-capture.ts` + - 管理 capture lifecycle、目标 tab 打开/复用、超时、回调 bridge。 +- Create: `src/background/agent-capture-state.ts` + - 将 capture 状态写入 `chrome.storage.session`,避免 MV3 service worker 重启后只剩内存状态。 +- Create: `src/background/active-tab-tracker.ts` + - 用 `chrome.tabs.onActivated` 和 `chrome.tabs.onUpdated` 记录每个 window 最近的非 bridge active tab,供 `targetMode = "active_tab"` 使用;不得新增 `chrome.windows` 权限。 +- Modify: `src/background/index.ts` + - 注册 active tab tracker、agent capture lifecycle、tab close/navigation cleanup、bridge tab/request 隔离和 service worker 重启恢复逻辑。 +- Modify: `src/background/detection.ts` + - 暴露可复用的“确保目标 tab 已检测完成”内部函数,避免复制检测逻辑;agent path 必须返回结构化结果或抛出错误,不能沿用现有吞异常 wrapper。 +- Modify: `src/background/tab-store.ts` + - 增加按 captureId 读取目标 tab 数据的辅助函数。 +- Create: `src/background/agent-bridge-tabs.ts` + - 维护 bridge tab registry,提供 `isAgentBridgeTab(tabId)`、`isAgentBridgeUrl(url)` 和 `isAgentBridgeRequest(details)`,供 `index.ts`、`page-support.ts`、`content-injector.ts` 和 agent capture cleanup 共用。 +- Modify: `src/background/dynamic-snapshot.ts` + - 暴露清理 pending dynamic snapshot 与 timer 的函数,支持 agent capture 的 `forceRefresh`。 +- Modify: `src/background/bundle-license.ts` + - 复用或暴露现有 `clearBundleLicenseTimer`,支持 agent capture 的 `forceRefresh` 和 cleanup。 +- Modify: `src/background/popup-cache.ts` + - 复用 `buildPopupRawResult` 与现有合并逻辑。 +- Test: `tests/agent-capture-orchestration.test.mjs` + - 使用 fake chrome APIs 覆盖 background capture 编排、active tab 选择、清理策略、service worker restart 和 token 不持久化边界。 + +### 体验采集脚本 + +- Create: `src/injected/experience-profiler.ts` + - 注入目标页 MAIN world 或 isolated context,采集 computed style、布局、组件和交互线索;模块 default export 必须是可被 Vite IIFE 包装后作为 `chrome.scripting.executeScript({ files })` result 返回的 JSON-serializable value 或 Promise。 +- Modify: `build-scripts/build-injected.mjs` + - 构建 `public/injected/experience-profiler.iife.js`,并沿用现有追加 `__StackPrismInjected_;` 的机制,让 executeScript 能拿到 IIFE 返回值。 +- Modify: `vite.injected.config.ts` + - 将 `experience-profiler` 加入 `ENTRIES`,否则 `build-injected.mjs` 即使新增 entry 数组也无法构建。 +- Modify: `src/manifest.config.ts` + - 不默认把 `injected/experience-profiler.iife.js` 加入 `web_accessible_resources`;`chrome.scripting.executeScript({ files })` 只需要扩展包内路径。只有实现证明需要网页或 content script 通过 `chrome.runtime.getURL()` / `fetch()` 读取该文件时,才允许加入最小 match 的独立 `web_accessible_resources` 条目,并在 `docs/dev/agent-bridge.md` 记录原因和风险。 +- Test: `tests/experience-profile-format.test.mjs` + - 对纯函数部分做静态输入输出测试。 +- Test: `tests/agent-bridge-manifest.test.mjs` + - 验证 manifest 权限、bridge content script match、content script 顺序、agent-only profiler 默认不进入 `web_accessible_resources`、以及未新增 `chrome.windows` 权限。 +- Create: `tests/fixtures/site-experience-fixture.html` + - 固定视觉/UI/UX fixture,包含颜色、字体、layout、按钮、表单、卡片、transition、跨源 iframe 占位和脱敏文本样本。 + +### 文档 + +- Modify: `docs/dev/architecture.md` + - 增加 Agent Bridge 架构与数据流。 +- Modify: `docs/dev/detection-flow.md` + - 增加 agent capture 流程。 +- Modify: `docs/dev/release.md` + - 增加手动验证项:bridge 脚本、插件握手、profile 输出。 +- Modify: `.github/workflows/release-extension.yml` + - 在打包 zip/crx 前运行 Agent Bridge 相关质量门禁,并检查 `dist/` 不包含 repo-local Skill、本地 bridge server 脚本、测试 fixture、`docs/superpowers/` 或 Python 字节码缓存。 +- Create: `docs/dev/agent-bridge.md` + - 面向开发者的协议与安全说明。 +- Modify: `docs/dev/index.md` + - 增加 Agent Bridge 开发文档入口,避免只进 sidebar 但开发索引不可发现。 +- Modify: `docs/.vitepress/config.ts` + - 把 `docs/dev/agent-bridge.md` 加入开发手册 sidebar,避免新增文档不可发现。 +- Modify: `PRIVACY.md` + - 明确 Agent Bridge 会采集浏览器侧可观测的技术和体验摘要,但不采集 cookie、Authorization、localStorage/sessionStorage 明文和完整敏感文本。 +- Modify: `README.md` + - 增加 Agent Bridge 能力入口和安装后使用边界。 +- Modify: `docs/guide/basic-usage.md` + - 增加面向普通用户的 Agent Bridge 使用说明和隐私边界。 +- Create: `docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md` + - 收口阶段记录所有验证命令、退出码、浏览器 smoke 结果、跳过项和剩余风险,满足 AGENTS 的主线回归验证沉淀要求。 + +## 执行门禁 + +- 当前仓库未发现 `tasks.md`、`issues.csv` 或等效任务跟踪文件;本计划位于 `docs/superpowers/plans/`,只能作为实现计划,不能冒充唯一任务源。 +- 真正开始实现前,执行者必须先与用户确认本计划可作为当前锁定任务,或按用户指定创建/更新正式任务跟踪文件并把状态置为“进行中”。 +- 每个 Task commit 前必须运行 `git diff --check`、`git diff --cached --check`、`git status --short`、`git diff --name-only` 和 `git diff --cached --name-only`。`git diff --check` 与 `git diff --cached --check` 都必须无输出。新文件在未 stage 前不会被 `git diff --check` 覆盖;因此必须先用 `git add -N ` 或在 staging 后用 `git diff --cached --check` 检查新增文件的 whitespace/conflict-marker 问题。 +- `git diff --name-only` 不显示未跟踪新文件,所以还必须用 `git status --short` 确认已跟踪改动、未跟踪文件和待提交文件都只包含该 Task 的 Files 列表和必要验证报告。如出现范围外文件,必须先拆分或回退该 Task 自己引入的无关改动。 +- 每个 Task 完成时必须在提交说明或 Task notes 中记录执行依据、验证命令和结果;不得并行推进多个 Task。 +- 本仓库现有 `src/content/content-observer.ts`、`src/background/dynamic-snapshot.ts`、`src/background/bundle-license.ts` 和 `src/background/popup-cache.ts` 已超过 300 行。任何 Task 触碰这些超限文件时,只允许做最小接线或先把相关职责抽到新文件,不能继续把新业务逻辑堆进去;新增 bridge 脚本和 helper 模块也必须按 300 行文件上限、50 行函数上限拆分。 + +## 开发任务 + +### Task 1: 锁定协议与类型 + +**Files:** + +- Modify: `package.json` +- Create: `src/types/agent-bridge.ts` +- Modify: `src/types/settings.ts` +- Modify: `src/utils/normalize-settings.ts` +- Create: `tests/helpers/load-ts-module.mjs` +- Create: `tests/fixtures/bridge-protocol-identifiers.json` +- Create: `tests/site-experience-profile.test.mjs` + +- Historical item: 修改 `package.json` 的 `test:unit` 为 `node --test --test-timeout=60000 tests/*.test.mjs` 或等效超时命令,满足后端/脚本测试 60 秒超时基线,防止 bridge 子进程测试卡死。 +- Historical item: 定义 `AgentCaptureRequest`、`AgentCaptureStatus`、`SiteExperienceProfile`、`AgentBridgeError`。 +- Historical item: 定义 `AgentBridgeCapabilities`,字段固定为 `agentBridge`、`siteExperienceProfileV1`、`profileChunkTransport`、`bridgeContentPost`、`storageSession`、`experienceProfiler`、`rawProfile`、`viewportMetadata`、`visualScreenshot`。 +- Historical item: 定义 agent bridge message union:`AgentBridgeHelloMessage`、`StartAgentCaptureMessage`、`AgentCaptureStatusMessage`、`AgentCaptureControlMessage`、`AgentProfileTransferBeginMessage`、`AgentProfileTransferChunkMessage`、`AgentProfileTransferCompleteMessage`、`AgentProfileTransferAckMessage`。 +- Historical item: 在 `DetectorSettings` 中增加 `agentBridgeEnabled` 运行时字段,`DEFAULT_SETTINGS` 和 `normalizeSettings` 的缺省值必须为 `false`;测试覆盖旧 sync 设置对象、非法类型和显式 `true` 三种输入,并确认 sync payload 中如果误带 `agentBridgeEnabled: true` 也不会自动开启,因为真实生效来源是 local opt-in。 +- Historical item: 写测试确认 `StartAgentCaptureMessage` 不允许 `bridgeToken` 字段,profile transfer messages 不允许 profile wrapper 字段。 +- Historical item: `tests/helpers/load-ts-module.mjs` 提供 `loadTsModule(path)`,用 `typescript.transpileModule` 编译 TS,并把 `@/foo` alias 改写为测试可 import 的 `src/foo` 相对路径;对有运行时 import 的模块编译到系统临时目录或仓库已忽略的 `tmp/compiled-tests/` 再用 file URL import,避免 data URL 相对 import 失败且不产生未跟踪测试产物。 +- Historical item: 定义 `bridgeProtocolVersion = 1`,并写入 request/status/profile 类型。 +- Historical item: 定义协议标识符常量和 validator:`apiToken`、`bridgeToken`、`captureId`、`sessionId`、`nonce`、`profileTransferId`、`cspNonce` 的 regex、长度、前缀必须与 Protocol identifier contract 一致,并导出给 bridge 脚本、插件 handshake 和测试复用。 +- Historical item: 新增 `tests/fixtures/bridge-protocol-identifiers.json`,覆盖每类标识符至少 2 个合法样例和非法样例:错误前缀、长度不足/过长、`+`、`/`、`=`、空白、Unicode、percent-encoded slash、点段、大小写错误、query 分隔符、空值。测试必须证明文档中的脱敏占位 `spb_xxx`、`spbt_xxx`、`cap_20260521_abcdef`、`s_xxx`、`n_xxx` 不会被 validator 接受。 +- Historical item: 写测试确认 schema 常量为 `stackprism.site_experience_profile.v1`。 +- Historical item: 写测试确认 `AgentBridgeCapabilities` 包含所有第一版必需 capability,且 `SiteExperienceProfile.browserContext.extensionCapabilities` 使用同一类型。 +- Historical item: 写测试确认 request 类型包含 `allowPrivateNetworkTarget`,默认值由 bridge 脚本处理;Task 1 不测试 DNS/private-network 行为,避免在 bridge 实现前写不可运行的测试。 +- Historical item: 写测试确认 error code 枚举至少包含:`NOT_FOUND`、`METHOD_NOT_ALLOWED`、`UNAUTHORIZED`、`FORBIDDEN`、`ORIGIN_NOT_ALLOWED`、`UNSUPPORTED_MEDIA_TYPE`、`UNSUPPORTED_TRANSFER_ENCODING`、`INVALID_JSON`、`INVALID_REQUEST`、`REQUEST_TOO_LARGE`、`REQUEST_TIMEOUT`、`SERVER_BUSY`、`STALE_STATUS_UPDATE`、`PORT_IN_USE`、`BRIDGE_INVALID_ENV`、`BRIDGE_START_TIMEOUT`、`BRIDGE_READY_PARSE_FAILED`、`BRIDGE_PROTOCOL_UNSUPPORTED`、`BRIDGE_REQUEST_MISMATCH`、`AGENT_BRIDGE_DISABLED`、`CAPTURE_BUSY`、`CAPTURE_TIMEOUT`、`EXTENSION_NOT_CONNECTED`、`BROWSER_OPEN_FAILED`、`BRIDGE_TOKEN_CANNOT_READ_PROFILE`、`PRIVATE_NETWORK_TARGET_BLOCKED`、`TARGET_DNS_LOOKUP_FAILED`、`BRIDGE_SELF_TARGET_BLOCKED`、`FINAL_URL_BLOCKED`、`ACTIVE_TAB_UNAVAILABLE`、`ACTIVE_TAB_MISMATCH`、`INCOGNITO_NOT_SUPPORTED`、`TARGET_LOAD_TIMEOUT`、`TARGET_LOAD_FAILED`、`TARGET_INJECTION_FAILED`、`TARGET_TAB_CLOSED`、`BRIDGE_TAB_CLOSED`、`TARGET_NAVIGATED_AWAY`、`SERVICE_WORKER_RESTARTED`、`BRIDGE_TRANSPORT_DISCONNECTED`、`PROFILE_TRANSPORT_FAILED`、`PROFILE_CHUNK_MISSING`、`PROFILE_HASH_MISMATCH`、`PROFILE_TOO_LARGE`、`RATE_LIMITED`、`NONCE_REUSED`、`CAPTURE_ALREADY_COMPLETED`、`CAPTURE_RESULT_EXPIRED`、`NOT_SUPPORTED`。重复 header、歧义 `Content-Length`/`Transfer-Encoding`、非法 request target 和非法 path/query 都复用 `INVALID_REQUEST`,不得新增一套不一致错误码。 +- Historical item: 验证:`pnpm run test:unit` 通过。 +- Historical item: 验证:`pnpm run typecheck` 通过,确认新增协议类型、测试 helper 和 schema 常量可编译并可打包。 +- Historical item: Commit: `feat: define agent bridge profile contract` + +### Task 2: 实现 profile builder + +**Files:** + +- Create: `src/utils/site-experience-profile.ts` +- Modify: `src/background/headers.ts` +- Modify: `tests/site-experience-profile.test.mjs` + +- Historical item: 从现有 popup/raw 数据构造 `techProfile` 和 `assetProfile`。 +- Historical item: 增加 `limitations` 与 `agentGuidance` 默认规则。 +- Historical item: 对 cookie、authorization、set-cookie、token-like 字段做脱敏。 +- Historical item: 扩展 `src/background/headers.ts` 的响应头脱敏:`set-cookie` 保留 cookie name 摘要,`cookie`、`authorization`、`proxy-authorization`、`x-api-key`、token-like header 值统一脱敏;测试覆盖 `headers`、`allHeaders`、profile evidence 不泄露原值。 +- Historical item: 对资源 URL 的 hash 和敏感 query 参数做脱敏;profile 不输出完整签名 URL、带 token 的图片/字体/脚本 URL。 +- Historical item: 对 UX 文本摘要执行 email、手机号、长数字 ID、金额和疑似姓名脱敏。 +- Historical item: 对 viewport 输出增加 `viewportMode`,无法多视口采集时显式写入 limitation。 +- Historical item: 对 `captureScreenshotMetadata` 输出增加明确分支:`true` 时只允许输出视口尺寸、关键元素 bounding box 和 above-fold 摘要;`false` 时不输出 bounding box / above-fold 细节。截图图像由 `captureScreenshot` 单独控制,默认不输出。 +- Historical item: 对 passive interaction、cross-origin iframe、closed shadow root 和不可访问 stylesheet 写入 limitations。 +- Historical item: 对截断结果写入 `evidence.truncation` 和对应 limitation,至少包含资源 URL、文本摘要、组件样本和 CSS rule 样本的 omitted count。 +- Historical item: profile builder 必须从 agent capture context 接收 `AgentBridgeCapabilities`,并原样写入 `browserContext.extensionCapabilities`;不得在 builder 内重新推断 capability。 +- Historical item: 验证空检测、低置信检测、完整 raw 检测三种输入。 +- Historical item: 验证:`pnpm run test:unit` 通过。 +- Historical item: 验证:`pnpm run typecheck` 通过,确认 profile builder 的 TypeScript 类型和扩展打包链路没有被破坏。 +- Historical item: Commit: `feat: build site experience profile payload` + +### Task 3: 实现体验采集脚本 + +**Files:** + +- Create: `src/injected/experience-profiler.ts` +- Create: `tests/experience-profile-format.test.mjs` +- Create: `tests/agent-bridge-manifest.test.mjs` +- Create: `tests/fixtures/site-experience-fixture.html` +- Modify: `build-scripts/build-injected.mjs` +- Modify: `vite.injected.config.ts` +- Modify: `src/manifest.config.ts` + +- Historical item: 采集 computed style token:颜色、字体、字号、行高、间距、圆角、阴影。 +- Historical item: 采集 layout landmarks:header/nav/main/footer/aside/hero/above-fold。 +- Historical item: 采集 component inventory:button/input/card/nav/tab/modal/table/list/badge。 +- Historical item: 采集 interaction tokens:transition、animation、sticky/fixed、focus/hover 可观察线索。 +- Historical item: 标记同源 iframe、跨源 iframe、open shadow root、closed shadow root 的可采集边界。 +- Historical item: 输出稳定、限长、脱敏的 JSON,不包含可见文本全文。 +- Historical item: `src/injected/experience-profiler.ts` 必须 `export default` 一个 JSON-serializable result 或 Promise;构建后的 `public/injected/experience-profiler.iife.js` 末尾必须追加 `__StackPrismInjected_experience_profiler__;`,确保 `chrome.scripting.executeScript({ files })` 的 `injection[0].result` 非空。 +- Historical item: 设置明确采集上限,例如最大 DOM 节点数、最大组件样本数、最大文本样本数、最大 CSS rule 数和最大资源 URL 数;超过上限时只返回截断摘要和 omitted count,不让 profile 超过 bridge 的 8 MB 上限。 +- Historical item: 设置 `experience-profiler` 注入脚本返回值上限,例如返回给 `chrome.scripting.executeScript` 的 JSON 字符串不超过 2 MB;超过上限时在注入脚本内部先截断样本、写入 `evidence.truncation.executeScriptResult` 和对应 limitation,不允许等到 background 收到超大 executeScript result 或扩展消息传输时才失败。 +- Historical item: 在 `vite.injected.config.ts` 的 `ENTRIES` 加入 `experience-profiler`,并在 `build-scripts/build-injected.mjs` 的 `entries` 数组加入同名项。 +- Historical item: `src/manifest.config.ts` 的 `web_accessible_resources` 使用最小暴露面;默认不暴露 `injected/experience-profiler.iife.js`。如果实现确实需要暴露,必须把它拆成独立条目、使用最小 `matches`,并在 `docs/dev/agent-bridge.md` 说明为什么不能只用 `chrome.scripting.executeScript({ files })`。 +- Historical item: `tests/agent-bridge-manifest.test.mjs` 验证 bridge content script 只匹配 `http://127.0.0.1/*`,普通 observer 仍是第一个 content script,未新增 `chrome.windows` 或其他无关权限,未配置 `externally_connectable`,且 `experience-profiler.iife.js` 默认不在 `web_accessible_resources` 中;若实现选择暴露,则测试必须验证独立条目和最小 match。 +- Historical item: `tests/experience-profile-format.test.mjs` 验证构建后的 profiler IIFE 文本包含 `__StackPrismInjected_experience_profiler__;`,并验证 profiler 默认导出形状可被结构化克隆为 executeScript result。 +- Historical item: 新增 `tests/fixtures/site-experience-fixture.html`,用固定 DOM/CSS 覆盖颜色、字体、布局、组件、transition 和敏感文本脱敏样本。 +- Historical item: 运行 `pnpm exec prettier --check build-scripts/build-injected.mjs vite.injected.config.ts tests/experience-profile-format.test.mjs tests/agent-bridge-manifest.test.mjs tests/fixtures/site-experience-fixture.html`,因为 `pnpm run lint` 只覆盖 `src`,不会检查 build script、Vite injected config 和测试 fixture。 +- Historical item: 验证:`pnpm run build:injected` 产出 `public/injected/experience-profiler.iife.js`。 +- Historical item: 验证:`pnpm run test:unit` 通过;该步骤必须在 `pnpm run build:injected` 之后执行,因为 `tests/experience-profile-format.test.mjs` 会读取 ignored 构建产物 `public/injected/experience-profiler.iife.js`。 +- Historical item: 验证:`pnpm run typecheck` 通过,确认 injected entry、manifest config 和扩展构建链路一致。 +- Historical item: Commit: `feat: collect visual and ux experience signals` + +### Task 4: 实现 bridge content script 握手 + +**Files:** + +- Create: `src/content/agent-bridge-client.ts` +- Modify: `src/manifest.config.ts` +- Modify: `src/background/content-injector.ts` +- Modify: `src/utils/page-support.ts` +- Modify: `src/content/content-observer.ts` +- Modify: `src/types/messages.ts` +- Modify: `src/background/message-router.ts` + +- Historical item: content script 只在 loopback bridge 页面激活。 +- Historical item: 在 `src/manifest.config.ts` 中保持普通 `content-observer.ts` 为第一个 content script,或同步修改 `content-injector.ts` 通过文件名查找 observer;验证主动注入不会误注入 `agent-bridge-client.ts`。 +- Historical item: 对非 `/bridge` path 或缺少 `stackprism-agent-bridge` meta 的 loopback 页面立即 return,不读取 DOM 详情、不发消息、不发请求。 +- Historical item: 解析 `session`、`capture`、`nonce` 和 HTML 内嵌 `bridgeToken`,校验 URL path 为 `/bridge`。 +- Historical item: 从 `#stackprism-agent-bridge-config[type="application/json"]` 解析 `bridgeToken`,不得依赖页面 JS 全局变量。 +- Historical item: 解析 `capture` 和 `nonce`,从 bridge 拉取 `GET /v1/captures/{id}/request`。 +- Historical item: 校验 `GET /v1/captures/{id}/request` 返回的 `captureId`、`sessionId`、`nonce` 和 `protocolVersion` 与当前 bridge 页面 config 完全一致;不一致时同源 POST `failed` 和 `BRIDGE_REQUEST_MISMATCH`,不得向 background 发送 `START_AGENT_CAPTURE`。 +- Historical item: 握手后同源 POST `waiting_extension` / `running` / `failed` 状态到 `POST /v1/captures/{id}/status`。 +- Historical item: bridge content script 为每个 status POST 维护单调递增 `sequence`,并只发送定义过的 phase;background 发来的 late phase 不得倒退覆盖 bridge server 中较新的 phase。 +- Historical item: 运行期间轮询 `GET /v1/captures/{id}/control`;收到 `cancel` 后通知 background 取消。 +- Historical item: bridge content script 校验 bridge 页面 config 的 `protocolVersion`;不等于插件 `bridgeProtocolVersion` 时同源 POST `failed` / `BRIDGE_PROTOCOL_UNSUPPORTED`,不得向 background 发送 `START_AGENT_CAPTURE`。 +- Historical item: background 必须在 `AGENT_BRIDGE_HELLO` 时绑定 `sender.tab.id`、`sender.tab.windowId`、bridge origin、session、capture 和 nonce;后续 `AGENT_CAPTURE_*` 消息必须同时匹配 sender tab id 与 bridge URL,不能只信任 message body 或只校验 URL。 +- Historical item: 向 background 发送 `AGENT_BRIDGE_HELLO`。 +- Historical item: background 必须拒绝来自非 loopback `/bridge` URL、缺少 tab/window id、或 message body 中 session/capture/nonce 与 `sender.url` query 不一致的 `AGENT_BRIDGE_HELLO`,返回结构化 `INVALID_REQUEST`;若同一 tab 已登记 bridge session,则还必须拒绝与既有登记不一致的重复 hello。单元测试覆盖伪造 sender URL、伪造 tab id 和重复 hello mismatch。 +- Historical item: background 返回插件版本、`protocolVersion`、`AgentBridgeCapabilities` 和握手状态;缺少第一版必需 capability 时返回 `NOT_SUPPORTED`,并让 bridge content script 同源 POST failed status。 +- Historical item: background 必须在 `AGENT_BRIDGE_HELLO` 时只从 `chrome.storage.local` 读取 `AGENT_BRIDGE_ENABLED_STORAGE_KEY` / `agentBridgeEnabled`;未开启时返回 `AGENT_BRIDGE_DISABLED`,bridge content script 同源 POST failed status,且 background 不登记 capture、不打开目标 tab、不发送 `START_AGENT_CAPTURE`。单元测试必须证明 `chrome.storage.sync` 里的旧 `agentBridgeEnabled: true` 不会让握手通过。 +- Historical item: capability 校验通过后,bridge content script 发送 `START_AGENT_CAPTURE`,payload 只包含 `captureId`、`sessionId`、`nonce`、`bridgeOrigin`、规范化 capture request 和 capabilities;不得把 `bridgeToken` 传给 background。 +- Historical item: bridge content script 保持与 background 的 `runtime.Port` 或等效消息通道,用于接收 profile payload 并执行同源 POST。 +- Historical item: 如果使用 `runtime.Port`,必须在 `src/background/message-router.ts` 或 agent capture 模块注册 `chrome.runtime.onConnect`,校验 `port.name`、`sender.tab.id`、`sender.tab.windowId`、`sender.url`、session、capture 和 nonce;未知 port name、非 bridge sender、重复 port、跨 capture port 或 sender 与登记 bridge tab 不一致时必须断开并返回结构化失败,不能退回普通 onMessage 路径。 +- Historical item: bridge content script 实现 `AGENT_PROFILE_TRANSFER_BEGIN`、`AGENT_PROFILE_TRANSFER_CHUNK`、`AGENT_PROFILE_TRANSFER_COMPLETE` 和 `AGENT_PROFILE_TRANSFER_ACK`:逐片 ack、校验 transfer message 的 `captureId`、`sessionId`、`nonce` 与本页 bridge config 一致,按 `profileTransferId` 重组,校验 `payloadBase64`、`byteLength`、UTF-8/JSON decode 和 `sha256`,再同源 POST 原始 `SiteExperienceProfile` JSON。 +- Historical item: bridge content script 同源 POST status/profile 时必须设置 `Content-Type: application/json` 和 `Authorization: Bearer {bridgeToken}`;缺任一 header 都应在单元测试中触发 bridge server 的 `UNSUPPORTED_MEDIA_TYPE` 或 `UNAUTHORIZED`。 +- Historical item: 分片传输失败必须同源 POST 结构化失败状态:缺片或超时为 `PROFILE_CHUNK_MISSING`,hash 不匹配为 `PROFILE_HASH_MISMATCH`,其他传输失败为 `PROFILE_TRANSPORT_FAILED`;不能让 Agent 只等到 capture timeout。 +- Historical item: profile POST 成功或失败都必须回传给 background;断连时 background 标记 `BRIDGE_TRANSPORT_DISCONNECTED`。 +- Historical item: `runtime.Port` 的 `onDisconnect` 发生在 capture 未结束时,bridge content script 必须用同源 `POST /v1/captures/{id}/status` 上报 `BRIDGE_TRANSPORT_DISCONNECTED`;background 重启恢复后也必须通过 `bridgeTabId` 通知 bridge content script 上报 `SERVICE_WORKER_RESTARTED` 或 `BRIDGE_TRANSPORT_DISCONNECTED`。 +- Historical item: bridge 页面没有 token 时返回显式错误,不静默成功。 +- Historical item: bridge 页面不触发普通 `content-observer` 和 badge 更新。 +- Historical item: 验证:`pnpm run build:injected` 通过;Task 3 已新增读取 `public/injected/*.iife.js` 的单元测试,后续全量 `test:unit` 在干净 checkout 中必须先生成 ignored injected 产物。 +- Historical item: 验证:`pnpm run test:unit` 通过。 +- Historical item: 验证:`pnpm run typecheck` 通过,确认 content script、manifest 和 message union 可编译并可打包。 +- Historical item: Task 4 阶段不得要求真实 bridge server 浏览器握手 smoke;JS bridge server 在 Task 6 才实现,真实握手与 DevTools 观察统一放到 Task 10。 +- Historical item: Commit: `feat: add local bridge handshake` + +### Task 5: 实现 background capture 编排 + +**Files:** + +- Create: `src/background/agent-capture.ts` +- Create: `src/background/agent-capture-state.ts` +- Create: `src/background/agent-bridge-tabs.ts` +- Create: `src/background/active-tab-tracker.ts` +- Create: `tests/agent-capture-orchestration.test.mjs` +- Modify: `src/background/index.ts` +- Modify: `src/background/message-router.ts` +- Modify: `src/background/detection.ts` +- Modify: `src/background/tab-store.ts` +- Modify: `src/background/dynamic-snapshot.ts` +- Modify: `src/background/bundle-license.ts` +- Modify: `src/background/popup-cache.ts` + +- Historical item: `START_AGENT_CAPTURE` 校验 URL、session/capture/nonce 绑定、include、viewports;background 不接收、不读取、不持久化 `bridgeToken`。 +- Historical item: `START_AGENT_CAPTURE` 二次校验 `agentBridgeEnabled`,避免设置页关闭后已有 bridge tab 继续发起采集;关闭后返回 `AGENT_BRIDGE_DISABLED`,并清理 bridge session。 +- Historical item: `START_AGENT_CAPTURE` payload 必须来自已登记 bridge tab 的 content script;background 必须拒绝含 `bridgeToken`、callback URL 或 profile wrapper 的 payload,返回 `INVALID_REQUEST`。 +- Historical item: `START_AGENT_CAPTURE` 校验 `options.forceRefresh`、`options.captureScreenshotMetadata`、`options.captureScreenshot`、`options.targetMode`、`options.keepTabOpen`、`options.allowPrivateNetworkTarget` 和 `options.maxResourceUrls`;未知字段必须返回 `INVALID_REQUEST`,不能静默忽略。 +- Historical item: capture 开始前检查 `chrome.storage.session` 可用;不可用时返回 `NOT_SUPPORTED` 和 `details.missingCapability = "storageSession"`,不得退回普通内存状态。 +- Historical item: 不得把 `chrome.storage.session` access level 放宽给 content script;若实现显式设置 access level,必须设置为 `TRUSTED_CONTEXTS`。单元测试必须断言没有调用 `setAccessLevel({ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" })`,并断言 content script 只能通过 runtime message/Port 访问 agent capture 状态。 +- Historical item: `src/background/active-tab-tracker.ts` 记录每个 window 最近的非 bridge active tab;bridge tab 激活时不能覆盖该记录;记录写入 `chrome.storage.session`,service worker 重启后仍可读取。 +- Historical item: `src/background/agent-bridge-tabs.ts` 提供 bridge tab/request registry;bridge tab、bridge 页面请求和 bridge API fetch 不得进入普通 `webRequest` header merge、`webNavigation` throttle reset、tab-store、popup-cache、badge 或 dynamic snapshot 流程。 +- Historical item: agent capture 的 deadline 必须使用持久化绝对时间,而不是只依赖 background 内存 timer。`agent-capture-state` 必须记录全局 capture deadline、cancel deadline 和 profile transfer deadline;所有事件入口和 service worker 模块初始化都必须调用同一个 deadline reconciliation helper,把过期 capture 标记为结构化失败或取消并清理自己创建的目标 tab。 +- Historical item: `src/background/message-router.ts` 对所有会读写 tab 数据的普通消息增加 sender/tab 校验和 bridge tab guard;bridge tab 发来的普通检测、动态快照、popup/raw/header 查询或后台检测消息必须拒绝或返回 unsupported,不能读写目标站点缓存,也不能用 message body 中的 `tabId` 操作其他 tab。 +- Historical item: 普通 runtime message 的 sender 校验必须保留 popup/options 正常能力:`sender.tab` 存在时按 content script 处理并要求 `sender.tab.id === tabId`;`sender.tab` 缺失但 `sender.url` 是本扩展 popup/options 页面时,只允许读取当前用户选择的普通 tab,且必须拒绝 bridge tab、incognito tab 和无权限 tab。单元测试覆盖 popup 正常读取、content script 伪造其他 tabId 被拒绝、bridge tab 查询被拒绝。 +- Historical item: background、content script 和 agent capture 模块的 console/debug 日志统一走 redaction helper;禁止打印完整 bridge URL query、nonce、token、Authorization header、profile body 和目标 URL 敏感 query。现有 `console.log(... url ...)` 触碰 bridge URL 或 target final URL 时必须改为 redacted URL。 +- Historical item: `targetMode = "active_tab"` 从 active-tab-tracker 读取 bridge tab 同窗口的上一张非 bridge active tab;缺失时返回 `ACTIVE_TAB_UNAVAILABLE`,URL 不匹配时返回 `ACTIVE_TAB_MISMATCH`。 +- Historical item: bridge tab 或目标 tab 的 `incognito` 为 true 时返回 `INCOGNITO_NOT_SUPPORTED`,并清理当前 capture;第一版不得跨普通窗口和隐身窗口传递状态。 +- Historical item: `reuse_or_new_tab` 和 `active_tab` 的 URL 匹配使用统一 helper:protocol/hostname 小写、默认端口折叠、fragment 丢弃、path 空值归一到 `/`,比较完整 URL(不含 hash,包含 query)。同 path 但 query 不同不得复用已有 tab;`active_tab` 场景必须返回 `ACTIVE_TAB_MISMATCH`。 +- Historical item: 监听 `chrome.webNavigation.onErrorOccurred` 的目标 tab main frame;加载失败时上报 `TARGET_LOAD_FAILED`,停止采集并清理自己创建的目标 tab,不得把浏览器错误页当目标站点 profile。 +- Historical item: 等待目标 tab `status === "complete"` 后,先通过 bridge content script 写入 `running/target_loaded` 和 `finalUrl`;bridge 接受 final URL 后才运行主动检测和 experience profiler。bridge 返回 `FINAL_URL_BLOCKED` 或 `BRIDGE_SELF_TARGET_BLOCKED` 时必须停止采集并清理自己创建的目标 tab。 +- Historical item: final URL 通过后再运行主动检测;agent capture 必须使用 `force: true` 或专用内部函数绕过 `DETECTION_THROTTLE_MS`,并在检测后等待 `waitMs` 收集动态资源;超时返回 `TARGET_LOAD_TIMEOUT`。 +- Historical item: 捕获 `chrome.scripting.executeScript` promise rejection 和 `chrome.runtime.lastError`;注入 content observer、page detector 或 experience profiler 任一步失败时返回 `TARGET_INJECTION_FAILED`,`details` 只记录脱敏原因类别,不包含完整 URL、token 或浏览器原始错误全文。 +- Historical item: 执行 `maxConcurrentCaptures = 1`,忙时返回 `CAPTURE_BUSY`。 +- Historical item: 打开或复用目标 tab;新建目标 tab 必须 `active: false`,记录 `createdByCapture`,触发现有技术检测。 +- Historical item: 在 `src/background/tab-store.ts` 中增加明确的 agent capture 清理入口,采集前清理目标 tab 的 tab data 与 popup cache。 +- Historical item: 在 `src/background/dynamic-snapshot.ts` 中导出 `clearDynamicSnapshotState(tabId)` 或等效函数,清理 `pendingDynamicSnapshots` 与 `dynamicSnapshotTimers`。 +- Historical item: 在 `src/background/bundle-license.ts` 复用现有 `clearBundleLicenseTimer(tabId)`;如当前函数未导出,则导出并由 agent capture cleanup 调用。 +- Historical item: 实现 `forceRefresh`:采集前统一调用 tab-store、popup cache、dynamic snapshot、bundle timer、detection throttle 的清理入口,避免复用旧页面缓存污染 profile。 +- Historical item: 从 `detection.ts` 拆出 agent capture 专用的检测函数;该函数必须返回检测完成信号和错误,不得使用现有 catch 后静默 return 的 `runActivePageDetection` 作为唯一结果来源。 +- Historical item: 注入 `experience-profiler.iife.js` 采集视觉/UI/UX 数据。 +- Historical item: 按 `include` 决定是否运行技术检测、experience profiler 和资源采样;未请求 section 返回空对象并在 `limitations` 写入 `section_not_requested`。 +- Historical item: 第一版不新增 `chrome.windows` 权限,不调整窗口尺寸;所有多视口请求都写入 `viewportMode = "current_viewport"` 和 limitation。 +- Historical item: 合并 popup/raw 数据与体验数据,调用 profile builder。 +- Historical item: 从 `popup-cache.ts` 导出 agent capture 需要的 raw/display 构建辅助函数,避免绕过现有去重、过滤和链接补全逻辑。 +- Historical item: 将 profile payload 通过 profile chunk transport 发给 bridge content script,由其重组、校验 sha256 后同源 POST 回 bridge callback endpoint;background 直连 localhost 只允许作为后续显式 CORS fallback,不在第一版默认路径。 +- Historical item: background 发送 profile 分片时必须把原始 `SiteExperienceProfile` 序列化为 UTF-8 JSON bytes,计算 sha256,再把每片 bytes 编码为 `payloadBase64`;单片 raw payload 不超过 `384 * 1024` bytes,并等待每片 ack;ack 超时、content script 返回失败或 transfer complete 未确认时,必须上报 `PROFILE_TRANSPORT_FAILED`,不得把 capture 标记为 completed。 +- Historical item: capture 完成、失败、取消或过期时,关闭插件自己创建且 `keepTabOpen = false` 的目标 tab。 +- Historical item: 监听目标 tab 或 bridge tab 关闭/导航;分别返回 `TARGET_TAB_CLOSED`、`BRIDGE_TAB_CLOSED` 或 `TARGET_NAVIGATED_AWAY`,并清理 capture 状态。 +- Historical item: capture 状态写入 `chrome.storage.session`,最小字段包含 `captureId`、`sessionId`、`nonce`、`bridgeOrigin`、`bridgeUrl`、`bridgeTabId`、`bridgeWindowId`、`targetTabId`、`targetWindowId`、`targetUrl`、`finalUrl`、`targetMode`、`createdByCapture`、`keepTabOpen`、`phase`、`status`、`startedAt`、`updatedAt`、`error`;不得持久化 `bridgeToken` 或 `apiToken`。 +- Historical item: `agent-capture-state`、active-tab tracker 和普通 tab cache 使用不同 storage key 前缀;提供集中 helper 列出和清理 agent capture state。capture 终态、bridge tab 关闭、扩展启动恢复失败和 E2E 清理阶段都必须删除对应 capture state,避免后续 capture 误读旧 tab ownership。 +- Historical item: service worker 重启后读取 `agent-capture-state`:未完成 capture 标记为 `SERVICE_WORKER_RESTARTED`,通过 `bridgeTabId` 通知 bridge content script 同源 POST 失败状态,并按 `createdByCapture`/`keepTabOpen` 清理目标 tab。 +- Historical item: 浏览器完全退出、扩展 reload/update、用户禁用扩展或 `chrome.storage.session` 被清空后,不尝试恢复未完成 capture;恢复入口必须 fail closed,清理残留 state 和自己创建的 target tab,让 Agent 侧通过 bridge timeout/expired 或状态查询看到结构化失败。 +- Historical item: 错误路径必须回传结构化错误,不能吞异常。 +- Historical item: `tests/agent-capture-orchestration.test.mjs` 使用 fake chrome APIs 覆盖 target URL 不支持、检测超时、注入失败、目标 tab 导航走偏、bridge 不可达、active_tab 缺失/不匹配、incognito 拒绝、service worker restart cleanup、browser/extension reload fail-closed cleanup、deadline reconciliation、`keepTabOpen = false` 只关闭插件创建的目标 tab、`chrome.storage.session` 中不持久化 `bridgeToken`/`apiToken`,以及 `chrome.storage.session` access level 不暴露给 untrusted content scripts。 +- Historical item: `tests/agent-capture-orchestration.test.mjs` 必须覆盖 bridge tab/request/message 隔离:`tabs.onUpdated`、`webNavigation.onCommitted`、`webRequest.onHeadersReceived` 和 `runtime.onMessage` 收到 bridge tab 或 `/v1/captures/*` 请求时,不写入 tab-store、popup-cache、badge、dynamic snapshot 或 header records;bridge tab 伪造 `PAGE_DETECTION_RESULT`、`DYNAMIC_PAGE_SNAPSHOT` 或带其他 tabId 的 popup/header 查询必须被拒绝;popup/options 正常读取普通 tab 仍通过。 +- Historical item: `tests/agent-capture-orchestration.test.mjs` 必须覆盖扩展侧日志脱敏:bridge URL query、nonce、token、Authorization header、profile body 和目标 URL 敏感 query 不出现在 console/debug 输出中。 +- Historical item: 如果实现使用 `runtime.Port`,测试必须覆盖未知 port name、非 bridge sender、重复 port、跨 capture port、错误 `sender.url` 和错误 tab id 都会断开且不会启动 capture 或写入 profile。 +- Historical item: 验证:`pnpm run build:injected` 通过;Task 3 已新增读取 `public/injected/*.iife.js` 的单元测试,后续全量 `test:unit` 在干净 checkout 中必须先生成 ignored injected 产物。 +- Historical item: 验证:`pnpm run test:unit` 通过。 +- Historical item: 验证:`pnpm run typecheck` 通过。 +- Historical item: Commit: `feat: orchestrate agent site capture` + +### Task 6: 实现 JS bridge 脚本 + +**Files:** + +- Create: `agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs` +- Create: `agent-skill/stackprism-site-experience/scripts/bridge/*.mjs` +- Create: `tests/stackprism-bridge.test.mjs` +- Create: `tests/fixtures/bridge-url-policy-cases.json` +- Reuse: `tests/fixtures/bridge-protocol-identifiers.json` +- Modify: `.gitignore` + +- Historical item: 使用 Node 标准库 `node:http`,不引入运行时依赖。 +- Historical item: `stackprism-bridge.mjs` 只保留 CLI guard、启动参数读取和 server lifecycle;HTTP routing、capture store、URL policy、DNS、body limit、browser open、redaction 和 error response 拆入 `scripts/bridge/*.mjs` helper,至少包含 `http-server.mjs`、`capture-store.mjs`、`url-policy.mjs`、`security.mjs` 和 `open-browser.mjs`,避免单文件超 300 行。 +- Historical item: 绑定 `127.0.0.1`,端口默认随机,支持环境变量 `STACKPRISM_BRIDGE_PORT`。 +- Historical item: 启动前校验环境变量:`STACKPRISM_BRIDGE_PORT` 未设置时才使用随机端口;设置后必须是 `1..65535` 的十进制整数;browser open 相关环境变量不得包含 NUL 字符。非法端口或 NUL 字符返回 `BRIDGE_INVALID_ENV`,非零退出,stdout 不输出 ready JSON,stderr 不泄露 token 或 bridge URL query;`STACKPRISM_BROWSER_OPEN_ARGS_JSON` 的非法 JSON/非数组/非字符串元素仍由打开浏览器步骤返回 `BROWSER_OPEN_FAILED`。 +- Historical item: 指定 `STACKPRISM_BRIDGE_PORT` 且端口被占用时,进程必须非零退出,stderr 输出脱敏 `PORT_IN_USE`,stdout 不输出 ready JSON。 +- Historical item: 启动成功后 stdout 只输出一行 JSON line,包含 `event`、`baseUrl`、`healthUrl`、`apiToken`、`protocolVersion`、`version`;其他日志写 stderr。ready JSON 必须在 server 已绑定且 endpoint 可接受请求后输出。 +- Historical item: 自动打开 bridge 页面:macOS 使用 `open`,Windows 使用 `rundll32.exe url.dll,FileProtocolHandler` 或等效非 shell API,Linux 使用 `xdg-open`;支持 `STACKPRISM_BROWSER_OPEN_COMMAND` 覆盖目标浏览器。不得默认使用 `cmd /c start`,除非测试证明 `?`、`&`、空格和引号不会被 shell 解释。 +- Historical item: 自动打开浏览器时不得把 bridge URL 拼进 shell 字符串;JS 使用 `spawn`/`execFile` 的参数数组,Python 使用 `subprocess` 参数数组或 `webbrowser` 安全 API。`STACKPRISM_BROWSER_OPEN_COMMAND` 第一版只表示可执行文件路径;如需额外参数,使用 JSON 数组环境变量 `STACKPRISM_BROWSER_OPEN_ARGS_JSON`,并把 bridge URL 作为最后一个独立参数。 +- Historical item: `STACKPRISM_BROWSER_OPEN_ARGS_JSON` 必须严格解析为字符串数组;非法 JSON、非数组或非字符串元素都返回 `BROWSER_OPEN_FAILED`,并在脱敏 `details.reason` 中标记 `invalid_open_args`。浏览器打开测试必须覆盖包含 `?`、`&`、空格和引号的 bridge URL 始终作为单个 argv 传入假命令,不能被 shell 拆分或解释。 +- Historical item: 支持测试环境变量 `STACKPRISM_BRIDGE_NO_OPEN=1` 禁止自动打开浏览器,避免单元测试弹出浏览器或依赖用户桌面环境;该模式下创建 capture 不得返回 `BROWSER_OPEN_FAILED`,而是返回 `queued` 和 `bridgeUrl`。 +- Historical item: `.gitignore` 加入 `agent-skill/**/scripts/**` 例外,并加入或确认 `__pycache__/`、`*.py[cod]` 仍被忽略;确认以下命令退出码为 1 且无输出:`git check-ignore -v --no-index agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs agent-skill/stackprism-site-experience/scripts/bridge/http-server.mjs agent-skill/stackprism-site-experience/scripts/stackprism_bridge.py agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/http_server.py`。该步骤必须在创建 JS/Python bridge 脚本前完成,否则 Task 6/7 的脚本会被根规则 `scripts/` 忽略并漏提交;使用 `--no-index`,避免已跟踪文件让 ignore 规则检查产生假阴性。 +- Historical item: 确认 `git check-ignore -v --no-index agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/__pycache__/http_server.pyc` 有命中,避免 Python 编译验证留下未跟踪字节码。 +- Historical item: 实现统一 JSON 错误响应;所有失败返回 `{ "error": { "code", "message", "details" } }`,且 `details` 不含 token、完整 header、完整 URL query 或 profile 片段。 +- Historical item: 实现未知路径、错误方法、缺失 Bearer、token scope 不匹配、非 JSON content type、非 UTF-8 charset、非法 UTF-8 body 和 JSON parse failure 的固定错误响应:`NOT_FOUND`、`METHOD_NOT_ALLOWED`、`UNAUTHORIZED`、`FORBIDDEN`、`UNSUPPORTED_MEDIA_TYPE`、`INVALID_JSON`。 +- Historical item: 实现 `OPTIONS` preflight 拒绝:返回 `405 METHOD_NOT_ALLOWED` 或等效结构化错误,带正确 `Allow` 头,但不返回任何 `Access-Control-Allow-*` 头;测试覆盖跨站网页无法通过 preflight 获得授权。 +- Historical item: 实现 request target 和 path/query 规范化:只接受 origin-form path;拒绝 absolute-form、authority-form、percent-encoded slash/backslash、空 path segment、`..`、重复 query 字段和未知 query 字段;`captureId`、`sessionId`、`nonce` 只接受 Protocol identifier contract 定义的固定 ASCII regex 和长度。 +- Historical item: 拒绝重复或歧义请求头:重复 `Host`、`Authorization`、`Content-Type`、`Content-Length`,非法 `Content-Length`,`Content-Length` 与 `Transfer-Encoding` 同时出现,不以 `chunked` 结尾的 `Transfer-Encoding`,以及非 `identity` 的 `Content-Encoding`。 +- Historical item: 对状态、request、control、profile 和错误响应设置 `Cache-Control: no-store` 与 `X-Content-Type-Options: nosniff`;profile endpoint 额外设置 `Referrer-Policy: no-referrer`。 +- Historical item: 校验 capture request:`url`、`mode`、`waitMs`、`include`、`viewports`、`options.forceRefresh`、`options.captureScreenshotMetadata`、`options.captureScreenshot`、`options.targetMode`、`options.keepTabOpen`、`options.allowPrivateNetworkTarget`、`options.maxResourceUrls` 和未知字段;超出协议范围时返回 `400 INVALID_REQUEST`,不得创建 capture 或打开浏览器。 +- Historical item: 使用安全随机源生成 `apiToken`、`bridgeToken`、`sessionId`、capture `nonce`、`profileTransferId` 和 bridge 页面 CSP nonce;不得使用 `Math.random()`、时间戳或递增计数器生成安全边界值。 +- Historical item: token 校验必须走共享 helper:先做格式和长度检查,再使用固定时间比较或等效安全比较;失败路径只返回统一 `UNAUTHORIZED`/`FORBIDDEN`,不得在错误或日志中区分“前缀正确但后缀错误”等可被枚举的信息。 +- Historical item: `/bridge` URL 不包含 API token;HTML 内嵌一次性 `bridgeToken`,并设置 no-store、no-referrer、nosniff、`X-Frame-Options: DENY`、`Cross-Origin-Opener-Policy: same-origin`、`Permissions-Policy` 和不含 `unsafe-inline`、包含 `script-src 'nonce-{cspNonce}'`、`style-src 'nonce-{cspNonce}'`、`connect-src 'self'`、`frame-ancestors 'none'` 的 CSP。 +- Historical item: bridge config JSON script 也必须带本次响应的 `nonce`,测试覆盖 ` @@ -696,6 +1096,12 @@ line-height: 1.5; padding-top: 152px; } + + @media (max-width: 760px) { + body { + padding-top: 0; + } + } + + +
+ +
+
+
+

运营概览

+

联系 user@example.com 或 13800138000,订单 1234567890123,金额 ¥199。

+ +
+ + + + +
Live
+ +
+
StackPrism
+ + diff --git a/tests/format-tech-stack.test.mjs b/tests/format-tech-stack.test.mjs index d318fe91..5bcc4ca0 100644 --- a/tests/format-tech-stack.test.mjs +++ b/tests/format-tech-stack.test.mjs @@ -94,7 +94,10 @@ test('formats detected technologies for humans and AI agents', async () => { assert.equal(structured.technologies.length, 4) assert.deepEqual(someTech.sources, ['响应头']) assert.equal(Object.hasOwn(someTech, 'source'), false) - assert.equal(structured.technologies.some(item => item.category === '应被过滤'), false) + assert.equal( + structured.technologies.some(item => item.category === '应被过滤'), + false + ) }) test('keeps empty detection reports readable and structured', async () => { diff --git a/tests/helpers/agent-bridge-browser-smoke-harness.mjs b/tests/helpers/agent-bridge-browser-smoke-harness.mjs new file mode 100644 index 00000000..e896260b --- /dev/null +++ b/tests/helpers/agent-bridge-browser-smoke-harness.mjs @@ -0,0 +1,1169 @@ +import { spawn } from 'node:child_process' +import dns from 'node:dns/promises' +import { existsSync, readdirSync } from 'node:fs' +import { readFile, mkdtemp, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import net from 'node:net' +import { homedir, tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const terminalStatuses = new Set(['completed', 'failed', 'cancelled', 'expired']) +const bridgeSecretPattern = /spbt?_[A-Za-z0-9_-]{20,}|(?:apiToken|bridgeToken)=/i + +export const sleep = ms => new Promise(resolveSleep => setTimeout(resolveSleep, ms)) +export const redactText = value => String(value || '').replace(/[A-Za-z0-9_-]{20,}/g, '[redacted]') + +export const assert = (condition, message) => { + if (!condition) throw new Error(message) +} + +const getJson = async url => (await fetch(url)).json() + +const playwrightChromeCandidates = () => { + const roots = [ + { + path: join(homedir(), 'Library/Caches/ms-playwright'), + executable: 'chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing' + }, + { path: join(homedir(), '.cache/ms-playwright'), executable: 'chrome-linux/chrome' } + ] + return roots.flatMap(root => { + if (!existsSync(root.path)) return [] + return readdirSync(root.path) + .filter(name => name.startsWith('chromium-')) + .sort() + .reverse() + .map(name => join(root.path, name, root.executable)) + }) +} + +const findBrowser = () => { + const browser = [process.env.STACKPRISM_BROWSER_SMOKE_CHROME, ...playwrightChromeCandidates()].filter(Boolean).find(existsSync) + if (!browser) throw new Error('Chrome for Testing was not found. Set STACKPRISM_BROWSER_SMOKE_CHROME.') + return browser +} + +export const connectTarget = async target => { + const socket = new WebSocket(target.webSocketDebuggerUrl) + await new Promise((resolveOpen, rejectOpen) => { + socket.addEventListener('open', resolveOpen, { once: true }) + socket.addEventListener('error', rejectOpen, { once: true }) + }) + let nextId = 0 + const pending = new Map() + let closed = false + const rejectPending = reason => { + if (closed) return + closed = true + const error = reason instanceof Error ? reason : new Error(String(reason || 'WebSocket closed')) + for (const callbacks of pending.values()) callbacks.reject(error) + pending.clear() + } + socket.addEventListener('message', event => { + if (closed) return + const message = JSON.parse(event.data) + if (!message.id || !pending.has(message.id)) return + const callbacks = pending.get(message.id) + pending.delete(message.id) + if (message.error) callbacks.reject(new Error(JSON.stringify(message.error))) + else callbacks.resolve(message.result) + }) + socket.addEventListener('close', () => rejectPending(new Error('WebSocket closed.'))) + socket.addEventListener('error', event => rejectPending(new Error(`WebSocket error: ${event?.message || 'unknown'}`))) + return { + send(method, params = {}) { + if (closed) return Promise.reject(new Error('WebSocket closed.')) + const id = ++nextId + socket.send(JSON.stringify({ id, method, params })) + return new Promise((resolveSend, rejectSend) => pending.set(id, { resolve: resolveSend, reject: rejectSend })) + }, + close() { + rejectPending(new Error('WebSocket closed by harness.')) + socket.close() + } + } +} + +export const createBrowserSmokeHarness = ({ root, dist, cdpPort }) => { + const cdpBaseUrl = `http://127.0.0.1:${cdpPort}` + + const waitForProcessExit = child => + !child || child.exitCode !== null || child.signalCode ? Promise.resolve() : new Promise(resolveExit => child.once('exit', resolveExit)) + + const stopProcess = async child => { + if (!child) return + if (child.exitCode === null && !child.killed) child.kill('SIGTERM') + await Promise.race([ + waitForProcessExit(child), + sleep(1000).then(() => { + if (child.exitCode === null) child.kill('SIGKILL') + }) + ]) + await Promise.race([waitForProcessExit(child), sleep(1000)]) + } + + const waitForCdp = async () => { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + return await getJson(`${cdpBaseUrl}/json/version`) + } catch { + await sleep(200) + } + } + throw new Error('Chrome DevTools endpoint did not start.') + } + + const stackPrismTargets = async () => { + const targets = await getJson(`${cdpBaseUrl}/json/list`) + return { + page: targets.find(target => target.type === 'page'), + worker: targets.find(target => target.type === 'service_worker' && target.url.includes('service-worker-loader.js')) + } + } + + const listTargets = async () => getJson(`${cdpBaseUrl}/json/list`) + + const closeTarget = async targetId => { + await fetch(`${cdpBaseUrl}/json/close/${targetId}`) + } + + const closePageTarget = async (target, page) => { + try { + page?.close() + } finally { + if (target?.id) await closeTarget(target.id).catch(() => {}) + } + } + + const rawHttp = (port, lines) => + new Promise((resolveRaw, rejectRaw) => { + const socket = net.createConnection({ host: '127.0.0.1', port: Number(port) }) + let data = '' + socket.on('connect', () => socket.write(lines.join('\r\n'))) + socket.on('data', chunk => { + data += chunk.toString('utf8') + }) + socket.on('error', rejectRaw) + socket.on('end', () => resolveRaw(data)) + }) + + const waitForWorker = async () => { + let lastTargets = [] + for (let attempt = 0; attempt < 20; attempt += 1) { + const targets = await getJson(`${cdpBaseUrl}/json/list`) + lastTargets = targets.map(target => ({ type: target.type, title: target.title, url: target.url })) + const worker = targets.find(target => target.type === 'service_worker' && target.url.includes('service-worker-loader.js')) + if (worker) return worker + await sleep(250) + } + throw new Error(`StackPrism service worker target was not found. Last targets: ${redactText(JSON.stringify(lastTargets))}`) + } + + const waitForNoWorker = async (attempts = 70) => { + let lastTargets = [] + for (let attempt = 0; attempt < attempts; attempt += 1) { + const targets = await getJson(`${cdpBaseUrl}/json/list`) + lastTargets = targets.map(target => ({ type: target.type, title: target.title, url: target.url })) + const worker = targets.find(target => target.type === 'service_worker' && target.url.includes('service-worker-loader.js')) + if (!worker) return { attempts: attempt + 1, elapsedMs: attempt * 1000 } + await sleep(1000) + } + throw new Error(`StackPrism service worker target remained active. Last targets: ${redactText(JSON.stringify(lastTargets))}`) + } + + const startChrome = async ({ extraArgs = [], profileDir = null } = {}) => { + const resolvedProfileDir = profileDir || (await mkdtemp(join(tmpdir(), 'stackprism-browser-smoke-'))) + const child = spawn(findBrowser(), [ + `--user-data-dir=${resolvedProfileDir}`, + `--remote-debugging-port=${cdpPort}`, + `--load-extension=${dist}`, + `--disable-extensions-except=${dist}`, + '--no-first-run', + '--no-default-browser-check', + ...extraArgs, + 'about:blank' + ]) + return { child, profileDir: resolvedProfileDir } + } + + const startChromeWithoutExtension = async ({ extraArgs = [] } = {}) => { + const profileDir = await mkdtemp(join(tmpdir(), 'stackprism-browser-smoke-no-extension-')) + const child = spawn(findBrowser(), [ + `--user-data-dir=${profileDir}`, + `--remote-debugging-port=${cdpPort}`, + '--no-first-run', + '--no-default-browser-check', + ...extraArgs, + 'about:blank' + ]) + return { child, profileDir } + } + + const startBridge = async (options = {}) => { + const suppressBrowserOpen = options.noOpen !== false + const child = spawn(process.execPath, ['agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs'], { + cwd: root, + env: { ...process.env, ...(suppressBrowserOpen ? { STACKPRISM_BRIDGE_NO_OPEN: '1' } : {}), ...(options.env || {}) }, + stdio: ['pipe', 'pipe', 'pipe'] + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', chunk => { + stdout += chunk.toString() + }) + child.stderr.on('data', chunk => { + stderr += chunk.toString() + }) + const stopChild = async () => { + await stopProcess(child) + } + for (let attempt = 0; attempt < 100 && !stdout.includes('\n'); attempt += 1) await sleep(100) + if (!stdout.includes('\n')) { + await stopChild() + throw new Error(`Bridge ready JSON was not printed. stderr=${redactText(stderr)}`) + } + try { + const ready = JSON.parse(stdout.trim().split('\n')[0]) + let stopped = false + const stop = async () => { + if (stopped) return + stopped = true + await stopChild() + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + await fetch(ready.healthUrl, { signal: AbortSignal.timeout(250) }) + } catch { + return + } + await sleep(100) + } + throw new Error(`Bridge health endpoint was still reachable after stop: ${ready.healthUrl}`) + } + return { child, ready, stderr: () => stderr, stop } + } catch (error) { + await stopChild() + throw new Error(`Bridge ready JSON was invalid. stderr=${redactText(stderr)} error=${redactText(error)}`) + } + } + + const stopBridge = async bridge => { + if (!bridge) return + if (typeof bridge.stop === 'function') await bridge.stop() + else await stopProcess(bridge.child) + } + + const setAgentBridgeEnabled = async (worker, enabled) => { + await waitForExtensionRuntime(worker) + await worker.send('Runtime.evaluate', { + expression: `chrome.storage.local.set({stackPrismSettings:{agentBridgeEnabled:${enabled ? 'true' : 'false'}}})`, + awaitPromise: true + }) + } + + const waitForExtensionRuntime = async worker => { + for (let attempt = 0; attempt < 30; attempt += 1) { + const result = await worker.send('Runtime.evaluate', { + expression: 'typeof chrome !== "undefined" && Boolean(chrome.runtime) && Boolean(chrome.storage?.local)', + returnByValue: true + }) + if (result.result?.value === true) return + await sleep(250) + } + throw new Error('StackPrism extension runtime APIs were not ready in the service worker target.') + } + + const createCapture = async (ready, request) => { + const body = JSON.stringify({ + url: request.url, + mode: 'experience', + waitMs: request.waitMs ?? 0, + include: request.include || ['tech', 'visual', 'layout', 'components', 'interaction', 'ux', 'assets'], + options: request.options || { allowPrivateNetworkTarget: false } + }) + const response = await fetch(`${ready.baseUrl}/v1/captures`, { + method: 'POST', + headers: { authorization: `Bearer ${ready.apiToken}`, 'content-type': 'application/json' }, + body + }) + return { status: response.status, body: await response.json() } + } + + const createPrivateTargetBlockedCapture = async (ready, target) => { + const targetUrl = typeof target === 'string' ? target : target.url + const blocked = await createCapture(ready, { + url: targetUrl, + options: { allowPrivateNetworkTarget: false, captureScreenshotMetadata: false, keepTabOpen: false, targetMode: 'new_tab' } + }) + const targets = await listTargets() + const targetStillVisible = targets.some(targetItem => targetItem.type === 'page' && String(targetItem.url || '').startsWith(targetUrl)) + return { + blocked, + requestCount: typeof target.requestCount === 'function' ? target.requestCount() : null, + targetStillVisible + } + } + + const createDnsNonGlobalBlockedCapture = async ready => { + const hostname = 'stackprism-browser-smoke.invalid' + const targetUrl = `https://${hostname}/dns-policy?token=secret#frag` + let resolvedAddresses = [] + let dnsError = '' + try { + resolvedAddresses = (await dns.lookup(hostname, { all: true, verbatim: true })).map(item => item.address) + } catch (error) { + dnsError = error?.code || (error instanceof Error ? error.message : String(error)) + } + const blocked = await createCapture(ready, { + url: targetUrl, + options: { allowPrivateNetworkTarget: false, captureScreenshotMetadata: false, keepTabOpen: false, targetMode: 'new_tab' } + }) + const targets = await listTargets() + const targetStillVisible = targets.some(target => target.type === 'page' && String(target.url || '').startsWith(targetUrl)) + return { blocked, dnsError, hostname, resolvedAddresses, targetStillVisible } + } + + const pollCapture = async (ready, captureId, attempts = 70) => { + let lastStatus = null + for (let attempt = 0; attempt < attempts; attempt += 1) { + await sleep(1000) + const response = await fetch(`${ready.baseUrl}/v1/captures/${captureId}`, { + headers: { authorization: `Bearer ${ready.apiToken}` } + }) + lastStatus = await response.json() + if (terminalStatuses.has(lastStatus.status)) return lastStatus + } + throw new Error(`Capture ${captureId} did not reach a terminal status. Last status: ${JSON.stringify(lastStatus)}`) + } + + const fetchJson = async (url, init) => { + const response = await fetch(url, init) + const text = await response.text() + return { status: response.status, body: text ? JSON.parse(text) : null, text } + } + + const newPageTarget = async () => { + const response = await fetch(`${cdpBaseUrl}/json/new?about:blank`, { method: 'PUT' }) + if (!response.ok) throw new Error(`Failed to create CDP page: ${response.status}`) + return response.json() + } + + const newIncognitoPageTarget = async (url = 'about:blank') => { + const version = await waitForCdp() + const browser = await connectTarget({ webSocketDebuggerUrl: version.webSocketDebuggerUrl }) + try { + const { browserContextId } = await browser.send('Target.createBrowserContext') + const { targetId } = await browser.send('Target.createTarget', { browserContextId, url }) + for (let attempt = 0; attempt < 30; attempt += 1) { + const target = (await listTargets()).find(item => item.id === targetId) + if (target?.webSocketDebuggerUrl) return { browserContextId, target } + await sleep(100) + } + throw new Error(`Incognito CDP target ${targetId} was not visible.`) + } finally { + browser.close() + } + } + + const disposeBrowserContext = async browserContextId => { + if (!browserContextId) return + const version = await waitForCdp() + const browser = await connectTarget({ webSocketDebuggerUrl: version.webSocketDebuggerUrl }) + try { + await browser.send('Target.disposeBrowserContext', { browserContextId }) + } finally { + browser.close() + } + } + + const navigateBridgePage = async (target, bridgeUrl) => { + const page = await connectTarget(target) + await page.send('Page.enable') + await page.send('Runtime.enable') + await page.send('Page.navigate', { url: bridgeUrl }) + let serializedHistory = '' + for (let attempt = 0; attempt < 20; attempt += 1) { + const currentUrl = await page.send('Runtime.evaluate', { expression: 'location.href', returnByValue: true }) + const navigationHistory = await page.send('Page.getNavigationHistory') + serializedHistory = JSON.stringify({ + currentUrl: currentUrl.result?.value, + navigationHistory + }) + if (serializedHistory.includes('/bridge?')) break + await sleep(50) + } + assert(serializedHistory.includes('/bridge?'), `Bridge navigation history did not include bridge URL: ${redactText(serializedHistory)}`) + assert( + !bridgeSecretPattern.test(serializedHistory), + `Bridge navigation history leaked token material: ${redactText(serializedHistory)}` + ) + return page + } + + const openBridgePage = async bridgeUrl => { + const target = await newPageTarget() + const page = await navigateBridgePage(target, bridgeUrl) + return { target, page } + } + + const openIncognitoBridgePage = async bridgeUrl => { + const { browserContextId, target } = await newIncognitoPageTarget() + const page = await navigateBridgePage(target, bridgeUrl) + const close = async () => { + try { + await closePageTarget(target, page) + } finally { + await disposeBrowserContext(browserContextId).catch(() => {}) + } + } + return { browserContextId, close, page, target } + } + + const probeBridgeIframeBlocking = async bridgeUrl => { + let attackerRequestCount = 0 + const server = createServer((_req, res) => { + attackerRequestCount += 1 + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }) + res.end(``) + }) + await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)) + const { port } = server.address() + const attackerUrl = `http://127.0.0.1:${port}/attacker-frame` + const target = await newPageTarget() + const page = await connectTarget(target) + let probe = null + try { + await page.send('Page.enable') + await page.send('Runtime.enable') + await page.send('Page.navigate', { url: attackerUrl }) + for (let attempt = 0; attempt < 20; attempt += 1) { + const result = await page.send('Runtime.evaluate', { + expression: `(() => { + const frame = document.querySelector('#bridge-frame'); + let frameAccess = 'missing'; + let frameBodyText = ''; + let frameHtmlIncludesBridgeToken = false; + try { + if (frame?.contentDocument) { + frameAccess = 'accessible'; + frameBodyText = frame.contentDocument.body?.innerText || ''; + frameHtmlIncludesBridgeToken = frame.contentDocument.documentElement?.outerHTML.includes('spbt_') === true; + } else { + frameAccess = 'not-accessible'; + } + } catch { + frameAccess = 'cross-origin-or-blocked'; + } + return JSON.stringify({ + bodyText: document.body?.innerText || '', + frameAccess, + frameBodyText, + frameCount: window.frames.length, + frameSrc: frame?.getAttribute('src') || '', + frameHtmlIncludesBridgeToken, + outerHtmlIncludesBridgeToken: document.documentElement.outerHTML.includes('spbt_') + }); + })()`, + returnByValue: true + }) + probe = JSON.parse(result.result?.value || '{}') + if (probe.frameCount > 0) break + await sleep(100) + } + const firstRender = await fetch(bridgeUrl) + const firstRenderText = await firstRender.text() + return { + attackerRequestCount, + attackerUrl, + firstRenderContainsBridgeToken: firstRenderText.includes('spbt_'), + firstRenderStatus: firstRender.status, + probe + } + } finally { + await closePageTarget(target, page) + server.close() + } + } + + const disableExtensionFromExtensionsPage = async extensionId => { + const safeExtensionId = String(extensionId || '') + if (!/^[a-z]{32}$/.test(safeExtensionId)) throw new Error('Extension id was not a Chrome extension id.') + const target = await newPageTarget() + const page = await connectTarget(target) + let lastResult = null + const extensionSelector = `extensions-item#${safeExtensionId}` + try { + await page.send('Page.enable') + await page.send('Runtime.enable') + await page.send('Page.navigate', { url: `chrome://extensions/?id=${safeExtensionId}` }) + for (let attempt = 0; attempt < 20; attempt += 1) { + await sleep(250) + const result = await page.send('Runtime.evaluate', { + expression: `(() => { + const manager = document.querySelector('extensions-manager'); + const itemList = manager?.shadowRoot?.querySelector('extensions-item-list'); + const item = itemList?.shadowRoot?.querySelector(${JSON.stringify(extensionSelector)}); + const toggle = item?.shadowRoot?.querySelector('#enableToggle'); + if (!toggle) return JSON.stringify({ found: false, before: null, after: null }); + const before = Boolean(toggle.checked); + if (before) toggle.click(); + return JSON.stringify({ found: true, before, after: Boolean(toggle.checked) }); + })()`, + returnByValue: true + }) + lastResult = JSON.parse(result.result?.value || '{}') + if (lastResult.found && lastResult.before === true && lastResult.after === false) return lastResult + if (lastResult.found && lastResult.before === false) return lastResult + } + return lastResult || { found: false, before: null, after: null } + } finally { + await closePageTarget(target, page) + } + } + + const reloadExtensionFromExtensionsPage = async extensionId => { + const safeExtensionId = String(extensionId || '') + if (!/^[a-z]{32}$/.test(safeExtensionId)) throw new Error('Extension id was not a Chrome extension id.') + const target = await newPageTarget() + const page = await connectTarget(target) + let lastResult = null + const extensionSelector = `extensions-item#${safeExtensionId}` + try { + await page.send('Page.enable') + await page.send('Runtime.enable') + await page.send('Page.navigate', { url: `chrome://extensions/?id=${safeExtensionId}` }) + for (let attempt = 0; attempt < 20; attempt += 1) { + await sleep(250) + const result = await page.send('Runtime.evaluate', { + expression: `(() => { + const manager = document.querySelector('extensions-manager'); + const itemList = manager?.shadowRoot?.querySelector('extensions-item-list'); + const item = itemList?.shadowRoot?.querySelector(${JSON.stringify(extensionSelector)}); + const reload = item?.shadowRoot?.querySelector('#dev-reload-button'); + if (!reload) return JSON.stringify({ found: false, clicked: false, disabled: null }); + const disabled = reload.disabled === true; + if (!disabled) reload.click(); + return JSON.stringify({ found: true, clicked: !disabled, disabled }); + })()`, + returnByValue: true + }) + lastResult = JSON.parse(result.result?.value || '{}') + if (lastResult.found && lastResult.clicked) return lastResult + if (lastResult.found && lastResult.disabled) return lastResult + } + return lastResult || { found: false, clicked: false, disabled: null } + } finally { + await closePageTarget(target, page) + } + } + + const enableIncognitoFromExtensionsPage = async extensionId => { + const safeExtensionId = String(extensionId || '') + if (!/^[a-z]{32}$/.test(safeExtensionId)) throw new Error('Extension id was not a Chrome extension id.') + const target = await newPageTarget() + const page = await connectTarget(target) + let lastResult = null + const extensionSelector = `extensions-item#${safeExtensionId}` + try { + await page.send('Page.enable') + await page.send('Runtime.enable') + await page.send('Page.navigate', { url: `chrome://extensions/?id=${safeExtensionId}` }) + for (let attempt = 0; attempt < 30; attempt += 1) { + await sleep(250) + const result = await page.send('Runtime.evaluate', { + expression: `(() => { + const manager = document.querySelector('extensions-manager'); + const managerRoot = manager?.shadowRoot; + const detailView = managerRoot?.querySelector('extensions-detail-view'); + if (!detailView) { + const itemList = managerRoot?.querySelector('extensions-item-list'); + const item = itemList?.shadowRoot?.querySelector(${JSON.stringify(extensionSelector)}); + const detailsButton = item?.shadowRoot?.querySelector('#detailsButton'); + if (detailsButton) detailsButton.click(); + return JSON.stringify({ found: false, detailView: false, clickedDetails: Boolean(detailsButton), before: null, after: null, disabled: null }); + } + const row = detailView.shadowRoot?.querySelector('#allow-incognito'); + const toggle = row?.shadowRoot?.querySelector('cr-toggle') || row?.shadowRoot?.querySelector('#crToggle'); + if (!row && !toggle) { + return JSON.stringify({ found: false, detailView: true, clickedDetails: false, before: null, after: null, disabled: null }); + } + const before = Boolean(row?.checked ?? toggle?.checked); + const disabled = Boolean(row?.disabled ?? toggle?.disabled); + if (!before && !disabled) (toggle || row).click(); + const after = Boolean(row?.checked ?? toggle?.checked); + const restartText = detailView.shadowRoot?.textContent || ''; + return JSON.stringify({ found: true, detailView: true, clickedDetails: false, before, after, disabled, restartRequired: /restart|重新启动|重启/i.test(restartText) }); + })()`, + returnByValue: true + }) + lastResult = JSON.parse(result.result?.value || '{}') + if (lastResult.found && lastResult.after === true) return lastResult + if (lastResult.found && lastResult.disabled) return lastResult + } + return lastResult || { found: false, before: null, after: null, disabled: null } + } finally { + await closePageTarget(target, page) + } + } + + const driveCapture = async (ready, capture) => { + const opened = await openBridgePage(capture.body.bridgeUrl) + const { page } = opened + const finalStatus = await pollCapture(ready, capture.body.id) + const profile = + finalStatus?.status === 'completed' + ? await fetchJson(`${ready.baseUrl}/v1/captures/${capture.body.id}/profile`, { + headers: { authorization: `Bearer ${ready.apiToken}` } + }) + : null + const dom = await page.send('Runtime.evaluate', { + expression: + '({ready:document.documentElement.dataset.stackprismAgentBridgeClient||"",error:document.documentElement.dataset.stackprismAgentBridgeError||"",title:document.title})', + returnByValue: true + }) + await closePageTarget(opened.target, page) + return { finalStatus, profile, dom: dom.result.value } + } + + const getExtensionCaptureState = async (worker, captureId) => { + const result = await worker.send('Runtime.evaluate', { + expression: `chrome.storage.session.get('agent-capture:${captureId}').then(value => JSON.stringify(value['agent-capture:${captureId}'] || null))`, + awaitPromise: true, + returnByValue: true + }) + return JSON.parse(result.result?.value || 'null') + } + + const debugExtensionCaptureState = async (worker, captureId) => { + const result = await worker.send('Runtime.evaluate', { + expression: `Promise.all([ + chrome.storage.session.get(null), + chrome.tabs.query({}) + ]).then(([storage, tabs]) => JSON.stringify({ + captureId: ${JSON.stringify(captureId)}, + storageKeys: Object.keys(storage).filter(key => key.startsWith('agent-capture:') || key.startsWith('agent-bridge-session:')).sort(), + tabUrls: tabs.map(tab => ({id: tab.id, windowId: tab.windowId, url: tab.url, status: tab.status, incognito: tab.incognito})) + }))`, + awaitPromise: true, + returnByValue: true + }) + try { + return JSON.parse(result.result?.value || '{}') + } catch { + return { parseError: true, value: result.result?.value || '' } + } + } + + const waitForExtensionCaptureState = async (worker, captureId, predicate, attempts = 100) => { + for (let attempt = 0; attempt < attempts; attempt += 1) { + const state = await getExtensionCaptureState(worker, captureId) + if (state && predicate(state)) return state + await sleep(100) + } + const debug = await debugExtensionCaptureState(worker, captureId).catch(error => ({ + error: error instanceof Error ? error.message : String(error) + })) + throw new Error(`Extension capture state was not observed for ${captureId}. Debug: ${redactText(JSON.stringify(debug))}`) + } + + const removeTab = async (worker, tabId) => { + await worker.send('Runtime.evaluate', { + expression: `chrome.tabs.remove(${Number(tabId)})`, + awaitPromise: true + }) + } + + const updateTabUrl = async (worker, tabId, url) => { + const result = await worker.send('Runtime.evaluate', { + expression: `chrome.tabs.update(${Number(tabId)}, { url: ${JSON.stringify(url)} }).then(tab => JSON.stringify({ id: tab.id, url: tab.url || '', status: tab.status || '' }))`, + awaitPromise: true, + returnByValue: true + }) + return JSON.parse(result.result?.value || '{}') + } + + const createExtensionTab = async (worker, url, { active = true } = {}) => { + const result = await worker.send('Runtime.evaluate', { + expression: `chrome.tabs.create({ url: ${JSON.stringify(url)}, active: ${active ? 'true' : 'false'} }).then(tab => JSON.stringify({ id: tab.id, windowId: tab.windowId, url: tab.url || '', active: tab.active === true }))`, + awaitPromise: true, + returnByValue: true + }) + return JSON.parse(result.result?.value || '{}') + } + + const listExtensionTabs = async worker => { + const result = await worker.send('Runtime.evaluate', { + expression: `chrome.tabs.query({}).then(tabs => JSON.stringify(tabs.map(tab => ({ id: tab.id, windowId: tab.windowId, url: tab.url || '', active: tab.active === true, status: tab.status || '' }))))`, + awaitPromise: true, + returnByValue: true + }) + return JSON.parse(result.result?.value || '[]') + } + + const waitForNoPageTarget = async urlPrefix => { + let visibleTargets = [] + for (let attempt = 0; attempt < 30; attempt += 1) { + const targets = await listTargets() + visibleTargets = targets.filter(target => target.type === 'page' && String(target.url || '').startsWith(urlPrefix)) + if (visibleTargets.length === 0) return { targetStillVisible: false, visibleTargets: [] } + await sleep(100) + } + return { + targetStillVisible: true, + visibleTargets: visibleTargets.map(target => ({ id: target.id, url: target.url })) + } + } + + const tabExists = async (worker, tabId) => { + const result = await worker.send('Runtime.evaluate', { + expression: `chrome.tabs.get(${Number(tabId)}).then(() => true).catch(() => false)`, + awaitPromise: true, + returnByValue: true + }) + return result.result?.value === true + } + + const driveCaptureWithClosedTarget = async (ready, worker, capture) => { + const opened = await openBridgePage(capture.body.bridgeUrl) + const state = await waitForExtensionCaptureState(worker, capture.body.id, value => Number.isInteger(value.targetTabId)) + await removeTab(worker, state.targetTabId) + const finalStatus = await pollCapture(ready, capture.body.id, 20) + await closePageTarget(opened.target, opened.page) + return { finalStatus, closedTabId: state.targetTabId } + } + + const driveCaptureWithClosedBridge = async (ready, worker, capture) => { + const target = await newPageTarget() + const page = await connectTarget(target) + await page.send('Page.enable') + await page.send('Runtime.enable') + await page.send('Page.navigate', { url: capture.body.bridgeUrl }) + const state = await waitForExtensionCaptureState(worker, capture.body.id, value => Number.isInteger(value.bridgeTabId)) + await removeTab(worker, state.bridgeTabId) + const finalStatus = await pollCapture(ready, capture.body.id, 20) + await closePageTarget(target, page) + return { finalStatus, closedTabId: state.bridgeTabId } + } + + const driveCaptureWithCancel = async (ready, worker, capture) => { + const opened = await openBridgePage(capture.body.bridgeUrl) + const state = await waitForExtensionCaptureState(worker, capture.body.id, value => Number.isInteger(value.targetTabId)) + const cancel = await fetchJson(`${ready.baseUrl}/v1/captures/${capture.body.id}`, { + method: 'DELETE', + headers: { authorization: `Bearer ${ready.apiToken}` } + }) + const finalStatus = await pollCapture(ready, capture.body.id, 20) + const targetStillExists = await tabExists(worker, state.targetTabId) + await closePageTarget(opened.target, opened.page) + return { cancelStatus: cancel.status, finalStatus, closedTabId: state.targetTabId, targetStillExists } + } + + const driveCaptureWithLocalOptInDisabled = async (ready, worker, capture, targetUrlPrefix) => { + const opened = await openBridgePage(capture.body.bridgeUrl) + try { + await waitForExtensionRuntime(worker) + const state = await waitForExtensionCaptureState(worker, capture.body.id, value => Number.isInteger(value.targetTabId)) + await setAgentBridgeEnabled(worker, false) + const finalStatus = await pollCapture(ready, capture.body.id, 20) + const targets = await listTargets() + const targetStillVisible = targets.some(target => target.type === 'page' && String(target.url || '').startsWith(targetUrlPrefix)) + return { finalStatus, targetTabId: state.targetTabId, targetStillVisible } + } finally { + await closePageTarget(opened.target, opened.page) + } + } + + const driveCaptureWithExpiredDeadlineReconciliation = async (ready, worker, capture, targetUrlPrefix) => { + const opened = await openBridgePage(capture.body.bridgeUrl) + try { + await waitForExtensionRuntime(worker) + const state = await waitForExtensionCaptureState( + worker, + capture.body.id, + value => Number.isInteger(value.targetTabId) && value.phase === 'target_loaded' + ) + const stateKey = `agent-capture:${capture.body.id}` + const mutation = await worker.send('Runtime.evaluate', { + expression: `chrome.storage.session.get(${JSON.stringify(stateKey)}).then(stored => { + const state = stored[${JSON.stringify(stateKey)}]; + if (!state) return JSON.stringify({ ok: false, reason: 'missing-state' }); + state.deadlineAt = Date.now() - 1000; + state.updatedAt = Date.now() - 1000; + return chrome.storage.session.set({ [${JSON.stringify(stateKey)}]: state }) + .then(() => chrome.tabs.create({ url: 'about:blank', active: false })) + .then(tab => JSON.stringify({ ok: true, triggerTabId: tab.id, targetTabId: state.targetTabId, deadlineAt: state.deadlineAt })); + })`, + awaitPromise: true, + returnByValue: true + }) + const mutated = JSON.parse(mutation.result?.value || '{}') + if (!mutated.ok) throw new Error(`Failed to force expired deadline: ${JSON.stringify(mutated)}`) + const finalStatus = await pollCapture(ready, capture.body.id, 20) + const targets = await listTargets() + const targetStillVisible = targets.some(target => target.type === 'page' && String(target.url || '').startsWith(targetUrlPrefix)) + const triggerStillVisible = targets.some(target => target.type === 'page' && target.id === String(mutated.triggerTabId)) + return { + finalStatus, + targetTabId: state.targetTabId, + triggerTabId: mutated.triggerTabId, + targetStillVisible, + triggerStillVisible + } + } finally { + await closePageTarget(opened.target, opened.page) + } + } + + const driveCaptureWithFinalUrlBlocked = async (ready, capture, targetUrlPrefixes) => { + const opened = await openBridgePage(capture.body.bridgeUrl) + try { + const prefixes = Array.isArray(targetUrlPrefixes) ? targetUrlPrefixes : [targetUrlPrefixes] + const finalStatus = await pollCapture(ready, capture.body.id, 20) + const targets = await listTargets() + const targetStillVisible = targets.some( + target => target.type === 'page' && prefixes.some(prefix => String(target.url || '').startsWith(String(prefix || ''))) + ) + return { finalStatus, targetStillVisible } + } finally { + await closePageTarget(opened.target, opened.page) + } + } + + const driveCaptureWithTargetNavigationAway = async (ready, worker, capture, awayUrl, targetUrlPrefix) => { + const opened = await openBridgePage(capture.body.bridgeUrl) + try { + await waitForExtensionRuntime(worker) + const state = await waitForExtensionCaptureState( + worker, + capture.body.id, + value => Number.isInteger(value.targetTabId) && typeof value.finalUrl === 'string' && value.finalUrl.length > 0 + ) + const navigated = await updateTabUrl(worker, state.targetTabId, awayUrl) + const finalStatus = await pollCapture(ready, capture.body.id, 20) + const profile = await fetchJson(`${ready.baseUrl}/v1/captures/${capture.body.id}/profile`, { + headers: { authorization: `Bearer ${ready.apiToken}` } + }) + const targets = await listTargets() + const targetStillVisible = targets.some(target => target.type === 'page' && String(target.url || '').startsWith(targetUrlPrefix)) + if (targetStillVisible) await removeTab(worker, state.targetTabId).catch(() => {}) + return { + finalStatus, + originalFinalUrl: state.finalUrl, + profileStatus: profile.status, + requestedAwayUrl: awayUrl, + targetTabId: state.targetTabId, + targetStillVisible, + updateResultUrl: navigated.url || '' + } + } finally { + await closePageTarget(opened.target, opened.page) + } + } + + const driveCaptureWithTargetTerminalFailure = async (ready, worker, capture, attempts = 20) => { + const opened = await openBridgePage(capture.body.bridgeUrl) + try { + await waitForExtensionRuntime(worker) + const state = await waitForExtensionCaptureState(worker, capture.body.id, value => Number.isInteger(value.targetTabId)) + const finalStatus = await pollCapture(ready, capture.body.id, attempts) + const profile = await fetchJson(`${ready.baseUrl}/v1/captures/${capture.body.id}/profile`, { + headers: { authorization: `Bearer ${ready.apiToken}` } + }) + const targetStillExists = await tabExists(worker, state.targetTabId) + if (targetStillExists) await removeTab(worker, state.targetTabId).catch(() => {}) + return { + finalStatus, + profileStatus: profile.status, + targetTabId: state.targetTabId, + targetStillExists + } + } finally { + await closePageTarget(opened.target, opened.page) + } + } + + const driveCaptureWithTargetLoadFailure = async (ready, worker, capture) => + driveCaptureWithTargetTerminalFailure(ready, worker, capture, 20) + + const driveCaptureWithTargetLoadTimeout = async (ready, worker, capture) => + driveCaptureWithTargetTerminalFailure(ready, worker, capture, 80) + + const driveCaptureWithExtensionReload = async (ready, capture, targetUrlPrefix) => { + const opened = await openBridgePage(capture.body.bridgeUrl) + let worker + try { + const workerTarget = await waitForWorker() + worker = await connectTarget(workerTarget) + await worker.send('Runtime.enable') + await waitForExtensionRuntime(worker) + const state = await waitForExtensionCaptureState(worker, capture.body.id, value => Number.isInteger(value.targetTabId)) + let reloadError = null + try { + await worker.send('Runtime.evaluate', { expression: 'chrome.runtime.reload()', awaitPromise: false }) + } catch (error) { + reloadError = redactText(error?.message || error) + } + const finalStatus = await pollCapture(ready, capture.body.id, 80) + const targets = await listTargets() + const targetStillVisible = targets.some(target => target.type === 'page' && String(target.url || '').startsWith(targetUrlPrefix)) + return { finalStatus, reloadError, targetTabId: state.targetTabId, targetStillVisible } + } finally { + worker?.close() + await closePageTarget(opened.target, opened.page) + } + } + + const driveCaptureWithClearedStorageSessionAndReload = async (ready, worker, capture, targetUrlPrefix) => { + const opened = await openBridgePage(capture.body.bridgeUrl) + try { + await waitForExtensionRuntime(worker) + const state = await waitForExtensionCaptureState(worker, capture.body.id, value => Number.isInteger(value.targetTabId)) + await worker.send('Runtime.evaluate', { + expression: 'chrome.storage.session.clear().then(() => chrome.runtime.reload())', + awaitPromise: false + }) + const finalStatus = await pollCapture(ready, capture.body.id, 80) + const targets = await listTargets() + const targetStillVisible = targets.some(target => target.type === 'page' && String(target.url || '').startsWith(targetUrlPrefix)) + return { finalStatus, targetTabId: state.targetTabId, targetStillVisible } + } finally { + await closePageTarget(opened.target, opened.page) + } + } + + const driveCaptureWithServiceWorkerTargetClose = async (ready, capture, targetUrlPrefix) => { + const opened = await openBridgePage(capture.body.bridgeUrl) + let worker + try { + const workerTarget = await waitForWorker() + worker = await connectTarget(workerTarget) + await worker.send('Runtime.enable') + await waitForExtensionRuntime(worker) + const state = await waitForExtensionCaptureState(worker, capture.body.id, value => Number.isInteger(value.targetTabId)) + await closeTarget(workerTarget.id) + const finalStatus = await pollCapture(ready, capture.body.id, 80) + const targets = await listTargets() + const targetStillVisible = targets.some(target => target.type === 'page' && String(target.url || '').startsWith(targetUrlPrefix)) + return { finalStatus, targetTabId: state.targetTabId, workerTargetId: workerTarget.id, targetStillVisible } + } finally { + worker?.close() + await closePageTarget(opened.target, opened.page) + } + } + + const startFixtureServer = async () => { + const html = await readFile(resolve(root, 'tests/fixtures/site-experience-fixture.html'), 'utf8') + const server = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }) + res.end(html) + }) + await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)) + const { port } = server.address() + return { server, url: `http://127.0.0.1:${port}/fixture?token=secret#frag` } + } + + const startProbeServer = async () => { + let requestCount = 0 + const server = createServer((_req, res) => { + requestCount += 1 + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }) + res.end('StackPrism probe
probe
') + }) + await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)) + const { port } = server.address() + return { server, url: `http://127.0.0.1:${port}/probe?token=secret#frag`, requestCount: () => requestCount } + } + + const startBridgeSelfRedirectServer = async bridgeBaseUrl => { + let requestCount = 0 + const finalUrlPrefix = `${bridgeBaseUrl}/redirected-final-target` + const server = createServer((_req, res) => { + requestCount += 1 + res.writeHead(302, { location: `${finalUrlPrefix}?token=secret#frag`, 'cache-control': 'no-store' }) + res.end() + }) + await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)) + const { port } = server.address() + return { + finalUrlPrefix, + requestCount: () => requestCount, + server, + url: `http://127.0.0.1:${port}/redirect-to-bridge?token=secret#frag` + } + } + + const startPrivateFinalProxyServer = async () => { + let proxyRequestCount = 0 + let privateRequestCount = 0 + const privateServer = createServer((_req, res) => { + privateRequestCount += 1 + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }) + res.end('Private final target
private final
') + }) + await new Promise(resolveListen => privateServer.listen(0, '127.0.0.1', resolveListen)) + const privatePort = privateServer.address().port + const finalUrlPrefix = `http://127.0.0.1:${privatePort}/private-final` + + const proxyServer = createServer((_req, res) => { + proxyRequestCount += 1 + res.writeHead(302, { location: `${finalUrlPrefix}?token=secret#frag`, 'cache-control': 'no-store' }) + res.end() + }) + await new Promise(resolveListen => proxyServer.listen(0, '127.0.0.1', resolveListen)) + const proxyPort = proxyServer.address().port + return { + finalUrlPrefix, + privateRequestCount: () => privateRequestCount, + privateServer, + proxyRequestCount: () => proxyRequestCount, + proxyServer, + proxyUrl: `http://127.0.0.1:${proxyPort}`, + url: 'http://93.184.216.34:18080/redirect-private-final?token=secret#frag' + } + } + + const startDnsFinalProxyServer = async ({ finalHostname }) => { + let proxyRequestCount = 0 + let finalRequestCount = 0 + const finalUrlPrefix = `http://${finalHostname}/dns-final` + const proxyServer = createServer((req, res) => { + proxyRequestCount += 1 + if (String(req.url || '').includes('/dns-final')) { + finalRequestCount += 1 + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }) + res.end('DNS final target
dns final
') + return + } + res.writeHead(302, { location: `${finalUrlPrefix}?token=secret#frag`, 'cache-control': 'no-store' }) + res.end() + }) + await new Promise(resolveListen => proxyServer.listen(0, '127.0.0.1', resolveListen)) + const proxyPort = proxyServer.address().port + return { + finalRequestCount: () => finalRequestCount, + finalUrlPrefix, + hostname: finalHostname, + proxyRequestCount: () => proxyRequestCount, + proxyServer, + proxyUrl: `http://127.0.0.1:${proxyPort}`, + url: 'http://93.184.216.34:18081/redirect-dns-final?token=secret#frag' + } + } + + const startSlowFixtureServer = async (delayMs = 10000) => { + const html = await readFile(resolve(root, 'tests/fixtures/site-experience-fixture.html'), 'utf8') + const server = createServer((_req, res) => { + setTimeout(() => { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }) + res.end(html) + }, delayMs) + }) + await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)) + const { port } = server.address() + return { server, url: `http://127.0.0.1:${port}/slow-fixture?token=secret#frag` } + } + + const startLoadFailureServer = async (delayMs = 1000) => { + let requestCount = 0 + const server = createServer((req, _res) => { + requestCount += 1 + setTimeout(() => { + req.socket.destroy() + }, delayMs) + }) + await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)) + const { port } = server.address() + return { + requestCount: () => requestCount, + server, + url: `http://127.0.0.1:${port}/target-load-failed?token=secret#frag` + } + } + + const startLargeProfileFixtureServer = async () => { + const longPath = 'asset-'.repeat(1800) + const images = Array.from( + { length: 36 }, + (_item, index) => + `large asset ${index}` + ).join('\n') + const html = `Large StackPrism Fixture

Large profile fixture

${images}
` + const server = createServer((req, res) => { + if (req.url?.startsWith('/large-assets/')) { + res.writeHead(204, { 'cache-control': 'no-store' }) + res.end() + return + } + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }) + res.end(html) + }) + await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)) + const { port } = server.address() + return { server, url: `http://127.0.0.1:${port}/large-fixture?token=secret#frag` } + } + + const cleanupChrome = async chrome => { + await stopProcess(chrome?.child) + await rm(chrome.profileDir, { recursive: true, force: true }) + } + + const stopChrome = async chrome => { + await stopProcess(chrome?.child) + } + + return { + cleanupChrome, + closeTarget, + connectTarget, + createCapture, + createDnsNonGlobalBlockedCapture, + createExtensionTab, + createPrivateTargetBlockedCapture, + disableExtensionFromExtensionsPage, + disposeBrowserContext, + driveCapture, + driveCaptureWithCancel, + driveCaptureWithClosedBridge, + driveCaptureWithClearedStorageSessionAndReload, + driveCaptureWithExtensionReload, + driveCaptureWithExpiredDeadlineReconciliation, + driveCaptureWithFinalUrlBlocked, + driveCaptureWithLocalOptInDisabled, + driveCaptureWithTargetLoadFailure, + driveCaptureWithTargetLoadTimeout, + driveCaptureWithTargetNavigationAway, + driveCaptureWithServiceWorkerTargetClose, + driveCaptureWithClosedTarget, + enableIncognitoFromExtensionsPage, + fetchJson, + getExtensionCaptureState, + listExtensionTabs, + listTargets, + openBridgePage, + openIncognitoBridgePage, + pollCapture, + probeBridgeIframeBlocking, + rawHttp, + reloadExtensionFromExtensionsPage, + removeTab, + setAgentBridgeEnabled, + startBridge, + startChrome, + startDnsFinalProxyServer, + startFixtureServer, + startBridgeSelfRedirectServer, + startPrivateFinalProxyServer, + startChromeWithoutExtension, + startLargeProfileFixtureServer, + startLoadFailureServer, + startProbeServer, + startSlowFixtureServer, + stopChrome, + waitForCdp, + waitForExtensionCaptureState, + waitForExtensionRuntime, + waitForNoWorker, + waitForNoPageTarget, + stopBridge, + waitForWorker + } +} diff --git a/tests/helpers/load-ts-module.mjs b/tests/helpers/load-ts-module.mjs new file mode 100644 index 00000000..868192ee --- /dev/null +++ b/tests/helpers/load-ts-module.mjs @@ -0,0 +1,97 @@ +import { mkdtemp, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import ts from 'typescript' + +const projectRoot = path.resolve(new URL('../..', import.meta.url).pathname) +let rootPromise = mkdtemp(path.join(tmpdir(), 'stackprism-ts-test-')) +let compiled = new Map() +let imported = new Map() + +const ensureTsPath = specifierPath => { + if (specifierPath.endsWith('.ts')) return specifierPath + return `${specifierPath}.ts` +} + +const toRelativeImport = (fromFile, toFile) => { + const relative = path.relative(path.dirname(fromFile), toFile).replaceAll(path.sep, '/') + return relative.startsWith('.') ? relative : `./${relative}` +} + +export const resetLoadTsModuleCaches = () => { + rootPromise = mkdtemp(path.join(tmpdir(), 'stackprism-ts-test-')) + compiled = new Map() + imported = new Map() +} + +export const loadTsModule = async modulePath => { + const root = await rootPromise + + const resolveProjectSpecifier = (specifier, fromSourceFile) => { + if (specifier.startsWith('@/')) { + return ensureTsPath(path.join(projectRoot, 'src', specifier.slice(2))) + } + if (specifier.startsWith('.')) { + return ensureTsPath(path.resolve(path.dirname(fromSourceFile), specifier)) + } + return null + } + + const compileFile = async sourceFile => { + const absoluteSource = path.resolve(projectRoot, sourceFile) + if (compiled.has(absoluteSource)) return compiled.get(absoluteSource) + + const compilePromise = (async () => { + const relativeSource = path.relative(projectRoot, absoluteSource) + const outputFile = path.join(root, relativeSource).replace(/\.ts$/, '.mjs') + + const source = await readFile(absoluteSource, 'utf8') + const rewriteSpecifier = async specifier => { + const dependency = resolveProjectSpecifier(specifier, absoluteSource) + if (!dependency) return specifier + return toRelativeImport(outputFile, await compileFile(dependency)) + } + + let rewritten = source + const replacements = [] + const collect = regex => { + for (const match of source.matchAll(regex)) { + replacements.push({ + start: match.index + match[1].length, + end: match.index + match[0].length - match[3].length, + specifier: match[2] + }) + } + } + collect(/(\bfrom\s+['"])([^'"]+)(['"])/g) + collect(/(\bimport\s+['"])([^'"]+)(['"])/g) + + for (const replacement of replacements.reverse()) { + const nextSpecifier = await rewriteSpecifier(replacement.specifier) + rewritten = `${rewritten.slice(0, replacement.start)}${nextSpecifier}${rewritten.slice(replacement.end)}` + } + + const { outputText } = ts.transpileModule(rewritten, { + compilerOptions: { + module: ts.ModuleKind.ES2022, + target: ts.ScriptTarget.ES2022 + }, + fileName: absoluteSource + }) + + await import('node:fs/promises').then(fs => fs.mkdir(path.dirname(outputFile), { recursive: true })) + await import('node:fs/promises').then(fs => fs.writeFile(outputFile, outputText, 'utf8')) + return outputFile + })() + + compiled.set(absoluteSource, compilePromise) + return compilePromise + } + + const outputFile = await compileFile(modulePath) + if (!imported.has(outputFile)) { + imported.set(outputFile, import(pathToFileURL(outputFile).href)) + } + return imported.get(outputFile) +} diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs new file mode 100644 index 00000000..ee52a244 --- /dev/null +++ b/tests/release-workflow.test.mjs @@ -0,0 +1,448 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { packageFirefox } from '../build-scripts/package-firefox.mjs' + +const workflowSource = await readFile(new URL('../.github/workflows/release-extension.yml', import.meta.url), 'utf8') +const normalizedWorkflowSource = workflowSource.replaceAll('\\/', '/') +const hygieneScript = extractReleaseHygieneScript(workflowSource) +const disclosureGateScript = extractNamedNodeScript(workflowSource, '校验 Agent Bridge 发布披露确认') + +function extractReleaseHygieneScript(source) { + const match = source.match(/node --input-type=module <<'NODE'\n(?&\u2028\u2029' }) + assert.equal(escaped.includes(''), false) + assert.equal(escaped.includes('&\u2028\u2029' + }) + const configText = html.match(/'), false) + assert.equal(configText.includes('&\u2028\u2029') +}) + +test('js bridge protocol helper validates documented identifier fixtures', () => { + for (const [kind, examples] of Object.entries(identifiers)) { + for (const value of examples.valid) { + assert.equal(isValidId(kind, value), true, `${kind} should accept ${value}`) + } + for (const value of examples.invalid) { + assert.equal(isValidId(kind, value), false, `${kind} should reject ${value}`) + } + } +}) + +test('js bridge serves health and creates no-open captures with bearer auth', async () => { + await withBridge(async ready => { + assert.match(ready.apiToken, /^spb_[A-Za-z0-9_-]{43}$/) + assert.equal(ready.server.maxConnections, 20) + assert.equal(ready.server.headersTimeout, 5000) + assert.equal(ready.server.requestTimeout, 35000) + assert.equal(ready.server.keepAliveTimeout, 2000) + + const health = await readJson(await fetch(ready.healthUrl)) + assert.equal(health.status, 200) + assert.equal(health.body.service, 'stackprism-agent-bridge') + assert.equal(health.body.protocolVersion, 1) + + const unauthorized = await readJson( + await fetch(`${ready.baseUrl}/v1/captures`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(baseCaptureRequest) + }) + ) + assert.equal(unauthorized.status, 401) + assert.equal(unauthorized.body.error.code, 'UNAUTHORIZED') + + const created = await createCapture(ready) + assert.equal(created.status, 200) + assert.match(created.body.id, /^cap_[A-Za-z0-9_-]{22}$/) + assert.equal(created.body.status, 'queued') + assert.deepEqual([...new URL(created.body.bridgeUrl).searchParams.keys()].sort(), ['capture', 'nonce', 'session']) + assert.equal(created.body.bridgeUrl.includes(ready.apiToken), false) + assert.equal(created.body.bridgeUrl.includes('apiToken'), false) + assert.equal(created.body.bridgeUrl.includes('bridgeToken'), false) + assert.doesNotMatch(created.body.bridgeUrl, /spbt?_[A-Za-z0-9_-]{20,}/) + }) +}) + +test('js bridge rate limits capture creation and api status reads', async () => { + await withBridge( + async ready => { + const created = await createCapture(ready) + assert.equal(created.status, 200) + + const busy = await createCapture(ready) + assert.equal(busy.status, 429) + assert.equal(busy.body.error.code, 'RATE_LIMITED') + + const firstStatus = await readJson(await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}`, { headers: auth(ready.apiToken) })) + assert.equal(firstStatus.status, 200) + const secondStatus = await readJson(await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}`, { headers: auth(ready.apiToken) })) + assert.equal(secondStatus.status, 429) + assert.equal(secondStatus.body.error.code, 'RATE_LIMITED') + }, + { rateLimits: { createLimitPerMinute: 1, queryLimitPerMinute: 1 } } + ) +}) + +test('js bridge rate limits api profile reads', async () => { + await withBridge( + async ready => { + const created = await createCapture(ready) + assert.equal(created.status, 200) + + const firstProfile = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}/profile`, { headers: auth(ready.apiToken) }) + ) + assert.equal(firstProfile.status, 409) + assert.equal(firstProfile.body.error.code, 'INVALID_REQUEST') + assertJsonSecurityHeaders(firstProfile, { referrerPolicy: true }) + + const secondProfile = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}/profile`, { headers: auth(ready.apiToken) }) + ) + assert.equal(secondProfile.status, 429) + assert.equal(secondProfile.body.error.code, 'RATE_LIMITED') + }, + { rateLimits: { queryLimitPerMinute: 1 } } + ) +}) + +test('bridge page renders bridge token once with hardened headers', async () => { + await withBridge(async ready => { + const created = await createCapture(ready) + const first = await fetch(created.body.bridgeUrl) + const html = await first.text() + const csp = first.headers.get('content-security-policy') + const cspNonce = csp.match(/script-src 'nonce-([^']+)'/)?.[1] + + assert.equal(first.status, 200) + assert.equal(first.headers.get('cache-control'), 'no-store') + assert.equal(first.headers.get('referrer-policy'), 'no-referrer') + assert.equal(first.headers.get('x-content-type-options'), 'nosniff') + assert.equal(first.headers.get('cross-origin-opener-policy'), 'same-origin') + assert.equal(first.headers.get('permissions-policy'), 'camera=(), microphone=(), geolocation=(), payment=(), usb=()') + assert.equal(csp.includes('unsafe-inline'), false) + assert.match(csp, /default-src 'none'/) + assert.match(csp, /connect-src 'self'/) + assert.match(csp, /img-src data: blob:/) + assert.doesNotMatch(csp, /img-src[^;]*'self'/) + assert.match(csp, /frame-ancestors 'none'/) + assert.match(csp, /base-uri 'none'/) + assert.match(csp, /form-action 'none'/) + assert.ok(cspNonce) + assert.match(csp, new RegExp(`style-src 'nonce-${cspNonce}'`)) + assert.equal(first.headers.get('x-frame-options'), 'DENY') + assert.match(html, /meta name="stackprism-agent-bridge" content="1"/) + assert.match(html, //) + assert.match(html, /data-phase="bridge_connected" aria-current="step"/) + assert.match(html, /id="profileContentSection"/) + assert.match(html, /id="profileContentGrid"/) + assert.match(html, /id="screenshotModal"/) + assert.match(html, /id="modalDownload"/) + assert.match(html, /id="modalCopyScreenshot"/) + assert.match(html, /id="modalClose"/) + assert.match(html, /id="modalScreenshot" class="modal-image" alt=""/) + assert.match(html, /addEventListener\('click',openScreenshot\)/) + assert.match(html, /navigator\.clipboard\.writeText/) + assert.match(html, /new ClipboardItem/) + assert.match(html, /showCopyStatus\('已复制全部信息。'\)/) + assert.match(html, /flashCopyButton\('已复制'\)/) + assert.match(html, /const clipboardScreenshotBlob=async/) + assert.match(html, /createImageBitmap\(blob\)/) + assert.match(html, /'image\/png':blob/) + assert.match(html, /复制截图失败:浏览器未允许写入剪切板,或截图格式无法转换。/) + assert.match(html, /截图预览无法加载/) + assert.match(html, /downloadBlob\(await fetchScreenshotBlob\(\),screenshotFilename\(\)\)/) + assert.match(html, /currentProfileBlob=null,currentProfileFetchPromise=null/) + assert.match(html, /const ensureProfileCached=\(\)=>/) + assert.match(html, /downloadBlob\(await ensureProfileCached\(\),profileFilename\(\)\)/) + assert.match(html, /if\(status==='completed'\)ensureProfileCached\(\)\.catch\(\(\)=>\{\}\)/) + assert.match(html, /\/profile-download/) + assert.doesNotMatch(html, /config\.captureId\+'\/profile'/) + assert.match(html, /currentScreenshot\?\.mimeType==='image\/png'\?'png'/) + assert.match(html, /currentScreenshot\?\.mimeType==='image\/webp'\?'webp'/) + assert.match(html, /currentScreenshotObjectUrl=URL\.createObjectURL\(blob\)/) + assert.match(html, /el\.targetScreenshot\.alt='目标页面截图预览'/) + assert.match(html, /el\.targetScreenshot\.alt=''/) + assert.match(html, /color-scheme:light dark/) + assert.match(html, /@media \(prefers-color-scheme:dark\)/) + assert.match(html, /class="result-grid"/) + assert.match(html, /class="summary-grid"/) + assert.match(html, /class="screenshot-panel"/) + assert.match(html, /border-radius:16px/) + assert.match(html, /\.summary-grid\{display:grid;grid-template-columns:repeat\(4,minmax\(0,1fr\)\)/) + assert.match(html, /grid-template-columns:repeat\(auto-fit,minmax\(min\(100%,300px\),1fr\)\)/) + assert.match(html, /\.target-copy\{min-width:0\}/) + assert.match(html, /\.target-url\{margin:0;display:-webkit-box;overflow:hidden;overflow-wrap:anywhere;word-break:break-word/) + assert.match(html, /\.content-card \*\{min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word\}/) + assert.match(html, /\.content-card\{min-width:0;min-height:88px;padding:10px;overflow:hidden/) + assert.match(html, /\.content-card ul\{display:grid;min-width:0;gap:3px/) + assert.match(html, /\.content-card li\{min-width:0;line-height:1\.38;white-space:normal\}/) + assert.doesNotMatch(bridgePageStyle, /@media \(max-width:980px\)\{[^}]*\.content-grid/) + assert.doesNotMatch(bridgePageStyle, /@media \(max-width:760px\)\{[^}]*\.content-grid/) + assert.match(html, /--sp-neutral-line:#e5e9ee/) + assert.match(html, /grid-template-columns:minmax\(0,1\.22fr\) minmax\(320px,\.82fr\)/) + assert.match(html, /class="summary-handoff" aria-label="摘要包含"/) + assert.match(html, /摘要包含/) + assert.match(html, /技术栈/) + assert.match(html, /首屏结构/) + assert.match(html, /height:clamp\(190px,14vw,230px\)/) + assert.match(html, /object-fit:cover/) + assert.match(html, /object-position:top center/) + assert.match(html, /-webkit-line-clamp:2/) + assert.match(html, /\.target-url\[href\]\{cursor:pointer\}/) + assert.match(html, /\.target-url\[href\]:hover\{text-decoration:underline/) + assert.match(html, /\.target-actions\{display:flex;min-width:0;flex-wrap:wrap;gap:10px;justify-content:flex-end\}/) + assert.match( + html, + /\.target-open-link\{min-width:132px;display:inline-flex;align-items:center;justify-content:center;text-decoration:none\}/ + ) + assert.match(html, /\.preview-button:disabled,.modal-close:disabled,.preview-button\[aria-disabled="true"\]\{cursor:not-allowed/) + assert.match(html, /targetHrefFor=value=>/) + assert.match(html, /url\.pathname\.includes\('\[redacted\]'\)/) + assert.match(html, /url\.search\.includes\('\[redacted\]'\)/) + assert.match(html, /setTargetUrl\(targetText\)/) + assert.match(html, /setTargetLink\(el\.openTargetUrl,targetHref\)/) + assert.match(html, /node\.removeAttribute\('aria-disabled'\)/) + assert.match(html, /\.bridge-header\{position:relative;display:block/) + assert.match(html, /\.summary-handoff\{display:none\}/) + assert.match(html, /class="target-actions"/) + assert.match(html, /class="preview-button primary target-copy-button"/) + assert.match(html, /class="flow-panel"/) + assert.match(html, /grid-template-columns:repeat\(8,minmax\(0,1fr\)\)/) + assert.match(html, /grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/) + assert.match(html, /\.bridge-card\[data-status="completed"\] \.status-panel\{display:none\}/) + assert.match(html, /\.bridge-card\[data-status="completed"\]:not\(\[data-steps-open="true"\]\) \.steps\{display:none\}/) + assert.match(html, /\.state-chip\[data-state="ready"\]/) + assert.match(html, /setScreenshotState\('截图可用','ready'\)/) + assert.match(html, /setStepsOpen\(false\)/) + assert.match(html, /addEventListener\('click',\(\)=>setStepsOpen\(!stepsOpen,true\)\)/) + assert.match( + html, + /\.preview-button:disabled,.modal-close:disabled,.preview-button\[aria-disabled="true"\]\{cursor:not-allowed;opacity:1;background:#f7fbfa/ + ) + assert.match(html, /setCopyStatus\(modalOpen\(\)\?el\.modalCopyStatus:el\.copyStatus,value,type\)/) + assert.match(html, /const restore=el\.screenshotFrame\.disabled\?el\.bridgeCard:el\.screenshotFrame/) + assert.match(html, /if\(current\|\|failedCurrent\)step\.setAttribute\('aria-current','step'\)/) + assert.match(html, /else step\.removeAttribute\('aria-current'\)/) + assert.match(html, /step\.classList\.toggle\('failed',failedCurrent\)/) + assert.match(html, /\.step\.failed/) + assert.match(html, /\.bridge-card\[data-status="failed"\] \.progress span/) + assert.match(html, /\.copy-status\[data-state="error"\]\{background:#2a1211;border-color:#7f1d1d;color:#fca5a5\}/) + assert.match(html, /color:#fca5a5/) + assert.match(html, /color:#fbbf24/) + assert.match(html, /disconnected:'连接已关闭'/) + assert.match(html, /const targetText=preview\.targetUrl\|\|config\.targetUrl\|\|'等待读取目标网址'/) + assert.match(html, /el\.targetUrl\.title=targetText/) + assert.match(html, /本机 bridge 服务已关闭,当前页面无法继续读取状态。/) + assert.doesNotMatch(html, /Bridge status unavailable/) + assert.match(html, /data-phase="profiling_experience"/) + assert.match(html, /本机通道/) + assert.match(html, /连接本机 Agent 与当前浏览器 profile,展示本次采集结果。/) + assert.match(html, /采集目标/) + assert.match(html, /id="targetHelper" class="target-helper"/) + assert.match(html, /采集完成后可复制给本机 Coding Agent 使用。/) + assert.match(html, /已生成 Agent 可读摘要,可复制给本机 Coding Agent 使用。/) + assert.match(html, /面向复刻任务整理技术栈、视觉结构、交互路径与资产线索。/) + assert.match(html, /复刻重点/) + assert.match(html, /先看 Agent 可读内容/) + assert.match(html, /本页只服务当前一次采集/) + assert.match(html, /摘要不含 token、nonce、raw JSON 或截图 data URL/) + assert.match(html, /Agent 可读内容/) + assert.match(html, /完整 Profile 可在本页完成后下载/) + assert.match(html, new RegExp(`id="stackprism-agent-bridge-config" type="application/json" nonce="${cspNonce}"`)) + assert.match(html, new RegExp(`