From 950f92b3d53efd3997eff05567fbb8b9ef03d53f Mon Sep 17 00:00:00 2001 From: Mison Date: Fri, 22 May 2026 14:30:10 +0800 Subject: [PATCH 01/49] docs: add stackprism agent bridge plan --- ...2026-05-21-stackprism-agent-bridge-plan.md | 1385 +++++++++++++++++ 1 file changed, 1385 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-21-stackprism-agent-bridge-plan.md 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..bf4c3dd3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-stackprism-agent-bridge-plan.md @@ -0,0 +1,1385 @@ +# StackPrism Agent Bridge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 构建一个不依赖 MCP、不需要用户手动复制/下载的 StackPrism Agent Bridge:用户安装浏览器插件后,AI Agent 通过 Skill 内 JS/PY 脚本启动本地 HTTP bridge,自动驱动插件采集目标网站的技术、视觉、UI/UX、交互与资源信息,并读取 `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`. + +--- + +## 总目标 + +让 AI Agent 在用户已安装 StackPrism 插件的普通 Chrome 内核浏览器中,无需用户复制、下载或点击插件按钮,即可通过本地 HTTP 接口获得目标网站的 Site Experience Profile,用于实现相似视觉效果、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 实现方案。 + +## 用户可见门禁 + +- Agent Bridge 必须有用户可见的持久设置 `agentBridgeEnabled`,写入 `chrome.storage.local` 作为本机 profile 级 opt-in,并进入运行时 `DetectorSettings` 归一化流程;设置页必须明确该开关会允许本地 Agent Bridge 读取当前浏览器可观测的页面技术与体验摘要并交给用户本机 loopback bridge。 +- 对 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.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://example.com", + "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, + "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,并让 `GET /v1/captures/{id}/profile` 返回 `410` 和错误码 `CAPTURE_RESULT_EXPIRED`,避免长期保留采集数据。 + +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 网段,降低本地接口被误用风险;用户确需分析本地开发站点时必须显式开启。 +- 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。 +- `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`。 +- 第一版必需 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://example.com/", + "finalUrl": "https://example.com/", + "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` +- `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`. + +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。真实 screenshot/pixel diff 另做显式能力,不放第一版。 +- 如果需要调整窗口尺寸,必须记录原窗口尺寸并在采集后恢复;没有 `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 + +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 不得把缺失字段理解为目标站点不存在该结构。 + +## 文件结构规划 + +### 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` + +- [ ] 修改 `package.json` 的 `test:unit` 为 `node --test --test-timeout=60000 tests/*.test.mjs` 或等效超时命令,满足后端/脚本测试 60 秒超时基线,防止 bridge 子进程测试卡死。 +- [ ] 定义 `AgentCaptureRequest`、`AgentCaptureStatus`、`SiteExperienceProfile`、`AgentBridgeError`。 +- [ ] 定义 `AgentBridgeCapabilities`,字段固定为 `agentBridge`、`siteExperienceProfileV1`、`profileChunkTransport`、`bridgeContentPost`、`storageSession`、`experienceProfiler`、`rawProfile`、`viewportMetadata`。 +- [ ] 定义 agent bridge message union:`AgentBridgeHelloMessage`、`StartAgentCaptureMessage`、`AgentCaptureStatusMessage`、`AgentCaptureControlMessage`、`AgentProfileTransferBeginMessage`、`AgentProfileTransferChunkMessage`、`AgentProfileTransferCompleteMessage`、`AgentProfileTransferAckMessage`。 +- [ ] 在 `DetectorSettings` 中增加 `agentBridgeEnabled` 运行时字段,`DEFAULT_SETTINGS` 和 `normalizeSettings` 的缺省值必须为 `false`;测试覆盖旧 sync 设置对象、非法类型和显式 `true` 三种输入,并确认 sync payload 中如果误带 `agentBridgeEnabled: true` 也不会自动开启,因为真实生效来源是 local opt-in。 +- [ ] 写测试确认 `StartAgentCaptureMessage` 不允许 `bridgeToken` 字段,profile transfer messages 不允许 profile wrapper 字段。 +- [ ] `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 失败且不产生未跟踪测试产物。 +- [ ] 定义 `bridgeProtocolVersion = 1`,并写入 request/status/profile 类型。 +- [ ] 定义协议标识符常量和 validator:`apiToken`、`bridgeToken`、`captureId`、`sessionId`、`nonce`、`profileTransferId`、`cspNonce` 的 regex、长度、前缀必须与 Protocol identifier contract 一致,并导出给 bridge 脚本、插件 handshake 和测试复用。 +- [ ] 新增 `tests/fixtures/bridge-protocol-identifiers.json`,覆盖每类标识符至少 2 个合法样例和非法样例:错误前缀、长度不足/过长、`+`、`/`、`=`、空白、Unicode、percent-encoded slash、点段、大小写错误、query 分隔符、空值。测试必须证明文档中的脱敏占位 `spb_xxx`、`spbt_xxx`、`cap_20260521_abcdef`、`s_xxx`、`n_xxx` 不会被 validator 接受。 +- [ ] 写测试确认 schema 常量为 `stackprism.site_experience_profile.v1`。 +- [ ] 写测试确认 `AgentBridgeCapabilities` 包含所有第一版必需 capability,且 `SiteExperienceProfile.browserContext.extensionCapabilities` 使用同一类型。 +- [ ] 写测试确认 request 类型包含 `allowPrivateNetworkTarget`,默认值由 bridge 脚本处理;Task 1 不测试 DNS/private-network 行为,避免在 bridge 实现前写不可运行的测试。 +- [ ] 写测试确认 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`,不得新增一套不一致错误码。 +- [ ] 验证:`pnpm run test:unit` 通过。 +- [ ] 验证:`pnpm run typecheck` 通过,确认新增协议类型、测试 helper 和 schema 常量可编译并可打包。 +- [ ] 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` + +- [ ] 从现有 popup/raw 数据构造 `techProfile` 和 `assetProfile`。 +- [ ] 增加 `limitations` 与 `agentGuidance` 默认规则。 +- [ ] 对 cookie、authorization、set-cookie、token-like 字段做脱敏。 +- [ ] 扩展 `src/background/headers.ts` 的响应头脱敏:`set-cookie` 保留 cookie name 摘要,`cookie`、`authorization`、`proxy-authorization`、`x-api-key`、token-like header 值统一脱敏;测试覆盖 `headers`、`allHeaders`、profile evidence 不泄露原值。 +- [ ] 对资源 URL 的 hash 和敏感 query 参数做脱敏;profile 不输出完整签名 URL、带 token 的图片/字体/脚本 URL。 +- [ ] 对 UX 文本摘要执行 email、手机号、长数字 ID、金额和疑似姓名脱敏。 +- [ ] 对 viewport 输出增加 `viewportMode`,无法多视口采集时显式写入 limitation。 +- [ ] 对 `captureScreenshotMetadata` 输出增加明确分支:`true` 时只允许输出视口尺寸、关键元素 bounding box 和 above-fold 摘要;`false` 时不输出 bounding box / above-fold 细节,并写测试确认不会误产出截图或像素数据。 +- [ ] 对 passive interaction、cross-origin iframe、closed shadow root 和不可访问 stylesheet 写入 limitations。 +- [ ] 对截断结果写入 `evidence.truncation` 和对应 limitation,至少包含资源 URL、文本摘要、组件样本和 CSS rule 样本的 omitted count。 +- [ ] profile builder 必须从 agent capture context 接收 `AgentBridgeCapabilities`,并原样写入 `browserContext.extensionCapabilities`;不得在 builder 内重新推断 capability。 +- [ ] 验证空检测、低置信检测、完整 raw 检测三种输入。 +- [ ] 验证:`pnpm run test:unit` 通过。 +- [ ] 验证:`pnpm run typecheck` 通过,确认 profile builder 的 TypeScript 类型和扩展打包链路没有被破坏。 +- [ ] 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` + +- [ ] 采集 computed style token:颜色、字体、字号、行高、间距、圆角、阴影。 +- [ ] 采集 layout landmarks:header/nav/main/footer/aside/hero/above-fold。 +- [ ] 采集 component inventory:button/input/card/nav/tab/modal/table/list/badge。 +- [ ] 采集 interaction tokens:transition、animation、sticky/fixed、focus/hover 可观察线索。 +- [ ] 标记同源 iframe、跨源 iframe、open shadow root、closed shadow root 的可采集边界。 +- [ ] 输出稳定、限长、脱敏的 JSON,不包含可见文本全文。 +- [ ] `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` 非空。 +- [ ] 设置明确采集上限,例如最大 DOM 节点数、最大组件样本数、最大文本样本数、最大 CSS rule 数和最大资源 URL 数;超过上限时只返回截断摘要和 omitted count,不让 profile 超过 bridge 的 8 MB 上限。 +- [ ] 设置 `experience-profiler` 注入脚本返回值上限,例如返回给 `chrome.scripting.executeScript` 的 JSON 字符串不超过 2 MB;超过上限时在注入脚本内部先截断样本、写入 `evidence.truncation.executeScriptResult` 和对应 limitation,不允许等到 background 收到超大 executeScript result 或扩展消息传输时才失败。 +- [ ] 在 `vite.injected.config.ts` 的 `ENTRIES` 加入 `experience-profiler`,并在 `build-scripts/build-injected.mjs` 的 `entries` 数组加入同名项。 +- [ ] `src/manifest.config.ts` 的 `web_accessible_resources` 使用最小暴露面;默认不暴露 `injected/experience-profiler.iife.js`。如果实现确实需要暴露,必须把它拆成独立条目、使用最小 `matches`,并在 `docs/dev/agent-bridge.md` 说明为什么不能只用 `chrome.scripting.executeScript({ files })`。 +- [ ] `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。 +- [ ] `tests/experience-profile-format.test.mjs` 验证构建后的 profiler IIFE 文本包含 `__StackPrismInjected_experience_profiler__;`,并验证 profiler 默认导出形状可被结构化克隆为 executeScript result。 +- [ ] 新增 `tests/fixtures/site-experience-fixture.html`,用固定 DOM/CSS 覆盖颜色、字体、布局、组件、transition 和敏感文本脱敏样本。 +- [ ] 运行 `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。 +- [ ] 验证:`pnpm run build:injected` 产出 `public/injected/experience-profiler.iife.js`。 +- [ ] 验证:`pnpm run test:unit` 通过;该步骤必须在 `pnpm run build:injected` 之后执行,因为 `tests/experience-profile-format.test.mjs` 会读取 ignored 构建产物 `public/injected/experience-profiler.iife.js`。 +- [ ] 验证:`pnpm run typecheck` 通过,确认 injected entry、manifest config 和扩展构建链路一致。 +- [ ] 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` + +- [ ] content script 只在 loopback bridge 页面激活。 +- [ ] 在 `src/manifest.config.ts` 中保持普通 `content-observer.ts` 为第一个 content script,或同步修改 `content-injector.ts` 通过文件名查找 observer;验证主动注入不会误注入 `agent-bridge-client.ts`。 +- [ ] 对非 `/bridge` path 或缺少 `stackprism-agent-bridge` meta 的 loopback 页面立即 return,不读取 DOM 详情、不发消息、不发请求。 +- [ ] 解析 `session`、`capture`、`nonce` 和 HTML 内嵌 `bridgeToken`,校验 URL path 为 `/bridge`。 +- [ ] 从 `#stackprism-agent-bridge-config[type="application/json"]` 解析 `bridgeToken`,不得依赖页面 JS 全局变量。 +- [ ] 解析 `capture` 和 `nonce`,从 bridge 拉取 `GET /v1/captures/{id}/request`。 +- [ ] 校验 `GET /v1/captures/{id}/request` 返回的 `captureId`、`sessionId`、`nonce` 和 `protocolVersion` 与当前 bridge 页面 config 完全一致;不一致时同源 POST `failed` 和 `BRIDGE_REQUEST_MISMATCH`,不得向 background 发送 `START_AGENT_CAPTURE`。 +- [ ] 握手后同源 POST `waiting_extension` / `running` / `failed` 状态到 `POST /v1/captures/{id}/status`。 +- [ ] bridge content script 为每个 status POST 维护单调递增 `sequence`,并只发送定义过的 phase;background 发来的 late phase 不得倒退覆盖 bridge server 中较新的 phase。 +- [ ] 运行期间轮询 `GET /v1/captures/{id}/control`;收到 `cancel` 后通知 background 取消。 +- [ ] bridge content script 校验 bridge 页面 config 的 `protocolVersion`;不等于插件 `bridgeProtocolVersion` 时同源 POST `failed` / `BRIDGE_PROTOCOL_UNSUPPORTED`,不得向 background 发送 `START_AGENT_CAPTURE`。 +- [ ] 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。 +- [ ] 向 background 发送 `AGENT_BRIDGE_HELLO`。 +- [ ] 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。 +- [ ] background 返回插件版本、`protocolVersion`、`AgentBridgeCapabilities` 和握手状态;缺少第一版必需 capability 时返回 `NOT_SUPPORTED`,并让 bridge content script 同源 POST failed status。 +- [ ] 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` 不会让握手通过。 +- [ ] capability 校验通过后,bridge content script 发送 `START_AGENT_CAPTURE`,payload 只包含 `captureId`、`sessionId`、`nonce`、`bridgeOrigin`、规范化 capture request 和 capabilities;不得把 `bridgeToken` 传给 background。 +- [ ] bridge content script 保持与 background 的 `runtime.Port` 或等效消息通道,用于接收 profile payload 并执行同源 POST。 +- [ ] 如果使用 `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 路径。 +- [ ] 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。 +- [ ] bridge content script 同源 POST status/profile 时必须设置 `Content-Type: application/json` 和 `Authorization: Bearer {bridgeToken}`;缺任一 header 都应在单元测试中触发 bridge server 的 `UNSUPPORTED_MEDIA_TYPE` 或 `UNAUTHORIZED`。 +- [ ] 分片传输失败必须同源 POST 结构化失败状态:缺片或超时为 `PROFILE_CHUNK_MISSING`,hash 不匹配为 `PROFILE_HASH_MISMATCH`,其他传输失败为 `PROFILE_TRANSPORT_FAILED`;不能让 Agent 只等到 capture timeout。 +- [ ] profile POST 成功或失败都必须回传给 background;断连时 background 标记 `BRIDGE_TRANSPORT_DISCONNECTED`。 +- [ ] `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`。 +- [ ] bridge 页面没有 token 时返回显式错误,不静默成功。 +- [ ] bridge 页面不触发普通 `content-observer` 和 badge 更新。 +- [ ] 验证:`pnpm run build:injected` 通过;Task 3 已新增读取 `public/injected/*.iife.js` 的单元测试,后续全量 `test:unit` 在干净 checkout 中必须先生成 ignored injected 产物。 +- [ ] 验证:`pnpm run test:unit` 通过。 +- [ ] 验证:`pnpm run typecheck` 通过,确认 content script、manifest 和 message union 可编译并可打包。 +- [ ] Task 4 阶段不得要求真实 bridge server 浏览器握手 smoke;JS bridge server 在 Task 6 才实现,真实握手与 DevTools 观察统一放到 Task 10。 +- [ ] 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` + +- [ ] `START_AGENT_CAPTURE` 校验 URL、session/capture/nonce 绑定、include、viewports;background 不接收、不读取、不持久化 `bridgeToken`。 +- [ ] `START_AGENT_CAPTURE` 二次校验 `agentBridgeEnabled`,避免设置页关闭后已有 bridge tab 继续发起采集;关闭后返回 `AGENT_BRIDGE_DISABLED`,并清理 bridge session。 +- [ ] `START_AGENT_CAPTURE` payload 必须来自已登记 bridge tab 的 content script;background 必须拒绝含 `bridgeToken`、callback URL 或 profile wrapper 的 payload,返回 `INVALID_REQUEST`。 +- [ ] `START_AGENT_CAPTURE` 校验 `options.forceRefresh`、`options.captureScreenshotMetadata`、`options.targetMode`、`options.keepTabOpen`、`options.allowPrivateNetworkTarget` 和 `options.maxResourceUrls`;未知字段必须返回 `INVALID_REQUEST`,不能静默忽略。 +- [ ] capture 开始前检查 `chrome.storage.session` 可用;不可用时返回 `NOT_SUPPORTED` 和 `details.missingCapability = "storageSession"`,不得退回普通内存状态。 +- [ ] 不得把 `chrome.storage.session` access level 放宽给 content script;若实现显式设置 access level,必须设置为 `TRUSTED_CONTEXTS`。单元测试必须断言没有调用 `setAccessLevel({ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" })`,并断言 content script 只能通过 runtime message/Port 访问 agent capture 状态。 +- [ ] `src/background/active-tab-tracker.ts` 记录每个 window 最近的非 bridge active tab;bridge tab 激活时不能覆盖该记录;记录写入 `chrome.storage.session`,service worker 重启后仍可读取。 +- [ ] `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 流程。 +- [ ] agent capture 的 deadline 必须使用持久化绝对时间,而不是只依赖 background 内存 timer。`agent-capture-state` 必须记录全局 capture deadline、cancel deadline 和 profile transfer deadline;所有事件入口和 service worker 模块初始化都必须调用同一个 deadline reconciliation helper,把过期 capture 标记为结构化失败或取消并清理自己创建的目标 tab。 +- [ ] `src/background/message-router.ts` 对所有会读写 tab 数据的普通消息增加 sender/tab 校验和 bridge tab guard;bridge tab 发来的普通检测、动态快照、popup/raw/header 查询或后台检测消息必须拒绝或返回 unsupported,不能读写目标站点缓存,也不能用 message body 中的 `tabId` 操作其他 tab。 +- [ ] 普通 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 查询被拒绝。 +- [ ] 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。 +- [ ] `targetMode = "active_tab"` 从 active-tab-tracker 读取 bridge tab 同窗口的上一张非 bridge active tab;缺失时返回 `ACTIVE_TAB_UNAVAILABLE`,URL 不匹配时返回 `ACTIVE_TAB_MISMATCH`。 +- [ ] bridge tab 或目标 tab 的 `incognito` 为 true 时返回 `INCOGNITO_NOT_SUPPORTED`,并清理当前 capture;第一版不得跨普通窗口和隐身窗口传递状态。 +- [ ] `reuse_or_new_tab` 和 `active_tab` 的 URL 匹配使用统一 helper:protocol/hostname 小写、默认端口折叠、fragment 丢弃、path 空值归一到 `/`,比较完整 URL(不含 hash,包含 query)。同 path 但 query 不同不得复用已有 tab;`active_tab` 场景必须返回 `ACTIVE_TAB_MISMATCH`。 +- [ ] 监听 `chrome.webNavigation.onErrorOccurred` 的目标 tab main frame;加载失败时上报 `TARGET_LOAD_FAILED`,停止采集并清理自己创建的目标 tab,不得把浏览器错误页当目标站点 profile。 +- [ ] 等待目标 tab `status === "complete"` 后,先通过 bridge content script 写入 `running/target_loaded` 和 `finalUrl`;bridge 接受 final URL 后才运行主动检测和 experience profiler。bridge 返回 `FINAL_URL_BLOCKED` 或 `BRIDGE_SELF_TARGET_BLOCKED` 时必须停止采集并清理自己创建的目标 tab。 +- [ ] final URL 通过后再运行主动检测;agent capture 必须使用 `force: true` 或专用内部函数绕过 `DETECTION_THROTTLE_MS`,并在检测后等待 `waitMs` 收集动态资源;超时返回 `TARGET_LOAD_TIMEOUT`。 +- [ ] 捕获 `chrome.scripting.executeScript` promise rejection 和 `chrome.runtime.lastError`;注入 content observer、page detector 或 experience profiler 任一步失败时返回 `TARGET_INJECTION_FAILED`,`details` 只记录脱敏原因类别,不包含完整 URL、token 或浏览器原始错误全文。 +- [ ] 执行 `maxConcurrentCaptures = 1`,忙时返回 `CAPTURE_BUSY`。 +- [ ] 打开或复用目标 tab;新建目标 tab 必须 `active: false`,记录 `createdByCapture`,触发现有技术检测。 +- [ ] 在 `src/background/tab-store.ts` 中增加明确的 agent capture 清理入口,采集前清理目标 tab 的 tab data 与 popup cache。 +- [ ] 在 `src/background/dynamic-snapshot.ts` 中导出 `clearDynamicSnapshotState(tabId)` 或等效函数,清理 `pendingDynamicSnapshots` 与 `dynamicSnapshotTimers`。 +- [ ] 在 `src/background/bundle-license.ts` 复用现有 `clearBundleLicenseTimer(tabId)`;如当前函数未导出,则导出并由 agent capture cleanup 调用。 +- [ ] 实现 `forceRefresh`:采集前统一调用 tab-store、popup cache、dynamic snapshot、bundle timer、detection throttle 的清理入口,避免复用旧页面缓存污染 profile。 +- [ ] 从 `detection.ts` 拆出 agent capture 专用的检测函数;该函数必须返回检测完成信号和错误,不得使用现有 catch 后静默 return 的 `runActivePageDetection` 作为唯一结果来源。 +- [ ] 注入 `experience-profiler.iife.js` 采集视觉/UI/UX 数据。 +- [ ] 按 `include` 决定是否运行技术检测、experience profiler 和资源采样;未请求 section 返回空对象并在 `limitations` 写入 `section_not_requested`。 +- [ ] 第一版不新增 `chrome.windows` 权限,不调整窗口尺寸;所有多视口请求都写入 `viewportMode = "current_viewport"` 和 limitation。 +- [ ] 合并 popup/raw 数据与体验数据,调用 profile builder。 +- [ ] 从 `popup-cache.ts` 导出 agent capture 需要的 raw/display 构建辅助函数,避免绕过现有去重、过滤和链接补全逻辑。 +- [ ] 将 profile payload 通过 profile chunk transport 发给 bridge content script,由其重组、校验 sha256 后同源 POST 回 bridge callback endpoint;background 直连 localhost 只允许作为后续显式 CORS fallback,不在第一版默认路径。 +- [ ] 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。 +- [ ] capture 完成、失败、取消或过期时,关闭插件自己创建且 `keepTabOpen = false` 的目标 tab。 +- [ ] 监听目标 tab 或 bridge tab 关闭/导航;分别返回 `TARGET_TAB_CLOSED`、`BRIDGE_TAB_CLOSED` 或 `TARGET_NAVIGATED_AWAY`,并清理 capture 状态。 +- [ ] 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`。 +- [ ] `agent-capture-state`、active-tab tracker 和普通 tab cache 使用不同 storage key 前缀;提供集中 helper 列出和清理 agent capture state。capture 终态、bridge tab 关闭、扩展启动恢复失败和 E2E 清理阶段都必须删除对应 capture state,避免后续 capture 误读旧 tab ownership。 +- [ ] service worker 重启后读取 `agent-capture-state`:未完成 capture 标记为 `SERVICE_WORKER_RESTARTED`,通过 `bridgeTabId` 通知 bridge content script 同源 POST 失败状态,并按 `createdByCapture`/`keepTabOpen` 清理目标 tab。 +- [ ] 浏览器完全退出、扩展 reload/update、用户禁用扩展或 `chrome.storage.session` 被清空后,不尝试恢复未完成 capture;恢复入口必须 fail closed,清理残留 state 和自己创建的 target tab,让 Agent 侧通过 bridge timeout/expired 或状态查询看到结构化失败。 +- [ ] 错误路径必须回传结构化错误,不能吞异常。 +- [ ] `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。 +- [ ] `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 仍通过。 +- [ ] `tests/agent-capture-orchestration.test.mjs` 必须覆盖扩展侧日志脱敏:bridge URL query、nonce、token、Authorization header、profile body 和目标 URL 敏感 query 不出现在 console/debug 输出中。 +- [ ] 如果实现使用 `runtime.Port`,测试必须覆盖未知 port name、非 bridge sender、重复 port、跨 capture port、错误 `sender.url` 和错误 tab id 都会断开且不会启动 capture 或写入 profile。 +- [ ] 验证:`pnpm run build:injected` 通过;Task 3 已新增读取 `public/injected/*.iife.js` 的单元测试,后续全量 `test:unit` 在干净 checkout 中必须先生成 ignored injected 产物。 +- [ ] 验证:`pnpm run test:unit` 通过。 +- [ ] 验证:`pnpm run typecheck` 通过。 +- [ ] 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` + +- [ ] 使用 Node 标准库 `node:http`,不引入运行时依赖。 +- [ ] `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 行。 +- [ ] 绑定 `127.0.0.1`,端口默认随机,支持环境变量 `STACKPRISM_BRIDGE_PORT`。 +- [ ] 启动前校验环境变量:`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`。 +- [ ] 指定 `STACKPRISM_BRIDGE_PORT` 且端口被占用时,进程必须非零退出,stderr 输出脱敏 `PORT_IN_USE`,stdout 不输出 ready JSON。 +- [ ] 启动成功后 stdout 只输出一行 JSON line,包含 `event`、`baseUrl`、`healthUrl`、`apiToken`、`protocolVersion`、`version`;其他日志写 stderr。ready JSON 必须在 server 已绑定且 endpoint 可接受请求后输出。 +- [ ] 自动打开 bridge 页面:macOS 使用 `open`,Windows 使用 `rundll32.exe url.dll,FileProtocolHandler` 或等效非 shell API,Linux 使用 `xdg-open`;支持 `STACKPRISM_BROWSER_OPEN_COMMAND` 覆盖目标浏览器。不得默认使用 `cmd /c start`,除非测试证明 `?`、`&`、空格和引号不会被 shell 解释。 +- [ ] 自动打开浏览器时不得把 bridge URL 拼进 shell 字符串;JS 使用 `spawn`/`execFile` 的参数数组,Python 使用 `subprocess` 参数数组或 `webbrowser` 安全 API。`STACKPRISM_BROWSER_OPEN_COMMAND` 第一版只表示可执行文件路径;如需额外参数,使用 JSON 数组环境变量 `STACKPRISM_BROWSER_OPEN_ARGS_JSON`,并把 bridge URL 作为最后一个独立参数。 +- [ ] `STACKPRISM_BROWSER_OPEN_ARGS_JSON` 必须严格解析为字符串数组;非法 JSON、非数组或非字符串元素都返回 `BROWSER_OPEN_FAILED`,并在脱敏 `details.reason` 中标记 `invalid_open_args`。浏览器打开测试必须覆盖包含 `?`、`&`、空格和引号的 bridge URL 始终作为单个 argv 传入假命令,不能被 shell 拆分或解释。 +- [ ] 支持测试环境变量 `STACKPRISM_BRIDGE_NO_OPEN=1` 禁止自动打开浏览器,避免单元测试弹出浏览器或依赖用户桌面环境;该模式下创建 capture 不得返回 `BROWSER_OPEN_FAILED`,而是返回 `queued` 和 `bridgeUrl`。 +- [ ] `.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 规则检查产生假阴性。 +- [ ] 确认 `git check-ignore -v --no-index agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/__pycache__/http_server.pyc` 有命中,避免 Python 编译验证留下未跟踪字节码。 +- [ ] 实现统一 JSON 错误响应;所有失败返回 `{ "error": { "code", "message", "details" } }`,且 `details` 不含 token、完整 header、完整 URL query 或 profile 片段。 +- [ ] 实现未知路径、错误方法、缺失 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`。 +- [ ] 实现 `OPTIONS` preflight 拒绝:返回 `405 METHOD_NOT_ALLOWED` 或等效结构化错误,带正确 `Allow` 头,但不返回任何 `Access-Control-Allow-*` 头;测试覆盖跨站网页无法通过 preflight 获得授权。 +- [ ] 实现 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 和长度。 +- [ ] 拒绝重复或歧义请求头:重复 `Host`、`Authorization`、`Content-Type`、`Content-Length`,非法 `Content-Length`,`Content-Length` 与 `Transfer-Encoding` 同时出现,不以 `chunked` 结尾的 `Transfer-Encoding`,以及非 `identity` 的 `Content-Encoding`。 +- [ ] 对状态、request、control、profile 和错误响应设置 `Cache-Control: no-store` 与 `X-Content-Type-Options: nosniff`;profile endpoint 额外设置 `Referrer-Policy: no-referrer`。 +- [ ] 校验 capture request:`url`、`mode`、`waitMs`、`include`、`viewports`、`options.forceRefresh`、`options.captureScreenshotMetadata`、`options.targetMode`、`options.keepTabOpen`、`options.allowPrivateNetworkTarget`、`options.maxResourceUrls` 和未知字段;超出协议范围时返回 `400 INVALID_REQUEST`,不得创建 capture 或打开浏览器。 +- [ ] 使用安全随机源生成 `apiToken`、`bridgeToken`、`sessionId`、capture `nonce`、`profileTransferId` 和 bridge 页面 CSP nonce;不得使用 `Math.random()`、时间戳或递增计数器生成安全边界值。 +- [ ] token 校验必须走共享 helper:先做格式和长度检查,再使用固定时间比较或等效安全比较;失败路径只返回统一 `UNAUTHORIZED`/`FORBIDDEN`,不得在错误或日志中区分“前缀正确但后缀错误”等可被枚举的信息。 +- [ ] `/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。 +- [ ] bridge config JSON script 也必须带本次响应的 `nonce`,测试覆盖 ``、`` + ) +} 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..50fda013 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/http-server.mjs @@ -0,0 +1,287 @@ +import http from 'node:http' +import { CaptureStore } from './capture-store.mjs' +import { + allowForCaptureEndpoint, + auth, + commitProfile, + finalStates, + publicStatus, + readProfile, + 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, + sanitizeBridgeError, + service, + version +} from './protocol.mjs' + +const DEFAULT_CREATE_LIMIT_PER_MINUTE = 10 +const DEFAULT_QUERY_LIMIT_PER_MINUTE = 120 +const DEFAULT_RESOURCE_POLICY = { + maxOpenConnections: 20, + headersTimeoutMs: 5000, + requestTimeoutMs: 10000, + keepAliveTimeoutMs: 2000 +} + +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 + } +} + +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))?$/.exec(pathname) + if (!match || !isValidId('captureId', match[1])) return null + const capture = store.get(match[1]) + return capture ? { capture, endpoint: match[2] || '' } : { missing: true } +} + +export const createBridgeServer = ({ port = 0, env = process.env, resolveHostname, now, rateLimits, resourcePolicy } = {}) => { + 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) + } + 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 = 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 } = routed + const rejectedOrigin = rejectCrossOriginSensitiveRequest(req, res, baseUrl) + if (rejectedOrigin) return rejectedOrigin + 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) + } + 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 = 'cleanup' + 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, { + 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 = 'cleanup' + 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) + }) + }) + server.maxConnections = policy.maxOpenConnections + server.headersTimeout = policy.headersTimeoutMs + server.requestTimeout = policy.requestTimeoutMs + server.keepAliveTimeout = policy.keepAliveTimeoutMs + + store = new CaptureStore({ baseUrl: '', openBrowser: url => openBrowser(url, env), now }) + + 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..5059af96 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/open-browser.mjs @@ -0,0 +1,57 @@ +import { spawnSync } from 'node:child_process' + +const containsNul = value => (typeof value === 'string' ? value.includes('\0') : Array.isArray(value) && value.some(containsNul)) + +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 openBrowser = (url, env = process.env, platform = process.platform) => { + const openConfig = parseOpenConfig(env) + if (!openConfig.ok) return { ok: false, details: { reason: openConfig.code, message: openConfig.message } } + + if (env.STACKPRISM_BRIDGE_NO_OPEN === '1') return { ok: true, skipped: true } + + 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' + } + + try { + const child = spawnSync(command, [...args, url], { stdio: 'ignore', shell: false, timeout: 2000 }) + if (child.error) return { ok: false, details: { reason: child.error.code === 'ETIMEDOUT' ? 'open_timeout' : 'spawn_failed' } } + if (child.status !== 0) return { ok: false, details: { reason: 'open_failed' } } + return { ok: true } + } catch { + return { ok: false, details: { reason: 'spawn_failed' } } + } +} 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..00772bdc --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/protocol.mjs @@ -0,0 +1,167 @@ +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_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' +]) + +const SENSITIVE_DETAIL_KEY = /authorization|cookie|token|nonce|secret/i +const ID_PATTERN = /\b(?:spbt?_|cap_|s_|n_|xfer_)[A-Za-z0-9_-]{8,}\b/g +const URL_PATTERN = /https?:\/\/[^\s"')\]}]+/g +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}$/, + profileTransferId: /^xfer_[A-Za-z0-9_-]{22}$/, + cspNonce: /^[A-Za-z0-9_-]{22}$/ +} + +export const makeId = prefix => + `${prefix}${randomBytes(prefix === '' ? 16 : prefix === 'spb_' || prefix === 'spbt_' ? 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 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') + +export const redactUrl = value => { + try { + const url = new URL(String(value || '')) + url.hash = '' + 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/security.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/security.mjs new file mode 100644 index 00000000..53ed36dd --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/security.mjs @@ -0,0 +1,164 @@ +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 + } + if (req.headers['content-encoding'] && req.headers['content-encoding'] !== '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 !== baseUrl) { + 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..abce5147 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/target-network-policy.mjs @@ -0,0 +1,43 @@ +import net from 'node:net' +import { isPrivateIpLiteral } 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, { fromCache = false } = {}) => { + if (request.options?.allowPrivateNetworkTarget === true) { + return { ok: true } + } + if (fromCache === true || value === undefined || value === null) { + return { + ok: false, + code: 'FINAL_URL_BLOCKED', + message: 'Final URL is blocked by target policy.', + details: { reason: 'target_network_address_unverified' } + } + } + if (typeof value !== 'string') return invalidNetworkAddress() + const address = normalizeNetworkAddress(value) + if (!address) { + return { + ok: false, + code: 'FINAL_URL_BLOCKED', + message: 'Final URL is blocked by target policy.', + details: { reason: 'target_network_address_unverified' } + } + } + if (net.isIP(address) === 0) return invalidNetworkAddress() + if (!isPrivateIpLiteral(address)) return { ok: true } + 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..ca47c806 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/url-policy.mjs @@ -0,0 +1,297 @@ +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 keepTabOpen allowPrivateNetworkTarget targetMode maxResourceUrls'.split(' ') +) +const booleanOptionKeys = ['forceRefresh', 'captureScreenshotMetadata', '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 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() + if (lowerHost === 'localhost') return true + if (lowerHost.startsWith('::ffff:')) { + return isPrivateIpLiteral(mappedIpv4Address(lowerHost.slice('::ffff:'.length))) + } + if (lowerHost.startsWith('0:0:0:0:0:ffff:')) { + return isPrivateIpLiteral(mappedIpv4Address(lowerHost.slice('0:0:0:0:0:ffff:'.length))) + } + if (net.isIP(host) === 4) return isPrivateIpv4Literal(host) + if (net.isIP(host) === 6) return isPrivateIpv6Literal(lowerHost) + 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 isIpLiteral = hostname => net.isIP(hostname.replace(/^\[|\]$/g, '')) !== 0 +const effectivePort = parsed => parsed.port || (parsed.protocol === 'http:' ? '80' : parsed.protocol === 'https:' ? '443' : '') + +const isBridgeLoopbackAlias = (hostname, bridgeHostname) => { + const host = hostname.replace(/^\[|\]$/g, '').toLowerCase() + const bridgeHost = bridgeHostname.replace(/^\[|\]$/g, '').toLowerCase() + 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 isPrivateResolvedAddress = item => { + const address = normalizeDnsAddress(item) + return Boolean(address && isPrivateIpLiteral(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 { + ok: false, + code: 'TARGET_DNS_LOOKUP_FAILED', + message: 'Target hostname could not be resolved.', + details: { reason: 'dns_lookup_failed' } + } + } + if (!Array.isArray(addresses) || !addresses.length) { + return { + ok: false, + code: 'TARGET_DNS_LOOKUP_FAILED', + message: 'Target hostname could not be resolved.', + details: { reason: 'dns_lookup_failed' } + } + } + if (addresses.some(isPrivateResolvedAddress)) { + return { + ok: false, + code: 'PRIVATE_NETWORK_TARGET_BLOCKED', + message: 'Private network targets are disabled.', + details: { reason: 'private_network_address' } + } + } + return { ok: true } +} + +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, + 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/stackprism-bridge.mjs b/agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs new file mode 100644 index 00000000..e58b8619 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism-bridge.mjs @@ -0,0 +1,65 @@ +#!/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_INVALID_ENV' + if (code !== 'PORT_IN_USE') { + process.stderr.write( + `${JSON.stringify({ + error: { + code: 'BRIDGE_START_FAILED', + message: 'Bridge server startup failed.', + details: { reason: caught?.code || caught?.name || 'unknown' } + } + })}\n` + ) + } + failStart(code, code === 'PORT_IN_USE' ? 'Configured bridge port is already in use.' : 'Failed to start bridge server.') + } +} 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..3376664f --- /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_INVALID_ENV", "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..936d9519 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/body.py @@ -0,0 +1,34 @@ +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): + 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): + 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..e9cb755d --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/bridge_page.py @@ -0,0 +1,72 @@ +from .protocol import PROTOCOL_VERSION, html_escape_script_json, new_csp_nonce +from .status import FINAL_STATES + + +def render_bridge_page(handler, capture): + with handler.server.store._lock: + if capture["status"] == "expired": + response = ("fail", 410, "CAPTURE_RESULT_EXPIRED", "Capture result expired.", None) + config_data = None + elif capture["status"] in FINAL_STATES: + error = capture.get("error") or {} + response = ( + "fail", + 409, + error.get("code") or "INVALID_REQUEST", + "Capture is already terminal.", + {"status": capture["status"]}, + ) + config_data = None + elif capture["bridgeTokenRenderedAt"] or capture["bridgeTokenClaimedAt"]: + response = ("fail", 409, "INVALID_REQUEST", "Bridge token has already been rendered.", None) + config_data = None + else: + response = None + capture["bridgeTokenRenderedAt"] = handler.server.store.now() + config_data = { + "captureId": capture["id"], + "sessionId": capture["sessionId"], + "nonce": capture["nonce"], + "bridgeToken": capture["bridgeToken"], + "protocolVersion": PROTOCOL_VERSION, + } + if response: + handler.fail(response[1], response[2], response[3], response[4]) + return + csp_nonce = new_csp_nonce() + config = html_escape_script_json(config_data) + 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}'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'", + ) + handler.end_headers() + status_script = ( + "const statusEl=document.getElementById('status');" + "const config=JSON.parse(document.getElementById('stackprism-agent-bridge-config').textContent);" + "const setStatus=(value)=>{statusEl.textContent=value};" + "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){setStatus(body?.error?.code||'Bridge request failed.');return}" + "setStatus(body.status+(body.phase?' / '+body.phase:''));" + "if(['completed','failed','cancelled','expired'].includes(body.status))return" + "}catch{setStatus('Bridge status unavailable.')}" + "setTimeout(poll,1000)};poll();" + ) + handler.wfile.write( + ( + '' + 'StackPrism Agent Bridge

Waiting for StackPrism extension.

' + f'' + f'' + "" + ).encode("utf-8") + ) 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..003e0726 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/capture_store.py @@ -0,0 +1,122 @@ +import threading +import time + +from .open_browser import open_browser +from .protocol import error_body, new_bridge_token, new_capture_id, new_nonce, new_session_id + + +EXTENSION_CONNECT_TIMEOUT_SECONDS = 30 +CAPTURE_TIMEOUT_SECONDS = 60 +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): + self.base_url = base_url + self.now = now + self.open_browser = open_browser_fn + self.captures = {} + 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, + "error": None, + "createdAt": now, + "extensionDeadlineAt": now + EXTENSION_CONNECT_TIMEOUT_SECONDS, + "deadlineAt": now + CAPTURE_TIMEOUT_SECONDS, + "cancelDeadlineAt": None, + "resultExpiresAt": None, + "bridgeTokenRenderedAt": None, + "bridgeTokenClaimedAt": 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" + 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["profile"] = profile + capture["status"] = "completed" + capture["phase"] = "cleanup" + capture["resultExpiresAt"] = self.now() + RESULT_TTL_SECONDS + + 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["error"] = error_body("CAPTURE_RESULT_EXPIRED", "Capture result expired.")["error"] + 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.captures.pop(item["id"], None) 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..182e41f9 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/http_server.py @@ -0,0 +1,297 @@ +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, + valid_id, +) +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 = 10 + + +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))?$", path) + if not match or not valid_id("captureId", match[1]): + return None + return self.server.store.get(match[1]), match[2] 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): + 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": + self.get_profile(capture) + return + self.method_not_allowed("GET, POST" if endpoint == "profile" else "GET") + + def get_profile(self, capture): + token_type = self.auth_capture(capture, "status") + if not token_type: + return + profile_headers = {"Referrer-Policy": "no-referrer"} + if token_type == "api" and self.rate_limited(self.server.api_token, "query"): + self.fail(429, "RATE_LIMITED", "Agent bridge rate limit exceeded.", extra_headers=profile_headers) + elif token_type == "bridge": + self.fail(403, "BRIDGE_TOKEN_CANNOT_READ_PROFILE", "Bridge token cannot read profile.", extra_headers=profile_headers) + elif capture["status"] == "expired": + self.fail(410, "CAPTURE_RESULT_EXPIRED", "Capture result expired.", extra_headers=profile_headers) + elif capture["status"] != "completed": + self.fail(409, "INVALID_REQUEST", "Capture profile is not ready.", extra_headers=profile_headers) + else: + self.send_json(200, capture["profile"], profile_headers) + + 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 = 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) + 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"] = "cleanup" + 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"] = "cleanup" + 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 = 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..24985427 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/open_browser.py @@ -0,0 +1,62 @@ +import json +import os +import platform +import subprocess + + +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 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 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"} + if env.get("STACKPRISM_BRIDGE_NO_OPEN") == "1": + return True, {"skipped": True} + + 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 platform.system() == "Darwin": + command = "open" + elif platform.system() == "Windows": + command = "rundll32.exe" + args = ["url.dll,FileProtocolHandler"] + else: + command = "xdg-open" + + try: + completed = subprocess.run([command, *args, url], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2) + except FileNotFoundError: + return False, {"reason": "command_not_found"} + except PermissionError: + return False, {"reason": "permission_denied"} + except subprocess.TimeoutExpired: + return False, {"reason": "timeout"} + except Exception as exc: + return False, {"reason": "spawn_failed", "error": str(exc)} + if completed.returncode != 0: + return False, {"reason": "open_failed"} + return True, {} 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..e6ded51a --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/protocol.py @@ -0,0 +1,190 @@ +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_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", +} + +SENSITIVE_DETAIL_KEY = re.compile(r"authorization|cookie|token|nonce|secret", re.I) +ID_PATTERN = re.compile(r"\b(?:spbt?_|cap_|s_|n_|xfer_)[A-Za-z0-9_-]{8,}\b") +URL_PATTERN = re.compile(r"https?://[^\s\"')\]}]+") +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}$"), + "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_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 redact_url(value): + from urllib.parse import urlparse, urlunparse + + parsed = urlparse(str(value or "")) + query = "[redacted]" if parsed.query else "" + return urlunparse((parsed.scheme, parsed.netloc, 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..dedfa012 --- /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("//") or "%2f" in path.lower() or "%5c" in path.lower(): + return 400, "INVALID_REQUEST", "Only origin-form request targets are allowed." + raw_path, _, raw_query = path.partition("?") + if "%2e" in path.lower(): + 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"} and safe_equal(token, handler.server.api_token): + return "api" + if scope in {"bridge", "status"} 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..7b64b42a --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/server_factory.py @@ -0,0 +1,48 @@ +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 = 10 + + +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): + 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 + server.store = CaptureStore(base_url, now, lambda url: open_browser(url, active_env)) + 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..23b56371 --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/status.py @@ -0,0 +1,53 @@ +from .protocol import PROTOCOL_VERSION, is_known_bridge_error_code + +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 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"] + 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" and not (body.get("error", {}).get("code") and body.get("error", {}).get("message")): + return False, "INVALID_REQUEST", "Failed status requires a structured error." + if body["status"] == "failed" and not is_known_bridge_error_code(body["error"]["code"]): + return False, "INVALID_REQUEST", "Failed status error code is invalid." + if body["status"] in {"cancelled", "failed"} and body["phase"] != "cleanup": + return False, "INVALID_REQUEST", "Terminal status must use cleanup phase." + if not isinstance(body.get("sequence"), int) 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..75dcc5ee --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/url_policy.py @@ -0,0 +1,289 @@ +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", "keepTabOpen", "allowPrivateNetworkTarget", "targetMode", "maxResourceUrls"} +BOOLEAN_OPTION_KEYS = {"forceRefresh", "captureScreenshotMetadata", "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", + ) +) +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 is_private_host(hostname): + if hostname == "localhost": + return True + try: + address = ipaddress.ip_address(hostname.strip("[]")) + if getattr(address, "ipv4_mapped", None): + address = address.ipv4_mapped + return any(address in network for network in PRIVATE_IP_NETWORKS) and not any( + address in network for network in PUBLIC_IP_EXCEPTIONS + ) + except ValueError: + return False + + +def is_ip_literal(hostname): + try: + ipaddress.ip_address(hostname.strip("[]")) + return True + except ValueError: + return False + + +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)) 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." + return { + "forceRefresh": options.get("forceRefresh") is True, + "captureScreenshotMetadata": options.get("captureScreenshotMetadata") is True, + "keepTabOpen": options.get("keepTabOpen") is True, + "allowPrivateNetworkTarget": options.get("allowPrivateNetworkTarget") is True, + "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 + return host in {"localhost", "::1", "0:0:0:0:0: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): + if request.get("options", {}).get("allowPrivateNetworkTarget") is True: + return None, None + if from_cache or value is None: + return "FINAL_URL_BLOCKED", {"reason": "target_network_address_unverified"} + if not isinstance(value, str): + return "INVALID_REQUEST", {"reason": "invalid_network_address"} + address = value.strip().strip("[]") + if not address: + return "FINAL_URL_BLOCKED", {"reason": "target_network_address_unverified"} + try: + ipaddress.ip_address(address) + except ValueError: + return "INVALID_REQUEST", {"reason": "invalid_network_address"} + if is_private_host(address): + return "FINAL_URL_BLOCKED", {"reason": "private_network_address"} + return None, None From ea87a91aa37e0b9ae3cdadff6d10625f53d7f0e3 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 26 May 2026 15:55:58 +0800 Subject: [PATCH 08/49] test: cover agent bridge contracts and smoke flows --- ...agent-bridge-browser-smoke-output.test.mjs | 321 ++ tests/agent-bridge-browser-smoke.mjs | 3597 +++++++++++++++++ tests/agent-bridge-complexity.test.mjs | 34 + tests/agent-bridge-handshake.test.mjs | 1092 ++++- tests/agent-bridge-manifest.test.mjs | 139 +- tests/agent-capture-orchestration.test.mjs | 3010 ++++++++++++++ tests/background-logging.test.mjs | 65 + tests/experience-profile-format.test.mjs | 7 + tests/fixtures/bridge-url-policy-cases.json | 59 + tests/format-tech-stack.test.mjs | 5 +- .../agent-bridge-browser-smoke-harness.mjs | 1169 ++++++ tests/helpers/load-ts-module.mjs | 92 +- tests/release-workflow.test.mjs | 176 + tests/site-experience-profile.test.mjs | 328 +- tests/stackprism-bridge.test.mjs | 1992 +++++++++ tests/stackprism_bridge_py.test.mjs | 2728 +++++++++++++ 16 files changed, 14728 insertions(+), 86 deletions(-) create mode 100644 tests/agent-bridge-browser-smoke-output.test.mjs create mode 100644 tests/agent-bridge-browser-smoke.mjs create mode 100644 tests/agent-bridge-complexity.test.mjs create mode 100644 tests/agent-capture-orchestration.test.mjs create mode 100644 tests/background-logging.test.mjs create mode 100644 tests/fixtures/bridge-url-policy-cases.json create mode 100644 tests/helpers/agent-bridge-browser-smoke-harness.mjs create mode 100644 tests/release-workflow.test.mjs create mode 100644 tests/stackprism-bridge.test.mjs create mode 100644 tests/stackprism_bridge_py.test.mjs diff --git a/tests/agent-bridge-browser-smoke-output.test.mjs b/tests/agent-bridge-browser-smoke-output.test.mjs new file mode 100644 index 00000000..a4e46f69 --- /dev/null +++ b/tests/agent-bridge-browser-smoke-output.test.mjs @@ -0,0 +1,321 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { test } from 'node:test' + +const smokeSource = await readFile(new URL('./agent-bridge-browser-smoke.mjs', import.meta.url), 'utf8') +const harnessSource = await readFile(new URL('./helpers/agent-bridge-browser-smoke-harness.mjs', import.meta.url), 'utf8') +const e2eReport = await readFile(new URL('../docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md', import.meta.url), 'utf8') +const completionAudit = await readFile(new URL('../docs/reviews/CR-AGENT-BRIDGE-COMPLETION-AUDIT-2026-05-24.md', import.meta.url), 'utf8') + +test('browser smoke summaries do not print token prefixes', () => { + assert.doesNotMatch(smokeSource, /\bapiTokenPrefix\b/) + assert.doesNotMatch(smokeSource, /\bbridgeTokenPrefix\b/) + assert.doesNotMatch(smokeSource, /\btokenPrefix\b/) + assert.doesNotMatch(harnessSource, /\btokenPrefix\b/) + assertNoReportTokenPrefix(e2eReport) + assertNoReportTokenPrefix(completionAudit) + assert.match(smokeSource, /\bapiTokenPresent: Boolean\(/) + assert.match(e2eReport, /\bapiTokenPresent = true\b/) +}) + +test('current reports separate public complex-site proof from default policy proof', () => { + const e2eCurrentSummary = sectionBetween(e2eReport, 'This report is no longer', '## Current Verification') + const e2eCurrentVerification = sectionBetween(e2eReport, '## Current Verification', '## Browser Smoke') + const latestSmoke = sectionBetween(e2eReport, 'Latest successful smoke:', '## Remaining Risks') + const task10Matrix = sectionBetween(completionAudit, 'Task 10 当前共有', '## Verification Run') + + assert.doesNotMatch(e2eCurrentSummary, /live-browser smoke covers `https:\/\/example\.com`/) + assert.doesNotMatch(e2eCurrentVerification, /Enabled captures completed for `https:\/\/example\.com`/) + assert.doesNotMatch(latestSmoke, /Capture targets: `https:\/\/example\.com`/) + assert.doesNotMatch(latestSmoke, /Public target assertions: final URL was `https:\/\/example\.com\/`/) + assert.doesNotMatch(task10Matrix, /Chrome for Testing smoke 覆盖 `https:\/\/example\.com`/) + assert.doesNotMatch(task10Matrix, /public complex-site gap remains/) + + assert.match(e2eCurrentSummary, /separate public complex-site capture for `https:\/\/www\.wikipedia\.org\/`/) + assert.match(e2eCurrentVerification, /public-complex-target/) + assert.match(e2eCurrentVerification, /198\.18\.0\.19/) + assert.match(latestSmoke, /Public target status: counted only through the explicit `public-complex-target` scenario/) + assert.match(latestSmoke, /privateNetworkOverrideUsed = true/) + assert.match(task10Matrix, /Proven locally with explicit DNS-proxy caveat/) + assert.match(smokeSource, /runPublicComplexTargetScenario/) + assert.match(smokeSource, /privateNetworkOverrideUsed: true/) +}) + +test('current reports keep incognito live metadata branch unproven', () => { + const e2eRemainingRisks = sectionFrom(e2eReport, '## Remaining Risks') + const e2eAdditionalChecks = sectionBetween(e2eReport, 'Additional focused checks recorded for this slice:', 'Environment note:') + const task10Matrix = sectionBetween(completionAudit, '| Target policy and target failures', '| Profile privacy and transfer') + const currentStatus = sectionFrom(completionAudit, '## Current Status') + + assert.match(smokeSource, /runIncognitoWindowBridgeProbeScenario/) + assert.match(smokeSource, /environment-skip-incognito-window-extension-not-connected/) + assert.match(e2eRemainingRisks, /incognito-window-bridge-probe/) + assert.match(e2eRemainingRisks, /failed as `EXTENSION_NOT_CONNECTED`/) + assert.match(e2eRemainingRisks, /Incognito live metadata rejection is still not fully run/) + assert.match(e2eRemainingRisks, /exact live incognito `INCOGNITO_NOT_SUPPORTED` browser metadata rejection/) + assert.match(e2eRemainingRisks, /chrome\.extension\.inIncognitoContext === true/) + assert.match(e2eRemainingRisks, /posts `INCOGNITO_NOT_SUPPORTED` before loading `\/request`/) + assert.match(e2eAdditionalChecks, /content incognito context follow-up/) + assert.match(e2eAdditionalChecks, /PROFILE_TRANSPORT_FAILED/) + assert.match(e2eAdditionalChecks, /failed \/ INCOGNITO_NOT_SUPPORTED/) + assert.match(task10Matrix, /`--incognito` 窗口路径 probe 也未加载扩展 content script/) + assert.match(task10Matrix, /chrome\.extension\.inIncognitoContext === true/) + assert.match(task10Matrix, /INCOGNITO_NOT_SUPPORTED/) + assert.match(currentStatus, /content client 已有 `chrome\.extension\.inIncognitoContext` 单元契约/) + assert.doesNotMatch(currentStatus, /精确 incognito `INCOGNITO_NOT_SUPPORTED` 浏览器元数据路径.*已完成/) +}) + +test('current verification records the latest unit test count', () => { + const e2eCurrentVerification = sectionBetween(e2eReport, '## Current Verification', '## Browser Smoke') + const task10Matrix = sectionBetween(completionAudit, 'Task 10 当前共有', '## Verification Run') + + assert.doesNotMatch(e2eCurrentVerification, /175 tests passed, 0 failed/) + assert.match(e2eCurrentVerification, /2026-05-26/) + assert.doesNotMatch(e2eCurrentVerification, /198 tests passed, 0 failed/) + assert.doesNotMatch(e2eCurrentVerification, /199 tests passed, 0 failed/) + assert.doesNotMatch(e2eCurrentVerification, /202 tests passed, 0 failed/) + assert.doesNotMatch(e2eCurrentVerification, /203 tests passed, 0 failed/) + assert.doesNotMatch(e2eCurrentVerification, /204 tests passed, 0 failed/) + assert.doesNotMatch(e2eCurrentVerification, /205 tests passed, 0 failed/) + assert.doesNotMatch(e2eCurrentVerification, /206 tests passed, 0 failed/) + assert.doesNotMatch(e2eCurrentVerification, /207 tests passed, 0 failed/) + assert.doesNotMatch(e2eCurrentVerification, /208 tests passed, 0 failed/) + assert.doesNotMatch(e2eCurrentVerification, /209 tests passed, 0 failed/) + assert.doesNotMatch(e2eCurrentVerification, /210 tests passed, 0 failed/) + assert.doesNotMatch(e2eCurrentVerification, /211 tests passed, 0 failed/) + assert.doesNotMatch(e2eCurrentVerification, /212 tests passed, 0 failed/) + assert.doesNotMatch(e2eCurrentVerification, /213 tests passed, 0 failed/) + assert.match(e2eCurrentVerification, /219 tests passed, 0 failed/) + assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 180 tests passed/) + assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 198 tests passed/) + assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 199 tests passed/) + assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 202 tests passed/) + assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 203 tests passed/) + assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 204 tests passed/) + assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 205 tests passed/) + assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 206 tests passed/) + assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 207 tests passed/) + assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 208 tests passed/) + assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 209 tests passed/) + assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 210 tests passed/) + assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 211 tests passed/) + assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 212 tests passed/) + assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 213 tests passed/) + assert.match(task10Matrix, /最新 `pnpm run test:unit` 为 219 tests passed/) +}) + +test('current reports include fresh lint and typecheck gate evidence', () => { + const e2eCurrentVerification = sectionBetween(e2eReport, '## Current Verification', '## Browser Smoke') + const verificationRun = sectionBetween(completionAudit, '## Verification Run', '## Current Status') + + assert.match(e2eCurrentVerification, /\| `pnpm run lint`[\s\S]*2026-05-26/) + assert.match(e2eCurrentVerification, /\| `pnpm run typecheck`[\s\S]*2026-05-26/) + assert.match(e2eCurrentVerification, /rate-limit smoke\/report follow-up/) + assert.match(e2eCurrentVerification, /vue-tsc --noEmit/) + assert.match(e2eCurrentVerification, /production build/) + assert.match(e2eCurrentVerification, /dist\/manifest\.json/) + assert.match(verificationRun, /追加 active-tab unavailable smoke\/report follow-up 后重新执行 `pnpm run lint`:通过/) + assert.match(verificationRun, /追加 active-tab unavailable smoke\/report follow-up 后重新执行 `pnpm run typecheck`:通过/) + assert.match(verificationRun, /追加 rate-limit smoke\/report follow-up 后重新执行 `pnpm run lint`:通过/) + assert.match(verificationRun, /追加 rate-limit smoke\/report follow-up 后重新执行 `pnpm run typecheck`:通过/) + assert.match(verificationRun, /重新生成 injected IIFE 与 `dist\/manifest\.json`/) +}) + +test('current reports include live result-expiry bridge page evidence', () => { + const e2eAdditionalChecks = sectionBetween(e2eReport, 'Additional focused checks recorded for this slice:', 'Environment note:') + const e2eRemainingRisks = sectionFrom(e2eReport, '## Remaining Risks') + const task10Matrix = sectionBetween(completionAudit, '| 终态、并发和取消语义', '| HTTP resource pressure') + const currentStatus = sectionFrom(completionAudit, '## Current Status') + + assert.match(smokeSource, /runResultExpiryBridgePageScenario/) + assert.match(smokeSource, /result-expiry-bridge-page/) + assert.match(e2eAdditionalChecks, /STACKPRISM_BROWSER_SMOKE_SCENARIO=result-expiry-bridge-page/) + assert.match(e2eAdditionalChecks, /410 \/ CAPTURE_RESULT_EXPIRED/) + assert.match(e2eAdditionalChecks, /did not contain bridge token material/) + assert.match(e2eRemainingRisks, /Completed-result expiry is also covered by `result-expiry-bridge-page` live Chrome smoke/) + assert.doesNotMatch(e2eRemainingRisks, /Expired bridge page token suppression remains covered by JS\/Python automated bridge tests/) + assert.match(task10Matrix, /completed result TTL 过期后 profile endpoint 和 `\/bridge` 均返回 `CAPTURE_RESULT_EXPIRED`/) + assert.match(currentStatus, /result expiry bridge page smoke/) +}) + +test('current reports include focused bridge self-target smoke evidence', () => { + const e2eAdditionalChecks = sectionBetween(e2eReport, 'Additional focused checks recorded for this slice:', 'Environment note:') + const targetPolicyRow = sectionBetween(completionAudit, '| Target policy and target failures', '| Profile privacy and transfer') + const currentStatus = sectionFrom(completionAudit, '## Current Status') + + assert.match(smokeSource, /runBridgeSelfTargetBlockedScenario/) + assert.match(smokeSource, /bridge-self-target-blocked/) + assert.match(e2eAdditionalChecks, /STACKPRISM_BROWSER_SMOKE_SCENARIO=bridge-self-target-blocked/) + assert.match(e2eAdditionalChecks, /400 \/ BRIDGE_SELF_TARGET_BLOCKED/) + assert.match(e2eAdditionalChecks, /`localhost` loopback alias/) + assert.match(e2eAdditionalChecks, /\/health\.activeCaptures` remained 0/) + assert.match(targetPolicyRow, /`bridge-self-target-blocked`/) + assert.match(targetPolicyRow, /当前 bridge origin 和 `localhost` alias/) + assert.doesNotMatch(targetPolicyRow, /self-target guard/) + assert.match(currentStatus, /bridge self-target smoke/) + assert.match(currentStatus, /bridge-origin 初始目标拒绝/) +}) + +test('current reports include live active-tab unavailable evidence', () => { + const e2eAdditionalChecks = sectionBetween(e2eReport, 'Additional focused checks recorded for this slice:', 'Environment note:') + const targetPolicyRow = sectionBetween(completionAudit, '| Target policy and target failures', '| Profile privacy and transfer') + const currentStatus = sectionFrom(completionAudit, '## Current Status') + + assert.match(smokeSource, /runActiveTabUnavailableScenario/) + assert.match(smokeSource, /active-tab-unavailable/) + assert.match(e2eAdditionalChecks, /STACKPRISM_BROWSER_SMOKE_SCENARIO=active-tab-unavailable/) + assert.match(e2eAdditionalChecks, /ACTIVE_TAB_UNAVAILABLE/) + assert.match(e2eAdditionalChecks, /profile read returned 409/) + assert.match(e2eAdditionalChecks, /target probe received 0 requests/) + assert.match(e2eAdditionalChecks, /no target URL tab was opened/) + assert.match(targetPolicyRow, /`active-tab-unavailable`/) + assert.match(targetPolicyRow, /target request count 0/) + assert.match(currentStatus, /active-tab unavailable smoke/) + assert.match(currentStatus, /active-tab 缺失 fail-closed/) +}) + +test('current reports include target-mode query and keep-tab-open smoke evidence', () => { + const e2eFollowUps = sectionBetween( + e2eReport, + '2026-05-25 targetMode query live smoke follow-up', + '2026-05-25 sequential capture pressure' + ) + const task10Matrix = sectionBetween(completionAudit, '| Target policy and target failures', '| Profile privacy and transfer') + + assert.match(smokeSource, /target-mode-query-boundaries/) + assert.match(smokeSource, /keepTabOpenWasTrue/) + assert.match(smokeSource, /keptNewTargetTabId/) + assert.match(e2eFollowUps, /STACKPRISM_BROWSER_SMOKE_SCENARIO=target-mode-query-boundaries/) + assert.match(e2eFollowUps, /keepTabOpen=true/) + assert.match(e2eFollowUps, /keptNewTargetTabId/) + assert.match(task10Matrix, /targetMode query live smoke/) + assert.match(task10Matrix, /keepTabOpen=true/) + assert.match(task10Matrix, /保留插件新建目标 tab/) +}) + +test('current reports include live request-shell rejection smoke evidence', () => { + const e2eAdditionalChecks = sectionBetween(e2eReport, 'Additional focused checks recorded for this slice:', 'Environment note:') + const resourcePressureRow = sectionBetween(completionAudit, '| HTTP resource pressure', '| Service worker / extension lifecycle') + + assert.match(smokeSource, /runRequestShellRejectionsScenario/) + assert.match(smokeSource, /request-shell-rejections/) + assert.match(smokeSource, /rejectedCases/) + assert.match(smokeSource, /duplicateContentType/) + assert.match(smokeSource, /duplicateContentLength/) + assert.match(smokeSource, /invalidContentLength/) + assert.match(smokeSource, /unsupportedCharset/) + assert.match(e2eAdditionalChecks, /STACKPRISM_BROWSER_SMOKE_SCENARIO=request-shell-rejections/) + assert.match(e2eAdditionalChecks, /19 类原始 request-shell/) + assert.match(e2eAdditionalChecks, /duplicate Content-Type/) + assert.match(e2eAdditionalChecks, /invalid Content-Length/) + assert.match(e2eAdditionalChecks, /target request count 0/) + assert.match(e2eAdditionalChecks, /healthAfterRejections/) + assert.match(resourcePressureRow, /request-shell-rejections/) + assert.match(resourcePressureRow, /19 类原始 request-shell/) + assert.match(resourcePressureRow, /拒绝后 `\/health` 恢复 200/) +}) + +test('current reports include live keep-alive timeout evidence', () => { + const e2eFollowUps = sectionBetween(e2eReport, '2026-05-25 resource timeouts smoke follow-up', '2026-05-25 release workflow gate') + const resourcePressureRow = sectionBetween(completionAudit, '| HTTP resource pressure', '| Service worker / extension lifecycle') + const verificationRun = sectionBetween(completionAudit, '## Verification Run', '## Current Status') + + assert.match(smokeSource, /probeKeepAliveIdleClose/) + assert.match(smokeSource, /afterKeepAliveIdleStatusLine/) + assert.match(e2eFollowUps, /STACKPRISM_BROWSER_SMOKE_SCENARIO=resource-timeouts/) + assert.match(e2eFollowUps, /default idle window/) + assert.match(e2eFollowUps, /subsequent `\/health` request returned 200/) + assert.match(resourcePressureRow, /默认 idle 窗口/) + assert.match(verificationRun, /STACKPRISM_BROWSER_SMOKE_SCENARIO=resource-timeouts STACKPRISM_BROWSER_SMOKE_CDP_PORT=9561/) +}) + +test('current reports include live default rate-limit evidence', () => { + const e2eFollowUps = sectionBetween( + e2eReport, + '2026-05-26 rate-limit smoke follow-up', + '2026-05-26 target URL validation smoke follow-up' + ) + const bridgeApiRow = sectionBetween(completionAudit, '| Bridge API semantics and rate limiting', '| Bridge 启动与环境错误') + const verificationRun = sectionBetween(completionAudit, '## Verification Run', '## Current Status') + + assert.match(smokeSource, /runRateLimitScenario/) + assert.match(smokeSource, /runProfileRateLimitScenario/) + assert.match(smokeSource, /rate-limit/) + assert.match(smokeSource, /profile-rate-limit/) + assert.match(smokeSource, /createRateLimit/) + assert.match(smokeSource, /queryRateLimit/) + assert.match(smokeSource, /profileRateLimit/) + assert.match(e2eFollowUps, /STACKPRISM_BROWSER_SMOKE_SCENARIO=rate-limit/) + assert.match(e2eFollowUps, /STACKPRISM_BROWSER_SMOKE_SCENARIO=profile-rate-limit/) + assert.match(e2eFollowUps, /11th create request returned `429 \/ RATE_LIMITED`/) + assert.match(e2eFollowUps, /121st status read returned `429 \/ RATE_LIMITED`/) + assert.match(e2eFollowUps, /121st profile read returned `429 \/ RATE_LIMITED`/) + assert.match(e2eFollowUps, /target request count was 0/) + assert.match(bridgeApiRow, /`rate-limit`/) + assert.match(bridgeApiRow, /`profile-rate-limit`/) + assert.match(bridgeApiRow, /默认 create limit 10/) + assert.match(bridgeApiRow, /默认 query limit 120/) + assert.match(bridgeApiRow, /120 次 profile reads/) + assert.match(verificationRun, /STACKPRISM_BROWSER_SMOKE_SCENARIO=rate-limit STACKPRISM_BROWSER_SMOKE_CDP_PORT=9562/) + assert.match(verificationRun, /STACKPRISM_BROWSER_SMOKE_SCENARIO=profile-rate-limit STACKPRISM_BROWSER_SMOKE_CDP_PORT=9564/) +}) + +test('current reports include live target URL validation evidence', () => { + const e2eFollowUps = sectionBetween(e2eReport, '2026-05-26 target URL validation smoke follow-up', 'Environment note:') + const targetPolicyRow = sectionBetween(completionAudit, '| Target policy and target failures', '| Profile privacy and transfer') + const verificationRun = sectionBetween(completionAudit, '## Verification Run', '## Current Status') + + assert.match(smokeSource, /runTargetUrlValidationScenario/) + assert.match(smokeSource, /target-url-validation/) + assert.match(smokeSource, /unsupportedProtocol/) + assert.match(smokeSource, /credentialUrl/) + assert.match(smokeSource, /loopbackPrivateTarget/) + assert.match(smokeSource, /bridgeSelfTarget/) + assert.match(e2eFollowUps, /STACKPRISM_BROWSER_SMOKE_SCENARIO=target-url-validation/) + assert.match(e2eFollowUps, /unsupported protocol/) + assert.match(e2eFollowUps, /credential URL/) + assert.match(e2eFollowUps, /loopback private target/) + assert.match(e2eFollowUps, /bridge self-target/) + assert.match(e2eFollowUps, /activeCaptures = 0/) + assert.match(e2eFollowUps, /target request count was 0/) + assert.match(targetPolicyRow, /`target-url-validation`/) + assert.match(targetPolicyRow, /unsupported protocol/) + assert.match(targetPolicyRow, /credential URL/) + assert.match(targetPolicyRow, /loopback private target/) + assert.match(targetPolicyRow, /bridge self-target/) + assert.match(verificationRun, /STACKPRISM_BROWSER_SMOKE_SCENARIO=target-url-validation STACKPRISM_BROWSER_SMOKE_CDP_PORT=9563/) +}) + +test('completion audit records current worktree hygiene counts', () => { + const currentStatus = sectionFrom(completionAudit, '## Current Status') + + assert.doesNotMatch(currentStatus, /47 个 unstaged 文件/) + assert.match(currentStatus, /52 个 staged 文件/) + assert.match(currentStatus, /39 个 unstaged 文件/) + assert.match(currentStatus, /4 个 untracked Agent Bridge 文件/) +}) + +const assertNoReportTokenPrefix = source => { + assert.doesNotMatch(source, /apiTokenPrefix\s*=\s*"spb_/) + assert.doesNotMatch(source, /bridgeTokenPrefix\s*=\s*"spbt_/) + assert.doesNotMatch(source, /把 `spb_`/) + assert.doesNotMatch(source, /把 `spbt_`/) + assert.doesNotMatch(source, /`spb_`\/`spbt_` token/) + assert.doesNotMatch(source, /`spb_` token/) + assert.doesNotMatch(source, /`spbt_` token/) + assert.doesNotMatch(source, /records only the extension id and token prefix/) +} + +const sectionBetween = (source, start, end) => { + const startIndex = source.indexOf(start) + assert.notEqual(startIndex, -1, `Missing section start: ${start}`) + const endIndex = source.indexOf(end, startIndex) + assert.notEqual(endIndex, -1, `Missing section end: ${end}`) + return source.slice(startIndex, endIndex) +} + +const sectionFrom = (source, start) => { + const startIndex = source.indexOf(start) + assert.notEqual(startIndex, -1, `Missing section start: ${start}`) + return source.slice(startIndex) +} diff --git a/tests/agent-bridge-browser-smoke.mjs b/tests/agent-bridge-browser-smoke.mjs new file mode 100644 index 00000000..96cef538 --- /dev/null +++ b/tests/agent-bridge-browser-smoke.mjs @@ -0,0 +1,3597 @@ +import { existsSync } from 'node:fs' +import dns from 'node:dns/promises' +import net from 'node:net' +import { resolve } from 'node:path' +import { createBridgeServer } from '../agent-skill/stackprism-site-experience/scripts/bridge/http-server.mjs' +import { protocolVersion } from '../agent-skill/stackprism-site-experience/scripts/bridge/protocol.mjs' +import { assert, createBrowserSmokeHarness, redactText } from './helpers/agent-bridge-browser-smoke-harness.mjs' + +const root = resolve(new URL('..', import.meta.url).pathname) +const dist = resolve(root, 'dist') +const cdpPort = Number(process.env.STACKPRISM_BROWSER_SMOKE_CDP_PORT || 9451) +const targetUrl = process.env.STACKPRISM_BROWSER_SMOKE_TARGET_URL || 'https://example.com' +const scenario = process.env.STACKPRISM_BROWSER_SMOKE_SCENARIO || 'default' +const cdpBaseUrl = `http://127.0.0.1:${cdpPort}` + +const hasMetadataKey = value => { + if (!value || typeof value !== 'object') return false + if (Array.isArray(value)) return value.some(hasMetadataKey) + return Object.entries(value).some(([key, item]) => /^(boundingBoxes|aboveFold|rect)$/i.test(key) || hasMetadataKey(item)) +} + +const hasScreenshotPayloadKey = value => { + if (!value || typeof value !== 'object') return false + if (Array.isArray(value)) return value.some(hasScreenshotPayloadKey) + return Object.entries(value).some( + ([key, item]) => + /^(screenshot|screenshotData|imageData|pixelData|pixels|dataUrl|base64Image)$/i.test(key) || hasScreenshotPayloadKey(item) + ) +} + +const profileSummary = (profile, captureId) => { + const body = profile?.body + if (!body) return null + const profileBytes = new TextEncoder().encode(JSON.stringify(body)).byteLength + const serialized = JSON.stringify(body).toLowerCase() + return { + httpStatus: profile.status, + schema: body.schema, + captureIdMatches: body.captureId === captureId, + userAgentPresent: Boolean(body.browserContext?.userAgent), + extensionVersion: body.browserContext?.extensionVersion || '', + targetFinalUrl: body.target?.finalUrl || '', + visualKeys: Object.keys(body.visualProfile || {}), + layoutKeys: Object.keys(body.layoutProfile || {}), + componentKeys: Object.keys(body.componentProfile || {}), + limitationCount: Array.isArray(body.limitations) ? body.limitations.length : 0, + profileBytes, + estimatedTransferChunks: Math.max(1, Math.ceil(profileBytes / (384 * 1024))), + screenshotMetadataPresent: hasMetadataKey(body), + screenshotPayloadPresent: hasScreenshotPayloadKey(body), + privacyLeakDetected: /cookie|authorization|set-cookie|token=secret|#frag/.test(serialized) + } +} + +const unrequestedSectionChecks = { + visual: 'visualProfile', + layout: 'layoutProfile', + components: 'componentProfile', + interaction: 'interactionProfile', + ux: 'uxProfile', + assets: 'assetProfile' +} + +const assertSectionNotRequested = (profile, sections) => { + const body = profile?.body + assert(body && Array.isArray(body.limitations), `Profile body did not include limitations: ${JSON.stringify(profile)}`) + for (const section of sections) { + const key = unrequestedSectionChecks[section] + assert(JSON.stringify(body[key] || {}) === '{}', `${key} was not empty for an unrequested section.`) + assert(body.limitations.includes(`${section}_section_not_requested`), `${section} limitation was missing.`) + } +} + +const openBridgeWithCdpScript = ` +const bridgeUrl = process.argv.at(-1) +const cdpBaseUrl = process.env.STACKPRISM_BROWSER_SMOKE_CDP_BASE_URL +if (!bridgeUrl || !cdpBaseUrl) process.exit(2) +const response = await fetch(cdpBaseUrl + '/json/new?' + encodeURIComponent(bridgeUrl), { method: 'PUT' }) +if (!response.ok) process.exit(3) +` + +const ensureDistBuilt = () => { + if (!existsSync(resolve(dist, 'manifest.json'))) throw new Error('dist/manifest.json is missing. Run pnpm run build first.') +} + +const withoutFragment = url => { + const parsed = new URL(url) + parsed.hash = '' + return parsed.toString() +} + +const resolveTargetAddresses = async value => { + const hostname = new URL(value).hostname + try { + return { + hostname, + addresses: (await dns.lookup(hostname, { all: true, verbatim: true })).map(item => item.address) + } + } catch (error) { + return { + hostname, + addresses: [], + dnsError: error?.code || (error instanceof Error ? error.message : String(error)) + } + } +} + +const assertRawHttpStatus = (raw, status, label) => { + assert(raw.startsWith(`HTTP/1.1 ${status} `), `${label} returned unexpected raw response: ${redactText(raw)}`) +} + +const assertRawHttpIncludes = (raw, pattern, label) => { + assert(pattern.test(raw), `${label} did not include ${pattern}: ${redactText(raw)}`) +} + +const rawStatusLine = raw => String(raw || '').split('\r\n')[0] + +const probeKeepAliveIdleClose = (port, hostHeader, { idleMs = 2600, timeoutMs = 5000 } = {}) => + new Promise((resolveProbe, rejectProbe) => { + const socket = net.createConnection({ host: '127.0.0.1', port: Number(port) }) + let data = '' + let closed = false + let settled = false + let idleTimer + const finish = result => { + if (settled) return + settled = true + clearTimeout(timeout) + clearTimeout(idleTimer) + socket.destroy() + resolveProbe(result) + } + const timeout = setTimeout(() => finish({ firstResponse: data, closed, reason: 'timeout' }), timeoutMs) + socket.on('connect', () => socket.write(`GET /health HTTP/1.1\r\nHost: ${hostHeader}\r\nConnection: keep-alive\r\n\r\n`)) + socket.on('data', chunk => { + data += chunk.toString('utf8') + if (data.includes('\r\n\r\n') && !idleTimer) { + idleTimer = setTimeout(() => finish({ firstResponse: data, closed, reason: closed ? 'closed' : 'open_after_idle' }), idleMs) + } + }) + socket.on('end', () => { + closed = true + }) + socket.on('close', () => { + closed = true + }) + socket.on('error', rejectProbe) + }) + +const openHoldingHttpSocket = (port, hostHeader) => + new Promise((resolveOpen, rejectOpen) => { + const socket = net.createConnection({ host: '127.0.0.1', port: Number(port) }) + let opened = false + socket.once('connect', () => { + opened = true + socket.write(`GET /health HTTP/1.1\r\nHost: ${hostHeader}\r\n`) + socket.on('error', () => {}) + resolveOpen(socket) + }) + socket.once('error', error => { + if (!opened) rejectOpen(error) + }) + }) + +const rawHttpWithTimeout = (port, lines, timeoutMs = 750) => + new Promise(resolveRaw => { + const socket = net.createConnection({ host: '127.0.0.1', port: Number(port) }) + let settled = false + let data = '' + const finish = reason => { + if (settled) return + settled = true + clearTimeout(timeout) + socket.destroy() + resolveRaw({ data, reason }) + } + const timeout = setTimeout(() => finish('timeout'), timeoutMs) + socket.on('connect', () => socket.write(lines.join('\r\n'))) + socket.on('data', chunk => { + data += chunk.toString('utf8') + }) + socket.on('error', error => finish(error?.code || 'error')) + socket.on('end', () => finish('end')) + socket.on('close', () => finish('close')) + }) + +const rawHttpPartialWithDeadline = (port, chunks, { deadlineMs = 12000, chunkDelayMs = 0 } = {}) => + new Promise((resolveRaw, rejectRaw) => { + const startedAt = Date.now() + const socket = net.createConnection({ host: '127.0.0.1', port: Number(port) }) + let settled = false + let data = '' + const finish = reason => { + if (settled) return + settled = true + clearTimeout(timeout) + socket.destroy() + resolveRaw({ data, elapsedMs: Date.now() - startedAt, reason }) + } + const writeChunks = async () => { + try { + for (const chunk of chunks) { + if (settled) return + socket.write(chunk) + if (chunkDelayMs > 0) await new Promise(resolveWait => setTimeout(resolveWait, chunkDelayMs)) + } + } catch (error) { + if (!settled) rejectRaw(error) + } + } + const timeout = setTimeout(() => finish('deadline'), deadlineMs) + socket.on('connect', writeChunks) + socket.on('data', chunk => { + data += chunk.toString('utf8') + }) + socket.on('error', error => finish(error?.code || 'error')) + socket.on('end', () => finish('end')) + socket.on('close', () => finish('close')) + }) + +const corsAllowHeaders = [ + 'access-control-allow-origin', + 'access-control-allow-headers', + 'access-control-allow-methods', + 'access-control-allow-credentials' +] + +const readJsonEnvelope = async response => ({ status: response.status, body: await response.json(), headers: response.headers }) + +const assertJsonSecurityHeaders = (response, label, { referrerPolicy = false } = {}) => { + assert( + /^application\/json; charset=utf-8\b/i.test(response.headers.get('content-type') || ''), + `${label} did not return JSON content-type.` + ) + assert(response.headers.get('cache-control') === 'no-store', `${label} did not return Cache-Control: no-store.`) + assert(response.headers.get('x-content-type-options') === 'nosniff', `${label} did not return X-Content-Type-Options: nosniff.`) + if (referrerPolicy) assert(response.headers.get('referrer-policy') === 'no-referrer', `${label} did not return no-referrer.`) +} + +const assertNoCorsAllowHeaders = (headers, label) => { + for (const header of corsAllowHeaders) { + assert(!headers.has(header), `${label} returned ${header}.`) + } +} + +const parseBridgeConfig = html => { + const match = html.match(/&\u2028\u2029' }) + assert.equal(escaped.includes(''), false) + assert.equal(escaped.includes('#stackprism-fragment' + const created = await createCapture(ready) + const hostileUrl = `${created.body.bridgeUrl}&unexpected=${encodeURIComponent(hostileMarker)}#${encodeURIComponent(hostileMarker)}` + const invalid = await fetch(hostileUrl) + const invalidHtml = await invalid.text() + + assert.equal(invalid.status, 400) + assert.match(invalidHtml, /INVALID_REQUEST/) + assert.doesNotMatch(invalidHtml, /stackprism-fragment/) + assert.doesNotMatch(invalidHtml, /&\\u2028\\u2029"}), +}, sort_keys=True)) +`) + assert.deepEqual(parsed.valid, { + apiToken: true, + bridgeToken: true, + captureId: true, + sessionId: true, + nonce: true, + profileTransferId: true, + cspNonce: true + }) + assert.equal(parsed.unknown, false) + assert.equal(parsed.redacted, 'https://example.com/app?[redacted]') + assert.equal(parsed.safe_equal_same, true) + assert.equal(parsed.safe_equal_short, false) + assert.equal(parsed.safe_equal_long_tail, false) + assert.equal(parsed.missing_phase_validation, true) + assert.equal(parsed.escaped.includes(''), false) + assert.equal(parsed.escaped.includes('#stackprism-fragment" + +def request_text(url): + try: + with urllib.request.urlopen(url, timeout=3) as response: + return response.status, response.read().decode("utf-8") + except urllib.error.HTTPError as error: + return error.code, error.read().decode("utf-8") + +def request_json(method, url, token=None, body=None): + headers = {} + data = None + if token: + headers["Authorization"] = f"Bearer {token}" + if body is not None: + headers["Content-Type"] = "application/json" + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=3) as response: + return response.status, json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + return error.code, json.loads(error.read().decode("utf-8")) + +def make_capture(ready): + capture_request = { + "url": "https://93.184.216.34/app?view=one#frag", + "mode": "experience", + "waitMs": 0, + "include": ["tech"], + "viewports": [], + "options": {"targetMode": "reuse_or_new_tab"}, + } + _, created = request_json("POST", f"{ready['baseUrl']}/v1/captures", ready["apiToken"], capture_request) + return created + +server, ready = create_server(0) +thread = threading.Thread(target=server.serve_forever, daemon=True) +thread.start() +try: + created = make_capture(ready) + hostile_url = created["bridgeUrl"] + "&unexpected=" + urllib.parse.quote(hostile) + "#" + urllib.parse.quote(hostile) + invalid_status, invalid_html = request_text(hostile_url) + + capture = server.store.get(created["id"]) + capture["status"] = "failed" + capture["phase"] = "cleanup" + capture["error"] = {"code": "TARGET_TAB_CLOSED", "message": hostile} + terminal_status, terminal_html = request_text(created["bridgeUrl"]) + + print(json.dumps({ + "invalidStatus": invalid_status, + "invalidHasCode": "INVALID_REQUEST" in invalid_html, + "invalidHasFragment": "stackprism-fragment" in invalid_html, + "invalidHasScript": "" in invalid_html, + "invalidHasToken": bool(re.search(r"spbt_[A-Za-z0-9_-]{43}", invalid_html)), + "terminalStatus": terminal_status, + "terminalHasCode": "TARGET_TAB_CLOSED" in terminal_html, + "terminalHasFragment": "stackprism-fragment" in terminal_html, + "terminalHasScript": "" in terminal_html, + "terminalHasToken": bool(re.search(r"spbt_[A-Za-z0-9_-]{43}", terminal_html)), + }, sort_keys=True)) +finally: + server.shutdown() + server.server_close() +`) + assert.equal(parsed.invalidStatus, 400) + assert.equal(parsed.invalidHasCode, true) + assert.equal(parsed.invalidHasFragment, false) + assert.equal(parsed.invalidHasScript, false) + assert.equal(parsed.invalidHasToken, false) + assert.equal(parsed.terminalStatus, 409) + assert.equal(parsed.terminalHasCode, true) + assert.equal(parsed.terminalHasFragment, false) + assert.equal(parsed.terminalHasScript, false) + assert.equal(parsed.terminalHasToken, false) +}) + +test('python fallback reports browser open failure during capture creation', async () => { + const child = startPythonBridgeWithEnv({ + STACKPRISM_BRIDGE_NO_OPEN: '0', + STACKPRISM_BROWSER_OPEN_COMMAND: '/definitely/missing/stackprism-browser' + }) + try { + const ready = await readFirstStdoutJson(child) + const rejected = await createCapture(ready) + assert.equal(rejected.status, 500) + assert.equal(rejected.body.error.code, 'BROWSER_OPEN_FAILED') + } finally { + child.kill('SIGTERM') + await once(child, 'exit') + } +}) + +test('python fallback reports invalid browser open args during capture creation', async t => { + const cases = [ + ['invalid_json', '{'], + ['non_array', JSON.stringify({ profile: 'Default' })], + ['non_string_arg', JSON.stringify(['--profile-directory=Default', 42])] + ] + + for (const [name, argsJson] of cases) { + await t.test(name, async () => { + const child = startPythonBridgeWithEnv({ + STACKPRISM_BRIDGE_NO_OPEN: '0', + STACKPRISM_BROWSER_OPEN_COMMAND: 'python3', + STACKPRISM_BROWSER_OPEN_ARGS_JSON: argsJson + }) + try { + const ready = await readFirstStdoutJson(child) + const rejected = await createCapture(ready) + assert.equal(rejected.status, 500) + assert.equal(rejected.body.error.code, 'BROWSER_OPEN_FAILED') + assert.deepEqual(rejected.body.error.details, { reason: 'invalid_open_args' }) + } finally { + child.kill('SIGTERM') + await once(child, 'exit') + } + }) + } +}) + +test('python fallback bridge token can fetch request and post profile', async () => { + const { child, ready } = await startPythonBridge() + try { + const created = await createCapture(ready) + const config = await loadBridgeConfig(created.body.bridgeUrl) + + const status = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}`, { + headers: { Authorization: `Bearer ${ready.apiToken}` } + }) + ) + assert.equal(status.status, 200) + assertJsonSecurityHeaders(status) + + const requestEnvelope = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}/request`, { + headers: { Authorization: `Bearer ${config.bridgeToken}` } + }) + ) + assert.equal(requestEnvelope.status, 200) + assertJsonSecurityHeaders(requestEnvelope) + assert.equal(requestEnvelope.body.captureId, created.body.id) + assert.equal(requestEnvelope.body.request.url, 'https://93.184.216.34/app?view=one') + assert.deepEqual(Object.keys(requestEnvelope.body).sort(), ['captureId', 'nonce', 'protocolVersion', 'request', 'sessionId']) + + const control = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}/control`, { + headers: { Authorization: `Bearer ${config.bridgeToken}` } + }) + ) + assert.equal(control.status, 200) + assertJsonSecurityHeaders(control) + + await acceptFinalUrl(ready, created.body.id, config.bridgeToken) + + const profile = profileFor(created.body.id) + const posted = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}/profile`, { + method: 'POST', + headers: { Authorization: `Bearer ${config.bridgeToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(profile) + }) + ) + assert.equal(posted.status, 200) + assertJsonSecurityHeaders(posted) + assert.equal(posted.body.status, 'completed') + + const completedControl = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}/control`, { + headers: { Authorization: `Bearer ${config.bridgeToken}` } + }) + ) + assert.equal(completedControl.status, 200) + assert.equal(completedControl.body.command, 'cancel') + assert.equal(completedControl.body.status, 'completed') + + const forbidden = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}/profile`, { + headers: { Authorization: `Bearer ${config.bridgeToken}` } + }) + ) + assert.equal(forbidden.status, 403) + assert.equal(forbidden.body.error.code, 'BRIDGE_TOKEN_CANNOT_READ_PROFILE') + assertJsonSecurityHeaders(forbidden, { referrerPolicy: true }) + + const fetched = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}/profile`, { + headers: { Authorization: `Bearer ${ready.apiToken}` } + }) + ) + assert.equal(fetched.status, 200) + assertJsonSecurityHeaders(fetched, { referrerPolicy: true }) + assert.equal(fetched.body.schema, 'stackprism.site_experience_profile.v1') + } finally { + child.kill('SIGTERM') + await once(child, 'exit') + } +}) + +test('python fallback rejects repeated and oversized profile submissions', async () => { + const { child, ready } = await startPythonBridge() + try { + const created = await createCapture(ready) + const config = await loadBridgeConfig(created.body.bridgeUrl) + const profile = profileFor(created.body.id) + await acceptFinalUrl(ready, created.body.id, config.bridgeToken) + + const posted = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}/profile`, { + method: 'POST', + headers: { Authorization: `Bearer ${config.bridgeToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(profile) + }) + ) + assert.equal(posted.status, 200) + + const repeated = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}/profile`, { + method: 'POST', + headers: { Authorization: `Bearer ${config.bridgeToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(profile) + }) + ) + assert.equal(repeated.status, 409) + assert.equal(repeated.body.error.code, 'CAPTURE_ALREADY_COMPLETED') + + const url = new URL(ready.baseUrl) + const repeatedOversized = await rawHttp(url.port, [ + `POST /v1/captures/${created.body.id}/profile HTTP/1.1`, + `Host: ${url.host}`, + `Authorization: Bearer ${config.bridgeToken}`, + 'Content-Type: application/json', + `Content-Length: ${8 * 1024 * 1024 + 1}`, + 'Connection: close', + '', + '' + ]) + assert.match(repeatedOversized, /409/) + assert.match(repeatedOversized, /CAPTURE_ALREADY_COMPLETED/) + + const oversized = await createCapture(ready) + assert.equal(oversized.status, 200) + const oversizedConfig = await loadBridgeConfig(oversized.body.bridgeUrl) + await acceptFinalUrl(ready, oversized.body.id, oversizedConfig.bridgeToken) + const rejected = await rawHttp(url.port, [ + `POST /v1/captures/${oversized.body.id}/profile HTTP/1.1`, + `Host: ${url.host}`, + `Authorization: Bearer ${oversizedConfig.bridgeToken}`, + 'Content-Type: application/json', + `Content-Length: ${8 * 1024 * 1024 + 1}`, + 'Connection: close', + '', + '{}' + ]) + assert.match(rejected, /413/) + assert.match(rejected, /PROFILE_TOO_LARGE/) + } finally { + child.kill('SIGTERM') + await once(child, 'exit') + } +}) + +test('python fallback serializes concurrent profile submissions for one capture', async () => { + const { child, ready } = await startPythonBridge() + try { + const created = await createCapture(ready) + const config = await loadBridgeConfig(created.body.bridgeUrl) + const profile = profileFor(created.body.id) + const profileText = JSON.stringify(profile) + const url = new URL(ready.baseUrl) + await acceptFinalUrl(ready, created.body.id, config.bridgeToken) + + const slowPost = rawHttpSplitBody( + url.port, + [ + `POST /v1/captures/${created.body.id}/profile HTTP/1.1`, + `Host: ${url.host}`, + `Authorization: Bearer ${config.bridgeToken}`, + 'Content-Type: application/json', + `Content-Length: ${Buffer.byteLength(profileText)}`, + 'Connection: close', + '', + '' + ], + profileText, + 64, + 150 + ).then(parseRawJsonResponse) + + await new Promise(resolve => setTimeout(resolve, 30)) + + const fastPost = fetch(`${ready.baseUrl}/v1/captures/${created.body.id}/profile`, { + method: 'POST', + headers: { Authorization: `Bearer ${config.bridgeToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(profile) + }) + + const results = await Promise.all([slowPost, fastPost.then(response => readJson(response))]) + const statuses = results.map(result => result.status).sort() + assert.deepEqual(statuses, [200, 409]) + assert.equal(results.find(result => result.status === 409).body.error.code, 'CAPTURE_ALREADY_COMPLETED') + } finally { + child.kill('SIGTERM') + await once(child, 'exit') + } +}) + +test('python fallback bridge token can read capture control', async () => { + const { child, ready } = await startPythonBridge() + try { + const created = await createCapture(ready) + const config = await loadBridgeConfig(created.body.bridgeUrl) + + const control = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}/control`, { + headers: { Authorization: `Bearer ${config.bridgeToken}` } + }) + ) + assert.equal(control.status, 200) + assert.equal(control.body.id, created.body.id) + assert.equal(control.body.command, 'continue') + assert.equal(control.body.status, 'queued') + + const cancel = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${ready.apiToken}` } + }) + ) + assert.equal(cancel.status, 200) + assert.equal(cancel.body.status, 'cancel_requested') + + const missingCancel = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/cap_AAAAAAAAAAAAAAAAAAAAAA`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${ready.apiToken}` } + }) + ) + assert.equal(missingCancel.status, 404) + assert.equal(missingCancel.body.error.code, 'NOT_FOUND') + + const cancelControl = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}/control`, { + headers: { Authorization: `Bearer ${config.bridgeToken}` } + }) + ) + assert.equal(cancelControl.status, 200) + assert.equal(cancelControl.body.command, 'cancel') + } finally { + child.kill('SIGTERM') + await once(child, 'exit') + } +}) + +test('python fallback bridge page does not render tokens after extension connect timeout', () => { + const parsed = pythonOneShot(` +from stackprism_bridge_lib.server_factory import create_server +import json +import re +import threading +import urllib.error +import urllib.request + +clock = {"now": 1000.0} + +def now(): + return clock["now"] + +def request_json(method, url, token=None, body=None): + headers = {} + data = None + if token: + headers["Authorization"] = f"Bearer {token}" + if body is not None: + headers["Content-Type"] = "application/json" + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=3) as response: + return response.status, response.read().decode("utf-8") + except urllib.error.HTTPError as error: + return error.code, error.read().decode("utf-8") + +server, ready = create_server(0, now=now) +thread = threading.Thread(target=server.serve_forever, daemon=True) +thread.start() +try: + _, created_body = request_json("POST", f"{ready['baseUrl']}/v1/captures", ready["apiToken"], ${JSON.stringify(request)}) + created = json.loads(created_body) + clock["now"] = 1000.0 + 31 + expired_status, expired_html = request_json("GET", created["bridgeUrl"]) + print(json.dumps({ + "status": expired_status, + "has_token": bool(re.search(r"spbt_[A-Za-z0-9_-]{43}", expired_html)), + "body": expired_html, + }, sort_keys=True)) +finally: + server.shutdown() + server.server_close() +`) + assert.equal(parsed.status, 409) + assert.equal(parsed.has_token, false) + assert.match(parsed.body, /EXTENSION_NOT_CONNECTED/) +}) + +test('python fallback bridge page and profile endpoint do not render tokens after completed result TTL expiry', () => { + const parsed = pythonOneShot(` +from stackprism_bridge_lib.server_factory import create_server +import json +import re +import threading +import urllib.error +import urllib.request +from html import unescape + +clock = {"now": 1000.0} + +def now(): + return clock["now"] + +def request_json(method, url, token=None, body=None): + headers = {} + data = None + if token: + headers["Authorization"] = f"Bearer {token}" + if body is not None: + headers["Content-Type"] = "application/json" + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=3) as response: + text = response.read().decode("utf-8") + parsed = json.loads(text) if text and response.headers.get("content-type", "").startswith("application/json") else None + return response.status, parsed, text + except urllib.error.HTTPError as error: + text = error.read().decode("utf-8") + try: + parsed = json.loads(text) + except json.JSONDecodeError: + parsed = None + return error.code, parsed, text + +def bridge_config(html): + match = re.search(r'', html) + if not match: + raise AssertionError("missing bridge config") + return json.loads(unescape(match.group(1))) + +def status_body(created, config, body): + return { + "captureId": created["id"], + "sessionId": config["sessionId"], + "nonce": config["nonce"], + "protocolVersion": 1, + **body, + } + +server, ready = create_server(0, now=now) +thread = threading.Thread(target=server.serve_forever, daemon=True) +thread.start() +try: + _status, created, _text = request_json("POST", f"{ready['baseUrl']}/v1/captures", ready["apiToken"], ${JSON.stringify(request)}) + _bridge_status, _bridge_body, bridge_text = request_json("GET", created["bridgeUrl"]) + config = bridge_config(bridge_text) + request_status, _request_envelope, _request_text = request_json("GET", f"{ready['baseUrl']}/v1/captures/{created['id']}/request", config["bridgeToken"]) + final_status, _final_body, _final_text = request_json( + "POST", + f"{ready['baseUrl']}/v1/captures/{created['id']}/status", + config["bridgeToken"], + status_body(created, config, {"status": "running", "phase": "target_loaded", "sequence": 1, "finalUrl": ${JSON.stringify(request.url)}, "targetNetworkAddress": "93.184.216.34"}), + ) + profile = ${JSON.stringify(profileFor('__CAPTURE_ID__'))} + profile["captureId"] = created["id"] + posted_status, posted_body, _posted_text = request_json( + "POST", + f"{ready['baseUrl']}/v1/captures/{created['id']}/profile", + config["bridgeToken"], + profile, + ) + capture = server.store.get(created["id"]) + clock["now"] = capture["resultExpiresAt"] + 1 + expired_profile_status, expired_profile_body, _expired_profile_text = request_json( + "GET", + f"{ready['baseUrl']}/v1/captures/{created['id']}/profile", + ready["apiToken"], + ) + expired_page_status, _expired_page_body, expired_page_text = request_json("GET", created["bridgeUrl"]) + print(json.dumps({ + "request_status": request_status, + "final_status": final_status, + "posted_status": posted_status, + "posted_capture_status": posted_body.get("status"), + "expired_profile_status": expired_profile_status, + "expired_profile_code": expired_profile_body["error"]["code"], + "expired_page_status": expired_page_status, + "expired_page_has_code": "CAPTURE_RESULT_EXPIRED" in expired_page_text, + "expired_page_has_token": bool(re.search(r"spbt_[A-Za-z0-9_-]{43}", expired_page_text)), + }, sort_keys=True)) +finally: + server.shutdown() + server.server_close() +`) + assert.equal(parsed.request_status, 200) + assert.equal(parsed.final_status, 200) + assert.equal(parsed.posted_status, 200) + assert.equal(parsed.posted_capture_status, 'completed') + assert.equal(parsed.expired_profile_status, 410) + assert.equal(parsed.expired_profile_code, 'CAPTURE_RESULT_EXPIRED') + assert.equal(parsed.expired_page_status, 410) + assert.equal(parsed.expired_page_has_code, true) + assert.equal(parsed.expired_page_has_token, false) +}) + +test('python fallback requires api token to cancel captures', async () => { + const { child, ready } = await startPythonBridge() + try { + const created = await createCapture(ready) + const config = await loadBridgeConfig(created.body.bridgeUrl) + + const forbidden = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${config.bridgeToken}` } + }) + ) + assert.equal(forbidden.status, 403) + assert.equal(forbidden.body.error.code, 'FORBIDDEN') + + const status = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}`, { + headers: { Authorization: `Bearer ${ready.apiToken}` } + }) + ) + assert.equal(status.status, 200) + assert.equal(status.body.status, 'queued') + assert.equal(status.body.phase, undefined) + } finally { + child.kill('SIGTERM') + await once(child, 'exit') + } +}) + +test('python fallback rejects stale status sequences and phase regressions', async () => { + const { child, ready } = await startPythonBridge() + try { + const created = await createCapture(ready) + const config = await loadBridgeConfig(created.body.bridgeUrl) + const statusUrl = `${ready.baseUrl}/v1/captures/${created.body.id}/status` + + const wrongIdentity = await readJson( + await fetch(statusUrl, { + method: 'POST', + headers: { Authorization: `Bearer ${config.bridgeToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify( + statusBody( + created.body.id, + { ...config, nonce: `n_${'A'.repeat(22)}` }, + { + status: 'waiting_extension', + phase: 'bridge_connected', + sequence: 1 + } + ) + ) + }) + ) + assert.equal(wrongIdentity.status, 400) + assert.equal(wrongIdentity.body.error.code, 'INVALID_REQUEST') + + const connected = await readJson( + await fetch(statusUrl, { + method: 'POST', + headers: { Authorization: `Bearer ${config.bridgeToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(statusBody(created.body.id, config, { status: 'waiting_extension', phase: 'bridge_connected', sequence: 1 })) + }) + ) + assert.equal(connected.status, 200) + assert.equal(connected.body.status, 'waiting_extension') + assert.equal(connected.body.phase, 'bridge_connected') + + const running = await readJson( + await fetch(statusUrl, { + method: 'POST', + headers: { Authorization: `Bearer ${config.bridgeToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(statusBody(created.body.id, config, { status: 'running', phase: 'request_loaded', sequence: 2 })) + }) + ) + assert.equal(running.status, 200) + assert.equal(running.body.status, 'running') + assert.equal(running.body.phase, 'request_loaded') + + const staleSequence = await readJson( + await fetch(statusUrl, { + method: 'POST', + headers: { Authorization: `Bearer ${config.bridgeToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(statusBody(created.body.id, config, { status: 'running', phase: 'target_opening', sequence: 2 })) + }) + ) + assert.equal(staleSequence.status, 409) + assert.equal(staleSequence.body.error.code, 'STALE_STATUS_UPDATE') + + const phaseRegression = await readJson( + await fetch(statusUrl, { + method: 'POST', + headers: { Authorization: `Bearer ${config.bridgeToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(statusBody(created.body.id, config, { status: 'running', phase: 'bridge_connected', sequence: 3 })) + }) + ) + assert.equal(phaseRegression.status, 409) + assert.equal(phaseRegression.body.error.code, 'STALE_STATUS_UPDATE') + + const nonRunningPhaseRegression = await readJson( + await fetch(statusUrl, { + method: 'POST', + headers: { Authorization: `Bearer ${config.bridgeToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(statusBody(created.body.id, config, { status: 'waiting_extension', phase: 'bridge_connected', sequence: 3 })) + }) + ) + assert.equal(nonRunningPhaseRegression.status, 409) + assert.equal(nonRunningPhaseRegression.body.error.code, 'STALE_STATUS_UPDATE') + } finally { + child.kill('SIGTERM') + await once(child, 'exit') + } +}) + +test('python fallback restricts bridge-token terminal status updates', async () => { + const { child, ready } = await startPythonBridge() + try { + const created = await createCapture(ready) + const config = await loadBridgeConfig(created.body.bridgeUrl) + const statusUrl = `${ready.baseUrl}/v1/captures/${created.body.id}/status` + + const cancelledWithoutDelete = await readJson( + await fetch(statusUrl, { + method: 'POST', + headers: { Authorization: `Bearer ${config.bridgeToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(statusBody(created.body.id, config, { status: 'cancelled', phase: 'cleanup', sequence: 1 })) + }) + ) + assert.equal(cancelledWithoutDelete.status, 409) + assert.equal(cancelledWithoutDelete.body.error.code, 'STALE_STATUS_UPDATE') + + const failedWithoutError = await readJson( + await fetch(statusUrl, { + method: 'POST', + headers: { Authorization: `Bearer ${config.bridgeToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(statusBody(created.body.id, config, { status: 'failed', phase: 'cleanup', sequence: 1 })) + }) + ) + assert.equal(failedWithoutError.status, 400) + assert.equal(failedWithoutError.body.error.code, 'INVALID_REQUEST') + + const failedWrongPhase = await readJson( + await fetch(statusUrl, { + method: 'POST', + headers: { Authorization: `Bearer ${config.bridgeToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify( + statusBody(created.body.id, config, { + status: 'failed', + phase: 'target_opening', + sequence: 1, + error: { code: 'TARGET_TAB_CLOSED', message: 'Target closed.' } + }) + ) + }) + ) + assert.equal(failedWrongPhase.status, 400) + assert.equal(failedWrongPhase.body.error.code, 'INVALID_REQUEST') + + const failedUnknownCode = await readJson( + await fetch(statusUrl, { + method: 'POST', + headers: { Authorization: `Bearer ${config.bridgeToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify( + statusBody(created.body.id, config, { + status: 'failed', + phase: 'cleanup', + sequence: 1, + error: { code: 'MADE_UP_ERROR', message: 'Unknown bridge error.' } + }) + ) + }) + ) + assert.equal(failedUnknownCode.status, 400) + assert.equal(failedUnknownCode.body.error.code, 'INVALID_REQUEST') + + const failedError = sensitiveFailedError(ready, created, config) + const failed = await readJson( + await fetch(statusUrl, { + method: 'POST', + headers: { Authorization: `Bearer ${config.bridgeToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify( + statusBody(created.body.id, config, { + status: 'failed', + phase: 'cleanup', + sequence: 1, + error: failedError + }) + ) + }) + ) + assert.equal(failed.status, 200) + assert.equal(failed.body.status, 'failed') + assert.equal(failed.body.error.code, 'TARGET_TAB_CLOSED') + assertErrorIsRedacted(failed.body.error, [ready.apiToken, config.bridgeToken, config.nonce]) + } finally { + child.kill('SIGTERM') + await once(child, 'exit') + } +}) + +test('python fallback rejects DELETE for every terminal capture state', () => { + const parsed = pythonOneShot(` +import re +import threading +import urllib.error +import urllib.request + +from stackprism_bridge_lib.server_factory import create_server + +clock = {"now": 1000.0} + +def now(): + return clock["now"] + +def request_json(method, url, token=None, body=None): + headers = {} + data = None + if token: + headers["Authorization"] = f"Bearer {token}" + if body is not None: + headers["Content-Type"] = "application/json" + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=3) as response: + return response.status, json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + return error.code, json.loads(error.read().decode("utf-8")) + +def bridge_config(bridge_url): + with urllib.request.urlopen(bridge_url, timeout=3) as response: + html = response.read().decode("utf-8") + match = re.search(r'`、` @@ -789,17 +852,18 @@ font-size: 22px; font-weight: 600; gap: 10px; - letter-spacing: -0.01em; + letter-spacing: 0; line-height: 1.2; margin-bottom: 6px; } h2 { - color: var(--muted); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; + color: var(--text); + font-size: 14px; + font-weight: 650; + letter-spacing: 0; + line-height: 1.35; + text-transform: none; } .version-badge { @@ -811,8 +875,9 @@ .hint { color: var(--muted); - font-size: 13px; - margin-bottom: 8px; + font-size: 14px; + line-height: 1.5; + margin-bottom: 12px; } // header-actions:透明 ghost + 一个 primary @@ -894,6 +959,7 @@ background: var(--panel); border: 1px solid var(--line); border-radius: 8px; + box-shadow: 0 1px 2px rgba(20, 35, 50, 0.03); margin-bottom: 16px; padding: 20px 24px 24px; } @@ -921,7 +987,7 @@ border-radius: 5px; color: var(--muted); cursor: pointer; - font-size: 12px; + font-size: 13px; padding: 4px 10px; transition: border-color 0.15s ease, @@ -937,19 +1003,203 @@ // category toggle 列表:去边框,紧凑 inline 风格 .category-grid { display: grid; - gap: 4px 16px; + gap: 8px; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); } .toggle-item { align-items: center; + background: var(--dt-bg); + border: 1px solid transparent; + border-radius: 6px; color: var(--text); cursor: pointer; display: flex; - font-size: 13px; + font-size: 14px; gap: 8px; - padding: 4px 0; + min-height: 36px; + padding: 7px 10px; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; + user-select: none; + + &:hover { + background: var(--accent-soft); + border-color: rgba(15, 118, 110, 0.2); + } + } + + .agent-bridge-panel { + background: linear-gradient(90deg, var(--accent-soft), transparent 52%), var(--panel); + border-color: rgba(15, 118, 110, 0.32); + box-shadow: 0 12px 28px rgba(15, 118, 110, 0.08); + overflow: hidden; + padding: 0; + + &.is-enabled { + border-color: rgba(4, 120, 87, 0.52); + box-shadow: 0 16px 34px rgba(4, 120, 87, 0.12); + } + + &.has-pending-change { + border-color: rgba(180, 83, 9, 0.42); + box-shadow: 0 14px 30px rgba(180, 83, 9, 0.1); + } + } + + .agent-bridge-main { + align-items: flex-start; + display: flex; + gap: 16px; + justify-content: space-between; + padding: 22px 24px 16px; + } + + .agent-bridge-title { + align-items: flex-start; + display: flex; + gap: 14px; + + p { + color: var(--muted); + font-size: 14px; + line-height: 1.5; + margin-top: 4px; + max-width: 620px; + } + } + + .agent-bridge-mark { + align-items: center; + background: var(--accent); + border-radius: 8px; + box-shadow: 0 10px 20px rgba(15, 118, 110, 0.22); + color: #ffffff; + display: inline-flex; + flex-shrink: 0; + height: 44px; + justify-content: center; + width: 44px; + } + + .agent-bridge-kicker { + color: var(--accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1; + margin-bottom: 7px; + text-transform: uppercase; + } + + .agent-bridge-heading { + color: var(--text); + font-size: 20px; + font-weight: 650; + letter-spacing: 0; + line-height: 1.2; + text-transform: none; + } + + .agent-bridge-state { + align-items: center; + background: var(--confidence-low-bg); + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + display: inline-flex; + flex-shrink: 0; + font-size: 13px; + font-weight: 600; + min-height: 30px; + padding: 4px 12px; + + &.active { + background: var(--confidence-high-bg); + border-color: rgba(4, 120, 87, 0.24); + color: var(--confidence-high-text); + } + + &.pending { + background: rgba(245, 158, 11, 0.12); + border-color: rgba(180, 83, 9, 0.26); + color: #92400e; + } + } + + .agent-bridge-control { + align-items: center; + background: rgba(255, 255, 255, 0.58); + border-top: 1px solid rgba(15, 118, 110, 0.16); + display: flex; + gap: 18px; + justify-content: space-between; + padding: 16px 24px 18px; + } + + :global(:root[data-theme='dark']) .agent-bridge-control { + background: rgba(15, 20, 25, 0.28); + } + + @media (prefers-color-scheme: dark) { + :global(:root:not([data-theme='light'])) .agent-bridge-control { + background: rgba(15, 20, 25, 0.28); + } + } + + .agent-bridge-toggle { + align-items: center; + color: var(--text); + cursor: pointer; + display: flex; + gap: 11px; + min-width: 0; user-select: none; + + strong, + small { + display: block; + } + + strong { + font-size: 15px; + font-weight: 650; + line-height: 1.25; + } + + small { + color: var(--muted); + font-size: 14px; + line-height: 1.45; + margin-top: 2px; + } + } + + .agent-bridge-facts { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; + + span { + align-items: center; + background: var(--panel); + border: 1px solid rgba(15, 118, 110, 0.18); + border-radius: 999px; + color: var(--muted); + display: inline-flex; + font-size: 13px; + gap: 5px; + min-height: 30px; + padding: 5px 10px; + white-space: nowrap; + } + + svg { + color: var(--accent); + } } // two-column / rule-textareas @@ -960,6 +1210,34 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } + .settings-fieldset { + background: var(--dt-bg); + border: 1px solid var(--tech-divider); + border-radius: 8px; + padding: 20px; + + :deep(.sp-textarea) { + background: var(--panel); + font-size: 14px; + } + + :deep(.sp-textarea-inner) { + font-size: 14px; + line-height: 1.6; + padding: 12px; + } + } + + .panel > :deep(.sp-textarea) { + font-size: 14px; + } + + .panel > :deep(.sp-textarea) :deep(.sp-textarea-inner) { + font-size: 14px; + line-height: 1.6; + padding: 12px; + } + .rule-textareas { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-top: 16px; @@ -969,9 +1247,9 @@ label span { color: var(--text); display: block; - font-size: 12px; - font-weight: 500; - letter-spacing: 0.01em; + font-size: 14px; + font-weight: 550; + letter-spacing: 0; margin-bottom: 6px; } @@ -980,6 +1258,13 @@ display: grid; gap: 12px 16px; grid-template-columns: repeat(3, minmax(0, 1fr)); + + :deep(.sp-input), + :deep(.sp-input-inner), + :deep(.sp-select-trigger), + :deep(.sp-select-input) { + font-size: 14px; + } } // match-targets:inline checkbox 列 @@ -994,7 +1279,7 @@ color: var(--muted); cursor: pointer; display: inline-flex; - font-size: 13px; + font-size: 14px; gap: 6px; } } @@ -1011,7 +1296,7 @@ border-radius: 6px; color: var(--text); cursor: pointer; - font-size: 13px; + font-size: 14px; padding: 6px 14px; transition: border-color 0.15s ease, @@ -1062,7 +1347,7 @@ color: var(--muted); display: flex; flex-direction: column; - font-size: 13px; + font-size: 14px; gap: 8px; padding: 32px 0 24px; } @@ -1074,13 +1359,13 @@ .rule-title { color: var(--text); - font-size: 13px; + font-size: 14px; font-weight: 600; } .rule-meta { color: var(--muted); - font-size: 12px; + font-size: 13px; margin-top: 2px; overflow-wrap: anywhere; } @@ -1134,6 +1419,20 @@ padding: 12px 16px; } + .agent-bridge-main, + .agent-bridge-control { + align-items: stretch; + flex-direction: column; + } + + .agent-bridge-state { + align-self: flex-start; + } + + .agent-bridge-facts { + justify-content: flex-start; + } + .msg { left: 14px; max-width: none; From ae7985309cd09c5ba62dfa50dcb301978e6cb0c9 Mon Sep 17 00:00:00 2001 From: Mison Date: Wed, 27 May 2026 17:56:22 +0800 Subject: [PATCH 16/49] docs: align agent bridge completion evidence --- README.md | 2 +- docs/dev/agent-bridge.md | 17 ++++++++++++-- docs/guide/basic-usage.md | 2 +- ...GENT-BRIDGE-COMPLETION-AUDIT-2026-05-24.md | 6 +++-- .../reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md | 2 +- ...2026-05-21-stackprism-agent-bridge-plan.md | 23 +++++++++++++++---- ...agent-bridge-browser-smoke-output.test.mjs | 6 +++-- 7 files changed, 44 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 89b38496..4e0f0cb5 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Agent Bridge 是可选的本机能力:用户在扩展设置中显式启用后 本能力默认关闭,启用状态只保存在当前浏览器 profile 的本机 `chrome.storage.local`,不会随 Chrome sync 同步到其他设备或其他 profile。换设备、换浏览器 profile 或重装扩展后,需要重新显式开启。 -Agent Bridge 使用 passive capture:不会点击页面、提交表单、登录账号或执行破坏性操作;`viewports` 只写入 profile 请求上下文,不是 CDP 移动仿真或真实手机截图。若 StackPrism 安装在非默认浏览器或非默认 profile,本机 Agent 需要通过 `STACKPRISM_BROWSER_OPEN_COMMAND` 指向对应 Chrome 内核浏览器,并用 `STACKPRISM_BROWSER_OPEN_ARGS_JSON` JSON 字符串数组传入用户 profile 参数,bridge URL 永远由脚本作为最后一个参数追加。未开启时会返回 `AGENT_BRIDGE_DISABLED`;打开到错误浏览器或 profile 时通常会返回 `EXTENSION_NOT_CONNECTED`。 +Agent Bridge 使用 passive capture:不会点击页面、提交表单、登录账号或执行破坏性操作;`viewports` 只写入 profile 请求上下文,不是 CDP 移动仿真或真实手机截图。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`。 本能力不做远程上传,不采集 Cookie、Authorization、localStorage/sessionStorage 明文或完整敏感文本。第一版信任本机用户启动的 bridge 进程,不防同机恶意进程或同浏览器 profile 中其他恶意扩展。Agent 读取 profile 后应优先参考体验结构和可观测事实,不盲目照搬目标站点私有技术实现。 diff --git a/docs/dev/agent-bridge.md b/docs/dev/agent-bridge.md index 56c04989..aa154153 100644 --- a/docs/dev/agent-bridge.md +++ b/docs/dev/agent-bridge.md @@ -35,15 +35,19 @@ Agent Bridge 让本机 AI Agent 在用户已安装并显式启用 StackPrism 扩 ## 本地脚本 +以下 `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 ``` @@ -55,7 +59,15 @@ Agent 只读取 stdout 的第一条 ready JSON line,并应在 10 秒内完成 大型页面 profile 通过分片传回 bridge 页面。若采集中出现 `BRIDGE_TRANSPORT_DISCONNECTED`、`PROFILE_TRANSPORT_FAILED`、`PROFILE_CHUNK_MISSING` 或 `CAPTURE_TIMEOUT`,Agent 应将本次 capture 视为失败,停止当前 bridge 子进程后重启,并用更小的 `include` 范围或更低的 `maxResourceUrls` 重试一次;不得从部分分片拼出“降级成功”的 profile。 -如果扩展安装在非默认浏览器或非默认用户 profile,设置 `STACKPRISM_BROWSER_OPEN_COMMAND` 指向对应 Chrome 内核浏览器可执行文件,并把 profile 参数放入 `STACKPRISM_BROWSER_OPEN_ARGS_JSON` 字符串数组。bridge URL 始终由脚本作为最后一个独立 argv 追加,不要写入环境变量或 shell 命令。 +如果扩展安装在非默认浏览器或非默认用户 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 @@ -78,7 +90,8 @@ Agent Bridge 输出 schema 为 `stackprism.site_experience_profile.v1`。当前 - `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`、`agentGuidance`: 记录来源覆盖、截断、未请求 section、不可访问 frame 或 shadow root 等边界。 +- `evidence`、`limitations`: 记录来源覆盖、截断、未请求 section、不可访问 frame 或 shadow root 等边界。 +- `agentGuidance`: 给下游 Agent 的实现建议。当前包含摘要、优先级、注意事项和 `recreationPlan`。`recreationPlan` 把 profile 转成复刻执行层:`implementationOrder`、`designTokens`、`layoutBlueprint`、`componentInventory`、`interactionChecklist`、`uxChecklist`、`assetHints` 和 `verificationChecklist`。这些字段只引用已脱敏的 profile 内容,不能把缺失字段理解为目标站点不存在对应结构。 下游 Agent 不得把 profile 当作页面完整拷贝。它是浏览器可观察事实和实现参考,不是后端私有实现、用户账号内容或真实截图像素。 diff --git a/docs/guide/basic-usage.md b/docs/guide/basic-usage.md index 1220becb..deea2c8f 100644 --- a/docs/guide/basic-usage.md +++ b/docs/guide/basic-usage.md @@ -78,4 +78,4 @@ Agent Bridge 是面向本机 AI Agent 的可选能力。启用后,本机 Agent Agent Bridge 使用 passive capture,不会点击页面、提交表单、登录账号或执行破坏性操作。`viewports` 只写入 profile 请求上下文,不是 CDP 移动仿真或真实手机截图。它不会要求您点击插件按钮、复制 JSON 或下载文件,但本版本信任您本机启动的 bridge 进程,不防同机恶意进程或同浏览器 profile 中其他恶意扩展。 -如果 StackPrism 安装在非默认浏览器或非默认 profile,本机 Agent 需要设置 `STACKPRISM_BROWSER_OPEN_COMMAND` 指向对应 Chrome 内核浏览器,并通过 `STACKPRISM_BROWSER_OPEN_ARGS_JSON` JSON 字符串数组传入用户 profile 参数;bridge URL 永远由脚本作为最后一个参数追加。未启用时会返回 `AGENT_BRIDGE_DISABLED`,打开到未安装扩展的浏览器或 profile 时通常会返回 `EXTENSION_NOT_CONNECTED`,这些都是需要用户处理的配置错误,不应由 Agent 静默重试或降级。 +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 index 428cdf93..d919ebde 100644 --- a/docs/reviews/CR-AGENT-BRIDGE-COMPLETION-AUDIT-2026-05-24.md +++ b/docs/reviews/CR-AGENT-BRIDGE-COMPLETION-AUDIT-2026-05-24.md @@ -110,7 +110,7 @@ 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 契约后,最新 `pnpm run test:unit` 为 222 tests passed | Proven locally | +| 基础命令门禁 | 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 契约后,最新 `pnpm run test:unit` 为 225 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 | @@ -400,10 +400,12 @@ Task 10 当前共有 78 个 checklist 项。下面按验收主题归并当前证 - 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 为空。 ## Current Status -可以认为 Agent Bridge 已具备可用的第一版实现:本机 JS/Python bridge、扩展 handshake、capture orchestration、profile transfer、隐私脱敏、用户 opt-in、文档和发布边界均已落地,并经自动化和 Chrome smoke 验证。 +可以认为 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 缺口仍保留。 diff --git a/docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md b/docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md index 6ae094ea..3b1458ae 100644 --- a/docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md +++ b/docs/reviews/CR-AGENT-BRIDGE-E2E-2026-05-22.md @@ -17,7 +17,7 @@ This report is no longer a middle-state ledger. The current worktree has automat | Command | Exit | Result | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pnpm run test:unit` | 0 | Fresh rerun on 2026-05-26 after the upstream rebase, network-observer unavailable guard, targetNetworkAddress IP-literal hardening, fixture-backed default smoke contract, and profile language/UX field follow-up; 222 tests passed, 0 failed. Coverage includes 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, 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, 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, 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. | +| `pnpm run test:unit` | 0 | Fresh rerun on 2026-05-27 after the profile `agentGuidance.recreationPlan`, cross-platform browser opener, settings UI, and report-count follow-ups; 225 tests passed, 0 failed. Coverage includes 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, 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, 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, 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. | 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 index b9b62409..0c70a1fd 100644 --- a/docs/superpowers/plans/2026-05-21-stackprism-agent-bridge-plan.md +++ b/docs/superpowers/plans/2026-05-21-stackprism-agent-bridge-plan.md @@ -2,7 +2,7 @@ > **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、交互与资源信息,并读取 `stackprism.site_experience_profile.v1`。 +**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,不直接调用扩展内部接口。 @@ -14,13 +14,13 @@ - 可用第一版已落地:扩展端 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 一阶分类白名单、默认 browser smoke fixture-backed 成功路径、Task 10 状态矩阵,以及开发文档和审计文档的 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 和长时资源压力矩阵仍需保留为未完成边界。 - 下方 Task 1 到 Task 9 保留为历史实施计划,已从 checkbox 改为 `Historical item:`,不再作为当前完成状态来源;当前状态以本节、Task 10 状态矩阵和 `docs/reviews/CR-AGENT-BRIDGE-*.md` 为准。 ## 总目标 -让 AI Agent 在用户已安装 StackPrism 插件的普通 Chrome 内核浏览器中,无需用户复制、下载或点击插件按钮,即可通过本地 HTTP 接口获得目标网站的 Site Experience Profile,用于实现相似视觉效果、UI/UX 体验、交互行为和必要的技术选型参考。 +让 AI Agent 在用户已安装 StackPrism 插件的普通 Chrome 内核浏览器中,无需用户复制、下载或点击插件按钮,即可通过本地 HTTP 接口获得目标网站的 Site Experience Profile。Profile 必须把浏览器可观测事实转成 Agent 可直接执行的复刻计划,用于快速实现相似视觉效果、UI/UX 体验、交互行为和必要的技术选型参考。 ## 明确不做 @@ -67,6 +67,7 @@ 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` 映射到目标项目实现与验证步骤。 ## 用户可见门禁 @@ -649,6 +650,18 @@ Frame and shadow DOM rule: - 完成后用桌面/移动截图、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 包 @@ -1217,7 +1230,7 @@ Current status source: | Public complex-site smoke | Partial local evidence | `public-complex-target` captured `https://www.wikipedia.org/` with non-empty visual/layout/component data, but this environment resolved the hostname to `198.18.0.19`, so the smoke explicitly used `allowPrivateNetworkTarget = true`; it is not proof that default no-private-network policy accepts resolver-rewritten public hostnames. | | Bridge API and rate limits | Complete locally | JS and Python tests plus live smoke cover auth scope, create/status/profile/control endpoints, query/create rate limits, busy ordering, terminal DELETE and no silent queue. | | Target URL, DNS and private-network policy | Complete for current fixtures; broader matrix remains external | Unit fixtures and smoke cover unsupported protocols, credentialed URL rejection, bridge self-target, private target block, DNS lookup failure, final URL block and browser-observed `targetNetworkAddress` IP-literal validation. Multi-network DNS/private-address matrices remain a live environment gate. | -| Profile schema, privacy and UX fields | Complete locally | Current profile includes `target.language` and first-order UX fields: `pagePurpose`, `primaryUserPath`, `informationHierarchy`, `ctaStrategy`, `trustSignals`, `navigationDepth`, `contentGrouping`, `frictionPoints` and bounded `textSamples`; profile builder tests cover redaction and no screenshot payload. | +| Profile schema, privacy and UX fields | Complete locally | Current profile includes `target.language`, first-order UX fields, and `agentGuidance.recreationPlan` with implementation order, design tokens, layout blueprint, component inventory, interaction/UX/asset checklists and verification checklist; profile builder tests cover redaction, no screenshot payload and recreation-plan output. | | Profile transfer and large profile | Complete locally | Tests and smoke cover 384 KiB raw chunking, ack/reassembly, sha256 match, missing chunk, ack timeout, hash mismatch, wrong binding, invalid payload and >8 MB rejection. | | Capture lifecycle and cleanup | Complete locally except running idle eviction exact live trigger | Tests and smoke cover cancellation, tab closure, target navigation/load failure/load timeout, extension reload, storage-session clear, deadline reconciliation, target cleanup, bridge process shutdown and no fake profile. Running-capture natural service worker idle eviction has only fail-closed cleanup evidence in the current Chrome behavior and remains a live gate. | | Incognito | Unit complete; live metadata branch not proven | Content client and tab metadata tests cover `INCOGNITO_NOT_SUPPORTED`. Current CDP and `--incognito` live probes fail closed as `EXTENSION_NOT_CONNECTED` without target fetch or fake profile, so exact live metadata rejection remains a browser configuration gate. | @@ -1336,7 +1349,7 @@ External gates retained after local completion: - 插件能自动采集目标 URL 的现有技术栈信息。 - 插件能新增采集视觉、布局、组件、交互、UX 和资产摘要。 - Agent 能读取 `stackprism.site_experience_profile.v1`。 -- profile 对“实现同样视觉效果、UI/UX 体验”有直接指导字段。 +- profile 对“实现同样视觉效果、UI/UX 体验”有直接指导字段,并通过 `agentGuidance.recreationPlan` 输出实现顺序、设计 token、布局蓝图、组件清单、交互/UX/资产清单和验证清单。 - `experience-profiler` 同时登记在 `build-scripts/build-injected.mjs` 和 `vite.injected.config.ts`,构建产物存在,且默认不作为 web-accessible resource 暴露给网页。 - 所有失败路径返回结构化错误。 - Agent 可观察到插件阶段状态、取消结果、bridge tab 关闭和目标 tab 关闭错误,而不是只等待超时。 diff --git a/tests/agent-bridge-browser-smoke-output.test.mjs b/tests/agent-bridge-browser-smoke-output.test.mjs index d42e3917..98509f21 100644 --- a/tests/agent-bridge-browser-smoke-output.test.mjs +++ b/tests/agent-bridge-browser-smoke-output.test.mjs @@ -124,7 +124,8 @@ test('current verification records the latest unit test count', () => { assert.doesNotMatch(e2eCurrentVerification, /212 tests passed, 0 failed/) assert.doesNotMatch(e2eCurrentVerification, /213 tests passed, 0 failed/) assert.doesNotMatch(e2eCurrentVerification, /219 tests passed, 0 failed/) - assert.match(e2eCurrentVerification, /222 tests passed, 0 failed/) + assert.doesNotMatch(e2eCurrentVerification, /222 tests passed, 0 failed/) + assert.match(e2eCurrentVerification, /225 tests passed, 0 failed/) assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 180 tests passed/) assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 198 tests passed/) assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 199 tests passed/) @@ -141,7 +142,8 @@ test('current verification records the latest unit test count', () => { assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 212 tests passed/) assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 213 tests passed/) assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 219 tests passed/) - assert.match(task10Matrix, /最新 `pnpm run test:unit` 为 222 tests passed/) + assert.doesNotMatch(task10Matrix, /最新 `pnpm run test:unit` 为 222 tests passed/) + assert.match(task10Matrix, /最新 `pnpm run test:unit` 为 225 tests passed/) }) test('current reports include fresh lint and typecheck gate evidence', () => { From 6b6cebd41424fa4f7f21e3794ad0ac344127ad44 Mon Sep 17 00:00:00 2001 From: Mison Date: Thu, 28 May 2026 13:25:55 +0800 Subject: [PATCH 17/49] feat: add optional agent bridge screenshot capture --- src/background/agent-capture-request.ts | 12 +- src/background/agent-capture-target.ts | 81 +++++++++++- src/background/agent-capture.ts | 8 ++ src/content/agent-bridge-request.ts | 2 + src/types/agent-bridge.ts | 12 +- src/utils/site-experience-guidance.ts | 15 ++- src/utils/site-experience-limitations.ts | 4 + src/utils/site-experience-profile.ts | 22 +++- tests/agent-capture-orchestration.test.mjs | 137 ++++++++++++++++++++- tests/site-experience-profile.test.mjs | 56 +++++++++ 10 files changed, 342 insertions(+), 7 deletions(-) diff --git a/src/background/agent-capture-request.ts b/src/background/agent-capture-request.ts index 0510df78..d4d97029 100644 --- a/src/background/agent-capture-request.ts +++ b/src/background/agent-capture-request.ts @@ -16,6 +16,7 @@ const viewportKeys = ['name', 'width', 'height', 'deviceScaleFactor'] const optionKeys = [ 'forceRefresh', 'captureScreenshotMetadata', + 'captureScreenshot', 'keepTabOpen', 'allowPrivateNetworkTarget', 'targetMode', @@ -73,6 +74,7 @@ const validateOptions = (options: AgentCaptureOptions & Record) Boolean(options) && hasOnlyKeys(options, optionKeys) && booleanOptionKeys.every(key => typeof options[key] === 'boolean') && + (options.captureScreenshot === undefined || typeof options.captureScreenshot === 'boolean') && allowedTargetModes.has(options.targetMode) && Number.isInteger(options.maxResourceUrls) && options.maxResourceUrls >= 0 && @@ -101,5 +103,13 @@ export const validateAgentCaptureRequest = ( if (!validateOptions(request.options as AgentCaptureOptions & Record)) { return { ok: false, error: error('INVALID_REQUEST', 'Capture options are invalid.') } } - return { ok: true, request: { ...request, url, include: includeOrder.filter(item => request.include.includes(item)) } } + return { + ok: true, + request: { + ...request, + url, + include: includeOrder.filter(item => request.include.includes(item)), + options: { ...request.options, captureScreenshot: request.options.captureScreenshot === true } + } + } } diff --git a/src/background/agent-capture-target.ts b/src/background/agent-capture-target.ts index 0401f107..694b07e9 100644 --- a/src/background/agent-capture-target.ts +++ b/src/background/agent-capture-target.ts @@ -4,12 +4,16 @@ import { clearBundleLicenseTimer } from './bundle-license' import { clearDynamicSnapshotState } from './dynamic-snapshot' import { clearBadge, clearTabSession } from './tab-store' import type { AgentCaptureRequest } from '@/types/agent-bridge' -import type { AgentBridgeError } from '@/types/agent-bridge' +import type { AgentBridgeError, AgentCaptureScreenshot } from '@/types/agent-bridge' import { makeAgentCaptureError } from './agent-capture-common' import { logBackgroundError } from './logging' const TARGET_LOAD_TIMEOUT_REPORTING_GRACE_MS = 5000 const MAX_TARGET_LOAD_WAIT_MS = 60000 +const SCREENSHOT_QUALITY = 72 +const MAX_SCREENSHOT_BYTES = 2 * 1024 * 1024 +const SCREENSHOT_CAPTURE_RETRY_DELAYS_MS = [250, 750, 1500, 2500] +const TAB_ACTIVATION_RETRY_DELAYS_MS = [0, 150, 500, 1000] export const cleanForCapture = async (tabId: number): Promise => { clearBundleLicenseTimer(tabId) @@ -135,6 +139,81 @@ export const executeExperienceProfiler = async ( } } +type ScreenshotCaptureResult = { screenshot: AgentCaptureScreenshot | null; limitations: string[] } + +const dataUrlByteLength = (value: string): number => new TextEncoder().encode(value).byteLength +const waitForDelay = (delayMs: number): Promise => new Promise(resolve => setTimeout(resolve, delayMs)) + +const waitForTabActive = async (tabId: number): Promise => { + for (const delayMs of TAB_ACTIVATION_RETRY_DELAYS_MS) { + if (delayMs > 0) await waitForDelay(delayMs) + const tab = await chrome.tabs.get(tabId).catch(() => null) + if (tab?.active === true) return true + } + return false +} + +const focusCaptureWindow = async (windowId: number): Promise => { + await chrome.windows?.update?.(windowId, { focused: true }).catch(() => {}) +} + +const captureVisibleTabDataUrl = async (windowId: number): Promise => { + let lastError: unknown = null + for (const delayMs of SCREENSHOT_CAPTURE_RETRY_DELAYS_MS) { + if (delayMs > 0) await waitForDelay(delayMs) + try { + return await chrome.tabs.captureVisibleTab(windowId, { format: 'jpeg', quality: SCREENSHOT_QUALITY }) + } catch (caught) { + lastError = caught + } + } + throw lastError +} + +export const captureVisibleViewportScreenshot = async ( + tabId: number, + windowId: number, + restoreTabId?: number +): Promise => { + if (!chrome.tabs.captureVisibleTab) return { screenshot: null, limitations: ['screenshot_capture_unavailable'] } + const activeTabs = await chrome.tabs.query({ active: true, windowId }).catch(() => []) + const previousActiveTabId = activeTabs.find(tab => tab.active)?.id ?? activeTabs[0]?.id + const tabToRestore = restoreTabId ?? previousActiveTabId + try { + await focusCaptureWindow(windowId) + if (previousActiveTabId !== tabId) await chrome.tabs.update(tabId, { active: true }) + if (!(await waitForTabActive(tabId))) throw new Error('SCREENSHOT_TARGET_NOT_ACTIVE') + const dataUrl = await captureVisibleTabDataUrl(windowId) + if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/jpeg;base64,')) { + return { screenshot: null, limitations: ['screenshot_capture_invalid'] } + } + const byteLength = dataUrlByteLength(dataUrl) + if (byteLength > MAX_SCREENSHOT_BYTES) { + return { screenshot: null, limitations: ['screenshot_image_too_large'] } + } + return { + screenshot: { + dataUrl, + mimeType: 'image/jpeg', + byteLength, + source: 'chrome.tabs.captureVisibleTab', + scope: 'visible_viewport', + capturedAt: new Date().toISOString() + }, + limitations: [] + } + } catch (caught) { + logBackgroundError('Agent screenshot capture failed', { tabId, error: caught }) + return { screenshot: null, limitations: ['screenshot_capture_failed'] } + } finally { + if (typeof tabToRestore === 'number' && tabToRestore !== tabId) { + await chrome.tabs.update(tabToRestore, { active: true }).catch(caught => + logBackgroundError('Agent screenshot tab restore failed', { tabId: tabToRestore, error: caught }) + ) + } + } +} + export const getAgentCaptureUserAgent = (): string => typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string' ? navigator.userAgent : '' diff --git a/src/background/agent-capture.ts b/src/background/agent-capture.ts index 1d7d3537..3c039967 100644 --- a/src/background/agent-capture.ts +++ b/src/background/agent-capture.ts @@ -27,6 +27,7 @@ import type { AgentCaptureResponse } from './agent-capture-common' import { cleanupTarget, cleanForCapture, + captureVisibleViewportScreenshot, executeExperienceProfiler, getAgentCaptureUserAgent, getExtensionVersion, @@ -182,12 +183,19 @@ const runCapture = async (state: AgentCaptureState, request: AgentCaptureRequest const [settings, data, tab] = await Promise.all([loadDetectorSettings(), getTabData(targetTabId), getTabSnapshot(targetTabId)]) const raw = shouldRunTech ? await buildPopupRawResult(data, settings, tab) : null const capturedAt = new Date().toISOString() + const screenshotResult = + request.options.captureScreenshot && request.include.includes('visual') + ? await captureVisibleViewportScreenshot(targetTabId, state.targetWindowId || state.bridgeWindowId, state.bridgeTabId) + : { screenshot: null, limitations: [] } + if (!(await shouldContinueCapture(state))) return const profile = buildSiteExperienceProfile({ captureId: state.captureId, request, raw, experience, capabilities, + screenshot: screenshotResult.screenshot, + limitations: screenshotResult.limitations, finalUrl, userAgent: getAgentCaptureUserAgent(), extensionVersion: getExtensionVersion(), diff --git a/src/content/agent-bridge-request.ts b/src/content/agent-bridge-request.ts index c9fb5a60..9f11de59 100644 --- a/src/content/agent-bridge-request.ts +++ b/src/content/agent-bridge-request.ts @@ -5,6 +5,7 @@ const VIEWPORT_FIELDS = new Set(['name', 'width', 'height', 'deviceScaleFactor'] const OPTION_FIELDS = new Set([ 'forceRefresh', 'captureScreenshotMetadata', + 'captureScreenshot', 'keepTabOpen', 'allowPrivateNetworkTarget', 'targetMode', @@ -127,6 +128,7 @@ const isCaptureRequest = (value: any): value is AgentCaptureRequest => ['forceRefresh', 'captureScreenshotMetadata', 'keepTabOpen', 'allowPrivateNetworkTarget'].every( key => typeof value.options[key] === 'boolean' ) && + (value.options.captureScreenshot === undefined || typeof value.options.captureScreenshot === 'boolean') && ALLOWED_TARGET_MODES.has(value.options.targetMode) && Number.isInteger(value.options.maxResourceUrls) && value.options.maxResourceUrls >= 0 && diff --git a/src/types/agent-bridge.ts b/src/types/agent-bridge.ts index 8a553c05..01f0967d 100644 --- a/src/types/agent-bridge.ts +++ b/src/types/agent-bridge.ts @@ -11,7 +11,7 @@ export const REQUIRED_AGENT_BRIDGE_CAPABILITIES = [ 'experienceProfiler' ] as const -export const AGENT_BRIDGE_CAPABILITIES = [...REQUIRED_AGENT_BRIDGE_CAPABILITIES, 'rawProfile', 'viewportMetadata'] as const +export const AGENT_BRIDGE_CAPABILITIES = [...REQUIRED_AGENT_BRIDGE_CAPABILITIES, 'rawProfile', 'viewportMetadata', 'visualScreenshot'] as const export type AgentBridgeCapability = (typeof AGENT_BRIDGE_CAPABILITIES)[number] @@ -128,12 +128,22 @@ export interface AgentCaptureViewport { export interface AgentCaptureOptions { forceRefresh: boolean captureScreenshotMetadata: boolean + captureScreenshot?: boolean keepTabOpen: boolean allowPrivateNetworkTarget: boolean targetMode: AgentCaptureTargetMode maxResourceUrls: number } +export interface AgentCaptureScreenshot { + dataUrl: string + mimeType: 'image/jpeg' + byteLength: number + source: 'chrome.tabs.captureVisibleTab' + scope: 'visible_viewport' + capturedAt: string +} + export interface AgentCaptureRequest { url: string mode: AgentCaptureMode diff --git a/src/utils/site-experience-guidance.ts b/src/utils/site-experience-guidance.ts index c1435841..5aa0f4f2 100644 --- a/src/utils/site-experience-guidance.ts +++ b/src/utils/site-experience-guidance.ts @@ -26,6 +26,8 @@ const toTextList = (value: unknown, limit: number): string[] => { const toRecord = (value: unknown): Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value) ? sanitizeRecord(value) : {} +const isPlainRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) const toCountRecord = (value: unknown): Record => { const counts: Record = {} @@ -44,6 +46,14 @@ const toTopKeys = (counts: Record, limit: number): string[] => const valueCount = (value: unknown): number => (Array.isArray(value) ? value.length : 0) const GEOMETRY_METADATA_KEYS = new Set(['rect', 'boundingbox', 'bounds']) +const screenshotSummary = (value: unknown) => { + const screenshot = isPlainRecord(value) ? value : {} + return { + screenshotIncluded: typeof screenshot.dataUrl === 'string' && /^data:image\/jpeg;base64,/i.test(screenshot.dataUrl), + screenshotScope: cleanGuidanceText(screenshot.scope), + screenshotMimeType: cleanGuidanceText(screenshot.mimeType) + } +} const hasGeometryMetadata = (value: unknown): boolean => { if (Array.isArray(value)) return value.some(hasGeometryMetadata) @@ -68,7 +78,9 @@ const toDomainHints = (value: unknown, limit: number): string[] => { } const buildRecreationPlan = (sections: GuidanceSections, limitations: string[]) => { - const visual = toRecord(sections.visualProfile) + const rawVisual = isPlainRecord(sections.visualProfile) ? sections.visualProfile : {} + const { screenshot: rawScreenshot, ...visualWithoutScreenshot } = rawVisual + const visual = toRecord(visualWithoutScreenshot) const layout = toRecord(sections.layoutProfile) const components = toRecord(sections.componentProfile) const interaction = toRecord(sections.interactionProfile) @@ -97,6 +109,7 @@ const buildRecreationPlan = (sections: GuidanceSections, limitations: string[]) radii: toTextList(visual.radii, 12), shadows: toTextList(visual.shadows, 12) }, + visualReference: screenshotSummary(rawScreenshot), layoutBlueprint: { landmarks: toTextList(layout.landmarks, 20), firstViewportSummary: toRecord(layout.aboveFold), diff --git a/src/utils/site-experience-limitations.ts b/src/utils/site-experience-limitations.ts index ea15efb6..9c1ee533 100644 --- a/src/utils/site-experience-limitations.ts +++ b/src/utils/site-experience-limitations.ts @@ -8,6 +8,10 @@ export const buildLimitations = (request: AgentCaptureRequest, experience: any): for (const item of cleanStringArray(experience?.limitations).map(redactText).map(cleanInlineText).filter(Boolean)) limitations.add(item) if (request.viewports.length) limitations.add('viewport_emulation_unsupported') if (request.options.captureScreenshotMetadata === false) limitations.add('screenshot_metadata_not_requested') + if (request.options.captureScreenshot !== true) limitations.add('screenshot_image_not_requested') + if (request.options.captureScreenshot === true && request.include && !request.include.includes('visual')) { + limitations.add('screenshot_image_requires_visual_section') + } if (request.include && !request.include.includes('tech')) limitations.add('tech_section_not_requested') if (request.include && !request.include.includes('visual')) limitations.add('visual_section_not_requested') if (request.include && !request.include.includes('layout')) limitations.add('layout_section_not_requested') diff --git a/src/utils/site-experience-profile.ts b/src/utils/site-experience-profile.ts index a81c4bcb..0ef6cc09 100644 --- a/src/utils/site-experience-profile.ts +++ b/src/utils/site-experience-profile.ts @@ -3,12 +3,13 @@ import { SITE_EXPERIENCE_PROFILE_SCHEMA, type AgentBridgeCapabilities, type AgentCaptureRequest, + type AgentCaptureScreenshot, type SiteExperienceProfile } from '@/types/agent-bridge' import type { PopupRawResult } from '@/types/popup' import { buildAgentGuidance } from '@/utils/site-experience-guidance' import { buildLimitations } from '@/utils/site-experience-limitations' -import { cleanInlineText, redactText, redactUrl } from '@/utils/site-experience-redaction' +import { cleanInlineText, isRecord, redactText, redactUrl } from '@/utils/site-experience-redaction' import { buildAssetProfile, buildComponentProfile, @@ -31,18 +32,35 @@ export interface BuildSiteExperienceProfileInput { finalUrl?: string userAgent?: string extensionVersion?: string + screenshot?: AgentCaptureScreenshot | null + limitations?: string[] capturedAt?: string loginState?: 'unknown' | 'likely_authenticated' | 'likely_public' pageSupported?: boolean } +const isValidScreenshot = (value: unknown): value is AgentCaptureScreenshot => + isRecord(value) && + typeof value.dataUrl === 'string' && + /^data:image\/jpeg;base64,[A-Za-z0-9+/=]+$/i.test(value.dataUrl) && + value.mimeType === 'image/jpeg' && + typeof value.byteLength === 'number' && + Number.isInteger(value.byteLength) && + value.byteLength > 0 && + value.source === 'chrome.tabs.captureVisibleTab' && + value.scope === 'visible_viewport' && + typeof value.capturedAt === 'string' + export const buildSiteExperienceProfile = (input: BuildSiteExperienceProfileInput): SiteExperienceProfile => { const include = new Set(input.request.include) const technologies = include.has('tech') ? (input.raw?.technologies || []).map(sanitizeTechnology) : [] const assetProfile = include.has('assets') ? buildAssetProfile(input.raw, input.experience, input.request.options.maxResourceUrls) : {} - const limitations = buildLimitations(input.request, input.experience) + const limitations = [...new Set([...buildLimitations(input.request, input.experience), ...(input.limitations || [])])] const techProfile = include.has('tech') ? buildTechProfile(technologies) : {} const visualProfile = include.has('visual') ? buildVisualProfile(input.experience, input.request.options.captureScreenshotMetadata) : {} + if (include.has('visual') && input.request.options.captureScreenshot && isValidScreenshot(input.screenshot)) { + visualProfile.screenshot = input.screenshot + } const layoutProfile = include.has('layout') ? buildLayoutProfile(input.experience, input.request.options.captureScreenshotMetadata) : {} const componentProfile = include.has('components') ? buildComponentProfile(input.experience, input.request.options.captureScreenshotMetadata) diff --git a/tests/agent-capture-orchestration.test.mjs b/tests/agent-capture-orchestration.test.mjs index cfd35d9e..40d78d8c 100644 --- a/tests/agent-capture-orchestration.test.mjs +++ b/tests/agent-capture-orchestration.test.mjs @@ -34,7 +34,8 @@ const fullCapabilities = { storageSession: true, experienceProfiler: true, rawProfile: true, - viewportMetadata: true + viewportMetadata: true, + visualScreenshot: true } const makeChrome = () => { @@ -100,6 +101,19 @@ const makeChrome = () => { tabs: { query: async () => tabs, get: async id => tabs.find(tab => tab.id === id) || { id, windowId: 1, url: '', incognito: false }, + update: async (id, update) => { + const tab = tabs.find(item => item.id === id) + if (!tab) throw new Error('TAB_NOT_FOUND') + if (update.active === true) { + for (const item of tabs) { + if (item.windowId === tab.windowId) item.active = item.id === id + } + } else if (update.active !== undefined) { + tab.active = update.active + } + if (update.url !== undefined) tab.url = update.url + return tab + }, create: async create => { const tab = { id: 3, windowId: 1, url: create.url, incognito: false, status: 'complete' } tabs.push(tab) @@ -482,6 +496,127 @@ test('agent capture stores no tokens and sends profile chunks through bridge tab restoreFetch() }) +test('agent capture can attach optional visible viewport screenshot evidence', async () => { + const env = makeChrome() + const screenshotDataUrl = `data:image/jpeg;base64,${Buffer.from('visible viewport').toString('base64')}` + env.tabs[0].active = true + env.tabs[1].active = false + env.chrome.tabs.captureVisibleTab = async (windowId, options) => { + assert.equal(windowId, 1) + assert.deepEqual(options, { format: 'jpeg', quality: 72 }) + assert.equal(env.tabs.find(tab => tab.id === 3).active, true) + return screenshotDataUrl + } + enableBridgeStatusAck(env) + enableFastHeaderFallback() + globalThis.chrome = env.chrome + const [{ registerBridgeSession }, { startAgentCapture, registerAgentProfileTransferPort }] = await Promise.all([ + loadTsModule('src/background/agent-bridge-session.ts'), + loadTsModule('src/background/agent-capture.ts') + ]) + await registerBridgeSession({ + tabId: 1, + windowId: 1, + bridgeOrigin: 'http://127.0.0.1:17370', + sessionId, + captureId, + nonce + }) + await connectProfileTransferPort(env, registerAgentProfileTransferPort) + + const response = await startAgentCapture( + { + type: 'START_AGENT_CAPTURE', + captureId, + sessionId, + nonce, + bridgeOrigin: 'http://127.0.0.1:17370', + request: { + ...baseRequest, + options: { ...baseRequest.options, targetMode: 'new_tab', captureScreenshot: true } + }, + capabilities: fullCapabilities + }, + { url: `http://127.0.0.1:17370/bridge?session=${sessionId}&capture=${captureId}&nonce=${nonce}`, tab: { id: 1, windowId: 1 } } + ) + + assert.equal(response.ok, true) + await waitForMessage(env.messages, message => message.type === 'AGENT_PROFILE_TRANSFER_COMPLETE') + const profile = decodeTransferredProfile(env.messages) + assert.equal(profile.visualProfile.screenshot.dataUrl, screenshotDataUrl) + assert.equal(profile.visualProfile.screenshot.mimeType, 'image/jpeg') + assert.equal(profile.visualProfile.screenshot.scope, 'visible_viewport') + assert.equal(profile.browserContext.extensionCapabilities.visualScreenshot, true) + assert.equal(env.tabs.find(tab => tab.id === 1).active, true) + delete globalThis.chrome + restoreFetch() +}) + +test('agent capture stops if capture is cancelled while optional screenshot is pending', async () => { + const env = makeChrome() + const screenshotDataUrl = `data:image/jpeg;base64,${Buffer.from('late viewport').toString('base64')}` + let resolveScreenshot + let screenshotStarted = false + env.tabs[0].active = true + env.tabs[1].active = false + env.chrome.tabs.captureVisibleTab = async () => { + screenshotStarted = true + return new Promise(resolve => { + resolveScreenshot = resolve + }) + } + enableBridgeStatusAck(env) + enableFastHeaderFallback() + globalThis.chrome = env.chrome + const [{ registerBridgeSession }, { startAgentCapture, cancelAgentCapture, registerAgentProfileTransferPort }, { listAgentCaptureIds }] = + await Promise.all([ + loadTsModule('src/background/agent-bridge-session.ts'), + loadTsModule('src/background/agent-capture.ts'), + loadTsModule('src/background/agent-capture-state.ts') + ]) + await registerBridgeSession({ + tabId: 1, + windowId: 1, + bridgeOrigin: 'http://127.0.0.1:17370', + sessionId, + captureId, + nonce + }) + await connectProfileTransferPort(env, registerAgentProfileTransferPort) + + const response = await startAgentCapture( + { + type: 'START_AGENT_CAPTURE', + captureId, + sessionId, + nonce, + bridgeOrigin: 'http://127.0.0.1:17370', + request: { + ...baseRequest, + options: { ...baseRequest.options, targetMode: 'new_tab', captureScreenshot: true } + }, + capabilities: fullCapabilities + }, + { url: `http://127.0.0.1:17370/bridge?session=${sessionId}&capture=${captureId}&nonce=${nonce}`, tab: { id: 1, windowId: 1 } } + ) + + assert.equal(response.ok, true) + await waitForCondition(() => screenshotStarted) + const cancel = await cancelAgentCapture( + { type: 'AGENT_CAPTURE_CONTROL', captureId, sessionId, nonce, command: 'cancel' }, + { url: `http://127.0.0.1:17370/bridge?session=${sessionId}&capture=${captureId}&nonce=${nonce}`, tab: { id: 1, windowId: 1 } } + ) + + assert.equal(cancel.ok, true) + assert.equal((await listAgentCaptureIds()).includes(captureId), false) + resolveScreenshot(screenshotDataUrl) + await new Promise(resolve => setTimeout(resolve, 50)) + assert.equal(env.messages.some(message => message.type?.startsWith('AGENT_PROFILE_TRANSFER_')), false) + assert.equal((await listAgentCaptureIds()).includes(captureId), false) + delete globalThis.chrome + restoreFetch() +}) + test('agent bridge storage session access level is not widened to untrusted contexts', async () => { const env = makeChrome() const accessLevelCalls = [] diff --git a/tests/site-experience-profile.test.mjs b/tests/site-experience-profile.test.mjs index 6fa04d78..951a880f 100644 --- a/tests/site-experience-profile.test.mjs +++ b/tests/site-experience-profile.test.mjs @@ -94,6 +94,7 @@ test('defines the site experience schema and required capabilities', async () => 'storageSession', 'experienceProfiler' ]) + assert.equal(contract.AGENT_BRIDGE_CAPABILITIES.includes('visualScreenshot'), true) }) test('validates all protocol identifiers with fixed ascii contracts', async () => { @@ -235,6 +236,7 @@ test('builds a redacted site experience profile from raw popup data and experien options: { forceRefresh: false, captureScreenshotMetadata: false, + captureScreenshot: false, keepTabOpen: false, allowPrivateNetworkTarget: false, targetMode: 'reuse_or_new_tab', @@ -332,6 +334,7 @@ test('builds a redacted site experience profile from raw popup data and experien assert.equal(profile.layoutProfile.boundingBoxes, undefined) assert.ok(profile.limitations.includes('viewport_emulation_unsupported')) assert.ok(profile.limitations.includes('screenshot_metadata_not_requested')) + assert.ok(profile.limitations.includes('screenshot_image_not_requested')) assert.ok(profile.limitations.includes('cross_origin_iframes_limited')) assert.ok(profile.limitations.includes('closed_shadow_roots_limited')) assert.ok(profile.limitations.includes('stylesheet_access_limited')) @@ -357,6 +360,7 @@ test('builds a redacted site experience profile from raw popup data and experien assert.equal(profile.agentGuidance.recreationPlan.assetHints.resourceUrlCount, 2) assert.equal(profile.agentGuidance.recreationPlan.verificationChecklist.length > 0, true) assert.deepEqual(profile.visualProfile.colorTokens, ['#123456']) + assert.equal(profile.visualProfile.screenshot, undefined) assert.doesNotMatch(serialized, /secret|Bearer|user@example\.com|13800138000|1234567890123|¥199|张三|preview=abc|#frag/) for (const url of [ profile.target.url, @@ -376,6 +380,58 @@ test('builds a redacted site experience profile from raw popup data and experien ) }) +test('builds optional screenshot payload only when explicitly requested', async () => { + const { buildSiteExperienceProfile } = await loadTsModule('src/utils/site-experience-profile.ts') + const profile = buildSiteExperienceProfile({ + captureId: 'cap_CCCCCCCCCCCCCCCCCCCCCC', + request: { + url: 'https://example.com/', + mode: 'experience', + waitMs: 0, + include: ['visual'], + viewports: [], + options: { + forceRefresh: false, + captureScreenshotMetadata: true, + captureScreenshot: true, + keepTabOpen: false, + allowPrivateNetworkTarget: false, + targetMode: 'new_tab', + maxResourceUrls: 300 + }, + protocolVersion: 1 + }, + raw: null, + experience: { visual: { colors: ['#101820'] } }, + screenshot: { + dataUrl: `data:image/jpeg;base64,${Buffer.from('shot').toString('base64')}`, + mimeType: 'image/jpeg', + byteLength: 31, + source: 'chrome.tabs.captureVisibleTab', + scope: 'visible_viewport', + capturedAt: '2026-05-27T00:00:00.000Z' + }, + capabilities: { + agentBridge: true, + siteExperienceProfileV1: true, + profileChunkTransport: true, + bridgeContentPost: true, + storageSession: true, + experienceProfiler: true, + rawProfile: false, + viewportMetadata: true, + visualScreenshot: true + }, + finalUrl: 'https://example.com/' + }) + + assert.equal(profile.visualProfile.screenshot.mimeType, 'image/jpeg') + assert.match(profile.visualProfile.screenshot.dataUrl, /^data:image\/jpeg;base64,/) + assert.equal(profile.visualProfile.screenshot.scope, 'visible_viewport') + assert.equal(profile.limitations.includes('screenshot_image_not_requested'), false) + assert.equal(profile.agentGuidance.recreationPlan.visualReference.screenshotIncluded, true) +}) + test('agent guidance sanitizes external profile labels before composing summary', async () => { const { buildAgentGuidance } = await loadTsModule('src/utils/site-experience-guidance.ts') const guidance = buildAgentGuidance({ primaryFrontend: 'Vue token=secret user@example.com\u0000'.repeat(8) }, [ From 441357433455abf14de9f052b193fa42899d3300 Mon Sep 17 00:00:00 2001 From: Mison Date: Thu, 28 May 2026 13:26:16 +0800 Subject: [PATCH 18/49] feat: improve agent bridge page screenshot preview --- .../stackprism-site-experience/SKILL.md | 4 + .../scripts/bridge/bridge-page.mjs | 160 +++++++++++ .../scripts/bridge/capture-store.mjs | 31 ++- .../scripts/bridge/http-handlers.mjs | 37 ++- .../scripts/bridge/url-policy.mjs | 5 +- .../stackprism_bridge_lib/bridge_page.py | 262 ++++++++++++++++-- .../stackprism_bridge_lib/capture_store.py | 39 ++- .../scripts/stackprism_bridge_lib/status.py | 33 ++- .../stackprism_bridge_lib/url_policy.py | 13 +- tests/agent-bridge-browser-smoke.mjs | 171 +++++++++--- tests/stackprism-bridge.test.mjs | 123 +++++++- tests/stackprism_bridge_py.test.mjs | 156 ++++++++++- 12 files changed, 954 insertions(+), 80 deletions(-) create mode 100644 agent-skill/stackprism-site-experience/scripts/bridge/bridge-page.mjs diff --git a/agent-skill/stackprism-site-experience/SKILL.md b/agent-skill/stackprism-site-experience/SKILL.md index a175486e..fc19e293 100644 --- a/agent-skill/stackprism-site-experience/SKILL.md +++ b/agent-skill/stackprism-site-experience/SKILL.md @@ -73,6 +73,7 @@ Call `POST /v1/captures` with `Authorization: Bearer {apiToken}`: "options": { "forceRefresh": true, "captureScreenshotMetadata": false, + "captureScreenshot": false, "keepTabOpen": false, "allowPrivateNetworkTarget": false, "targetMode": "reuse_or_new_tab", @@ -85,6 +86,8 @@ Use the real target URL for the task. Do not treat `https://example.com` as the 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"`. The profile will include `visualProfile.screenshot.dataUrl` when Chrome can capture the visible target viewport. This can briefly activate the target tab before returning to the bridge page. Treat it as optional evidence; models without image input should ignore it. The screenshot is kept in the bridge's in-memory profile and is cleared when the completed profile TTL expires or the bridge process exits. A user-downloaded screenshot file is managed by the browser download location and is not auto-deleted by StackPrism. 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, stop the bridge child process, start a new bridge, and retry once with a smaller `include` set or lower `maxResourceUrls`. Do not synthesize a profile from partial chunks. Handle user-actionable failures explicitly: @@ -96,6 +99,7 @@ Handle user-actionable failures explicitly: ## Use The Profile - Start from `agentGuidance.recreationPlan`. Follow its `implementationOrder`, then map `designTokens`, `layoutBlueprint`, `componentInventory`, `interactionChecklist`, `uxChecklist`, and `assetHints` into the target project. +- Use `visualProfile.screenshot.dataUrl` as an optional visual reference only when it is present and your model supports image input. - 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. 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..0c96445a --- /dev/null +++ b/agent-skill/stackprism-site-experience/scripts/bridge/bridge-page.mjs @@ -0,0 +1,160 @@ +const bridgePageStyle = ` +:root{color-scheme:light;--sp-bg:#f4f8f7;--sp-panel:#ffffff;--sp-line:#c7e2db;--sp-ink:#132127;--sp-muted:#5e6c78;--sp-accent:#0f766e;--sp-soft:#e9f7f3;--sp-warn:#a45a00;--sp-danger:#b42318} +*{box-sizing:border-box} +body{margin:0;min-height:100vh;background:linear-gradient(180deg,#f9fbfb 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;display:grid;place-items:center;padding:28px} +.bridge-card{width:min(760px,100%);overflow:hidden;border:1px solid var(--sp-line);border-radius:14px;background:var(--sp-panel);box-shadow:0 24px 60px rgba(15,118,110,.12)} +.bridge-header{display:grid;grid-template-columns:auto 1fr auto;gap:18px;align-items:center;padding:28px 30px;border-bottom:1px solid var(--sp-line);background:linear-gradient(90deg,#f3fbf8 0%,#fff 76%)} +.bridge-mark{width:54px;height:54px;border-radius:14px;display:grid;place-items:center;background:var(--sp-accent);color:#fff;font-weight:800;letter-spacing:.02em;box-shadow:0 14px 30px rgba(15,118,110,.25)} +.bridge-kicker{margin:0 0 4px;color:var(--sp-accent);font-size:12px;font-weight:800;letter-spacing:.08em;text-transform:uppercase} +.bridge-title{margin:0;font-size:26px;line-height:1.15} +.bridge-copy{margin:8px 0 0;color:var(--sp-muted);font-size:15px} +.bridge-badge{padding:7px 13px;border:1px solid var(--sp-line);border-radius:999px;background:var(--sp-soft);color:var(--sp-accent);font-weight:700;white-space:nowrap} +.bridge-body{padding:26px 30px 30px} +.status-panel{display:grid;gap:10px;margin-bottom:24px} +.state-label{font-size:20px;font-weight:800} +.status-text{margin:0;color:var(--sp-muted)} +.progress{height:10px;overflow:hidden;border-radius:999px;background:#edf2f2} +.progress span{display:block;width:8%;height:100%;border-radius:inherit;background:linear-gradient(90deg,#0f766e,#14b8a6);transition:width .25s ease} +.preview-panel{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,320px);gap:16px;align-items:start;margin:0 0 24px;padding:16px;border:1px solid #dce8e5;border-radius:12px;background:#fbfefe} +.preview-label{margin:0 0 6px;color:var(--sp-muted);font-size:12px;font-weight:800;letter-spacing:.06em;text-transform:uppercase} +.target-url{margin:0;overflow-wrap:anywhere;color:var(--sp-ink);font-weight:700} +.screenshot-frame{width:100%;min-height:150px;display:grid;place-items:center;overflow:hidden;border:1px solid #dce8e5;border-radius:10px;background:#f3f7f7;cursor:pointer;padding:0;text-align:inherit} +.screenshot-frame:disabled{cursor:not-allowed} +.screenshot-frame:focus-visible,.preview-button:focus-visible,.modal-close:focus-visible{outline:3px solid rgba(15,118,110,.35);outline-offset:2px} +.screenshot-frame img{display:none;width:100%;height:auto;max-height:220px;object-fit:cover} +.screenshot-frame.has-image img{display:block} +.screenshot-frame.has-image .screenshot-empty{display:none} +.screenshot-empty{padding:18px;color:var(--sp-muted);text-align:center} +.preview-actions{display:flex;gap:8px;margin-top:10px} +.preview-button{min-height:36px;padding:0 12px;border:1px solid #b9dcd5;border-radius:8px;background:#fff;color:var(--sp-accent);font:inherit;font-weight:700;cursor:pointer} +.preview-button:disabled{cursor:not-allowed;opacity:.55} +.screenshot-modal{position:fixed;inset:0;z-index:20;display:none;place-items:center;padding:28px;background:rgba(10,18,24,.72)} +.screenshot-modal[data-open="true"]{display:grid} +.modal-card{width:min(1180px,100%);max-height:92vh;display:grid;grid-template-rows: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;gap:8px;align-items:center} +.modal-close{min-height:36px;padding:0 12px;border:1px solid #35665f;border-radius:8px;background:#10211e;color:#e7f7f3;font:inherit;font-weight:700;cursor:pointer} +.modal-image{display:block;width:100%;height:100%;max-height:calc(92vh - 62px);object-fit:contain;background:#030807} +.steps{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:0;padding:0;list-style:none} +.step{min-height:70px;padding:12px;border:1px solid #dce8e5;border-radius:10px;background:#fafdfc;color:var(--sp-muted)} +.step-index{display:inline-grid;place-items:center;width:22px;height:22px;margin-bottom:8px;border-radius:999px;background:#edf3f2;color:var(--sp-muted);font-size:12px;font-weight:700} +.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)} +.bridge-footer{display:flex;flex-wrap:wrap;gap:10px;align-items:center;justify-content:space-between;margin-top:24px;padding-top:20px;border-top:1px solid #e7eeee} +.bridge-note{margin:0;color:var(--sp-muted)} +.pills{display:flex;flex-wrap:wrap;gap:8px} +.pill{padding:6px 10px;border:1px solid #d6e7e3;border-radius:999px;color:var(--sp-muted);background:#fbfefe;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:#fff5f5;color:var(--sp-danger)} +.bridge-card[data-status="cancelled"] .bridge-badge{border-color:#f0d2a8;background:#fff8ed;color:var(--sp-warn)} +@media (max-width:680px){.bridge-shell{padding:16px}.bridge-header{grid-template-columns:auto 1fr;padding:22px}.bridge-badge{grid-column:1/-1;justify-self:start}.bridge-body{padding:22px}.preview-panel{grid-template-columns:1fr}.steps{grid-template-columns:1fr}.bridge-title{font-size:22px}.screenshot-modal{padding:14px}.modal-bar{align-items:flex-start;flex-direction:column}.modal-actions{width:100%;justify-content:flex-end}} +` + +const bridgePageScript = ` +const config=JSON.parse(document.getElementById('stackprism-agent-bridge-config').textContent); +const ids=['status','stateLabel','statusBadge','progressBar','bridgeCard','targetUrl','screenshotFrame','targetScreenshot','screenshotDownload','screenshotModal','modalScreenshot','modalClose','modalDownload']; +const el=Object.fromEntries(ids.map(id=>[id,document.getElementById(id)])); +const steps=[...document.querySelectorAll('[data-phase]')]; +let currentScreenshot=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:'结果已过期'}; +const finalStatuses=['completed','failed','cancelled','expired']; +const setStatus=(value)=>{el.status.textContent=value}; +const screenshotExtension=()=>currentScreenshot?.mimeType==='image/png'?'png':currentScreenshot?.mimeType==='image/webp'?'webp':'jpg'; +const screenshotFilename=()=>('stackprism-'+config.captureId+'-screenshot.'+screenshotExtension()); +const setScreenshot=(screenshot)=>{ +currentScreenshot=screenshot?.dataUrl?screenshot:null; +el.targetScreenshot.toggleAttribute('src',false); +el.modalScreenshot.toggleAttribute('src',false); +if(currentScreenshot){el.targetScreenshot.src=currentScreenshot.dataUrl;el.modalScreenshot.src=currentScreenshot.dataUrl;} +el.screenshotFrame.classList.toggle('has-image',Boolean(currentScreenshot)); +el.screenshotFrame.disabled=!currentScreenshot; +el.screenshotDownload.disabled=!currentScreenshot; +el.modalDownload.disabled=!currentScreenshot; +}; +const downloadScreenshot=()=>{ +if(!currentScreenshot)return; +const link=document.createElement('a'); +link.href=currentScreenshot.dataUrl; +link.download=screenshotFilename(); +document.body.append(link); +link.click(); +link.remove(); +}; +const openScreenshot=()=>{if(currentScreenshot){el.screenshotModal.dataset.open='true';el.modalClose.focus();}}; +const closeScreenshot=()=>{el.screenshotModal.dataset.open='false';el.screenshotFrame.focus();}; +const updateSteps=(phase,status)=>{ +const index=status==='completed'?phases.length-1:Math.max(0,phases.indexOf(phase)); +steps.forEach(step=>{ +const stepIndex=phases.indexOf(step.dataset.phase); +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||{}; +el.bridgeCard.dataset.status=status; +el.stateLabel.textContent=label; +el.statusBadge.textContent=label; +setStatus(body?.error?.code||phaseLabels[phase]||status); +el.targetUrl.textContent=preview.targetUrl||'等待读取目标网址'; +setScreenshot(preview.screenshot); +updateSteps(phase,status); +}; +el.screenshotFrame.addEventListener('click',openScreenshot); +el.screenshotDownload.addEventListener('click',downloadScreenshot); +el.modalDownload.addEventListener('click',downloadScreenshot); +el.modalClose.addEventListener('click',closeScreenshot); +el.screenshotModal.addEventListener('click',event=>{if(event.target===el.screenshotModal)closeScreenshot();}); +document.addEventListener('keydown',event=>{if(event.key==='Escape'&&el.screenshotModal.dataset.open==='true')closeScreenshot();}); +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:'cleanup',error:{code:body?.error?.code||'Bridge request failed.'}});return;} +render(body); +if(finalStatuses.includes(body.status))return; +}catch{render({status:'failed',phase:'cleanup',error:{code:'Bridge status unavailable.'}});} +setTimeout(poll,1000); +}; +poll(); +` + +export const renderBridgePageHtml = (cspNonce, config) => ` + + + + + +StackPrism Agent Bridge + + + +
+
+
+ +

本机通道

StackPrism Agent Bridge

正在连接本机 Agent 与当前浏览器 profile,请保持本页打开。

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

等待 StackPrism 扩展连接。

+

目标网址

等待读取目标网址

截图预览

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

本页只服务当前一次采集,不需要手动填写信息。

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 index 58c97220..f9f01163 100644 --- a/agent-skill/stackprism-site-experience/scripts/bridge/capture-store.mjs +++ b/agent-skill/stackprism-site-experience/scripts/bridge/capture-store.mjs @@ -12,12 +12,16 @@ const captureDeadlineError = capture => : { code: 'CAPTURE_TIMEOUT', message: 'Capture timed out.' } export class CaptureStore { - constructor({ baseUrl, openBrowser, now = () => Date.now() }) { + 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) { @@ -87,12 +91,32 @@ export class CaptureStore { 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.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' @@ -125,6 +149,7 @@ export class CaptureStore { .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) } } @@ -133,7 +158,8 @@ export class CaptureStore { capture.status = 'completed' capture.phase = 'cleanup' capture.profile = profile - capture.resultExpiresAt = this.now() + RESULT_TTL_MS + capture.resultExpiresAt = this.now() + this.resultTtlMs + this.scheduleResultExpiry(capture) } requestCancel(capture) { @@ -142,6 +168,7 @@ export class CaptureStore { } 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 index 1e646511..ebef4130 100644 --- a/agent-skill/stackprism-site-experience/scripts/bridge/http-handlers.mjs +++ b/agent-skill/stackprism-site-experience/scripts/bridge/http-handlers.mjs @@ -1,4 +1,5 @@ -import { fail, htmlEscapeScriptJson, isKnownBridgeErrorCode, json, newCspNonce, protocolVersion, safeEqual } from './protocol.mjs' +import { fail, htmlEscapeScriptJson, isKnownBridgeErrorCode, json, newCspNonce, protocolVersion, redactUrl, safeEqual } from './protocol.mjs' +import { renderBridgePageHtml } from './bridge-page.mjs' export const finalStates = new Set(['completed', 'failed', 'cancelled', 'expired']) @@ -37,10 +38,37 @@ export const scopeForEndpoint = (method, endpoint) => { return method === 'GET' && endpoint === 'profile' ? 'status' : 'api' } +const screenshotDataUrlPattern = /^data:image\/(jpeg|png|webp);base64,/ + +const screenshotPreview = capture => { + const screenshot = capture.profile?.visualProfile?.screenshot + const match = typeof screenshot?.dataUrl === 'string' ? screenshot.dataUrl.match(screenshotDataUrlPattern) : null + if (!match) { + return null + } + return { + dataUrl: screenshot.dataUrl, + mimeType: `image/${match[1]}`, + byteLength: screenshot.byteLength, + scope: screenshot.scope + } +} + +const previewForCapture = capture => { + const targetUrl = redactUrl(capture.finalUrl || capture.request?.url) + const preview = {} + if (targetUrl) preview.targetUrl = targetUrl + const screenshot = capture.status === 'completed' ? screenshotPreview(capture) : null + if (screenshot) preview.screenshot = screenshot + 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 + const preview = previewForCapture(capture) + if (preview) status.preview = preview return status } @@ -143,10 +171,7 @@ export const renderBridge = (res, capture) => { '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-${cspNonce}'; style-src 'nonce-${cspNonce}'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'` + 'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${cspNonce}'; style-src 'nonce-${cspNonce}'; img-src data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'` }) - const statusScript = `const statusEl=document.getElementById('status');const config=JSON.parse(document.getElementById('stackprism-agent-bridge-config').textContent);const setStatus=(value)=>{statusEl.textContent=value};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){setStatus(body?.error?.code||'Bridge request failed.');return}setStatus(body.status+(body.phase?' / '+body.phase:''));if(['completed','failed','cancelled','expired'].includes(body.status))return}catch{setStatus('Bridge status unavailable.')}setTimeout(poll,1000)};poll();` - res.end( - `StackPrism Agent Bridge

Waiting for StackPrism extension.

` - ) + res.end(renderBridgePageHtml(cspNonce, config)) } diff --git a/agent-skill/stackprism-site-experience/scripts/bridge/url-policy.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/url-policy.mjs index ca47c806..deb53a7a 100644 --- a/agent-skill/stackprism-site-experience/scripts/bridge/url-policy.mjs +++ b/agent-skill/stackprism-site-experience/scripts/bridge/url-policy.mjs @@ -5,9 +5,9 @@ const includeOrder = ['tech', 'visual', 'layout', 'components', 'interaction', ' 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 keepTabOpen allowPrivateNetworkTarget targetMode maxResourceUrls'.split(' ') + 'forceRefresh captureScreenshotMetadata captureScreenshot keepTabOpen allowPrivateNetworkTarget targetMode maxResourceUrls'.split(' ') ) -const booleanOptionKeys = ['forceRefresh', 'captureScreenshotMetadata', 'keepTabOpen', 'allowPrivateNetworkTarget'] +const booleanOptionKeys = ['forceRefresh', 'captureScreenshotMetadata', 'captureScreenshot', 'keepTabOpen', 'allowPrivateNetworkTarget'] const DNS_LOOKUP_TIMEOUT_MS = 2000 const isPlainRecord = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value) @@ -264,6 +264,7 @@ export const normalizeCaptureRequest = async (body, bridgeOrigin, { resolveHostn options: { forceRefresh: options.forceRefresh === true, captureScreenshotMetadata: options.captureScreenshotMetadata === true, + captureScreenshot: options.captureScreenshot === true, keepTabOpen: options.keepTabOpen === true, allowPrivateNetworkTarget: options.allowPrivateNetworkTarget === true, targetMode, 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 index e9cb755d..2c59e5c7 100644 --- 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 @@ -2,6 +2,243 @@ from .status import FINAL_STATES +BRIDGE_PAGE_STYLE = """ +:root{color-scheme:light;--sp-bg:#f4f8f7;--sp-panel:#ffffff;--sp-line:#c7e2db;--sp-ink:#132127;--sp-muted:#5e6c78;--sp-accent:#0f766e;--sp-soft:#e9f7f3;--sp-warn:#a45a00;--sp-danger:#b42318} +*{box-sizing:border-box} +body{margin:0;min-height:100vh;background:linear-gradient(180deg,#f9fbfb 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;display:grid;place-items:center;padding:28px} +.bridge-card{width:min(760px,100%);overflow:hidden;border:1px solid var(--sp-line);border-radius:14px;background:var(--sp-panel);box-shadow:0 24px 60px rgba(15,118,110,.12)} +.bridge-header{display:grid;grid-template-columns:auto 1fr auto;gap:18px;align-items:center;padding:28px 30px;border-bottom:1px solid var(--sp-line);background:linear-gradient(90deg,#f3fbf8 0%,#fff 76%)} +.bridge-mark{width:54px;height:54px;border-radius:14px;display:grid;place-items:center;background:var(--sp-accent);color:#fff;font-weight:800;letter-spacing:.02em;box-shadow:0 14px 30px rgba(15,118,110,.25)} +.bridge-kicker{margin:0 0 4px;color:var(--sp-accent);font-size:12px;font-weight:800;letter-spacing:.08em;text-transform:uppercase} +.bridge-title{margin:0;font-size:26px;line-height:1.15} +.bridge-copy{margin:8px 0 0;color:var(--sp-muted);font-size:15px} +.bridge-badge{padding:7px 13px;border:1px solid var(--sp-line);border-radius:999px;background:var(--sp-soft);color:var(--sp-accent);font-weight:700;white-space:nowrap} +.bridge-body{padding:26px 30px 30px} +.status-panel{display:grid;gap:10px;margin-bottom:24px} +.state-label{font-size:20px;font-weight:800} +.status-text{margin:0;color:var(--sp-muted)} +.progress{height:10px;overflow:hidden;border-radius:999px;background:#edf2f2} +.progress span{display:block;width:8%;height:100%;border-radius:inherit;background:linear-gradient(90deg,#0f766e,#14b8a6);transition:width .25s ease} +.preview-panel{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,320px);gap:16px;align-items:start;margin:0 0 24px;padding:16px;border:1px solid #dce8e5;border-radius:12px;background:#fbfefe} +.preview-label{margin:0 0 6px;color:var(--sp-muted);font-size:12px;font-weight:800;letter-spacing:.06em;text-transform:uppercase} +.target-url{margin:0;overflow-wrap:anywhere;color:var(--sp-ink);font-weight:700} +.screenshot-frame{width:100%;min-height:150px;display:grid;place-items:center;overflow:hidden;border:1px solid #dce8e5;border-radius:10px;background:#f3f7f7;cursor:pointer;padding:0;text-align:inherit} +.screenshot-frame:disabled{cursor:not-allowed} +.screenshot-frame:focus-visible,.preview-button:focus-visible,.modal-close:focus-visible{outline:3px solid rgba(15,118,110,.35);outline-offset:2px} +.screenshot-frame img{display:none;width:100%;height:auto;max-height:220px;object-fit:cover} +.screenshot-frame.has-image img{display:block} +.screenshot-frame.has-image .screenshot-empty{display:none} +.screenshot-empty{padding:18px;color:var(--sp-muted);text-align:center} +.preview-actions{display:flex;gap:8px;margin-top:10px} +.preview-button{min-height:36px;padding:0 12px;border:1px solid #b9dcd5;border-radius:8px;background:#fff;color:var(--sp-accent);font:inherit;font-weight:700;cursor:pointer} +.preview-button:disabled{cursor:not-allowed;opacity:.55} +.screenshot-modal{position:fixed;inset:0;z-index:20;display:none;place-items:center;padding:28px;background:rgba(10,18,24,.72)} +.screenshot-modal[data-open="true"]{display:grid} +.modal-card{width:min(1180px,100%);max-height:92vh;display:grid;grid-template-rows: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;gap:8px;align-items:center} +.modal-close{min-height:36px;padding:0 12px;border:1px solid #35665f;border-radius:8px;background:#10211e;color:#e7f7f3;font:inherit;font-weight:700;cursor:pointer} +.modal-image{display:block;width:100%;height:100%;max-height:calc(92vh - 62px);object-fit:contain;background:#030807} +.steps{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:0;padding:0;list-style:none} +.step{min-height:70px;padding:12px;border:1px solid #dce8e5;border-radius:10px;background:#fafdfc;color:var(--sp-muted)} +.step-index{display:inline-grid;place-items:center;width:22px;height:22px;margin-bottom:8px;border-radius:999px;background:#edf3f2;color:var(--sp-muted);font-size:12px;font-weight:700} +.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)} +.bridge-footer{display:flex;flex-wrap:wrap;gap:10px;align-items:center;justify-content:space-between;margin-top:24px;padding-top:20px;border-top:1px solid #e7eeee} +.bridge-note{margin:0;color:var(--sp-muted)} +.pills{display:flex;flex-wrap:wrap;gap:8px} +.pill{padding:6px 10px;border:1px solid #d6e7e3;border-radius:999px;color:var(--sp-muted);background:#fbfefe;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:#fff5f5;color:var(--sp-danger)} +.bridge-card[data-status="cancelled"] .bridge-badge{border-color:#f0d2a8;background:#fff8ed;color:var(--sp-warn)} +@media (max-width:680px){.bridge-shell{padding:16px}.bridge-header{grid-template-columns:auto 1fr;padding:22px}.bridge-badge{grid-column:1/-1;justify-self:start}.bridge-body{padding:22px}.preview-panel{grid-template-columns:1fr}.steps{grid-template-columns:1fr}.bridge-title{font-size:22px}.screenshot-modal{padding:14px}.modal-bar{align-items:flex-start;flex-direction:column}.modal-actions{width:100%;justify-content:flex-end}} +""" + +BRIDGE_PAGE_SCRIPT = """ +const statusEl=document.getElementById('status'); +const stateLabelEl=document.getElementById('stateLabel'); +const badgeEl=document.getElementById('statusBadge'); +const progressEl=document.getElementById('progressBar'); +const cardEl=document.getElementById('bridgeCard'); +const targetUrlEl=document.getElementById('targetUrl'); +const screenshotFrameEl=document.getElementById('screenshotFrame'); +const screenshotImgEl=document.getElementById('targetScreenshot'); +const screenshotDownloadEl=document.getElementById('screenshotDownload'); +const screenshotModalEl=document.getElementById('screenshotModal'); +const modalImageEl=document.getElementById('modalScreenshot'); +const modalCloseEl=document.getElementById('modalClose'); +const modalDownloadEl=document.getElementById('modalDownload'); +const stepEls=[...document.querySelectorAll('[data-phase]')]; +const config=JSON.parse(document.getElementById('stackprism-agent-bridge-config').textContent); +let currentScreenshot=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:'结果已过期'}; +const setStatus=(value)=>{statusEl.textContent=value}; +const screenshotExtension=()=>currentScreenshot?.mimeType==='image/png'?'png':currentScreenshot?.mimeType==='image/webp'?'webp':'jpg'; +const screenshotFilename=()=>('stackprism-'+config.captureId+'-screenshot.'+screenshotExtension()); +const setScreenshot=(screenshot)=>{ +currentScreenshot=screenshot?.dataUrl?screenshot:null; +if(currentScreenshot){ +screenshotImgEl.src=currentScreenshot.dataUrl; +modalImageEl.src=currentScreenshot.dataUrl; +screenshotFrameEl.classList.add('has-image'); +screenshotFrameEl.disabled=false; +screenshotDownloadEl.disabled=false; +modalDownloadEl.disabled=false; +}else{ +screenshotImgEl.removeAttribute('src'); +modalImageEl.removeAttribute('src'); +screenshotFrameEl.classList.remove('has-image'); +screenshotFrameEl.disabled=true; +screenshotDownloadEl.disabled=true; +modalDownloadEl.disabled=true; +} +}; +const downloadScreenshot=()=>{ +if(!currentScreenshot)return; +const link=document.createElement('a'); +link.href=currentScreenshot.dataUrl; +link.download=screenshotFilename(); +document.body.append(link); +link.click(); +link.remove(); +}; +const openScreenshot=()=>{ +if(!currentScreenshot)return; +screenshotModalEl.dataset.open='true'; +modalCloseEl.focus(); +}; +const closeScreenshot=()=>{ +screenshotModalEl.dataset.open='false'; +screenshotFrameEl.focus(); +}; +const updateSteps=(phase,status)=>{ +const index=status==='completed'?phases.length-1:Math.max(0,phases.indexOf(phase)); +stepEls.forEach(step=>{ +const stepIndex=phases.indexOf(step.dataset.phase); +step.classList.toggle('done',stepIndex{ +const status=body?.status||'waiting_extension'; +const phase=body?.phase||'bridge_connected'; +const label=statusLabels[status]||status; +const detail=body?.error?.code||phaseLabels[phase]||status; +const preview=body?.preview||{}; +cardEl.dataset.status=status; +stateLabelEl.textContent=label; +badgeEl.textContent=label; +setStatus(detail); +targetUrlEl.textContent=preview.targetUrl||'等待读取目标网址'; +if(preview.screenshot?.dataUrl){ +setScreenshot(preview.screenshot); +}else{ +setScreenshot(null); +} +updateSteps(phase,status); +}; +screenshotFrameEl.addEventListener('click',openScreenshot); +screenshotDownloadEl.addEventListener('click',downloadScreenshot); +modalDownloadEl.addEventListener('click',downloadScreenshot); +modalCloseEl.addEventListener('click',closeScreenshot); +screenshotModalEl.addEventListener('click',(event)=>{if(event.target===screenshotModalEl)closeScreenshot();}); +document.addEventListener('keydown',(event)=>{if(event.key==='Escape'&&screenshotModalEl.dataset.open==='true')closeScreenshot();}); +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:'cleanup',error:{code:body?.error?.code||'Bridge request failed.'}});return} +render(body); +if(['completed','failed','cancelled','expired'].includes(body.status))return; +}catch{ +render({status:'failed',phase:'cleanup',error:{code:'Bridge status unavailable.'}}); +} +setTimeout(poll,1000); +}; +poll(); +""" + + +BRIDGE_PAGE_HTML_TEMPLATE = """ + + + + + +StackPrism Agent Bridge + + + +
+
+
+ +
+

本机通道

+

StackPrism Agent Bridge

+

正在连接本机 Agent 与当前浏览器 profile,请保持本页打开。

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

等待 StackPrism 扩展连接。

+ +
+
+
+

目标网址

+

等待读取目标网址

+
+
+

截图预览

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

本页只服务当前一次采集,不需要手动填写信息。

+
127.0.0.1当前 profile只读采集
+
+
+
+
+ + + + +""" + + +def render_bridge_page_html(csp_nonce, config): + return BRIDGE_PAGE_HTML_TEMPLATE.format(csp_nonce=csp_nonce, style=BRIDGE_PAGE_STYLE, config=config, script=BRIDGE_PAGE_SCRIPT) + + def render_bridge_page(handler, capture): with handler.server.store._lock: if capture["status"] == "expired": @@ -45,28 +282,7 @@ def render_bridge_page(handler, capture): 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}'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'", + f"default-src 'none'; script-src 'nonce-{csp_nonce}'; style-src 'nonce-{csp_nonce}'; img-src data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'", ) handler.end_headers() - status_script = ( - "const statusEl=document.getElementById('status');" - "const config=JSON.parse(document.getElementById('stackprism-agent-bridge-config').textContent);" - "const setStatus=(value)=>{statusEl.textContent=value};" - "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){setStatus(body?.error?.code||'Bridge request failed.');return}" - "setStatus(body.status+(body.phase?' / '+body.phase:''));" - "if(['completed','failed','cancelled','expired'].includes(body.status))return" - "}catch{setStatus('Bridge status unavailable.')}" - "setTimeout(poll,1000)};poll();" - ) - handler.wfile.write( - ( - '' - 'StackPrism Agent Bridge

Waiting for StackPrism extension.

' - f'' - f'' - "" - ).encode("utf-8") - ) + handler.wfile.write(render_bridge_page_html(csp_nonce, config).encode("utf-8")) 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 index 003e0726..99daedc5 100644 --- 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 @@ -19,11 +19,14 @@ def capture_deadline_error(capture): class CaptureStore: - def __init__(self, base_url, now=time.time, open_browser_fn=open_browser): + 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): @@ -88,7 +91,31 @@ def mark_profile(self, capture, profile): capture["profile"] = profile capture["status"] = "completed" capture["phase"] = "cleanup" - capture["resultExpiresAt"] = self.now() + RESULT_TTL_SECONDS + 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: @@ -97,6 +124,7 @@ def expire_if_needed(self, capture): capture["status"] = "expired" capture["profile"] = 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" @@ -119,4 +147,11 @@ def prune_terminal_records(self): 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/status.py b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/status.py index 23b56371..7e12420c 100644 --- a/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/status.py +++ b/agent-skill/stackprism-site-experience/scripts/stackprism_bridge_lib/status.py @@ -1,4 +1,6 @@ -from .protocol import PROTOCOL_VERSION, is_known_bridge_error_code +import re + +from .protocol import PROTOCOL_VERSION, is_known_bridge_error_code, redact_url FINAL_STATES = {"completed", "failed", "cancelled", "expired"} PLUGIN_WRITABLE_STATUSES = {"waiting_extension", "running", "cancelled", "failed"} @@ -13,6 +15,32 @@ "cleanup", ] PHASE_ORDER = {phase: index for index, phase in enumerate(STATUS_PHASES)} +SCREENSHOT_DATA_URL_PATTERN = re.compile(r"^data:image/(jpeg|png|webp);base64,") + + +def screenshot_preview(profile): + screenshot = (((profile or {}).get("visualProfile") or {}).get("screenshot") or {}) + 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 + return { + "dataUrl": data_url, + "mimeType": f"image/{match.group(1)}", + "byteLength": screenshot.get("byteLength"), + "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.get("profile")) if capture["status"] == "completed" else None + if screenshot: + preview["screenshot"] = screenshot + return preview def public_status(capture): @@ -21,6 +49,9 @@ def public_status(capture): status["phase"] = capture["phase"] if capture.get("error"): status["error"] = capture["error"] + preview = public_preview(capture) + if preview: + status["preview"] = preview return status 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 index 75dcc5ee..54a05fb7 100644 --- 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 @@ -8,8 +8,16 @@ REQUEST_KEYS = {"url", "mode", "waitMs", "include", "viewports", "options"} -OPTION_KEYS = {"forceRefresh", "captureScreenshotMetadata", "keepTabOpen", "allowPrivateNetworkTarget", "targetMode", "maxResourceUrls"} -BOOLEAN_OPTION_KEYS = {"forceRefresh", "captureScreenshotMetadata", "keepTabOpen", "allowPrivateNetworkTarget"} +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 @@ -144,6 +152,7 @@ def normalize_options(options): return { "forceRefresh": options.get("forceRefresh") is True, "captureScreenshotMetadata": options.get("captureScreenshotMetadata") is True, + "captureScreenshot": options.get("captureScreenshot") is True, "keepTabOpen": options.get("keepTabOpen") is True, "allowPrivateNetworkTarget": options.get("allowPrivateNetworkTarget") is True, "targetMode": target_mode, diff --git a/tests/agent-bridge-browser-smoke.mjs b/tests/agent-bridge-browser-smoke.mjs index f5d6d467..28de6c58 100644 --- a/tests/agent-bridge-browser-smoke.mjs +++ b/tests/agent-bridge-browser-smoke.mjs @@ -25,7 +25,9 @@ const hasScreenshotPayloadKey = value => { if (Array.isArray(value)) return value.some(hasScreenshotPayloadKey) return Object.entries(value).some( ([key, item]) => - /^(screenshot|screenshotData|imageData|pixelData|pixels|dataUrl|base64Image)$/i.test(key) || hasScreenshotPayloadKey(item) + /^(screenshotData|imageData|pixelData|pixels|dataUrl|base64Image)$/i.test(key) || + (key === 'screenshot' && Boolean(item?.dataUrl)) || + hasScreenshotPayloadKey(item) ) } @@ -1564,6 +1566,101 @@ const runPublicComplexTargetScenario = async () => { } } +const runVisualScreenshotScenario = async () => { + ensureDistBuilt() + const harness = createBrowserSmokeHarness({ root, dist, cdpPort }) + const fixture = await harness.startFixtureServer() + const chrome = await harness.startChrome() + let bridge + let worker + try { + const version = await harness.waitForCdp() + const workerTarget = await harness.waitForWorker() + worker = await harness.connectTarget(workerTarget) + await worker.send('Runtime.enable') + await harness.waitForExtensionRuntime(worker) + await harness.setAgentBridgeEnabled(worker, true) + bridge = await harness.startBridge() + + const capture = await harness.createCapture(bridge.ready, { + url: fixture.url, + include: ['visual', 'layout', 'ux'], + waitMs: 100, + options: { + allowPrivateNetworkTarget: true, + captureScreenshot: true, + captureScreenshotMetadata: false, + keepTabOpen: false, + maxResourceUrls: 50, + targetMode: 'new_tab' + } + }) + assert(capture.status === 200, `Visual screenshot capture creation failed: ${JSON.stringify(capture)}`) + const driven = await harness.driveCapture(bridge.ready, capture) + assert(driven.finalStatus?.status === 'completed', `Visual screenshot capture did not complete: ${JSON.stringify(driven.finalStatus)}`) + assert(driven.profile?.status === 200, `Visual screenshot profile read failed: ${JSON.stringify(driven.profile)}`) + const profile = profileSummary(driven.profile, capture.body.id) + const screenshot = driven.profile.body.visualProfile?.screenshot + const visualReference = driven.profile.body.agentGuidance?.recreationPlan?.visualReference + const screenshotFailureLimitations = [ + 'screenshot_capture_unavailable', + 'screenshot_capture_invalid', + 'screenshot_image_too_large', + 'screenshot_capture_failed' + ] + if (profile?.screenshotPayloadPresent) { + assert(typeof screenshot?.dataUrl === 'string' && screenshot.dataUrl.startsWith('data:image/jpeg;base64,'), 'Screenshot data URL is invalid.') + assert(screenshot.mimeType === 'image/jpeg', `Unexpected screenshot mime type: ${screenshot.mimeType}`) + assert(screenshot.scope === 'visible_viewport', `Unexpected screenshot scope: ${screenshot.scope}`) + assert(Number.isInteger(screenshot.byteLength) && screenshot.byteLength > 1000, `Unexpected screenshot byte length: ${screenshot.byteLength}`) + assert(visualReference?.screenshotIncluded === true, 'Agent guidance did not mark screenshot as included.') + } else { + assert( + screenshotFailureLimitations.some(limitation => driven.profile.body.limitations.includes(limitation)), + `Visual screenshot request omitted payload without a screenshot limitation: ${JSON.stringify(driven.profile.body.limitations)}` + ) + assert(visualReference?.screenshotIncluded === false, 'Agent guidance marked missing screenshot as included.') + } + assert(!driven.profile.body.limitations.includes('screenshot_image_not_requested'), 'Profile still reported screenshot not requested.') + + console.log( + JSON.stringify( + { + browser: version.Browser, + dist, + scenario, + bridge: { + baseUrl: bridge.ready.baseUrl, + protocolVersion: bridge.ready.protocolVersion, + apiTokenPresent: Boolean(bridge.ready.apiToken) + }, + visualScreenshot: { + status: driven.finalStatus.status, + phase: driven.finalStatus.phase, + profile, + screenshot: { + included: Boolean(profile.screenshotPayloadPresent), + mimeType: screenshot?.mimeType || '', + byteLength: screenshot?.byteLength || 0, + scope: screenshot?.scope || '', + source: screenshot?.source || '' + }, + visualReference + }, + bridgeStderrTail: redactText(bridge.stderr().slice(-500)) + }, + null, + 2 + ) + ) + } finally { + worker?.close() + await harness.stopBridge(bridge) + fixture.server.close() + await harness.cleanupChrome(chrome) + } +} + const runServiceWorkerIdleWakeScenario = async () => { ensureDistBuilt() const harness = createBrowserSmokeHarness({ root, dist, cdpPort }) @@ -3561,41 +3658,43 @@ const scenarioRunner = ? runTechOnlyScenario : scenario === 'public-complex-target' ? runPublicComplexTargetScenario - : scenario === 'service-worker-idle-wake' - ? runServiceWorkerIdleWakeScenario - : scenario === 'sequential-capture-pressure' - ? runSequentialCapturePressureScenario - : scenario === 'target-navigated-away' - ? runTargetNavigatedAwayScenario - : scenario === 'target-load-failed' - ? runTargetLoadFailedScenario - : scenario === 'target-load-timeout' - ? runTargetLoadTimeoutScenario - : scenario === 'target-mode-query-boundaries' - ? runTargetModeQueryBoundariesScenario - : scenario === 'bridge-iframe-blocked' - ? runBridgeIframeBlockedScenario - : scenario === 'wrong-profile-extension-missing' - ? runWrongProfileExtensionMissingScenario - : scenario === 'host-validation' - ? runHostValidationScenario - : scenario === 'response-headers-cors' - ? runResponseHeadersCorsScenario - : scenario === 'request-shell-rejections' - ? runRequestShellRejectionsScenario - : scenario === 'connection-pressure' - ? runConnectionPressureScenario - : scenario === 'resource-timeouts' - ? runResourceTimeoutsScenario - : scenario === 'rate-limit' - ? runRateLimitScenario - : scenario === 'profile-rate-limit' - ? runProfileRateLimitScenario - : scenario === 'target-url-validation' - ? runTargetUrlValidationScenario - : scenario === 'result-expiry-bridge-page' - ? runResultExpiryBridgePageScenario - : run + : scenario === 'visual-screenshot' + ? runVisualScreenshotScenario + : scenario === 'service-worker-idle-wake' + ? runServiceWorkerIdleWakeScenario + : scenario === 'sequential-capture-pressure' + ? runSequentialCapturePressureScenario + : scenario === 'target-navigated-away' + ? runTargetNavigatedAwayScenario + : scenario === 'target-load-failed' + ? runTargetLoadFailedScenario + : scenario === 'target-load-timeout' + ? runTargetLoadTimeoutScenario + : scenario === 'target-mode-query-boundaries' + ? runTargetModeQueryBoundariesScenario + : scenario === 'bridge-iframe-blocked' + ? runBridgeIframeBlockedScenario + : scenario === 'wrong-profile-extension-missing' + ? runWrongProfileExtensionMissingScenario + : scenario === 'host-validation' + ? runHostValidationScenario + : scenario === 'response-headers-cors' + ? runResponseHeadersCorsScenario + : scenario === 'request-shell-rejections' + ? runRequestShellRejectionsScenario + : scenario === 'connection-pressure' + ? runConnectionPressureScenario + : scenario === 'resource-timeouts' + ? runResourceTimeoutsScenario + : scenario === 'rate-limit' + ? runRateLimitScenario + : scenario === 'profile-rate-limit' + ? runProfileRateLimitScenario + : scenario === 'target-url-validation' + ? runTargetUrlValidationScenario + : scenario === 'result-expiry-bridge-page' + ? runResultExpiryBridgePageScenario + : run scenarioRunner().catch(error => { console.error(error instanceof Error ? error.message : String(error)) diff --git a/tests/stackprism-bridge.test.mjs b/tests/stackprism-bridge.test.mjs index 437c976f..db442d9f 100644 --- a/tests/stackprism-bridge.test.mjs +++ b/tests/stackprism-bridge.test.mjs @@ -143,7 +143,7 @@ const acceptFinalUrl = async (ready, captureId, bridgeToken, finalUrl = baseCapt assert.equal(envelope.body.phase, 'target_loaded') } -const profileFor = captureId => ({ +const profileFor = (captureId, overrides = {}) => ({ schema: 'stackprism.site_experience_profile.v1', captureId, generatedAt: new Date(0).toISOString(), @@ -158,7 +158,8 @@ const profileFor = captureId => ({ assetProfile: {}, evidence: {}, limitations: [], - agentGuidance: {} + agentGuidance: {}, + ...overrides }) test('js bridge protocol helpers use strict token comparison and script-safe JSON', () => { @@ -280,6 +281,7 @@ test('bridge page renders bridge token once with hardened headers', async () => 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:/) assert.match(csp, /frame-ancestors 'none'/) assert.match(csp, /base-uri 'none'/) assert.match(csp, /form-action 'none'/) @@ -287,7 +289,24 @@ test('bridge page renders bridge token once with hardened headers', async () => 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, /class="bridge-card"/) + assert.match(html, /id="progressBar"/) + assert.match(html, /id="targetUrl"/) + assert.match(html, /id="targetScreenshot"/) + assert.match(html, /id="screenshotDownload"/) + assert.match(html, /id="screenshotModal"/) + assert.match(html, /id="modalDownload"/) + assert.match(html, /id="modalClose"/) + assert.match(html, /addEventListener\('click',openScreenshot\)/) + assert.match(html, /download=screenshotFilename\(\)/) + assert.match(html, /currentScreenshot\?\.mimeType==='image\/png'\?'png'/) + assert.match(html, /currentScreenshot\?\.mimeType==='image\/webp'\?'webp'/) + assert.match(html, /data-phase="profiling_experience"/) + assert.match(html, /本机通道/) + assert.match(html, /正在连接本机 Agent 与当前浏览器 profile/) + assert.match(html, /本页只服务当前一次采集/) assert.match(html, new RegExp(`id="stackprism-agent-bridge-config" type="application/json" nonce="${cspNonce}"`)) + assert.match(html, new RegExp(` 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 index c4284ee5..364997d1 100644 --- 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 @@ -9,6 +9,7 @@ + StackPrism Agent Bridge diff --git a/build-scripts/package-firefox.mjs b/build-scripts/package-firefox.mjs index e95c7800..43175592 100644 --- a/build-scripts/package-firefox.mjs +++ b/build-scripts/package-firefox.mjs @@ -120,6 +120,13 @@ function writeFirefoxManifest({ manifestPath }, logger) { 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', diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs index 6da2ec4a..61a444fd 100644 --- a/tests/release-workflow.test.mjs +++ b/tests/release-workflow.test.mjs @@ -159,7 +159,14 @@ test('firefox package manifest omits reserved data collection permissions', asyn await withFirefoxDist( { 'dist/manifest.json': manifest({ - background: { service_worker: 'service-worker-loader.js' } + background: { service_worker: 'service-worker-loader.js' }, + web_accessible_resources: [ + { + resources: ['rules/*', 'tech-links.json'], + matches: ['http://*/*', 'https://*/*'], + use_dynamic_url: false + } + ] }), 'dist/service-worker-loader.js': "import './assets/background-entry.js'", 'dist/assets/background-entry.js': 'chrome.runtime.onInstalled.addListener(() => {})' @@ -169,6 +176,12 @@ test('firefox package manifest omits reserved data collection permissions', asyn const manifestJson = JSON.parse(await readFile(result.manifestPath, 'utf8')) assert.deepEqual(manifestJson.background, { scripts: ['background.js'] }) + assert.deepEqual(manifestJson.web_accessible_resources, [ + { + resources: ['rules/*', 'tech-links.json'], + matches: ['http://*/*', 'https://*/*'] + } + ]) assert.deepEqual(manifestJson.browser_specific_settings, { gecko: { id: 'stackprism@setube.github.io', @@ -176,7 +189,9 @@ test('firefox package manifest omits reserved data collection permissions', asyn } }) assert.equal('data_collection_permissions' in manifestJson.browser_specific_settings.gecko, false) + assert.equal('use_dynamic_url' in manifestJson.web_accessible_resources[0], false) assert.doesNotMatch(JSON.stringify(manifestJson), /data_collection_permissions/) + assert.doesNotMatch(JSON.stringify(manifestJson), /use_dynamic_url/) assert.ok((await stat(join(cwd, 'dist-firefox/background.js'))).isFile()) assert.ok((await stat(join(cwd, 'release/stackprism-v1.3.71.xpi'))).isFile()) } diff --git a/tests/stackprism-bridge.test.mjs b/tests/stackprism-bridge.test.mjs index 46ba9f58..cdc679e2 100644 --- a/tests/stackprism-bridge.test.mjs +++ b/tests/stackprism-bridge.test.mjs @@ -951,6 +951,7 @@ test('bridge page renders bridge token once with hardened headers', async () => 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'/) @@ -958,6 +959,7 @@ test('bridge page renders bridge token once with hardened headers', async () => 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, / Date: Thu, 11 Jun 2026 15:55:26 +0800 Subject: [PATCH 44/49] test: cover StackPrism skill contracts --- .../stackprism-site-experience/README.md | 2 + .../stackprism-site-experience/SKILL.md | 2 + tests/stackprism-skill.test.mjs | 54 +++++++++++++++++++ tests/stackprism_bridge_py.test.mjs | 47 ++++++++++++---- 4 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 tests/stackprism-skill.test.mjs diff --git a/agent-skill/stackprism-site-experience/README.md b/agent-skill/stackprism-site-experience/README.md index 0fe90dde..6ac7d823 100644 --- a/agent-skill/stackprism-site-experience/README.md +++ b/agent-skill/stackprism-site-experience/README.md @@ -20,6 +20,8 @@ Use `capture-site.mjs` for ordinary agent work. Use `stackprism-bridge.mjs` or t 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. diff --git a/agent-skill/stackprism-site-experience/SKILL.md b/agent-skill/stackprism-site-experience/SKILL.md index b1bed713..6836ebbe 100644 --- a/agent-skill/stackprism-site-experience/SKILL.md +++ b/agent-skill/stackprism-site-experience/SKILL.md @@ -42,6 +42,8 @@ Set `STACKPRISM_BROWSER_OPEN_COMMAND` and `STACKPRISM_BROWSER_OPEN_ARGS_JSON` on The helper prints one JSON summary on stdout. `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. diff --git a/tests/stackprism-skill.test.mjs b/tests/stackprism-skill.test.mjs new file mode 100644 index 00000000..623e159a --- /dev/null +++ b/tests/stackprism-skill.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { test } from 'node:test' + +const skillSource = await readFile(new URL('../agent-skill/stackprism-site-experience/SKILL.md', import.meta.url), 'utf8') +const readmeSource = await readFile(new URL('../agent-skill/stackprism-site-experience/README.md', import.meta.url), 'utf8') +const openaiYamlSource = await readFile(new URL('../agent-skill/stackprism-site-experience/agents/openai.yaml', import.meta.url), 'utf8') +const consumptionGuideSource = await readFile( + new URL('../agent-skill/stackprism-site-experience/references/agent-consumption-guide.md', import.meta.url), + 'utf8' +) +const profileSchemaSource = await readFile( + new URL('../agent-skill/stackprism-site-experience/references/site-experience-profile-schema.md', import.meta.url), + 'utf8' +) + +test('stackprism site experience skill advertises the current bridge workflow', () => { + assert.match(skillSource, /name: stackprism-site-experience/) + assert.match(skillSource, /description: .*real URL.*StackPrism/) + assert.match(skillSource, /scripts\/capture-site\.mjs/) + assert.match(skillSource, /scripts\/stackprism-bridge\.mjs/) + assert.match(skillSource, /scripts\/stackprism_bridge\.py/) + assert.match(skillSource, /STACKPRISM_BROWSER_OPEN_COMMAND/) + assert.match(skillSource, /STACKPRISM_BROWSER_OPEN_ARGS_JSON/) + assert.match(skillSource, /--allow-private-network/) + assert.match(skillSource, /PRIVATE_NETWORK_TARGET_BLOCKED/) + assert.match(skillSource, /CAPTURE_BUSY/) + assert.match(skillSource, /AGENT_BRIDGE_DISABLED/) + assert.match(skillSource, /EXTENSION_NOT_CONNECTED/) +}) + +test('stackprism site experience skill points agents to repo-local references', () => { + assert.match(skillSource, /references\/site-experience-profile-schema\.md/) + assert.match(skillSource, /references\/agent-consumption-guide\.md/) + assert.match(profileSchemaSource, /Schema id: `stackprism\.site_experience_profile\.v1`/) + assert.match(profileSchemaSource, /agentGuidance/) + assert.match(profileSchemaSource, /recreationPlan/) + assert.match(profileSchemaSource, /Screenshot image base64 is intentionally omitted/) + assert.match(consumptionGuideSource, /Start from `agentGuidance\.recreationPlan`/) + assert.match(consumptionGuideSource, /Read `limitations`/) +}) + +test('stackprism site experience skill UI metadata remains aligned with global discovery', () => { + assert.match(openaiYamlSource, /display_name: 'StackPrism Site Experience'/) + assert.match(openaiYamlSource, /short_description: 'Evidence for website recreation'/) + assert.match(openaiYamlSource, /\$stackprism-site-experience/) + assert.match(openaiYamlSource, /allow_implicit_invocation: true/) +}) + +test('repo-local skill documents bridge asset parity and local installation boundary', () => { + assert.match(readmeSource, /not automatically installed into Codex or any global skill registry/) + assert.match(readmeSource, /JavaScript bridge and Python fallback intentionally share the same bridge page CSS and client script text/) + assert.match(readmeSource, /tests\/stackprism_bridge_py\.test\.mjs/) +}) diff --git a/tests/stackprism_bridge_py.test.mjs b/tests/stackprism_bridge_py.test.mjs index ecab00e5..8ccf7286 100644 --- a/tests/stackprism_bridge_py.test.mjs +++ b/tests/stackprism_bridge_py.test.mjs @@ -6,6 +6,7 @@ import net from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' +import { bridgePageScript, bridgePageStyle } from '../agent-skill/stackprism-site-experience/scripts/bridge/bridge-page-assets.mjs' import identifiers from './fixtures/bridge-protocol-identifiers.json' with { type: 'json' } import urlPolicyCases from './fixtures/bridge-url-policy-cases.json' with { type: 'json' } @@ -19,7 +20,11 @@ const request = { } const readJson = async response => ({ status: response.status, body: await response.json(), headers: response.headers }) -const readBytes = async response => ({ status: response.status, body: Buffer.from(await response.arrayBuffer()), headers: response.headers }) +const readBytes = async response => ({ + status: response.status, + body: Buffer.from(await response.arrayBuffer()), + headers: response.headers +}) const sensitiveFailedError = (ready, created, config) => { const sensitiveUrl = `${created.body.bridgeUrl}&token=secret&apiToken=${ready.apiToken}&bridgeToken=${config.bridgeToken}#frag` @@ -576,6 +581,19 @@ print(json.dumps(result, sort_keys=True)) assert.equal(parsed.message, 'INVALID_CSP_NONCE') }) +test('python fallback bridge page assets match javascript bridge assets', () => { + const parsed = pythonOneShot(` +from stackprism_bridge_lib.bridge_page_assets import BRIDGE_PAGE_SCRIPT, BRIDGE_PAGE_STYLE +print(json.dumps({ + "script": BRIDGE_PAGE_SCRIPT, + "style": BRIDGE_PAGE_STYLE, +}, sort_keys=True)) +`) + + assert.equal(parsed.style, bridgePageStyle) + assert.equal(parsed.script, bridgePageScript) +}) + test('python fallback bridge page reports renderer nonce failures over HTTP', () => { const parsed = pythonOneShot(` from stackprism_bridge_lib.server_factory import create_server @@ -753,7 +771,10 @@ test('python fallback bridge page has CSP nonce and script-safe config', async ( assert.match(html, /id="bridgeCard" class="bridge-card" data-status="waiting_extension" aria-labelledby="bridge-title" tabindex="-1"/) assert.match(html, /id="progressBar"/) assert.match(html, /id="targetUrl" class="target-url" title="" target="_blank" rel="noopener noreferrer" aria-disabled="true"/) - assert.match(html, /id="openTargetUrl" class="preview-button target-open-link" target="_blank" rel="noopener noreferrer" aria-disabled="true" tabindex="-1"/) + assert.match( + html, + /id="openTargetUrl" class="preview-button target-open-link" target="_blank" rel="noopener noreferrer" aria-disabled="true" tabindex="-1"/ + ) assert.match(html, /id="targetScreenshot"/) assert.match(html, /id="targetScreenshot" alt=""/) assert.match(html, /id="screenshotMeta"/) @@ -768,10 +789,7 @@ test('python fallback bridge page has CSP nonce and script-safe config', async ( assert.match(html, /id="screenshotStateBadge" class="state-chip" data-state="pending"/) assert.match(html, /id="screenshotEmpty"/) assert.match(html, /id="stepSummary" class="step-summary" role="status" aria-live="polite"/) - assert.match( - html, - /id="toggleSteps" class="flow-toggle" type="button" aria-controls="captureSteps" aria-expanded="false"/, - ) + assert.match(html, /id="toggleSteps" class="flow-toggle" type="button" aria-controls="captureSteps" aria-expanded="false"/) assert.match(html, /
    /) assert.match(html, /data-phase="bridge_connected" aria-current="step"/) assert.match(html, /id="profileContentSection"/) @@ -799,7 +817,10 @@ test('python fallback bridge page has CSP nonce and script-safe config', async ( assert.match(html, /if\(status==='completed'\)ensureProfileCached\(\)\.catch\(\(\)=>\{\}\)/) assert.match(html, /\/profile-download/) assert.doesNotMatch(html, /config\.captureId\+'\/profile'/) - assert.match(html, /button:not\(:disabled\),a\[href\],input:not\(:disabled\),select:not\(:disabled\),textarea:not\(:disabled\),\[tabindex\]:not\(\[tabindex="-1"\]\)/) + assert.match( + html, + /button:not\(:disabled\),a\[href\],input:not\(:disabled\),select:not\(:disabled\),textarea:not\(:disabled\),\[tabindex\]:not\(\[tabindex="-1"\]\)/ + ) assert.match(html, /currentScreenshot\?\.mimeType==='image\/png'\?'png'/) assert.match(html, /currentScreenshot\?\.mimeType==='image\/webp'\?'webp'/) assert.match(html, /currentScreenshotObjectUrl=URL\.createObjectURL\(blob\)/) @@ -816,7 +837,10 @@ test('python fallback bridge page has CSP nonce and script-safe config', async ( 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, /\.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, + /\.target-open-link\{min-width:132px;display:inline-flex;align-items:center;justify-content:center;text-decoration:none\}/ + ) 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/) @@ -856,7 +880,7 @@ test('python fallback bridge page has CSP nonce and script-safe config', async ( 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/, + /\.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/) @@ -1265,7 +1289,10 @@ test('python fallback bridge token can fetch request and post profile', async () ) assert.equal(screenshotDownload.status, 200) assert.equal(screenshotDownload.headers.get('content-type'), 'image/jpeg') - assert.equal(screenshotDownload.headers.get('content-disposition'), `attachment; filename="stackprism-${created.body.id}-screenshot.jpg"`) + assert.equal( + screenshotDownload.headers.get('content-disposition'), + `attachment; filename="stackprism-${created.body.id}-screenshot.jpg"` + ) assert.equal(screenshotDownload.body.toString('utf8'), 'fake-jpeg') const apiScreenshotDownload = await readBytes( From c2493494358c7cbd490dc5e279cb3a751719c14e Mon Sep 17 00:00:00 2001 From: Mison Date: Thu, 11 Jun 2026 15:57:19 +0800 Subject: [PATCH 45/49] docs: update Agent Bridge validation gates --- docs/dev/agent-bridge.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/dev/agent-bridge.md b/docs/dev/agent-bridge.md index 5eed8a04..1b6cd93f 100644 --- a/docs/dev/agent-bridge.md +++ b/docs/dev/agent-bridge.md @@ -56,6 +56,8 @@ 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 子进程并等待退出。 @@ -132,8 +134,14 @@ smoke 结果按三类理解: ```bash pnpm run build:injected pnpm run test:unit +pnpm run lint pnpm run typecheck -pnpm run build +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 +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 和 XPI 产物边界。验证后应清理或忽略 `dist/`、`dist-firefox/`、`release/`、`public/injected/`、`docs/.vitepress/dist/`、`docs/public/icon.svg` 与 Python `__pycache__`,不要把这些本地产物纳入提交。 From 5aa3f120f0dff574ebda2580de5ae34fb4fbee80 Mon Sep 17 00:00:00 2001 From: Mison Date: Thu, 11 Jun 2026 16:57:42 +0800 Subject: [PATCH 46/49] docs: tighten StackPrism skill guidance --- AGENTS.md | 2 +- README.md | 2 + .../stackprism-site-experience/README.md | 2 + .../stackprism-site-experience/SKILL.md | 19 +++--- .../agents/openai.yaml | 4 +- .../references/agent-consumption-guide.md | 14 +++-- .../site-experience-profile-schema.md | 2 +- tests/stackprism-skill.test.mjs | 61 +++++++++++++++++-- 8 files changed, 84 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 59445277..a4ea2f98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,7 @@ ## 13. Skills 使用规则 -- 开始任务前扫描仓库内可用技能文档。当前已知 repo-local skill 是 `agent-skill/stackprism-site-experience/SKILL.md`。 +- 开始任务前只需轻量确认仓库内可用技能文档。当前已知 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 状态。 diff --git a/README.md b/README.md index 245c8dfb..f6585403 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,8 @@ node agent-skill/stackprism-site-experience/scripts/capture-site.mjs \ --out /tmp/stackprism-profile.json ``` +本机开发目标、`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)。 ## 规则维护 diff --git a/agent-skill/stackprism-site-experience/README.md b/agent-skill/stackprism-site-experience/README.md index 6ac7d823..8d05f427 100644 --- a/agent-skill/stackprism-site-experience/README.md +++ b/agent-skill/stackprism-site-experience/README.md @@ -28,6 +28,8 @@ Lifecycle: direct bridge screenshot links are valid only while the local bridge 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. + ## Security Notes - API tokens are process-local and must not be written into files. diff --git a/agent-skill/stackprism-site-experience/SKILL.md b/agent-skill/stackprism-site-experience/SKILL.md index 6836ebbe..f04ed891 100644 --- a/agent-skill/stackprism-site-experience/SKILL.md +++ b/agent-skill/stackprism-site-experience/SKILL.md @@ -1,6 +1,6 @@ --- name: stackprism-site-experience -description: Use when an AI agent needs to recreate or improve a website from a real URL. Captures browser-observed tech stack, UI design, layout, interactions, UX, and assets through StackPrism so the agent can build from evidence. +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, or live fact 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 @@ -9,19 +9,20 @@ StackPrism helps AI agents recreate websites from real browser evidence: tech st 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, or migration requirement. +- 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, or cases where the user has not installed and enabled StackPrism Agent Bridge. +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 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:`. -- Local development targets require `"allowPrivateNetworkTarget": true`. +- Local development targets require both extension settings consent for all network targets and helper/request option `"allowPrivateNetworkTarget": true`. - 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. @@ -52,7 +53,7 @@ The opened bridge page is also a local result workbench. After completion it sho 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, 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 `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. @@ -91,6 +92,7 @@ Keep the executable and arguments separate. For example, use `STACKPRISM_BROWSER 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`. @@ -128,16 +130,17 @@ Large pages can produce multi-chunk profile transfers. If the browser extension 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 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. ## Use The Profile -- Start from `agentGuidance.recreationPlan`. Follow its `implementationOrder`, then map `designTokens`, `layoutBlueprint`, `componentInventory`, `interactionChecklist`, `uxChecklist`, and `assetHints` 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. +- 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 diff --git a/agent-skill/stackprism-site-experience/agents/openai.yaml b/agent-skill/stackprism-site-experience/agents/openai.yaml index 3cab07d3..f5e1aa57 100644 --- a/agent-skill/stackprism-site-experience/agents/openai.yaml +++ b/agent-skill/stackprism-site-experience/agents/openai.yaml @@ -1,7 +1,7 @@ interface: display_name: 'StackPrism Site Experience' - short_description: 'Evidence for website recreation' + short_description: 'Capture browser evidence profiles' brand_color: '#0F766E' - default_prompt: 'Use $stackprism-site-experience to inspect this URL and recreate a similar site from observed tech stack, UI, layout, UX, and assets.' + default_prompt: 'Use $stackprism-site-experience to capture a StackPrism Agent Bridge profile for this http(s) URL, after confirming the extension is installed, Agent Bridge is enabled, and the target is not a login-protected private page.' 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 index 0c40b2d6..e01c1ea4 100644 --- a/agent-skill/stackprism-site-experience/references/agent-consumption-guide.md +++ b/agent-skill/stackprism-site-experience/references/agent-consumption-guide.md @@ -2,13 +2,15 @@ Use the profile as evidence for recreating a similar website, not as a copyable page dump. -1. Start from `agentGuidance.recreationPlan`; it turns observed evidence into implementation order, design tokens, layout blueprint, component inventory, interaction checklist, UX checklist, asset hints, and verification checks. -2. Read `limitations`. Do not infer that a missing section means the site lacks that feature. -3. Use `visualProfile`, `layoutProfile`, and `componentProfile` to match the observable experience. -4. If `visualProfile.screenshot.downloadUrl` is present, open or download that image to inspect the actual visual effect. The Profile JSON intentionally omits screenshot base64; do not assume the JSON text alone shows the final visual appearance. +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. Verify the result with screenshots, DOM geometry, and interaction smoke tests in the destination app. +8. Implement against the destination project's conventions, then verify with `agentGuidance.recreationPlan.verificationChecklist`, screenshots, DOM geometry, and interaction smoke tests. -Never reproduce private text, account identifiers, credentials, tokens, or user-specific data from a target page. +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, and neither `apiToken`, `bridgeToken`, nonce, raw profile JSON, nor screenshot data URLs should be copied into downstream code, issue text, 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, or 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 index ded4d373..253dcf62 100644 --- a/agent-skill/stackprism-site-experience/references/site-experience-profile-schema.md +++ b/agent-skill/stackprism-site-experience/references/site-experience-profile-schema.md @@ -15,7 +15,7 @@ Top-level fields: - `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`, `designTokens`, `layoutBlueprint`, `componentInventory`, `interactionChecklist`, `uxChecklist`, `assetHints`, and `verificationChecklist`. +- `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. diff --git a/tests/stackprism-skill.test.mjs b/tests/stackprism-skill.test.mjs index 623e159a..efcf70fb 100644 --- a/tests/stackprism-skill.test.mjs +++ b/tests/stackprism-skill.test.mjs @@ -14,9 +14,48 @@ const profileSchemaSource = await readFile( 'utf8' ) +const frontmatter = source => { + const match = source.match(/^---\n(?[\s\S]*?)\n---/) + assert.ok(match?.groups?.body, 'skill must have YAML frontmatter') + return Object.fromEntries( + match.groups.body.split('\n').map(line => { + const separatorIndex = line.indexOf(':') + assert.ok(separatorIndex > 0, `invalid frontmatter line: ${line}`) + return [line.slice(0, separatorIndex).trim(), line.slice(separatorIndex + 1).trim()] + }) + ) +} + +const yamlValue = (source, key) => { + const match = source.match(new RegExp(`^\\s*${key}:\\s*'(?[^']+)'`, 'm')) + assert.ok(match?.groups?.value, `missing ${key} in openai.yaml`) + return match.groups.value +} + +const fencedBlocks = source => [...source.matchAll(/```(?\w+)?\n(?[\s\S]*?)```/g)].map(match => match.groups) + test('stackprism site experience skill advertises the current bridge workflow', () => { - assert.match(skillSource, /name: stackprism-site-experience/) - assert.match(skillSource, /description: .*real URL.*StackPrism/) + const metadata = frontmatter(skillSource) + const bashBlocks = fencedBlocks(skillSource) + .filter(block => block.lang === 'bash') + .map(block => block.body) + + assert.equal(metadata.name, 'stackprism-site-experience') + assert.match(metadata.description, /StackPrism Agent Bridge profile/) + assert.match(metadata.description, /specific http\(s\) URL/) + assert.match(metadata.description, /Do not use for generic UI edits/) + assert.ok( + bashBlocks.some(block => block.includes('scripts/capture-site.mjs')), + 'skill must show preferred capture helper' + ) + assert.ok( + bashBlocks.some(block => block.includes('scripts/stackprism-bridge.mjs')), + 'skill must show JS bridge' + ) + assert.ok( + bashBlocks.some(block => block.includes('scripts/stackprism_bridge.py')), + 'skill must show Python fallback' + ) assert.match(skillSource, /scripts\/capture-site\.mjs/) assert.match(skillSource, /scripts\/stackprism-bridge\.mjs/) assert.match(skillSource, /scripts\/stackprism_bridge\.py/) @@ -27,6 +66,9 @@ test('stackprism site experience skill advertises the current bridge workflow', assert.match(skillSource, /CAPTURE_BUSY/) assert.match(skillSource, /AGENT_BRIDGE_DISABLED/) assert.match(skillSource, /EXTENSION_NOT_CONNECTED/) + assert.match(skillSource, /Firefox profiles/) + assert.match(skillSource, /"--profile","\/absolute\/path\/to\/profile"/) + assert.match(skillSource, /correct Chrome, Edge, or Firefox profile/) }) test('stackprism site experience skill points agents to repo-local references', () => { @@ -38,17 +80,28 @@ test('stackprism site experience skill points agents to repo-local references', assert.match(profileSchemaSource, /Screenshot image base64 is intentionally omitted/) assert.match(consumptionGuideSource, /Start from `agentGuidance\.recreationPlan`/) assert.match(consumptionGuideSource, /Read `limitations`/) + assert.match(consumptionGuideSource, /verificationChecklist/) + assert.match(consumptionGuideSource, /Raw `\/profile` access still requires the API token/) + assert.match(consumptionGuideSource, /Screenshots are not pixel-redacted/) + assert.match(profileSchemaSource, /`visualReference` for optional screenshot handling/) + assert.match(profileSchemaSource, /`verificationChecklist` for destination-app acceptance checks/) }) test('stackprism site experience skill UI metadata remains aligned with global discovery', () => { + assert.equal(yamlValue(openaiYamlSource, 'display_name'), 'StackPrism Site Experience') + assert.equal(yamlValue(openaiYamlSource, 'short_description'), 'Capture browser evidence profiles') + assert.match(yamlValue(openaiYamlSource, 'default_prompt'), /\$stackprism-site-experience/) + assert.match(yamlValue(openaiYamlSource, 'default_prompt'), /StackPrism Agent Bridge profile/) + assert.match(yamlValue(openaiYamlSource, 'default_prompt'), /extension is installed/) + assert.match(yamlValue(openaiYamlSource, 'default_prompt'), /not a login-protected private page/) assert.match(openaiYamlSource, /display_name: 'StackPrism Site Experience'/) - assert.match(openaiYamlSource, /short_description: 'Evidence for website recreation'/) - assert.match(openaiYamlSource, /\$stackprism-site-experience/) assert.match(openaiYamlSource, /allow_implicit_invocation: true/) }) test('repo-local skill documents bridge asset parity and local installation boundary', () => { assert.match(readmeSource, /not automatically installed into Codex or any global skill registry/) assert.match(readmeSource, /JavaScript bridge and Python fallback intentionally share the same bridge page CSS and client script text/) + assert.match(readmeSource, /Local development targets such as `localhost`/) + assert.match(readmeSource, /--allow-private-network/) assert.match(readmeSource, /tests\/stackprism_bridge_py\.test\.mjs/) }) From 96de3336cc9e73623e81a7ebf00079bd15ddf8e7 Mon Sep 17 00:00:00 2001 From: Mison Date: Thu, 11 Jun 2026 22:45:33 +0800 Subject: [PATCH 47/49] fix: harden agent bridge skill capture workflow --- .github/workflows/release-extension.yml | 16 +- README.md | 3 +- .../stackprism-site-experience/README.md | 4 +- .../stackprism-site-experience/SKILL.md | 10 +- .../agents/openai.yaml | 4 +- .../references/agent-consumption-guide.md | 9 +- .../scripts/capture-site-args.mjs | 67 ++++ .../scripts/capture-site.mjs | 100 ++--- build-scripts/package-firefox.mjs | 74 +++- docs/dev/agent-bridge.md | 4 +- tests/release-workflow.test.mjs | 62 +++- tests/stackprism-bridge.test.mjs | 341 ++++++++++++++++-- tests/stackprism-skill.test.mjs | 58 ++- 13 files changed, 638 insertions(+), 114 deletions(-) create mode 100644 agent-skill/stackprism-site-experience/scripts/capture-site-args.mjs diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index ffa7948f..94006e97 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -67,7 +67,7 @@ jobs: node --input-type=module <<'NODE' import { readdirSync, readFileSync, statSync } from 'node:fs' - import { join, relative } from 'node:path' + import { basename, join, relative } from 'node:path' const root = 'dist' const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')) @@ -75,6 +75,10 @@ jobs: 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', @@ -83,11 +87,14 @@ jobs: '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') @@ -97,7 +104,7 @@ jobs: for (const path of resource.resources || []) { if ( agentOnlyPathPatterns.some(pattern => pattern.test(path)) || - agentOnlySourceFiles.some(file => path.includes(file)) || + isAgentOnlySourcePath(path) || path.includes('experience-profiler.iife.js') ) { failures.push(`web_accessible_resources exposes agent-only path: ${path}`) @@ -108,8 +115,7 @@ jobs: const disallowed = [ ...agentOnlyPathPatterns, /(?:^|\/)__pycache__(?:\/|$)/, - /\.py[co]?$/, - ...agentOnlySourceFiles.map(file => new RegExp(`${file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`)) + /\.py[co]?$/ ] const walk = dir => { @@ -120,7 +126,7 @@ jobs: walk(full) continue } - if (disallowed.some(pattern => pattern.test(rel))) { + if (disallowed.some(pattern => pattern.test(rel)) || isAgentOnlySourcePath(rel)) { failures.push(`dist contains agent-only or test artifact: ${rel}`) } } diff --git a/README.md b/README.md index f6585403..9794db3d 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,8 @@ Agent Bridge 是默认关闭的本机能力。用户在扩展设置中显式启 ```bash node agent-skill/stackprism-site-experience/scripts/capture-site.mjs \ --url https://example.com \ - --out /tmp/stackprism-profile.json + --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。 diff --git a/agent-skill/stackprism-site-experience/README.md b/agent-skill/stackprism-site-experience/README.md index 8d05f427..bf6af1d5 100644 --- a/agent-skill/stackprism-site-experience/README.md +++ b/agent-skill/stackprism-site-experience/README.md @@ -12,11 +12,11 @@ Paths in this package are relative to the StackPrism repository root. If an agen - `scripts/stackprism-bridge.mjs`: JavaScript loopback bridge, preferred. - `scripts/stackprism_bridge.py`: Python standard-library fallback. -Both scripts print a single ready JSON line to stdout after the HTTP server is bound. Logs and startup errors go to stderr. +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` 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. +`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. diff --git a/agent-skill/stackprism-site-experience/SKILL.md b/agent-skill/stackprism-site-experience/SKILL.md index f04ed891..e727ffb4 100644 --- a/agent-skill/stackprism-site-experience/SKILL.md +++ b/agent-skill/stackprism-site-experience/SKILL.md @@ -1,6 +1,6 @@ --- 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, or live fact 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. +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 @@ -36,12 +36,13 @@ 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 + --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. -The helper prints one JSON summary on stdout. `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. +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. @@ -125,7 +126,7 @@ Then poll `GET /v1/captures/{id}` and read `GET /v1/captures/{id}/profile` when 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, stop the bridge child process, start a new bridge, and retry once with a smaller `include` set or lower `maxResourceUrls`. Do not synthesize a profile from partial chunks. +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, stop the bridge child process, start a new bridge, and retry once with a smaller `--include` set or lower `--max-resource-urls`. For example, use `--include tech,visual,layout,components,ux` before dropping visual evidence entirely. Do not synthesize a profile from partial chunks. Handle user-actionable failures explicitly: @@ -135,6 +136,7 @@ Handle user-actionable failures explicitly: ## Use The Profile +- Read `limitations` first. Do not infer that a missing section means the site lacks that feature. - 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. diff --git a/agent-skill/stackprism-site-experience/agents/openai.yaml b/agent-skill/stackprism-site-experience/agents/openai.yaml index f5e1aa57..58b32aaa 100644 --- a/agent-skill/stackprism-site-experience/agents/openai.yaml +++ b/agent-skill/stackprism-site-experience/agents/openai.yaml @@ -1,7 +1,7 @@ interface: display_name: 'StackPrism Site Experience' - short_description: 'Capture browser evidence profiles' + short_description: 'Capture StackPrism Agent Bridge URL profiles' brand_color: '#0F766E' - default_prompt: 'Use $stackprism-site-experience to capture a StackPrism Agent Bridge profile for this http(s) URL, after confirming the extension is installed, Agent Bridge is enabled, and the target is not a login-protected private page.' + default_prompt: 'Use $stackprism-site-experience to capture a StackPrism Agent Bridge profile for http(s) URL for website recreation, UX comparison, live browser evidence, or Agent Bridge E2E validation; confirm extension/Bridge are enabled and it is not a private login page.' 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 index e01c1ea4..9e6093d3 100644 --- a/agent-skill/stackprism-site-experience/references/agent-consumption-guide.md +++ b/agent-skill/stackprism-site-experience/references/agent-consumption-guide.md @@ -11,6 +11,13 @@ Use the profile as evidence for recreating a similar website, not as a copyable 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. +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. +- 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. + 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, and neither `apiToken`, `bridgeToken`, nonce, raw profile JSON, nor screenshot data URLs should be copied into downstream code, issue text, 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, or private pages. +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, or private pages. Use a public demo, desensitized test environment, design brief, or user-provided summary instead. 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 index a0c6d637..743ff3ab 100644 --- a/agent-skill/stackprism-site-experience/scripts/capture-site.mjs +++ b/agent-skill/stackprism-site-experience/scripts/capture-site.mjs @@ -3,82 +3,35 @@ 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 { - makeBridgeError, - parseTerminalSettleMs, - requestJson, - sleep, - stopChild -} from './capture-runtime.mjs' +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_INCLUDE = ['tech', 'visual', 'layout', 'components', 'interaction', 'ux', 'assets'] 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 DEFAULT_REQUEST_TIMEOUT_MS = 30000 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 usage = () => { - process.stderr.write( - [ - '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.', - ' --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') + '\n' - ) -} - -const parseArgs = argv => { - const args = { - waitMs: 3000, - requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS, - maxResourceUrls: 300, - 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 === '--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.' } - } - 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.' } +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 } - return { ok: true, args } } const readReady = child => @@ -103,6 +56,11 @@ const readReady = child => }) 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 })) }) }) @@ -113,7 +71,7 @@ const captureRequest = args => ({ url: args.url, mode: 'experience', waitMs: args.waitMs, - include: DEFAULT_INCLUDE, + include: args.include, viewports: [DEFAULT_VIEWPORT], options: { forceRefresh: args.forceRefresh, @@ -208,16 +166,23 @@ const normalizeErrorCode = error => { } 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) { - usage() - throw new Error(parsed.message) + throw makeArgumentError(parsed.message) } const { args } = parsed const result = await runCapture(args) @@ -230,6 +195,8 @@ const main = async () => { 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 || '', @@ -253,6 +220,8 @@ const main = async () => { screenshotPath: result.screenshotArtifact?.path || '', screenshotDownloadUrl: screenshot?.downloadUrl || '', profileDownloadReady: result.profileDownloadReady, + include: args.include, + captureScreenshot: args.captureScreenshot, techCount: result.profile.techProfile?.technologies?.length || 0 })}\n` ) @@ -265,6 +234,7 @@ main().catch(error => { ok: false, error: { code, + message: sanitizeErrorMessage(error, code), details: sanitizeErrorDetails(error) } })}\n` diff --git a/build-scripts/package-firefox.mjs b/build-scripts/package-firefox.mjs index 43175592..e2e1ab3f 100644 --- a/build-scripts/package-firefox.mjs +++ b/build-scripts/package-firefox.mjs @@ -1,5 +1,5 @@ -import { cpSync, rmSync, readFileSync, writeFileSync, mkdirSync, existsSync, createWriteStream } from 'node:fs' -import { basename, resolve, dirname } from 'node:path' +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' @@ -7,6 +7,24 @@ 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) @@ -14,10 +32,19 @@ export async function packageFirefox({ root = defaultRoot, logger = console } = 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 } } +function pathBasename(value) { + return basename(String(value || '').replaceAll('\\', '/')) +} + +function isAgentOnlySourcePath(path) { + return agentOnlySourceFileSet.has(pathBasename(path)) +} + function firefoxPackagePaths(root) { const firefoxDir = resolve(root, 'dist-firefox') return { @@ -139,6 +166,49 @@ function writeFirefoxManifest({ manifestPath }, logger) { return manifest } +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}`) + } + } + } + + 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}`) + } + } + + if (failures.length) { + throw new Error(`[package-firefox] Firefox artifact hygiene failed:\n${failures.map(failure => `- ${failure}`).join('\n')}`) + } +} + +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 +} + async function writeXpi({ root, firefoxDir, manifest, logger }) { const releaseDir = resolve(root, 'release') if (!existsSync(releaseDir)) mkdirSync(releaseDir) diff --git a/docs/dev/agent-bridge.md b/docs/dev/agent-bridge.md index 1b6cd93f..47596cfc 100644 --- a/docs/dev/agent-bridge.md +++ b/docs/dev/agent-bridge.md @@ -62,7 +62,7 @@ JavaScript bridge 与 Python fallback 的 bridge 页面 CSS 和客户端脚本 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 子进程后重启,并用更小的 `include` 范围或更低的 `maxResourceUrls` 重试一次;不得从部分分片拼出“降级成功”的 profile。 +大型页面 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 命令。 @@ -144,4 +144,4 @@ python3 -m py_compile agent-skill/stackprism-site-experience/scripts/stackprism_ 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 和 XPI 产物边界。验证后应清理或忽略 `dist/`、`dist-firefox/`、`release/`、`public/injected/`、`docs/.vitepress/dist/`、`docs/public/icon.svg` 与 Python `__pycache__`,不要把这些本地产物纳入提交。 +`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/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs index 61a444fd..ee52a244 100644 --- a/tests/release-workflow.test.mjs +++ b/tests/release-workflow.test.mjs @@ -119,6 +119,10 @@ test('release workflow rejects agent bridge helper source files from dist artifa '__pycache__', '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', @@ -169,7 +173,9 @@ test('firefox package manifest omits reserved data collection permissions', asyn ] }), 'dist/service-worker-loader.js': "import './assets/background-entry.js'", - 'dist/assets/background-entry.js': 'chrome.runtime.onInstalled.addListener(() => {})' + 'dist/assets/background-entry.js': 'chrome.runtime.onInstalled.addListener(() => {})', + 'dist/assets/my-security.mjs': 'export {}', + 'dist/injected/experience-profiler.iife.js': 'void 0' }, async cwd => { const result = await packageFirefox({ root: cwd, logger: { log() {} } }) @@ -192,7 +198,10 @@ test('firefox package manifest omits reserved data collection permissions', asyn assert.equal('use_dynamic_url' in manifestJson.web_accessible_resources[0], false) assert.doesNotMatch(JSON.stringify(manifestJson), /data_collection_permissions/) assert.doesNotMatch(JSON.stringify(manifestJson), /use_dynamic_url/) + assert.doesNotMatch(JSON.stringify(manifestJson.web_accessible_resources), /experience-profiler/) assert.ok((await stat(join(cwd, 'dist-firefox/background.js'))).isFile()) + assert.ok((await stat(join(cwd, 'dist-firefox/assets/my-security.mjs'))).isFile()) + assert.ok((await stat(join(cwd, 'dist-firefox/injected/experience-profiler.iife.js'))).isFile()) assert.ok((await stat(join(cwd, 'release/stackprism-v1.3.71.xpi'))).isFile()) } ) @@ -231,7 +240,10 @@ test('firefox package bundles content script loaders into plain scripts', async const scripts = manifestJson.content_scripts.flatMap(script => script.js) assert.deepEqual(scripts, ['firefox/content-observer.js', 'firefox/agent-bridge-client.js']) - assert.equal(scripts.some(file => /loader/i.test(file)), false) + assert.equal( + scripts.some(file => /loader/i.test(file)), + false + ) for (const file of scripts) { const bundled = await readFile(join(cwd, 'dist-firefox', file), 'utf8') @@ -242,6 +254,51 @@ test('firefox package bundles content script loaders into plain scripts', async ) }) +test('firefox package rejects agent-only files before writing xpi', async () => { + await withFirefoxDist( + { + 'dist/manifest.json': manifest({ + background: { service_worker: 'service-worker-loader.js' } + }), + 'dist/service-worker-loader.js': "import './assets/background-entry.js'", + 'dist/assets/background-entry.js': 'chrome.runtime.onInstalled.addListener(() => {})', + 'dist/assets/http-server.mjs': 'export {}' + }, + async cwd => { + await assert.rejects( + () => packageFirefox({ root: cwd, logger: { log() {} } }), + /Firefox artifact hygiene failed:[\s\S]*dist-firefox contains agent-only or test artifact: assets\/http-server\.mjs/ + ) + await assert.rejects(stat(join(cwd, 'release/stackprism-v1.3.71.xpi'))) + } + ) +}) + +test('firefox package rejects web accessible agent-only resources', async () => { + await withFirefoxDist( + { + 'dist/manifest.json': manifest({ + background: { service_worker: 'service-worker-loader.js' }, + web_accessible_resources: [ + { + resources: ['injected/experience-profiler.iife.js', 'assets/security.mjs'], + matches: ['http://127.0.0.1/*'] + } + ] + }), + 'dist/service-worker-loader.js': "import './assets/background-entry.js'", + 'dist/assets/background-entry.js': 'chrome.runtime.onInstalled.addListener(() => {})' + }, + async cwd => { + await assert.rejects( + () => packageFirefox({ root: cwd, logger: { log() {} } }), + /Firefox artifact hygiene failed:[\s\S]*web_accessible_resources exposes agent-only path: injected\/experience-profiler\.iife\.js[\s\S]*web_accessible_resources exposes agent-only path: assets\/security\.mjs/ + ) + await assert.rejects(stat(join(cwd, 'release/stackprism-v1.3.71.xpi'))) + } + ) +}) + test('release workflow requires Agent Bridge disclosure confirmation before packaging', () => { assert.match(normalizedWorkflowSource, /agent_bridge_disclosure_confirmed/) assert.match(normalizedWorkflowSource, /Agent Bridge disclosure confirmed/) @@ -253,6 +310,7 @@ test('release workflow dist hygiene script passes a clean dist artifact', async { 'dist/manifest.json': manifest(), 'dist/assets/agent-bridge-client.ts-DEPRr4GS.js': 'export {}', + 'dist/assets/my-security.mjs': 'export {}', 'dist/injected/page-detector.iife.js': 'void 0' }, cwd => { diff --git a/tests/stackprism-bridge.test.mjs b/tests/stackprism-bridge.test.mjs index cdc679e2..32344c35 100644 --- a/tests/stackprism-bridge.test.mjs +++ b/tests/stackprism-bridge.test.mjs @@ -18,7 +18,10 @@ import { resolveBrowserOpenCommand } from '../agent-skill/stackprism-site-experience/scripts/bridge/open-browser.mjs' import { htmlEscapeScriptJson, isValidId, safeEqual } from '../agent-skill/stackprism-site-experience/scripts/bridge/protocol.mjs' -import { readJson as readBridgeRequestJson, rejectBadRequestShell } from '../agent-skill/stackprism-site-experience/scripts/bridge/security.mjs' +import { + readJson as readBridgeRequestJson, + rejectBadRequestShell +} from '../agent-skill/stackprism-site-experience/scripts/bridge/security.mjs' import { normalizeCaptureRequest } from '../agent-skill/stackprism-site-experience/scripts/bridge/url-policy.mjs' import { parseTerminalSettleMs } from '../agent-skill/stackprism-site-experience/scripts/capture-runtime.mjs' import identifiers from './fixtures/bridge-protocol-identifiers.json' with { type: 'json' } @@ -43,7 +46,11 @@ const baseCaptureRequest = { const auth = token => ({ Authorization: `Bearer ${token}` }) const readJson = async response => ({ status: response.status, body: await response.json(), headers: response.headers }) -const readBytes = async response => ({ status: response.status, body: Buffer.from(await response.arrayBuffer()), headers: response.headers }) +const readBytes = async response => ({ + status: response.status, + body: Buffer.from(await response.arrayBuffer()), + headers: response.headers +}) const createClassList = () => { const values = new Set() @@ -441,7 +448,8 @@ test('settings page revokes browser data consent before saving Agent Bridge disa assert.match(settings.slice(helperStart, saveStart), /await revokeAgentBridgeDataConsent\(\)/) assert.match(saveSection, /if \(!\(await revokeDisabledAgentBridgeDataConsent\(settings\.agentBridgeEnabled\)\)\) return/) assert.ok( - saveSection.indexOf('await revokeDisabledAgentBridgeDataConsent(settings.agentBridgeEnabled)') < saveSection.indexOf('chrome.storage.sync.set'), + saveSection.indexOf('await revokeDisabledAgentBridgeDataConsent(settings.agentBridgeEnabled)') < + saveSection.indexOf('chrome.storage.sync.set'), 'settings save must revoke browser data consent before writing disabled Agent Bridge state' ) }) @@ -523,18 +531,37 @@ test('bridge page script supports profile download, screenshot actions and modal assert.equal(elements.modalCopyStatus.textContent, '已复制截图。') document.activeElement = elements.bridgeCard - const tabFromOutside = { key: 'Tab', preventDefaultCalled: false, preventDefault() { this.preventDefaultCalled = true } } + const tabFromOutside = { + key: 'Tab', + preventDefaultCalled: false, + preventDefault() { + this.preventDefaultCalled = true + } + } document.listeners.keydown(tabFromOutside) assert.equal(tabFromOutside.preventDefaultCalled, true) assert.equal(document.activeElement, elements.modalCopyScreenshot) document.activeElement = elements.modalClose - const tabForward = { key: 'Tab', preventDefaultCalled: false, preventDefault() { this.preventDefaultCalled = true } } + const tabForward = { + key: 'Tab', + preventDefaultCalled: false, + preventDefault() { + this.preventDefaultCalled = true + } + } document.listeners.keydown(tabForward) assert.equal(tabForward.preventDefaultCalled, true) assert.equal(document.activeElement, elements.modalCopyScreenshot) - const tabBackward = { key: 'Tab', shiftKey: true, preventDefaultCalled: false, preventDefault() { this.preventDefaultCalled = true } } + const tabBackward = { + key: 'Tab', + shiftKey: true, + preventDefaultCalled: false, + preventDefault() { + this.preventDefaultCalled = true + } + } document.listeners.keydown(tabBackward) assert.equal(tabBackward.preventDefaultCalled, true) assert.equal(document.activeElement, elements.modalClose) @@ -828,10 +855,7 @@ test('js bridge protocol helpers use strict token comparison and script-safe JSO }) test('js bridge page renderer validates nonce and script-escapes object config', () => { - assert.throws( - () => renderBridgePageHtml('bad" nonce', { value: 'https://example.com/' }), - /INVALID_CSP_NONCE/ - ) + assert.throws(() => renderBridgePageHtml('bad" nonce', { value: 'https://example.com/' }), /INVALID_CSP_NONCE/) const html = renderBridgePageHtml('abcdefghijklmnopqrstuv', { value: '&\u2028\u2029' @@ -963,7 +987,10 @@ test('bridge page renders bridge token once with hardened headers', async () => assert.match(html, /id="bridgeCard" class="bridge-card" data-status="waiting_extension" aria-labelledby="bridge-title" tabindex="-1"/) assert.match(html, /id="progressBar"/) assert.match(html, /id="targetUrl" class="target-url" title="" target="_blank" rel="noopener noreferrer" aria-disabled="true"/) - assert.match(html, /id="openTargetUrl" class="preview-button target-open-link" target="_blank" rel="noopener noreferrer" aria-disabled="true" tabindex="-1"/) + assert.match( + html, + /id="openTargetUrl" class="preview-button target-open-link" target="_blank" rel="noopener noreferrer" aria-disabled="true" tabindex="-1"/ + ) assert.match(html, /id="targetScreenshot"/) assert.match(html, /id="targetScreenshot" alt=""/) assert.match(html, /id="screenshotMeta"/) @@ -978,10 +1005,7 @@ test('bridge page renders bridge token once with hardened headers', async () => assert.match(html, /id="screenshotStateBadge" class="state-chip" data-state="pending"/) assert.match(html, /id="screenshotEmpty"/) assert.match(html, /id="stepSummary" class="step-summary" role="status" aria-live="polite"/) - assert.match( - html, - /id="toggleSteps" class="flow-toggle" type="button" aria-controls="captureSteps" aria-expanded="false"/, - ) + assert.match(html, /id="toggleSteps" class="flow-toggle" type="button" aria-controls="captureSteps" aria-expanded="false"/) assert.match(html, /
      /) assert.match(html, /data-phase="bridge_connected" aria-current="step"/) assert.match(html, /id="profileContentSection"/) @@ -1042,7 +1066,10 @@ test('bridge page renders bridge token once with hardened headers', async () => 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, + /\.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\]'\)/) @@ -1065,7 +1092,7 @@ test('bridge page renders bridge token once with hardened headers', async () => 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/, + /\.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/) @@ -1447,7 +1474,10 @@ test('bridge token can fetch request and post profile but cannot read profile', ) assert.equal(screenshotDownload.status, 200) assert.equal(screenshotDownload.headers.get('content-type'), 'image/jpeg') - assert.equal(screenshotDownload.headers.get('content-disposition'), `attachment; filename="stackprism-${created.body.id}-screenshot.jpg"`) + assert.equal( + screenshotDownload.headers.get('content-disposition'), + `attachment; filename="stackprism-${created.body.id}-screenshot.jpg"` + ) assert.equal(screenshotDownload.body.toString('utf8'), 'fake-jpeg') const apiScreenshotDownload = await readBytes( @@ -1547,14 +1577,18 @@ test('profile reads and authenticated screenshot downloads refresh result TTL', const firstExpiry = capture.resultExpiresAt now = firstExpiry - 1 - const profileRead = await readJson(await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}/profile`, { headers: auth(ready.apiToken) })) + const profileRead = await readJson( + await fetch(`${ready.baseUrl}/v1/captures/${created.body.id}/profile`, { headers: auth(ready.apiToken) }) + ) assert.equal(profileRead.status, 200) assert.equal(profileRead.body.visualProfile.screenshot.dataUrl, undefined) assert.equal(capture.resultExpiresAt, now + 100) assert.ok(capture.resultExpiresAt > firstExpiry) now = firstExpiry + 1 - const screenshot = await readBytes(await fetch(profileRead.body.visualProfile.screenshot.downloadUrl, { headers: auth(config.bridgeToken) })) + const screenshot = await readBytes( + await fetch(profileRead.body.visualProfile.screenshot.downloadUrl, { headers: auth(config.bridgeToken) }) + ) assert.equal(screenshot.status, 200) assert.equal(screenshot.headers.get('content-type'), 'image/png') assert.equal(screenshot.body.toString('utf8'), 'fake-png') @@ -1564,7 +1598,9 @@ test('profile reads and authenticated screenshot downloads refresh result TTL', assert.ok(capture.resultExpiresAt > firstExpiry + 99) now = capture.resultExpiresAt + 1 - const expiredScreenshot = await readJson(await fetch(profileRead.body.visualProfile.screenshot.downloadUrl, { headers: auth(config.bridgeToken) })) + const expiredScreenshot = await readJson( + await fetch(profileRead.body.visualProfile.screenshot.downloadUrl, { headers: auth(config.bridgeToken) }) + ) assert.equal(expiredScreenshot.status, 410) assert.equal(expiredScreenshot.body.error.code, 'CAPTURE_RESULT_EXPIRED') assert.equal(capture.profile, null) @@ -2054,12 +2090,16 @@ test('bridge cli rejects occupied configured port before ready output', async () }) test('bridge cli startup failure code is part of the bridge error contract', async () => { - const { isKnownBridgeErrorCode, sanitizeBridgeError } = await import('../agent-skill/stackprism-site-experience/scripts/bridge/protocol.mjs') + const { isKnownBridgeErrorCode, sanitizeBridgeError } = + await import('../agent-skill/stackprism-site-experience/scripts/bridge/protocol.mjs') assert.equal(isKnownBridgeErrorCode('BRIDGE_START_FAILED'), true) assert.equal(isKnownBridgeErrorCode('BRIDGE_PAGE_RENDER_FAILED'), true) assert.equal(sanitizeBridgeError({ code: 'BRIDGE_START_FAILED', message: 'Bridge server startup failed.' }).code, 'BRIDGE_START_FAILED') - assert.equal(sanitizeBridgeError({ code: 'BRIDGE_PAGE_RENDER_FAILED', message: 'Bridge page render failed.' }).code, 'BRIDGE_PAGE_RENDER_FAILED') + assert.equal( + sanitizeBridgeError({ code: 'BRIDGE_PAGE_RENDER_FAILED', message: 'Bridge page render failed.' }).code, + 'BRIDGE_PAGE_RENDER_FAILED' + ) const sanitized = sanitizeBridgeError({ code: 'INVALID_REQUEST', message: `screenshot shot_${'a'.repeat(43)} failed`, @@ -2136,6 +2176,94 @@ test('capture-site helper terminal settle parser defaults invalid nullable value assert.equal(parseTerminalSettleMs('invalid'), 3000) }) +test('capture-site helper emits one machine-readable argument error', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'stackprism-capture-site-args-')) + const profilePath = join(tempDir, 'profile.json') + + try { + const child = spawn( + process.execPath, + [ + 'agent-skill/stackprism-site-experience/scripts/capture-site.mjs', + '--url', + 'https://example.test/', + '--out', + profilePath, + '--include', + 'tech,unknown' + ], + { + cwd: new URL('..', import.meta.url), + stdio: ['ignore', 'pipe', 'pipe'] + } + ) + let stdout = '' + let stderr = '' + child.stdout.on('data', chunk => { + stdout += String(chunk) + }) + child.stderr.on('data', chunk => { + stderr += String(chunk) + }) + const [code] = await once(child, 'exit') + const lines = stderr.trim().split('\n') + const parsed = JSON.parse(stderr.trim()) + + assert.notEqual(code, 0) + assert.equal(stdout.trim(), '') + assert.equal(lines.length, 1) + assert.equal(parsed.ok, false) + assert.equal(parsed.error.code, 'INVALID_REQUEST') + assert.match(parsed.error.message, /--include/) + assert.match(parsed.error.details.usage, /Usage: node agent-skill\/stackprism-site-experience\/scripts\/capture-site\.mjs/) + assert.match(parsed.error.details.usage, /--no-screenshot/) + assert.doesNotMatch(stderr, /^Usage:/) + assert.equal(existsSync(profilePath), false) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } +}) + +test('capture-site helper preserves PORT_IN_USE startup errors', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'stackprism-capture-site-port-')) + const profilePath = join(tempDir, 'profile.json') + const occupied = net.createServer() + occupied.listen(0, '127.0.0.1') + await once(occupied, 'listening') + const { port } = occupied.address() + + try { + const child = spawn( + process.execPath, + ['agent-skill/stackprism-site-experience/scripts/capture-site.mjs', '--url', 'https://example.test/', '--out', profilePath], + { + cwd: new URL('..', import.meta.url), + env: { ...process.env, STACKPRISM_BRIDGE_PORT: String(port) }, + stdio: ['ignore', 'pipe', 'pipe'] + } + ) + let stdout = '' + let stderr = '' + child.stdout.on('data', chunk => { + stdout += String(chunk) + }) + child.stderr.on('data', chunk => { + stderr += String(chunk) + }) + const [code] = await once(child, 'exit') + const parsed = JSON.parse(stderr.trim()) + + assert.notEqual(code, 0) + assert.equal(stdout.trim(), '') + assert.equal(parsed.error.code, 'PORT_IN_USE') + assert.match(parsed.error.message, /port is already in use/i) + assert.equal(existsSync(profilePath), false) + } finally { + await new Promise(resolve => occupied.close(resolve)) + rmSync(tempDir, { recursive: true, force: true }) + } +}) + test('capture-site helper keeps bridge stdin open and writes profile artifacts', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'stackprism-capture-site-')) const fakeBridgePath = join(tempDir, 'fake-bridge.mjs') @@ -2292,8 +2420,10 @@ process.stdin.resume() assert.equal(summary.screenshotPresent, true) assert.equal(summary.screenshotWritten, true) assert.equal(summary.profileDownloadReady, true) + assert.equal(summary.captureScreenshot, true) assert.equal(result.techCount, 2) assert.equal(result.screenshotWritten, true) + assert.equal(result.captureScreenshot, true) assert.equal(result.screenshotPath, screenshotPath) assert.equal(result.screenshotDownloadUrl, pathToFileURL(screenshotPath).href) assert.equal(result.profileDownloadReady, true) @@ -2314,6 +2444,7 @@ process.stdin.resume() assert.equal(fakeBridgeLog.requestBody.options.forceRefresh, false) assert.equal(fakeBridgeLog.requestBody.options.captureScreenshot, true) assert.equal(fakeBridgeLog.requestBody.options.allowPrivateNetworkTarget, false) + assert.deepEqual(fakeBridgeLog.requestBody.include, ['tech', 'visual', 'layout', 'components', 'interaction', 'ux', 'assets']) assert.equal(fakeBridgeLog.statusReads >= 2, true) assert.equal(fakeBridgeLog.profileDownloadedAt > 0, true) assert.equal(fakeBridgeLog.screenshotDownloadedAt > 0, true) @@ -2325,6 +2456,104 @@ process.stdin.resume() } }) +test('capture-site helper accepts a reduced include set for retry captures', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'stackprism-capture-site-include-')) + const fakeBridgePath = join(tempDir, 'fake-bridge-include.mjs') + const profilePath = join(tempDir, 'profile.json') + const logPath = join(tempDir, 'fake-bridge-log.json') + const fakeProfile = profileFor('pending', { + target: { url: 'https://example.test/', finalUrl: 'https://example.test/final', language: 'en' }, + techProfile: { technologies: [] }, + agentGuidance: { recreationPlan: { implementationOrder: ['layout'] } } + }) + + writeFileSync( + fakeBridgePath, + ` +import http from 'node:http' +import { writeFileSync } from 'node:fs' +const logPath = ${JSON.stringify(logPath)} +const captureId = 'cap_1234567890123456789012' +const profile = ${JSON.stringify(fakeProfile)} +let requestBody = null +const token = 'spb_${'f'.repeat(43)}' +const server = http.createServer((req, res) => { + const chunks = [] + req.on('data', chunk => chunks.push(chunk)) + req.on('end', () => { + const send = (status, body) => { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify(body)) + } + if (req.method === 'POST' && req.url === '/v1/captures') { + requestBody = JSON.parse(Buffer.concat(chunks).toString('utf8')) + return send(200, { id: captureId, status: 'queued' }) + } + if (req.url === '/v1/captures/' + captureId) return send(200, { id: captureId, status: 'completed', phase: 'cleanup' }) + if (req.url === '/v1/captures/' + captureId + '/profile-download') return send(200, { ...profile, captureId }) + return send(404, { error: { code: 'NOT_FOUND' } }) + }) +}) +server.listen(0, '127.0.0.1', () => { + const port = server.address().port + process.stdout.write(JSON.stringify({ + event: 'stackprism-bridge-ready', + protocolVersion: 1, + baseUrl: 'http://127.0.0.1:' + port, + healthUrl: 'http://127.0.0.1:' + port + '/health', + apiToken: token + }) + '\\n') +}) +process.stdin.on('end', () => { + writeFileSync(logPath, JSON.stringify({ requestBody })) + server.close(() => process.exit(0)) +}) +process.stdin.resume() +`, + 'utf8' + ) + + try { + const child = spawn( + process.execPath, + [ + 'agent-skill/stackprism-site-experience/scripts/capture-site.mjs', + '--url', + 'https://example.test/', + '--out', + profilePath, + '--include', + 'tech,visual,ux', + '--no-screenshot' + ], + { + cwd: new URL('..', import.meta.url), + env: { ...process.env, STACKPRISM_CAPTURE_BRIDGE_SCRIPT: fakeBridgePath, STACKPRISM_CAPTURE_TERMINAL_SETTLE_MS: '50' }, + stdio: ['ignore', 'pipe', 'pipe'] + } + ) + let stdout = '' + let stderr = '' + child.stdout.on('data', chunk => { + stdout += String(chunk) + }) + child.stderr.on('data', chunk => { + stderr += String(chunk) + }) + const [code] = await once(child, 'exit') + assert.equal(code, 0, stderr) + const summary = JSON.parse(stdout.trim()) + const fakeBridgeLog = JSON.parse(readFileSync(logPath, 'utf8')) + assert.deepEqual(fakeBridgeLog.requestBody.include, ['tech', 'visual', 'ux']) + assert.equal(fakeBridgeLog.requestBody.options.captureScreenshot, false) + assert.equal(summary.include.includes('components'), false) + assert.deepEqual(summary.include, ['tech', 'visual', 'ux']) + assert.equal(summary.captureScreenshot, false) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } +}) + test('capture-site helper can explicitly force-refresh the fresh target tab', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'stackprism-capture-site-force-refresh-')) const fakeBridgePath = join(tempDir, 'fake-bridge-force-refresh.mjs') @@ -2481,6 +2710,72 @@ process.stdin.resume() } }) +test('capture-site helper exits with CAPTURE_BUSY without writing profile', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'stackprism-capture-site-busy-')) + const fakeBridgePath = join(tempDir, 'fake-bridge-busy.mjs') + const profilePath = join(tempDir, 'profile.json') + writeFileSync( + fakeBridgePath, + ` +import http from 'node:http' +const token = 'spb_${'g'.repeat(43)}' +const server = http.createServer((req, res) => { + const send = (status, body) => { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify(body)) + } + if (req.method === 'POST' && req.url === '/v1/captures') { + return send(409, { + error: { + code: 'CAPTURE_BUSY', + message: 'Capture already active.', + details: { captureId: 'cap_abcdefghijklmnopqrstuv' } + } + }) + } + return send(404, { error: { code: 'NOT_FOUND' } }) +}) +server.listen(0, '127.0.0.1', () => { + const port = server.address().port + process.stdout.write(JSON.stringify({ event: 'stackprism-bridge-ready', protocolVersion: 1, baseUrl: 'http://127.0.0.1:' + port, healthUrl: 'http://127.0.0.1:' + port + '/health', apiToken: token }) + '\\n') +}) +process.stdin.on('end', () => server.close(() => process.exit(0))) +process.stdin.resume() +`, + 'utf8' + ) + + try { + const child = spawn( + process.execPath, + ['agent-skill/stackprism-site-experience/scripts/capture-site.mjs', '--url', 'https://example.test/', '--out', profilePath], + { + cwd: new URL('..', import.meta.url), + env: { ...process.env, STACKPRISM_CAPTURE_BRIDGE_SCRIPT: fakeBridgePath }, + stdio: ['ignore', 'pipe', 'pipe'] + } + ) + let stdout = '' + let stderr = '' + child.stdout.on('data', chunk => { + stdout += String(chunk) + }) + child.stderr.on('data', chunk => { + stderr += String(chunk) + }) + const [code] = await once(child, 'exit') + const error = JSON.parse(stderr.trim()) + + assert.notEqual(code, 0) + assert.equal(stdout.trim(), '') + assert.equal(error.error.code, 'CAPTURE_BUSY') + assert.match(error.error.message, /Capture already active/) + assert.equal(existsSync(profilePath), false) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } +}) + test('capture-site helper does not use nonstandard error bodies as stderr codes', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'stackprism-capture-site-nonstandard-error-')) const fakeBridgePath = join(tempDir, 'fake-bridge-nonstandard-error.mjs') diff --git a/tests/stackprism-skill.test.mjs b/tests/stackprism-skill.test.mjs index efcf70fb..f18d9dfa 100644 --- a/tests/stackprism-skill.test.mjs +++ b/tests/stackprism-skill.test.mjs @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises' import { test } from 'node:test' const skillSource = await readFile(new URL('../agent-skill/stackprism-site-experience/SKILL.md', import.meta.url), 'utf8') +const agentsSource = await readFile(new URL('../AGENTS.md', import.meta.url), 'utf8') const readmeSource = await readFile(new URL('../agent-skill/stackprism-site-experience/README.md', import.meta.url), 'utf8') const openaiYamlSource = await readFile(new URL('../agent-skill/stackprism-site-experience/agents/openai.yaml', import.meta.url), 'utf8') const consumptionGuideSource = await readFile( @@ -34,6 +35,14 @@ const yamlValue = (source, key) => { const fencedBlocks = source => [...source.matchAll(/```(?\w+)?\n(?[\s\S]*?)```/g)].map(match => match.groups) +const assertBefore = (source, first, second) => { + const firstIndex = source.indexOf(first) + const secondIndex = source.indexOf(second) + assert.notEqual(firstIndex, -1, `missing ${first}`) + assert.notEqual(secondIndex, -1, `missing ${second}`) + assert.ok(firstIndex < secondIndex, `${first} must appear before ${second}`) +} + test('stackprism site experience skill advertises the current bridge workflow', () => { const metadata = frontmatter(skillSource) const bashBlocks = fencedBlocks(skillSource) @@ -43,7 +52,10 @@ test('stackprism site experience skill advertises the current bridge workflow', assert.equal(metadata.name, 'stackprism-site-experience') assert.match(metadata.description, /StackPrism Agent Bridge profile/) assert.match(metadata.description, /specific http\(s\) URL/) + assert.match(metadata.description, /Agent Bridge E2E verification/) assert.match(metadata.description, /Do not use for generic UI edits/) + assert.match(metadata.description, /backend-only work/) + assert.match(metadata.description, /StackPrism internal code review/) assert.ok( bashBlocks.some(block => block.includes('scripts/capture-site.mjs')), 'skill must show preferred capture helper' @@ -62,6 +74,11 @@ test('stackprism site experience skill advertises the current bridge workflow', assert.match(skillSource, /STACKPRISM_BROWSER_OPEN_COMMAND/) assert.match(skillSource, /STACKPRISM_BROWSER_OPEN_ARGS_JSON/) assert.match(skillSource, /--allow-private-network/) + assert.match(skillSource, /--include tech,visual,layout,components,interaction,ux,assets/) + assert.match(skillSource, /--max-resource-urls/) + assert.match(skillSource, /one JSON summary on stdout/) + assert.match(skillSource, /one JSON error object to stderr/) + assert.match(skillSource, /`error\.code`, `error\.message`, and sanitized `error\.details`/) assert.match(skillSource, /PRIVATE_NETWORK_TARGET_BLOCKED/) assert.match(skillSource, /CAPTURE_BUSY/) assert.match(skillSource, /AGENT_BRIDGE_DISABLED/) @@ -80,20 +97,42 @@ test('stackprism site experience skill points agents to repo-local references', assert.match(profileSchemaSource, /Screenshot image base64 is intentionally omitted/) assert.match(consumptionGuideSource, /Start from `agentGuidance\.recreationPlan`/) assert.match(consumptionGuideSource, /Read `limitations`/) + assertBefore(consumptionGuideSource, 'Read `limitations` first', 'Start from `agentGuidance.recreationPlan`') + assertBefore(skillSource, 'Read `limitations` first', 'Start from `agentGuidance.recreationPlan`') assert.match(consumptionGuideSource, /verificationChecklist/) assert.match(consumptionGuideSource, /Raw `\/profile` access still requires the API token/) + assert.match(consumptionGuideSource, /`apiToken`, `bridgeToken`, nonce, raw profile JSON, nor screenshot data URLs/) + assert.match(consumptionGuideSource, /If `visualProfile` or screenshot evidence is missing, do not claim visual parity/) + assert.match(consumptionGuideSource, /A tech-only profile supports technology, dependency, and runtime observations only/) + assert.match(consumptionGuideSource, /not sufficient for UI implementation, visual comparison, or visual verification/) + assert.match(consumptionGuideSource, /Destination project conventions override the source page's stack/) + assert.match(consumptionGuideSource, /component library, routing, state, CSS architecture, test framework/) + assert.match(consumptionGuideSource, /Interaction smoke tests should cover viewport, key path, hover and focus states/) + assert.match(consumptionGuideSource, /responsive breakpoint, screenshot or DOM geometry evidence, and explicit limitations/) assert.match(consumptionGuideSource, /Screenshots are not pixel-redacted/) + assert.match(consumptionGuideSource, /login-protected, account-specific, or private pages/) + assert.match(consumptionGuideSource, /public demo, desensitized test environment, design brief, or user-provided summary/) assert.match(profileSchemaSource, /`visualReference` for optional screenshot handling/) assert.match(profileSchemaSource, /`verificationChecklist` for destination-app acceptance checks/) }) test('stackprism site experience skill UI metadata remains aligned with global discovery', () => { + const shortDescription = yamlValue(openaiYamlSource, 'short_description') + const defaultPrompt = yamlValue(openaiYamlSource, 'default_prompt') + assert.equal(yamlValue(openaiYamlSource, 'display_name'), 'StackPrism Site Experience') - assert.equal(yamlValue(openaiYamlSource, 'short_description'), 'Capture browser evidence profiles') - assert.match(yamlValue(openaiYamlSource, 'default_prompt'), /\$stackprism-site-experience/) - assert.match(yamlValue(openaiYamlSource, 'default_prompt'), /StackPrism Agent Bridge profile/) - assert.match(yamlValue(openaiYamlSource, 'default_prompt'), /extension is installed/) - assert.match(yamlValue(openaiYamlSource, 'default_prompt'), /not a login-protected private page/) + assert.equal(shortDescription, 'Capture StackPrism Agent Bridge URL profiles') + assert.ok(shortDescription.length >= 25 && shortDescription.length <= 64) + assert.match(defaultPrompt, /\$stackprism-site-experience/) + assert.match(defaultPrompt, /StackPrism Agent Bridge profile/) + assert.match(defaultPrompt, /website recreation/) + assert.match(defaultPrompt, /UX comparison/) + assert.match(defaultPrompt, /live browser evidence/) + assert.match(defaultPrompt, /Agent Bridge E2E validation/) + assert.match(defaultPrompt, /extension/) + assert.match(defaultPrompt, /not a .*private login page/) + assert.ok(defaultPrompt.length <= 260) + assert.equal(defaultPrompt.split(/[.!?]\s+/).filter(Boolean).length, 1) assert.match(openaiYamlSource, /display_name: 'StackPrism Site Experience'/) assert.match(openaiYamlSource, /allow_implicit_invocation: true/) }) @@ -101,7 +140,16 @@ test('stackprism site experience skill UI metadata remains aligned with global d test('repo-local skill documents bridge asset parity and local installation boundary', () => { assert.match(readmeSource, /not automatically installed into Codex or any global skill registry/) assert.match(readmeSource, /JavaScript bridge and Python fallback intentionally share the same bridge page CSS and client script text/) + assert.match(readmeSource, /--include tech,visual,layout,components,interaction,ux,assets/) + assert.match(readmeSource, /--max-resource-urls /) + assert.match(readmeSource, /The direct bridge scripts print a single ready JSON line/) + assert.match(readmeSource, /one JSON summary to stdout/) + assert.match(readmeSource, /one JSON error object to stderr/) assert.match(readmeSource, /Local development targets such as `localhost`/) assert.match(readmeSource, /--allow-private-network/) assert.match(readmeSource, /tests\/stackprism_bridge_py\.test\.mjs/) + assert.match(agentsSource, /repo-local skill/) + assert.match(agentsSource, /JavaScript bridge 优先/) + assert.match(agentsSource, /Python fallback/) + assert.match(agentsSource, /ready JSON/) }) From 336c8ec954dde673cc0571f746458a43cda72d40 Mon Sep 17 00:00:00 2001 From: Mison Date: Fri, 12 Jun 2026 11:12:02 +0800 Subject: [PATCH 48/49] docs(skill): preserve agent bridge retry context --- .../stackprism-site-experience/README.md | 23 +++- .../stackprism-site-experience/SKILL.md | 130 +++++++++++++++++- .../agents/openai.yaml | 2 +- .../references/agent-consumption-guide.md | 15 +- tests/stackprism-skill.test.mjs | 81 ++++++++++- 5 files changed, 241 insertions(+), 10 deletions(-) diff --git a/agent-skill/stackprism-site-experience/README.md b/agent-skill/stackprism-site-experience/README.md index bf6af1d5..d1decfe1 100644 --- a/agent-skill/stackprism-site-experience/README.md +++ b/agent-skill/stackprism-site-experience/README.md @@ -28,7 +28,28 @@ Lifecycle: direct bridge screenshot links are valid only while the local bridge 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. +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, prefer an explicit opener/profile command so the bridge page opens in the browser profile that has StackPrism installed: + +```bash +cd +STACKPRISM_BROWSER_OPEN_COMMAND="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ +STACKPRISM_BROWSER_OPEN_ARGS_JSON='["--profile-directory=Default"]' \ +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 login-protected, account-specific, billing, admin, inbox, dashboard, internal, or private user pages. Ask for a public demo URL, desensitized test-environment URL, user-provided already-redacted screenshot or recording, design brief, or anonymized page-structure summary instead. Screenshots are not pixel-redacted. If the user refers to the current browser page without a URL, ask for a public or desensitized `http:` or `https:` URL instead of using active-tab capture. + +Large-page transfer failures should use a bounded retry ladder. Retry commands must preserve the original capture context: same `TARGET_URL`, browser/profile opener env such as `STACKPRISM_BROWSER_OPEN_COMMAND` and `STACKPRISM_BROWSER_OPEN_ARGS_JSON`, and target policy flags such as `--allow-private-network`. For local/private retries, keep `STACKPRISM_CAPTURE_TARGET_FLAGS='--allow-private-network'` or include the flag directly on every reduced attempt. First retry with `--include tech,visual,layout,components,ux --max-resource-urls 150`; if that still fails and the user accepts losing screenshot evidence, state that the final result cannot support visual parity, then run one final `--include tech,layout,components,ux --max-resource-urls 50 --no-screenshot` attempt. Never synthesize a profile from partial chunks. + +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 diff --git a/agent-skill/stackprism-site-experience/SKILL.md b/agent-skill/stackprism-site-experience/SKILL.md index e727ffb4..999f5f55 100644 --- a/agent-skill/stackprism-site-experience/SKILL.md +++ b/agent-skill/stackprism-site-experience/SKILL.md @@ -22,10 +22,26 @@ Do not use this skill for backend-only tasks, generic web search, SEO content ex - 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 one of: a public demo URL, a desensitized test-environment URL, a user-supplied redacted screenshot or recording, a design brief, or an anonymized page-structure summary. 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, 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 has already 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: @@ -42,6 +58,21 @@ node agent-skill/stackprism-site-experience/scripts/capture-site.mjs \ 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: + +```bash +cd +STACKPRISM_BROWSER_OPEN_COMMAND="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ +STACKPRISM_BROWSER_OPEN_ARGS_JSON='["--profile-directory=Default"]' \ +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. @@ -58,6 +89,48 @@ If the helper exits with `PRIVATE_NETWORK_TARGET_BLOCKED` for a local developmen 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`, keep `STACKPRISM_CAPTURE_TARGET_FLAGS='--allow-private-network'` or include the flag directly 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 +# For public targets, leave STACKPRISM_CAPTURE_TARGET_FLAGS unset. +# For original local/private attempts, set it to: --allow-private-network. +# 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" \ + ${STACKPRISM_CAPTURE_TARGET_FLAGS:-} \ + --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 accepts losing screenshot evidence, run one final reduced non-visual attempt, then stop and report the remaining failure: + +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 and STACKPRISM_CAPTURE_TARGET_FLAGS. +node agent-skill/stackprism-site-experience/scripts/capture-site.mjs \ + --url "$TARGET_URL" \ + ${STACKPRISM_CAPTURE_TARGET_FLAGS:-} \ + --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 @@ -111,7 +184,7 @@ Call `POST /v1/captures` with `Authorization: Bearer {apiToken}`: "options": { "forceRefresh": false, "captureScreenshotMetadata": false, - "captureScreenshot": false, + "captureScreenshot": true, "keepTabOpen": false, "allowPrivateNetworkTarget": false, "targetMode": "reuse_or_new_tab", @@ -126,7 +199,7 @@ Then poll `GET /v1/captures/{id}` and read `GET /v1/captures/{id}/profile` when 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, stop the bridge child process, start a new bridge, and retry once with a smaller `--include` set or lower `--max-resource-urls`. For example, use `--include tech,visual,layout,components,ux` before dropping visual evidence entirely. Do not synthesize a profile from partial chunks. +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: @@ -134,6 +207,59 @@ Handle user-actionable failures explicitly: - `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": "Chrome", + "browserVersion": "recorded separately", + "profileIdentifier": "Default", + "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. diff --git a/agent-skill/stackprism-site-experience/agents/openai.yaml b/agent-skill/stackprism-site-experience/agents/openai.yaml index 58b32aaa..57f9b7dc 100644 --- a/agent-skill/stackprism-site-experience/agents/openai.yaml +++ b/agent-skill/stackprism-site-experience/agents/openai.yaml @@ -2,6 +2,6 @@ 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 a StackPrism Agent Bridge profile for http(s) URL for website recreation, UX comparison, live browser evidence, or Agent Bridge E2E validation; confirm extension/Bridge are enabled and it is not a private login page.' + 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 index 9e6093d3..40590ae8 100644 --- a/agent-skill/stackprism-site-experience/references/agent-consumption-guide.md +++ b/agent-skill/stackprism-site-experience/references/agent-consumption-guide.md @@ -15,9 +15,20 @@ 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, and neither `apiToken`, `bridgeToken`, nonce, raw profile JSON, nor screenshot data URLs should be copied into downstream code, issue text, or prompts. +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. -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, or private pages. Use a public demo, desensitized test environment, design brief, or user-provided summary instead. +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 one of: a public demo URL, a desensitized test-environment URL, a user-supplied redacted screenshot or recording, a design brief, or an anonymized page-structure summary. 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 has already removed private content. diff --git a/tests/stackprism-skill.test.mjs b/tests/stackprism-skill.test.mjs index f18d9dfa..d0bc249a 100644 --- a/tests/stackprism-skill.test.mjs +++ b/tests/stackprism-skill.test.mjs @@ -74,6 +74,9 @@ test('stackprism site experience skill advertises the current bridge workflow', assert.match(skillSource, /STACKPRISM_BROWSER_OPEN_COMMAND/) assert.match(skillSource, /STACKPRISM_BROWSER_OPEN_ARGS_JSON/) assert.match(skillSource, /--allow-private-network/) + assert.match(skillSource, /confirm the dev server is running and the URL is reachable/) + assert.match(skillSource, /Localhost support is only for public, demo, or explicitly desensitized development pages/) + assert.match(skillSource, /local, intranet, or internal pages that contain login state/) assert.match(skillSource, /--include tech,visual,layout,components,interaction,ux,assets/) assert.match(skillSource, /--max-resource-urls/) assert.match(skillSource, /one JSON summary on stdout/) @@ -86,6 +89,46 @@ test('stackprism site experience skill advertises the current bridge workflow', assert.match(skillSource, /Firefox profiles/) assert.match(skillSource, /"--profile","\/absolute\/path\/to\/profile"/) assert.match(skillSource, /correct Chrome, Edge, or Firefox profile/) + assert.match(skillSource, /## Private Page Boundary/) + assert.match(skillSource, /even when the user says they own the account/) + assert.match(skillSource, /I cannot automatically capture that private or logged-in page with StackPrism/) + assert.match(skillSource, /public demo URL, a desensitized test-environment URL/) + assert.match(skillSource, /current browser page/) + assert.match(skillSource, /do not use `active_tab`/) + assert.match(skillSource, /Accept a user-provided redacted screenshot or recording only if the user has already removed private content/) + assert.match(skillSource, /http:\/\/127\.0\.0\.1:5173\//) + assert.match(skillSource, /\/Applications\/Google Chrome\.app\/Contents\/MacOS\/Google Chrome/) + assert.match(skillSource, /--include tech,visual,layout,components,ux/) + assert.match(skillSource, /--max-resource-urls 150/) + assert.match(skillSource, /--include tech,layout,components,ux/) + assert.match(skillSource, /--max-resource-urls 50/) + assert.match(skillSource, /--no-screenshot/) + assert.match(skillSource, /Retry attempts must preserve the original capture context exactly/) + assert.match(skillSource, /same `STACKPRISM_BROWSER_OPEN_COMMAND`/) + assert.match(skillSource, /same target policy flags such as `--allow-private-network`/) + assert.match(skillSource, /STACKPRISM_CAPTURE_TARGET_FLAGS='--allow-private-network'/) + assert.match(skillSource, /\$\{STACKPRISM_CAPTURE_TARGET_FLAGS:-\}/) + assert.match(skillSource, /Before the final non-visual attempt/) + assert.match(skillSource, /the result can only support structural, technology, component, and limited UX findings/) + assert.match(skillSource, /The current experience profile is passive/) + assert.match(skillSource, /does not click, type, submit forms, or exercise workflows/) + assert.match(skillSource, /"captureScreenshot": true/) + assert.match(skillSource, /## E2E Evidence Manifest/) + assert.match(skillSource, /browserName/) + assert.match(skillSource, /profileIdentifier/) + assert.match(skillSource, /extensionVersion/) + assert.match(skillSource, /privateNetworkOverrideUsed/) + assert.match(skillSource, /Safe to report/) + assert.match(skillSource, /Never paste or commit/) + assert.match(skillSource, /`apiToken`/) + assert.match(skillSource, /token-bearing bridge URLs/) + assert.match(skillSource, /`captureId` may appear in local result artifacts/) + assert.match(skillSource, /shasum -a 256/) + assert.match(skillSource, /stat -f '%N %z bytes'/) + assert.match(skillSource, /Use this public report template instead of pasting raw stdout/) + assert.match(skillSource, /"captureId": "\[redacted\]"/) + assert.match(skillSource, /extension details page/) + assert.match(skillSource, /Do not include extension internal UUIDs/) }) test('stackprism site experience skill points agents to repo-local references', () => { @@ -101,17 +144,32 @@ test('stackprism site experience skill points agents to repo-local references', assertBefore(skillSource, 'Read `limitations` first', 'Start from `agentGuidance.recreationPlan`') assert.match(consumptionGuideSource, /verificationChecklist/) assert.match(consumptionGuideSource, /Raw `\/profile` access still requires the API token/) - assert.match(consumptionGuideSource, /`apiToken`, `bridgeToken`, nonce, raw profile JSON, nor screenshot data URLs/) + assert.match(consumptionGuideSource, /Never copy `apiToken`, `bridgeToken`, nonce/) + assert.match(consumptionGuideSource, /raw profile JSON containing private content/) + assert.match(consumptionGuideSource, /screenshot data URLs/) assert.match(consumptionGuideSource, /If `visualProfile` or screenshot evidence is missing, do not claim visual parity/) assert.match(consumptionGuideSource, /A tech-only profile supports technology, dependency, and runtime observations only/) assert.match(consumptionGuideSource, /not sufficient for UI implementation, visual comparison, or visual verification/) + assert.match(consumptionGuideSource, /A reduced non-visual retry supports structural, technology, and limited UX findings only/) + assert.match(consumptionGuideSource, /A reduced retry is valid only if it preserved the original capture context/) + assert.match(consumptionGuideSource, /target policy flags such as `--allow-private-network`/) assert.match(consumptionGuideSource, /Destination project conventions override the source page's stack/) assert.match(consumptionGuideSource, /component library, routing, state, CSS architecture, test framework/) assert.match(consumptionGuideSource, /Interaction smoke tests should cover viewport, key path, hover and focus states/) assert.match(consumptionGuideSource, /responsive breakpoint, screenshot or DOM geometry evidence, and explicit limitations/) + assert.match(consumptionGuideSource, /StackPrism experience capture is passive/) + assert.match(consumptionGuideSource, /does not click, type, submit forms, or exercise workflows/) + assert.match(consumptionGuideSource, /Safe report fields/) + assert.match(consumptionGuideSource, /unredacted `captureId`/) assert.match(consumptionGuideSource, /Screenshots are not pixel-redacted/) - assert.match(consumptionGuideSource, /login-protected, account-specific, or private pages/) - assert.match(consumptionGuideSource, /public demo, desensitized test environment, design brief, or user-provided summary/) + assert.match(consumptionGuideSource, /login-protected, account-specific, billing/) + assert.match(consumptionGuideSource, /billing, admin, inbox, dashboard, internal/) + assert.match(consumptionGuideSource, /I cannot automatically capture that private or logged-in page with StackPrism/) + assert.match(consumptionGuideSource, /public demo URL, a desensitized test-environment URL/) + assert.match(consumptionGuideSource, /user-supplied redacted screenshot or recording/) + assert.match(consumptionGuideSource, /Localhost or intranet targets are acceptable only when they are public, demo, or explicitly desensitized development pages/) + assert.match(consumptionGuideSource, /current browser page/) + assert.match(consumptionGuideSource, /Accept redacted screenshots or recordings only when the user has already removed private content/) assert.match(profileSchemaSource, /`visualReference` for optional screenshot handling/) assert.match(profileSchemaSource, /`verificationChecklist` for destination-app acceptance checks/) }) @@ -130,7 +188,7 @@ test('stackprism site experience skill UI metadata remains aligned with global d assert.match(defaultPrompt, /live browser evidence/) assert.match(defaultPrompt, /Agent Bridge E2E validation/) assert.match(defaultPrompt, /extension/) - assert.match(defaultPrompt, /not a .*private login page/) + assert.match(defaultPrompt, /not private login\/account/) assert.ok(defaultPrompt.length <= 260) assert.equal(defaultPrompt.split(/[.!?]\s+/).filter(Boolean).length, 1) assert.match(openaiYamlSource, /display_name: 'StackPrism Site Experience'/) @@ -147,6 +205,21 @@ test('repo-local skill documents bridge asset parity and local installation boun assert.match(readmeSource, /one JSON error object to stderr/) assert.match(readmeSource, /Local development targets such as `localhost`/) assert.match(readmeSource, /--allow-private-network/) + assert.match(readmeSource, /Localhost support is only for public, demo, or explicitly desensitized development pages/) + assert.match(readmeSource, /http:\/\/127\.0\.0\.1:5173\//) + assert.match(readmeSource, /Do not capture login-protected, account-specific/) + assert.match(readmeSource, /already-redacted screenshot or recording/) + assert.match(readmeSource, /current browser page without a URL/) + assert.match(readmeSource, /Retry commands must preserve the original capture context/) + assert.match(readmeSource, /browser\/profile opener env such as `STACKPRISM_BROWSER_OPEN_COMMAND`/) + assert.match(readmeSource, /STACKPRISM_CAPTURE_TARGET_FLAGS='--allow-private-network'/) + assert.match(readmeSource, /--max-resource-urls 150/) + assert.match(readmeSource, /--max-resource-urls 50 --no-screenshot/) + assert.match(readmeSource, /cannot support visual parity/) + assert.match(readmeSource, /evidence manifest outside the repository/) + assert.match(readmeSource, /Never paste or commit `apiToken`/) + assert.match(readmeSource, /shasum -a 256/) + assert.match(readmeSource, /redact `captureId`/) assert.match(readmeSource, /tests\/stackprism_bridge_py\.test\.mjs/) assert.match(agentsSource, /repo-local skill/) assert.match(agentsSource, /JavaScript bridge 优先/) From 9c2076ad4908bf5fcc0abda720a374a111842a4b Mon Sep 17 00:00:00 2001 From: Mison Date: Fri, 12 Jun 2026 16:02:18 +0800 Subject: [PATCH 49/49] fix: harden agent bridge skill helpers --- README.md | 3 +- .../stackprism-site-experience/README.md | 10 +- .../stackprism-site-experience/SKILL.md | 60 +++++++--- .../references/agent-consumption-guide.md | 15 ++- .../scripts/bridge/capture-store.mjs | 4 +- .../scripts/bridge/http-server.mjs | 2 +- .../scripts/bridge/open-browser.mjs | 92 +++++++++++++-- .../scripts/bridge/url-policy.mjs | 31 +++-- .../stackprism_bridge_lib/open_browser.py | 51 +++++++-- .../stackprism_bridge_lib/url_policy.py | 2 + tests/stackprism-bridge.test.mjs | 106 +++++++++++++++--- tests/stackprism-skill.test.mjs | 72 ++++++++++-- tests/stackprism_bridge_py.test.mjs | 76 +++++++++++-- 13 files changed, 438 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index 9794db3d..39e3c71e 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,9 @@ Agent Bridge 是默认关闭的本机能力。用户在扩展设置中显式启 常用采集命令: ```bash +TARGET_URL="https://public-or-desensitized.example" node agent-skill/stackprism-site-experience/scripts/capture-site.mjs \ - --url https://example.com \ + --url "$TARGET_URL" \ --out /tmp/stackprism-profile.json \ --include tech,visual,layout,components,interaction,ux,assets ``` diff --git a/agent-skill/stackprism-site-experience/README.md b/agent-skill/stackprism-site-experience/README.md index d1decfe1..111f02d8 100644 --- a/agent-skill/stackprism-site-experience/README.md +++ b/agent-skill/stackprism-site-experience/README.md @@ -30,12 +30,12 @@ When selecting a non-default browser or profile, keep the opener executable and 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, prefer an explicit opener/profile command so the bridge page opens in the browser profile that has StackPrism installed: +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=Default"]' \ +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 \ @@ -45,9 +45,11 @@ node agent-skill/stackprism-site-experience/scripts/capture-site.mjs \ --include tech,visual,layout,components,interaction,ux,assets ``` -Do not capture login-protected, account-specific, billing, admin, inbox, dashboard, internal, or private user pages. Ask for a public demo URL, desensitized test-environment URL, user-provided already-redacted screenshot or recording, design brief, or anonymized page-structure summary instead. Screenshots are not pixel-redacted. If the user refers to the current browser page without a URL, ask for a public or desensitized `http:` or `https:` URL instead of using active-tab capture. +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 use a bounded retry ladder. Retry commands must preserve the original capture context: same `TARGET_URL`, browser/profile opener env such as `STACKPRISM_BROWSER_OPEN_COMMAND` and `STACKPRISM_BROWSER_OPEN_ARGS_JSON`, and target policy flags such as `--allow-private-network`. For local/private retries, keep `STACKPRISM_CAPTURE_TARGET_FLAGS='--allow-private-network'` or include the flag directly on every reduced attempt. First retry with `--include tech,visual,layout,components,ux --max-resource-urls 150`; if that still fails and the user accepts losing screenshot evidence, state that the final result cannot support visual parity, then run one final `--include tech,layout,components,ux --max-resource-urls 50 --no-screenshot` attempt. Never synthesize a profile from partial chunks. +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. diff --git a/agent-skill/stackprism-site-experience/SKILL.md b/agent-skill/stackprism-site-experience/SKILL.md index 999f5f55..78889eb5 100644 --- a/agent-skill/stackprism-site-experience/SKILL.md +++ b/agent-skill/stackprism-site-experience/SKILL.md @@ -15,7 +15,7 @@ Use this skill proactively when the task involves: - 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 unless the task actually needs a target URL Profile capture through Agent Bridge. +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 @@ -35,12 +35,12 @@ Do not run the helper or manual Bridge API against login-protected, account-spec Use this refusal template: ```text -I cannot automatically capture that private or logged-in page with StackPrism. Please provide one of: a public demo URL, a desensitized test-environment URL, a user-supplied redacted screenshot or recording, a design brief, or an anonymized page-structure summary. I can use StackPrism only after the target is public or explicitly desensitized. +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, and do not add `--allow-private-network` as a workaround for privacy. +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 has already removed private content; do not ask the user to create a screenshot of a private page for StackPrism capture. +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 @@ -58,12 +58,12 @@ node agent-skill/stackprism-site-experience/scripts/capture-site.mjs \ 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: +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=Default"]' \ +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 \ @@ -91,18 +91,17 @@ If the helper exits with `CAPTURE_BUSY`, wait a few seconds, stop any bridge chi 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`, keep `STACKPRISM_CAPTURE_TARGET_FLAGS='--allow-private-network'` or include the flag directly on every reduced retry. The only intended retry changes are `--include`, `--max-resource-urls`, and the final `--no-screenshot` boundary. +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 -# For public targets, leave STACKPRISM_CAPTURE_TARGET_FLAGS unset. -# For original local/private attempts, set it to: --allow-private-network. +# 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" \ - ${STACKPRISM_CAPTURE_TARGET_FLAGS:-} \ --out /tmp/stackprism-profile-retry-1.json \ --result-out /tmp/stackprism-result-retry-1.json \ --screenshot-out /tmp/stackprism-screenshot-retry-1.jpg \ @@ -110,16 +109,16 @@ node agent-skill/stackprism-site-experience/scripts/capture-site.mjs \ --max-resource-urls 150 ``` -If that still fails and the user accepts losing screenshot evidence, run one final reduced non-visual attempt, then stop and report the remaining failure: +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 and STACKPRISM_CAPTURE_TARGET_FLAGS. +# 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" \ - ${STACKPRISM_CAPTURE_TARGET_FLAGS:-} \ --out /tmp/stackprism-profile-retry-2.json \ --result-out /tmp/stackprism-result-retry-2.json \ --include tech,layout,components,ux \ @@ -170,6 +169,35 @@ Platform notes: - 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}`: @@ -231,9 +259,9 @@ Use this public report template instead of pasting raw stdout: ```json { - "browserName": "Chrome", + "browserName": "", "browserVersion": "recorded separately", - "profileIdentifier": "Default", + "profileIdentifier": "", "extensionVersion": "1.3.74", "agentBridgeEnabled": true, "targetUrl": "https://target.example/", @@ -263,6 +291,8 @@ If browser/profile identity matters for the E2E claim, record one non-sensitive ## 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. diff --git a/agent-skill/stackprism-site-experience/references/agent-consumption-guide.md b/agent-skill/stackprism-site-experience/references/agent-consumption-guide.md index 40590ae8..91c9434b 100644 --- a/agent-skill/stackprism-site-experience/references/agent-consumption-guide.md +++ b/agent-skill/stackprism-site-experience/references/agent-consumption-guide.md @@ -1,6 +1,6 @@ # Agent Consumption Guide -Use the profile as evidence for recreating a similar website, not as a copyable page dump. +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. @@ -11,6 +11,15 @@ Use the profile as evidence for recreating a similar website, not as a copyable 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. @@ -28,7 +37,7 @@ Safe report fields are sanitized error code/message/details, redacted target and 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 one of: a public demo URL, a desensitized test-environment URL, a user-supplied redacted screenshot or recording, a design brief, or an anonymized page-structure summary. I can use StackPrism only after the target is public or explicitly desensitized. +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 has already removed private content. +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/scripts/bridge/capture-store.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/capture-store.mjs index 686c030a..6a180605 100644 --- a/agent-skill/stackprism-site-experience/scripts/bridge/capture-store.mjs +++ b/agent-skill/stackprism-site-experience/scripts/bridge/capture-store.mjs @@ -56,7 +56,7 @@ export class CaptureStore { return capture } - create(request) { + async create(request) { if (this.activeCount() > 0) { return { ok: false, status: 429, code: 'CAPTURE_BUSY', message: 'Another capture is already active.' } } @@ -88,7 +88,7 @@ export class CaptureStore { capture.screenshotUrl = `${this.baseUrl}/v1/captures/${capture.id}/screenshot-download/${capture.screenshotDownloadId}` this.captures.set(capture.id, capture) this.pruneTerminalRecords() - const opened = this.openBrowser(capture.bridgeUrl) + 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 || {} } diff --git a/agent-skill/stackprism-site-experience/scripts/bridge/http-server.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/http-server.mjs index d01a7a11..782a9df2 100644 --- a/agent-skill/stackprism-site-experience/scripts/bridge/http-server.mjs +++ b/agent-skill/stackprism-site-experience/scripts/bridge/http-server.mjs @@ -109,7 +109,7 @@ export const createBridgeServer = ({ port = 0, env = process.env, resolveHostnam 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 = store.create(normalized.request) + 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, diff --git a/agent-skill/stackprism-site-experience/scripts/bridge/open-browser.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/open-browser.mjs index 16bcc645..07fba013 100644 --- a/agent-skill/stackprism-site-experience/scripts/bridge/open-browser.mjs +++ b/agent-skill/stackprism-site-experience/scripts/bridge/open-browser.mjs @@ -1,8 +1,59 @@ -import { spawnSync } from 'node:child_process' +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 @@ -53,12 +104,34 @@ export const resolveBrowserOpenCommand = (env = process.env, platform = process. return { ok: true, command, args } } -export const openBrowser = (url, env = process.env, platform = process.platform) => { +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 } } - if (String(url).includes('\0') || String(url).includes('\n') || String(url).includes('\r')) { - return { ok: false, details: { reason: 'invalid_url' } } - } + const openUrl = validateOpenUrl(url) + if (!openUrl.ok) return openUrl if (env.STACKPRISM_BRIDGE_NO_OPEN === '1') return { ok: true, skipped: true } @@ -67,12 +140,13 @@ export const openBrowser = (url, env = process.env, platform = process.platform) 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 = spawnSync(command, [...args, url], { stdio: 'ignore', shell: false, timeout: timeout.timeoutMs }) - if (child.error) return { ok: false, details: { reason: child.error.code === 'ETIMEDOUT' ? 'open_timeout' : 'spawn_failed' } } - if (child.status !== 0) return { ok: false, details: { reason: 'open_failed' } } - return { ok: true } + 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/url-policy.mjs b/agent-skill/stackprism-site-experience/scripts/bridge/url-policy.mjs index 44c3c600..aaa67cf5 100644 --- a/agent-skill/stackprism-site-experience/scripts/bridge/url-policy.mjs +++ b/agent-skill/stackprism-site-experience/scripts/bridge/url-policy.mjs @@ -58,13 +58,9 @@ const publicIpExceptionBlockList = createBlockList({ 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 (lowerHost.startsWith('::ffff:')) { - return isPrivateIpLiteral(mappedIpv4Address(lowerHost.slice('::ffff:'.length))) - } - if (lowerHost.startsWith('0:0:0:0:0:ffff:')) { - return isPrivateIpLiteral(mappedIpv4Address(lowerHost.slice('0:0:0:0:0:ffff:'.length))) - } if (net.isIP(host) === 4) return isPrivateIpv4Literal(host) if (net.isIP(host) === 6) return isPrivateIpv6Literal(lowerHost) return false @@ -72,10 +68,9 @@ export const isPrivateIpLiteral = hostname => { 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') - const lowerHost = host.toLowerCase() - if (lowerHost.startsWith('::ffff:')) return isProxyReservedIpLiteral(mappedIpv4Address(lowerHost.slice('::ffff:'.length))) - if (lowerHost.startsWith('0:0:0:0:0:ffff:')) return isProxyReservedIpLiteral(mappedIpv4Address(lowerHost.slice('0:0:0:0:0:ffff:'.length))) return false } @@ -96,12 +91,26 @@ const mappedIpv4Address = value => { 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 isBridgeLoopbackAlias = (hostname, bridgeHostname) => { +const normalizeBridgeLoopbackHost = hostname => { const host = hostname.replace(/^\[|\]$/g, '').toLowerCase() - const bridgeHost = bridgeHostname.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' 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 index 349b9ef1..055ac1f6 100644 --- 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 @@ -1,18 +1,32 @@ 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 == "": @@ -58,6 +72,23 @@ def resolve_browser_open_command(env=os.environ, system=None): 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: @@ -67,6 +98,8 @@ def open_browser(url, env=os.environ): 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} @@ -78,17 +111,21 @@ def open_browser(url, env=os.environ): return False, timeout_details command = resolved["command"] args = resolved["args"] + if not command_exists(command): + return False, {"reason": "command_not_found"} try: - completed = subprocess.run([command, *args, url], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout_seconds) + 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 subprocess.TimeoutExpired: - return False, {"reason": "open_timeout"} - except Exception as exc: - return False, {"reason": "spawn_failed", "error": str(exc)} - if completed.returncode != 0: - return False, {"reason": "open_failed"} + except Exception: + return False, {"reason": "spawn_failed"} return True, {} 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 index 17fd78c4..554338c9 100644 --- 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 @@ -59,6 +59,8 @@ def parse_ip_address(hostname): 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 diff --git a/tests/stackprism-bridge.test.mjs b/tests/stackprism-bridge.test.mjs index 32344c35..f2225044 100644 --- a/tests/stackprism-bridge.test.mjs +++ b/tests/stackprism-bridge.test.mjs @@ -52,6 +52,13 @@ const readBytes = async response => ({ headers: response.headers }) +const waitForFileSync = filePath => { + const deadline = Date.now() + 2000 + const waitBuffer = new Int32Array(new SharedArrayBuffer(4)) + while (!existsSync(filePath) && Date.now() < deadline) Atomics.wait(waitBuffer, 0, 0, 25) + assert.equal(existsSync(filePath), true, `expected file to exist: ${filePath}`) +} + const createClassList = () => { const values = new Set() return { @@ -1816,14 +1823,14 @@ test('js bridge rejects connections beyond the configured active connection limi ) }) -test('capture store can actively prune expired completed profiles', () => { +test('capture store can actively prune expired completed profiles', async () => { let now = 1000 const store = new CaptureStore({ baseUrl: 'http://127.0.0.1:17370', openBrowser: () => ({ ok: true }), now: () => now }) - const created = store.create(baseCaptureRequest) + const created = await store.create(baseCaptureRequest) assert.equal(created.ok, true) store.markProfile(created.capture, profileFor(created.capture.id)) assert.equal(created.capture.status, 'completed') @@ -1852,7 +1859,7 @@ test('capture store actively expires completed profiles without a later request' timer.cleared = true } }) - const created = store.create(baseCaptureRequest) + const created = await store.create(baseCaptureRequest) assert.equal(created.ok, true) store.markProfile(created.capture, profileFor(created.capture.id)) assert.equal(scheduled.length, 1) @@ -1866,21 +1873,21 @@ test('capture store actively expires completed profiles without a later request' assert.equal(created.capture.error.code, 'CAPTURE_RESULT_EXPIRED') }) -test('capture store distinguishes extension, target load, and running timeouts', () => { +test('capture store distinguishes extension, target load, and running timeouts', async () => { let now = 1000 const store = new CaptureStore({ baseUrl: 'http://127.0.0.1:17370', openBrowser: () => ({ ok: true }), now: () => now }) - const queued = store.create(baseCaptureRequest).capture + const queued = (await store.create(baseCaptureRequest)).capture now = queued.extensionDeadlineAt + 1 store.pruneExpiredResults() assert.equal(queued.status, 'failed') assert.equal(queued.error.code, 'EXTENSION_NOT_CONNECTED') now = 2000 - const targetOpening = store.create(baseCaptureRequest).capture + const targetOpening = (await store.create(baseCaptureRequest)).capture targetOpening.status = 'running' targetOpening.phase = 'target_opening' assert.equal(targetOpening.deadlineAt - targetOpening.createdAt, 95000) @@ -1890,7 +1897,7 @@ test('capture store distinguishes extension, target load, and running timeouts', assert.equal(targetOpening.error.code, 'TARGET_LOAD_TIMEOUT') now = 3000 - const running = store.create(baseCaptureRequest).capture + const running = (await store.create(baseCaptureRequest)).capture running.status = 'running' running.phase = 'profiling_experience' now = running.deadlineAt + 1 @@ -1962,7 +1969,7 @@ test('js bridge factory validates browser open environment before server bind', ) }) -test('js bridge open-browser helper validates parsed env before spawning', () => { +test('js bridge open-browser helper validates parsed env before spawning', async () => { assert.deepEqual(parseOpenTimeoutMs({}), { ok: true, timeoutMs: 5000 }) assert.deepEqual(parseOpenTimeoutMs({ STACKPRISM_BROWSER_OPEN_TIMEOUT_MS: '250' }), { ok: true, timeoutMs: 250 }) assert.deepEqual(parseOpenTimeoutMs({ STACKPRISM_BROWSER_OPEN_TIMEOUT_MS: '99' }), { @@ -1970,36 +1977,55 @@ test('js bridge open-browser helper validates parsed env before spawning', () => details: { reason: 'invalid_open_timeout' } }) - const result = openBrowser('http://127.0.0.1:1/bridge', { + const result = await openBrowser('http://127.0.0.1:1/bridge', { STACKPRISM_BROWSER_OPEN_COMMAND: process.execPath, STACKPRISM_BROWSER_OPEN_ARGS_JSON: JSON.stringify(['bad\0arg']) }) assert.deepEqual(result, { ok: false, details: { reason: 'BRIDGE_INVALID_ENV', message: 'Browser open environment contains NUL.' } }) - const invalidTimeout = openBrowser('http://127.0.0.1:1/bridge', { + const invalidTimeout = await openBrowser('http://127.0.0.1:1/bridge', { STACKPRISM_BROWSER_OPEN_COMMAND: process.execPath, STACKPRISM_BROWSER_OPEN_TIMEOUT_MS: '30001' }) assert.deepEqual(invalidTimeout, { ok: false, details: { reason: 'invalid_open_timeout' } }) - const invalidUrl = openBrowser('http://127.0.0.1:1/bridge\nnext', { STACKPRISM_BRIDGE_NO_OPEN: '1' }) + const invalidUrl = await openBrowser('http://127.0.0.1:1/bridge\nnext', { STACKPRISM_BRIDGE_NO_OPEN: '1' }) assert.deepEqual(invalidUrl, { ok: false, details: { reason: 'invalid_url' } }) + + const credentialUrl = await openBrowser('http://user:pass@127.0.0.1:1/bridge', { STACKPRISM_BRIDGE_NO_OPEN: '1' }) + assert.deepEqual(credentialUrl, { ok: false, details: { reason: 'invalid_url' } }) + + const invalidScheme = await openBrowser('file:///tmp/stackprism.html', { STACKPRISM_BRIDGE_NO_OPEN: '1' }) + assert.deepEqual(invalidScheme, { ok: false, details: { reason: 'invalid_scheme', allowed: ['http', 'https'] } }) + + const missingCommand = await openBrowser('http://127.0.0.1:1/bridge', { + STACKPRISM_BROWSER_OPEN_COMMAND: '/definitely/missing/stackprism-browser' + }) + assert.deepEqual(missingCommand, { ok: false, details: { reason: 'command_not_found' } }) + + const openFailed = await openBrowser('http://127.0.0.1:1/bridge', { + STACKPRISM_BROWSER_OPEN_COMMAND: process.execPath, + STACKPRISM_BROWSER_OPEN_ARGS_JSON: JSON.stringify(['--input-type=module', '-e', 'process.exit(7)']), + STACKPRISM_BROWSER_OPEN_TIMEOUT_MS: '1000' + }) + assert.deepEqual(openFailed, { ok: false, details: { reason: 'open_failed', exitCode: 7 } }) }) -test('js bridge open-browser helper appends bridge URL as one argv', () => { +test('js bridge open-browser helper appends bridge URL as one argv', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'stackprism-open-')) const argvPath = join(tempDir, 'argv.json') const bridgeUrl = 'http://127.0.0.1:17370/bridge?session=s&capture=c&nonce=n value"quote;&cmd=$(echo bad)' const script = "import { writeFileSync } from 'node:fs'; writeFileSync(process.argv[1], JSON.stringify(process.argv.slice(2)))" try { - const result = openBrowser(bridgeUrl, { + const result = await openBrowser(bridgeUrl, { STACKPRISM_BROWSER_OPEN_COMMAND: process.execPath, STACKPRISM_BROWSER_OPEN_ARGS_JSON: JSON.stringify(['--input-type=module', '-e', script, argvPath]) }) assert.deepEqual(result, { ok: true }) + waitForFileSync(argvPath) assert.deepEqual(JSON.parse(readFileSync(argvPath, 'utf8')), [bridgeUrl]) } finally { rmSync(tempDir, { recursive: true, force: true }) @@ -2028,6 +2054,34 @@ test('js bridge open-browser helper selects platform default opener without shel args: ['--profile-directory=Default'] } ) + assert.deepEqual( + resolveBrowserOpenCommand( + { + STACKPRISM_BROWSER_OPEN_COMMAND: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + STACKPRISM_BROWSER_OPEN_ARGS_JSON: JSON.stringify(['--profile-directory=Profile 2']) + }, + 'win32' + ), + { + ok: true, + command: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + args: ['--profile-directory=Profile 2'] + } + ) + assert.deepEqual( + resolveBrowserOpenCommand( + { + STACKPRISM_BROWSER_OPEN_COMMAND: 'firefox', + STACKPRISM_BROWSER_OPEN_ARGS_JSON: JSON.stringify(['-P', 'stackprism-dev']) + }, + 'linux' + ), + { + ok: true, + command: 'firefox', + args: ['-P', 'stackprism-dev'] + } + ) }) test('bridge cli rejects invalid configured port before ready output', async () => { @@ -2907,6 +2961,16 @@ test('js bridge rejects cross-origin, private target, and self-target requests', assert.equal(privateTarget.status, 400) assert.equal(privateTarget.body.error.code, 'PRIVATE_NETWORK_TARGET_BLOCKED') + const compatibleIpv6PrivateTarget = await readJson( + await fetch(`${ready.baseUrl}/v1/captures`, { + method: 'POST', + headers: { ...auth(ready.apiToken), 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...baseCaptureRequest, url: 'http://[::7f00:1]:3000/' }) + }) + ) + assert.equal(compatibleIpv6PrivateTarget.status, 400) + assert.equal(compatibleIpv6PrivateTarget.body.error.code, 'PRIVATE_NETWORK_TARGET_BLOCKED') + const selfTarget = await readJson( await fetch(`${ready.baseUrl}/v1/captures`, { method: 'POST', @@ -2935,6 +2999,22 @@ test('js bridge rejects cross-origin, private target, and self-target requests', assert.equal(localhostSelfTarget.status, 400) assert.equal(localhostSelfTarget.body.error.code, 'BRIDGE_SELF_TARGET_BLOCKED') + for (const alias of ['2130706433', '127.1', '0x7f000001', '[::ffff:127.0.0.1]', '[::7f00:1]']) { + const aliasSelfTarget = await readJson( + await fetch(`${ready.baseUrl}/v1/captures`, { + method: 'POST', + headers: { ...auth(ready.apiToken), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...baseCaptureRequest, + url: ready.baseUrl.replace('127.0.0.1', alias), + options: { ...baseCaptureRequest.options, allowPrivateNetworkTarget: true } + }) + }) + ) + assert.equal(aliasSelfTarget.status, 400, alias) + assert.equal(aliasSelfTarget.body.error.code, 'BRIDGE_SELF_TARGET_BLOCKED', alias) + } + const crossOrigin = await readJson( await fetch(`${ready.baseUrl}/v1/captures`, { method: 'POST', diff --git a/tests/stackprism-skill.test.mjs b/tests/stackprism-skill.test.mjs index d0bc249a..4014878f 100644 --- a/tests/stackprism-skill.test.mjs +++ b/tests/stackprism-skill.test.mjs @@ -56,6 +56,7 @@ test('stackprism site experience skill advertises the current bridge workflow', assert.match(metadata.description, /Do not use for generic UI edits/) assert.match(metadata.description, /backend-only work/) assert.match(metadata.description, /StackPrism internal code review/) + assert.match(skillSource, /StackPrism internal source review, refactor, or maintenance/) assert.ok( bashBlocks.some(block => block.includes('scripts/capture-site.mjs')), 'skill must show preferred capture helper' @@ -89,15 +90,29 @@ test('stackprism site experience skill advertises the current bridge workflow', assert.match(skillSource, /Firefox profiles/) assert.match(skillSource, /"--profile","\/absolute\/path\/to\/profile"/) assert.match(skillSource, /correct Chrome, Edge, or Firefox profile/) + assert.match(skillSource, /Cross-platform explicit profile examples/) + assert.match(skillSource, /Set environment variables with the syntax of the current shell/) + assert.match(skillSource, /examples below describe the values/) + assert.match(skillSource, /Windows Chrome/) + assert.match(skillSource, /C:\\Program Files\\Google\\Chrome\\Application\\chrome\.exe/) + assert.match(skillSource, /Windows Edge/) + assert.match(skillSource, /Linux Chrome\/Chromium/) + assert.match(skillSource, /STACKPRISM_BROWSER_OPEN_COMMAND=firefox/) + assert.match(skillSource, /\["-P",""\]/) assert.match(skillSource, /## Private Page Boundary/) assert.match(skillSource, /even when the user says they own the account/) assert.match(skillSource, /I cannot automatically capture that private or logged-in page with StackPrism/) assert.match(skillSource, /public demo URL, a desensitized test-environment URL/) assert.match(skillSource, /current browser page/) assert.match(skillSource, /do not use `active_tab`/) - assert.match(skillSource, /Accept a user-provided redacted screenshot or recording only if the user has already removed private content/) + assert.match(skillSource, /If you already have a redacted screenshot or recording with private content removed/) + assert.match(skillSource, /do not create a new screenshot of the private page for this request/) + assert.match(skillSource, /do not ask the user to create screenshots for private pages/) + assert.match(skillSource, /Accept a user-provided redacted screenshot or recording only if the user already has one/) assert.match(skillSource, /http:\/\/127\.0\.0\.1:5173\//) assert.match(skillSource, /\/Applications\/Google Chrome\.app\/Contents\/MacOS\/Google Chrome/) + assert.match(skillSource, //) + assert.match(skillSource, /do not use `Default` unless the user confirms StackPrism is installed and enabled there/) assert.match(skillSource, /--include tech,visual,layout,components,ux/) assert.match(skillSource, /--max-resource-urls 150/) assert.match(skillSource, /--include tech,layout,components,ux/) @@ -106,9 +121,11 @@ test('stackprism site experience skill advertises the current bridge workflow', assert.match(skillSource, /Retry attempts must preserve the original capture context exactly/) assert.match(skillSource, /same `STACKPRISM_BROWSER_OPEN_COMMAND`/) assert.match(skillSource, /same target policy flags such as `--allow-private-network`/) - assert.match(skillSource, /STACKPRISM_CAPTURE_TARGET_FLAGS='--allow-private-network'/) - assert.match(skillSource, /\$\{STACKPRISM_CAPTURE_TARGET_FLAGS:-\}/) + assert.match(skillSource, /add that flag directly after `--url "\$TARGET_URL"`/) + assert.match(skillSource, /For an original local\/private attempt, add/) + assert.doesNotMatch(skillSource, /STACKPRISM_CAPTURE_TARGET_FLAGS/) assert.match(skillSource, /Before the final non-visual attempt/) + assert.match(skillSource, /Do not run the final `--no-screenshot` retry until the user confirms/) assert.match(skillSource, /the result can only support structural, technology, component, and limited UX findings/) assert.match(skillSource, /The current experience profile is passive/) assert.match(skillSource, /does not click, type, submit forms, or exercise workflows/) @@ -127,8 +144,20 @@ test('stackprism site experience skill advertises the current bridge workflow', assert.match(skillSource, /stat -f '%N %z bytes'/) assert.match(skillSource, /Use this public report template instead of pasting raw stdout/) assert.match(skillSource, /"captureId": "\[redacted\]"/) + assert.match(skillSource, /"browserName": ""/) + assert.match(skillSource, /"profileIdentifier": ""/) assert.match(skillSource, /extension details page/) assert.match(skillSource, /Do not include extension internal UUIDs/) + assert.match(skillSource, /## Firefox E2E Validation/) + assert.match(skillSource, /exact safe public `http:` or `https:` smoke URL/) + assert.match(skillSource, /do not choose an arbitrary public page/) + assert.match(skillSource, /\/Applications\/Firefox\.app\/Contents\/MacOS\/firefox/) + assert.match(skillSource, //) + assert.match(skillSource, /browserName` to `Firefox/) + assert.match(skillSource, /exact `-P` profile name or `--profile` path label/) + assert.match(skillSource, /When the user asks for a recreation brief/) + assert.match(skillSource, /Record the captured viewport names and dimensions/) + assert.match(skillSource, /Do not infer mobile or responsive breakpoint behavior/) }) test('stackprism site experience skill points agents to repo-local references', () => { @@ -159,6 +188,15 @@ test('stackprism site experience skill points agents to repo-local references', assert.match(consumptionGuideSource, /responsive breakpoint, screenshot or DOM geometry evidence, and explicit limitations/) assert.match(consumptionGuideSource, /StackPrism experience capture is passive/) assert.match(consumptionGuideSource, /does not click, type, submit forms, or exercise workflows/) + assert.match(consumptionGuideSource, /For recreation briefs, structure the brief from `agentGuidance\.recreationPlan`/) + assert.match(consumptionGuideSource, /Recreation brief structure/) + assert.match(consumptionGuideSource, /Evidence and limitations/) + assert.match(consumptionGuideSource, /captured viewport names and dimensions/) + assert.match(consumptionGuideSource, /Technical direction/) + assert.match(consumptionGuideSource, /Visual direction/) + assert.match(consumptionGuideSource, /Layout and components/) + assert.match(consumptionGuideSource, /Interaction and UX/) + assert.match(consumptionGuideSource, /Assets and verification/) assert.match(consumptionGuideSource, /Safe report fields/) assert.match(consumptionGuideSource, /unredacted `captureId`/) assert.match(consumptionGuideSource, /Screenshots are not pixel-redacted/) @@ -166,10 +204,12 @@ test('stackprism site experience skill points agents to repo-local references', assert.match(consumptionGuideSource, /billing, admin, inbox, dashboard, internal/) assert.match(consumptionGuideSource, /I cannot automatically capture that private or logged-in page with StackPrism/) assert.match(consumptionGuideSource, /public demo URL, a desensitized test-environment URL/) - assert.match(consumptionGuideSource, /user-supplied redacted screenshot or recording/) + assert.match(consumptionGuideSource, /If you already have a redacted screenshot or recording with private content removed/) + assert.match(consumptionGuideSource, /do not create a new screenshot of the private page for this request/) assert.match(consumptionGuideSource, /Localhost or intranet targets are acceptable only when they are public, demo, or explicitly desensitized development pages/) assert.match(consumptionGuideSource, /current browser page/) - assert.match(consumptionGuideSource, /Accept redacted screenshots or recordings only when the user has already removed private content/) + assert.match(consumptionGuideSource, /Accept redacted screenshots or recordings only when the user already has them/) + assert.match(consumptionGuideSource, /do not ask the user to create new screenshots of private pages/) assert.match(profileSchemaSource, /`visualReference` for optional screenshot handling/) assert.match(profileSchemaSource, /`verificationChecklist` for destination-app acceptance checks/) }) @@ -207,15 +247,23 @@ test('repo-local skill documents bridge asset parity and local installation boun assert.match(readmeSource, /--allow-private-network/) assert.match(readmeSource, /Localhost support is only for public, demo, or explicitly desensitized development pages/) assert.match(readmeSource, /http:\/\/127\.0\.0\.1:5173\//) - assert.match(readmeSource, /Do not capture login-protected, account-specific/) - assert.match(readmeSource, /already-redacted screenshot or recording/) - assert.match(readmeSource, /current browser page without a URL/) - assert.match(readmeSource, /Retry commands must preserve the original capture context/) - assert.match(readmeSource, /browser\/profile opener env such as `STACKPRISM_BROWSER_OPEN_COMMAND`/) - assert.match(readmeSource, /STACKPRISM_CAPTURE_TARGET_FLAGS='--allow-private-network'/) + assert.match(readmeSource, //) + assert.match(readmeSource, /use `Default` only if StackPrism is really enabled there/) + assert.match(readmeSource, /Do not capture private or logged-in pages/) + assert.match(readmeSource, /already-redacted screenshot\/recording/) + assert.match(readmeSource, /Do not ask users to create new screenshots of private pages/) + assert.match(readmeSource, /current browser page/) + assert.match(readmeSource, /retry with less data, not partial results/) + assert.match(readmeSource, /Keep the same URL, browser\/profile env, and private-network flag on every retry/) assert.match(readmeSource, /--max-resource-urls 150/) assert.match(readmeSource, /--max-resource-urls 50 --no-screenshot/) - assert.match(readmeSource, /cannot support visual parity/) + assert.match(readmeSource, /only if the user accepts losing screenshot evidence/) + assert.match(readmeSource, /For Firefox E2E, use an exact safe public URL/) + assert.match(readmeSource, /if the URL is missing, ask for one/) + assert.match(readmeSource, /do not choose an arbitrary public page or `example\.com`/) + assert.match(readmeSource, /explicit Firefox profile/) + assert.match(readmeSource, //) + assert.match(readmeSource, /browserName: "Firefox"/) assert.match(readmeSource, /evidence manifest outside the repository/) assert.match(readmeSource, /Never paste or commit `apiToken`/) assert.match(readmeSource, /shasum -a 256/) diff --git a/tests/stackprism_bridge_py.test.mjs b/tests/stackprism_bridge_py.test.mjs index 8ccf7286..7effabf0 100644 --- a/tests/stackprism_bridge_py.test.mjs +++ b/tests/stackprism_bridge_py.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { spawn, spawnSync } from 'node:child_process' import { once } from 'node:events' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import net from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -26,6 +26,13 @@ const readBytes = async response => ({ headers: response.headers }) +const waitForFileSync = filePath => { + const deadline = Date.now() + 2000 + const waitBuffer = new Int32Array(new SharedArrayBuffer(4)) + while (!existsSync(filePath) && Date.now() < deadline) Atomics.wait(waitBuffer, 0, 0, 25) + assert.equal(existsSync(filePath), true, `expected file to exist: ${filePath}`) +} + const sensitiveFailedError = (ready, created, config) => { const sensitiveUrl = `${created.body.bridgeUrl}&token=secret&apiToken=${ready.apiToken}&bridgeToken=${config.bridgeToken}#frag` return { @@ -666,12 +673,11 @@ print(json.dumps(results, sort_keys=True)) test('python fallback open-browser helper validates env and URL before spawning', () => { const parsed = pythonOneShot(` -import subprocess import stackprism_bridge_lib.open_browser as open_browser_module -from stackprism_bridge_lib.open_browser import open_browser +from stackprism_bridge_lib.open_browser import open_browser, windows_command_candidates -def timeout_run(*args, **kwargs): - raise subprocess.TimeoutExpired(args[0], kwargs.get("timeout")) +def exploding_popen(*_args, **_kwargs): + raise RuntimeError("/Users/example/secret-browser failed") checks = { "nul_env": open_browser("http://127.0.0.1:1/bridge", {"STACKPRISM_BROWSER_OPEN_COMMAND": "bad\\0cmd"}), @@ -680,21 +686,30 @@ checks = { {"STACKPRISM_BROWSER_OPEN_COMMAND": "python3", "STACKPRISM_BROWSER_OPEN_ARGS_JSON": json.dumps(["bad\\0arg"])}, ), "invalid_url": open_browser("http://127.0.0.1:1/bridge\\nnext", {"STACKPRISM_BRIDGE_NO_OPEN": "1"}), + "credential_url": open_browser("http://user:pass@127.0.0.1:1/bridge", {"STACKPRISM_BRIDGE_NO_OPEN": "1"}), "invalid_scheme": open_browser("file:///tmp/stackprism.html", {"STACKPRISM_BRIDGE_NO_OPEN": "1"}), "missing_command": open_browser("http://127.0.0.1:1/bridge", {"STACKPRISM_BROWSER_OPEN_COMMAND": "/definitely/missing/stackprism-browser"}), "invalid_timeout": open_browser("http://127.0.0.1:1/bridge", {"STACKPRISM_BROWSER_OPEN_COMMAND": "python3", "STACKPRISM_BROWSER_OPEN_TIMEOUT_MS": "30001"}), + "open_failed": open_browser( + "http://127.0.0.1:1/bridge", + {"STACKPRISM_BROWSER_OPEN_COMMAND": "python3", "STACKPRISM_BROWSER_OPEN_ARGS_JSON": json.dumps(["-c", "import sys; sys.exit(7)"]), "STACKPRISM_BROWSER_OPEN_TIMEOUT_MS": "1000"}, + ), + "windows_candidates": windows_command_candidates(r"C:\\Program Files\\Browser\\browser", {"PATHEXT": ".EXE;.CMD"}), } -open_browser_module.subprocess.run = timeout_run -checks["open_timeout"] = open_browser("http://127.0.0.1:1/bridge", {"STACKPRISM_BROWSER_OPEN_COMMAND": "python3"}) +open_browser_module.subprocess.Popen = exploding_popen +checks["spawn_failed"] = open_browser("http://127.0.0.1:1/bridge", {"STACKPRISM_BROWSER_OPEN_COMMAND": "python3"}) print(json.dumps({name: result for name, result in checks.items()}, sort_keys=True)) `) assert.deepEqual(parsed.nul_env, [false, { reason: 'BRIDGE_INVALID_ENV', message: 'Browser open environment contains NUL.' }]) assert.deepEqual(parsed.nul_json_args, [false, { reason: 'BRIDGE_INVALID_ENV', message: 'Browser open environment contains NUL.' }]) assert.deepEqual(parsed.invalid_url, [false, { reason: 'invalid_url' }]) + assert.deepEqual(parsed.credential_url, [false, { reason: 'invalid_url' }]) assert.deepEqual(parsed.invalid_scheme, [false, { reason: 'invalid_scheme', allowed: ['http', 'https'] }]) assert.deepEqual(parsed.missing_command, [false, { reason: 'command_not_found' }]) assert.deepEqual(parsed.invalid_timeout, [false, { reason: 'invalid_open_timeout' }]) - assert.deepEqual(parsed.open_timeout, [false, { reason: 'open_timeout' }]) + assert.deepEqual(parsed.windows_candidates, ['C:\\Program Files\\Browser\\browser.EXE', 'C:\\Program Files\\Browser\\browser.CMD']) + assert.deepEqual(parsed.open_failed, [false, { reason: 'open_failed', exitCode: 7 }]) + assert.deepEqual(parsed.spawn_failed, [false, { reason: 'spawn_failed' }]) }) test('python fallback open-browser helper appends bridge URL as one argv', () => { @@ -713,6 +728,7 @@ print(json.dumps(open_browser(${JSON.stringify(bridgeUrl)}, { `) assert.deepEqual(parsed, [true, {}]) + waitForFileSync(argvPath) assert.deepEqual(JSON.parse(readFileSync(argvPath, 'utf8')), [bridgeUrl]) } finally { rmSync(tempDir, { recursive: true, force: true }) @@ -731,6 +747,14 @@ checks = { "STACKPRISM_BROWSER_OPEN_COMMAND": "/usr/bin/google-chrome", "STACKPRISM_BROWSER_OPEN_ARGS_JSON": json.dumps(["--profile-directory=Default"]), }, "Linux"), + "windows_custom": resolve_browser_open_command({ + "STACKPRISM_BROWSER_OPEN_COMMAND": r"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "STACKPRISM_BROWSER_OPEN_ARGS_JSON": json.dumps(["--profile-directory=Profile 2"]), + }, "Windows"), + "linux_firefox": resolve_browser_open_command({ + "STACKPRISM_BROWSER_OPEN_COMMAND": "firefox", + "STACKPRISM_BROWSER_OPEN_ARGS_JSON": json.dumps(["-P", "stackprism-dev"]), + }, "Linux"), } print(json.dumps(checks, sort_keys=True)) `) @@ -739,6 +763,11 @@ print(json.dumps(checks, sort_keys=True)) assert.deepEqual(parsed.windows, [true, { command: 'rundll32.exe', args: ['url.dll,FileProtocolHandler'] }]) assert.deepEqual(parsed.linux, [true, { command: 'xdg-open', args: [] }]) assert.deepEqual(parsed.custom, [true, { command: '/usr/bin/google-chrome', args: ['--profile-directory=Default'] }]) + assert.deepEqual(parsed.windows_custom, [ + true, + { command: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', args: ['--profile-directory=Profile 2'] } + ]) + assert.deepEqual(parsed.linux_firefox, [true, { command: 'firefox', args: ['-P', 'stackprism-dev'] }]) }) test('python fallback bridge page has CSP nonce and script-safe config', async () => { @@ -3431,6 +3460,22 @@ empty_lookup_request = { "viewports": [], "options": {"targetMode": "reuse_or_new_tab"}, } +compatible_ipv6_private_request = { + "url": "http://[::7f00:1]:3000/", + "mode": "experience", + "waitMs": 0, + "include": ["tech"], + "viewports": [], + "options": {"targetMode": "reuse_or_new_tab"}, +} +compatible_ipv6_self_request = { + "url": "http://[::7f00:1]:17370/bridge", + "mode": "experience", + "waitMs": 0, + "include": ["tech"], + "viewports": [], + "options": {"targetMode": "reuse_or_new_tab", "allowPrivateNetworkTarget": True}, +} def resolver(hostname): if hostname == "public.example": @@ -3473,6 +3518,8 @@ proxy_reserved_ip_literal_normalized, proxy_reserved_ip_literal_code, proxy_rese special_use_normalized, special_use_code, special_use_details, special_use_message = normalize_capture_request(special_use_request, "http://127.0.0.1:17370", resolver) failed_lookup_normalized, failed_lookup_code, failed_lookup_details, failed_lookup_message = normalize_capture_request(failed_lookup_request, "http://127.0.0.1:17370", failed_resolver) empty_lookup_normalized, empty_lookup_code, empty_lookup_details, empty_lookup_message = normalize_capture_request(empty_lookup_request, "http://127.0.0.1:17370", empty_resolver) +compatible_ipv6_private_normalized, compatible_ipv6_private_code, compatible_ipv6_private_details, compatible_ipv6_private_message = normalize_capture_request(compatible_ipv6_private_request, "http://127.0.0.1:17370", resolver) +compatible_ipv6_self_normalized, compatible_ipv6_self_code, compatible_ipv6_self_details, compatible_ipv6_self_message = normalize_capture_request(compatible_ipv6_self_request, "http://127.0.0.1:17370", resolver) special_use_results = [ normalize_with_single_address(special_use_request, address) for address in ${JSON.stringify(urlPolicyCases.specialUseHostname.resolvedAddresses)} @@ -3515,6 +3562,14 @@ print(json.dumps({ "emptyLookupCode": empty_lookup_code, "emptyLookupDetails": empty_lookup_details, "emptyLookupMessage": empty_lookup_message, + "compatibleIpv6PrivateNormalized": compatible_ipv6_private_normalized, + "compatibleIpv6PrivateCode": compatible_ipv6_private_code, + "compatibleIpv6PrivateDetails": compatible_ipv6_private_details, + "compatibleIpv6PrivateMessage": compatible_ipv6_private_message, + "compatibleIpv6SelfNormalized": compatible_ipv6_self_normalized, + "compatibleIpv6SelfCode": compatible_ipv6_self_code, + "compatibleIpv6SelfDetails": compatible_ipv6_self_details, + "compatibleIpv6SelfMessage": compatible_ipv6_self_message, "specialUseResults": special_use_results, "publicExceptionResults": public_exception_results, }, sort_keys=True)) @@ -3552,6 +3607,11 @@ print(json.dumps({ assert.equal(parsed.emptyLookupNormalized, null) assert.equal(parsed.emptyLookupCode, 'TARGET_DNS_LOOKUP_FAILED') assert.equal(parsed.emptyLookupDetails.reason, 'dns_lookup_failed') + assert.equal(parsed.compatibleIpv6PrivateNormalized, null) + assert.equal(parsed.compatibleIpv6PrivateCode, 'PRIVATE_NETWORK_TARGET_BLOCKED') + assert.equal(parsed.compatibleIpv6PrivateDetails.reason, 'private_network_address') + assert.equal(parsed.compatibleIpv6SelfNormalized, null) + assert.equal(parsed.compatibleIpv6SelfCode, 'BRIDGE_SELF_TARGET_BLOCKED') for (const result of parsed.specialUseResults) { assert.equal(result.normalizedUrl, null, result.address) assert.equal(result.code, urlPolicyCases.specialUseHostname.errorCode, result.address)