diff --git a/.gitignore b/.gitignore index e458ed5..8144a4f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .worktrees/ +target/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c1a322a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# Repository Guidelines + +## Project Structure & Module Organization +`docs/` is the main source of product, architecture, integration, and team-process documentation. Keep active engineering documents in `docs/*.md`; presentation exports belong under `docs/archive/领导演示资料/`. `frontend/sgClaw验证/` contains the only active runnable UI: a Vue 2 verification page (`index.html`, `index.vue`) plus helper scripts (`serve.sh`, `download-libs.sh`, `testRunner.js`). `frontend/README.md` and `docs/README.md` describe what is active versus archived. + +## Build, Test, and Development Commands +There is no formal build system in the repository today. Use the local verification page directly: + +- `bash frontend/sgClaw验证/serve.sh` + Starts a local HTTP server on port `8080` by default. +- `bash frontend/sgClaw验证/serve.sh 9090` + Serves the verification page on a custom port. +- `bash frontend/sgClaw验证/download-libs.sh` + Downloads Vue 2.6.14 and Element UI assets into `frontend/sgClaw验证/lib/` for offline use. + +Open `http://localhost:8080/index.html` after starting the server. + +## Coding Style & Naming Conventions +Match the existing style in each file. Frontend code uses 2-space indentation, semicolon-free JavaScript, and simple Vue 2 patterns. Shell scripts should stay Bash-compatible, include `set -e`, and keep usage notes at the top. Preserve existing Chinese file names and domain terminology; add new docs with concise, descriptive names such as `L5-xxx.md` or `xxx_printable.md` when extending the documentation set. + +## Testing Guidelines +Testing is currently manual and centered on `frontend/sgClaw验证/testRunner.js`. Validate changes by serving the page, running the relevant verification flows, and recording whether the change affects external API checks, internal browser integration checks, or end-to-end scenarios. If a change touches archived presentation assets, verify links and exported files still open correctly. + +## Commit & Pull Request Guidelines +Git history currently contains only `first commit`, so no strong convention is established yet. Use short imperative commit subjects, for example `docs: update browser integration notes` or `frontend: adjust verification report layout`. PRs should include a clear summary, affected paths, manual validation steps, and screenshots when `frontend/sgClaw验证/` UI output changes. Link related docs or issues when the change updates architecture or process guidance. + +## Security & Configuration Tips +Do not commit real API keys. The verification page expects runtime globals such as `window.__SGCLAW_TEST_OPENAI_KEY__` and `window.__SGCLAW_TEST_CLAUDE_KEY__`; keep them in local test-only setup, not tracked files. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6f7db36 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,577 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sgclaw" +version = "0.1.0" +dependencies = [ + "hex", + "hmac", + "serde", + "serde_json", + "sha2", + "thiserror", + "uuid", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..445c5cd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "sgclaw" +version = "0.1.0" +edition = "2021" + +[dependencies] +hex = "0.4" +hmac = "0.12" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "1" +uuid = { version = "1", features = ["v4"] } diff --git a/README.md b/README.md index 0f2e341..6b3b376 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,19 @@ # sgClaw sgClaw 项目仓库。 + +## 当前工程形态 + +- `src/`:Rust 侧最小可联调实现,包含 pipe 协议、握手、`BrowserPipeTool`、MAC Policy。 +- `tests/`:协议、握手、工具与 JSON Line 联调测试。 +- `resources/rules.json`:本地安全策略白名单。 +- `docs/`:项目架构、联调协议与团队启动文档。 +- `frontend/sgClaw验证/`:本地验证页面与辅助脚本。 + +## 常用命令 + +```bash +cargo test +cargo run +bash frontend/sgClaw验证/serve.sh +``` diff --git a/docs/browser_team_kickoff.md b/docs/browser_team_kickoff.md new file mode 100644 index 0000000..0a49900 --- /dev/null +++ b/docs/browser_team_kickoff.md @@ -0,0 +1,396 @@ +# sgClaw 浏览器团队开发启动文档 + +**适用对象**:Chromium / C++ 浏览器开发团队(P2) +**目标**:浏览器团队拿到本文档后即可独立启动开发,并在一个周期后与 sgClaw 项目团队完成 Pipe 联调。 +**协议版本**:`1.0` +**冻结日期**:`2026-03-24` + +--- + +## 1. 开发目标 + +浏览器团队本周期只负责浏览器侧 Pipe 接入,不负责 LLM、Skill、Memory、Agent 推理。 + +本周期结束时,浏览器侧必须具备以下能力: + +1. 能从浏览器主进程启动 `sgclaw` Rust 子进程。 +2. 能通过 `stdin/stdout` 与 `sgclaw` 进行双向 `JSON Line` 通信。 +3. 能解析 sgClaw 发来的 `command` 消息,并路由到现有 `CommandRouter`。 +4. 能执行最小可联调动作:`click`、`type`、`navigate`、`getText`。 +5. 能返回结构化 `response` 消息。 +6. 能在浏览器侧执行域名和 action 白名单校验。 + +本周期不做: + +1. 不改现有 `CommandRouter` 的核心接口。 +2. 不新造一套浏览器操作 API。 +3. 不改为 HTTP、WebSocket、Named Pipe。 +4. 不实现 Rust 侧逻辑。 + +--- + +## 2. 架构边界 + +浏览器侧是父进程,`sgclaw` 是子进程。浏览器侧新增三个模块: + +1. `SgClawProcessHost` + 负责子进程启动、停止、状态管理、异常退出处理。 +2. `PipeListener` + 负责异步读取 `sgclaw stdout`,按行解析 JSON 并分发。 +3. `MacWhitelistCheck` + 负责浏览器侧二次安全校验,防止越权 action 落到 `CommandRouter`。 + +浏览器侧数据流固定如下: + +`Side Panel / UI -> SgClawProcessHost -> STDIO Pipe -> sgclaw` + +`sgclaw -> STDIO Pipe -> PipeListener -> MacWhitelistCheck -> CommandRouter -> response` + +--- + +## 3. 浏览器团队负责的交付物 + +本周期交付以下文件或等价模块: + +1. `sgclaw_process_host.h` +2. `sgclaw_process_host.cc` +3. `pipe_listener.h` +4. `pipe_listener.cc` +5. `mac_whitelist_check.h` +6. `mac_whitelist_check.cc` +7. `rules.json` +8. `sgclaw_unittests` 中对应单元测试 + +建议目录: + +```text +chrome/browser/superrpa/sgclaw/ + sgclaw_process_host.h + sgclaw_process_host.cc + pipe_listener.h + pipe_listener.cc + mac_whitelist_check.h + mac_whitelist_check.cc + test/ + sgclaw_process_host_unittest.cc + pipe_listener_unittest.cc + mac_whitelist_check_unittest.cc + resources/ + rules.json +``` + +--- + +## 4. 冻结接口 + +### 4.1 传输协议 + +1. 传输层固定为 `STDIO Pipe`。 +2. 编码固定为 `UTF-8`。 +3. 消息边界固定为 `JSON Line`,每行一条完整 JSON。 +4. 单条消息最大 `1 MB`。 +5. `stdout` 只允许输出协议消息,日志必须走 `stderr`。 + +### 4.2 握手协议 + +浏览器发送: + +```json +{"type":"init","version":"1.0","hmac_seed":"0123456789abcdef","capabilities":["browser_action"]} +``` + +sgClaw 返回: + +```json +{"type":"init_ack","version":"1.0","agent_id":"uuid-v4","supported_actions":["click","type","navigate","getText","getHtml","waitForSelector","pageScreenshot","select","scrollTo","getAomSnapshot","storageSet","storageGet","zombieSpawn","zombieKill"]} +``` + +约束: + +1. 浏览器必须在子进程启动后 `5s` 内发送 `init`。 +2. `5s` 内收不到 `init_ack`,判定启动失败。 +3. `version` 不一致,必须立即终止会话。 + +### 4.3 command 消息格式 + +```json +{ + "type":"command", + "seq":12, + "action":"click", + "params":{"selector":"#submit","wait_after":300}, + "security":{ + "expected_domain":"oa.example.com", + "hmac":"" + } +} +``` + +字段要求: + +1. `seq` 为正整数,必须唯一。 +2. `action` 必须在白名单内。 +3. `params` 必须是对象。 +4. `security.expected_domain` 和 `security.hmac` 必须存在。 + +### 4.4 response 消息格式 + +成功: + +```json +{ + "type":"response", + "seq":12, + "success":true, + "data":{"text":"提交成功"}, + "aom_snapshot":[], + "timing":{"queue_ms":2,"exec_ms":38} +} +``` + +失败: + +```json +{ + "type":"response", + "seq":12, + "success":false, + "data":{ + "error":{ + "code":"CMD_SELECTOR_NOT_FOUND", + "message":"selector '#submit' not found" + } + }, + "aom_snapshot":[], + "timing":{"queue_ms":1,"exec_ms":10} +} +``` + +约束: + +1. 一个 `command.seq` 只能对应一个 `response.seq`。 +2. 失败必须返回结构化错误,不允许只返回字符串。 +3. `timing` 必须始终带上。 + +--- + +## 5. 本周期最小 Action 集 + +联调周期只强制四个动作: + +1. `click` +2. `type` +3. `navigate` +4. `getText` + +动作语义: + +1. `click` + 调用现有点击能力,支持可选 `wait_after`。 +2. `type` + 在目标输入框输入文本,支持 `clear_first`。 +3. `navigate` + 导航到目标 URL。 +4. `getText` + 获取目标节点文本。 + +其余 action 可保留接口但不进入本周期强制验收。 + +--- + +## 6. 浏览器侧实现要求 + +### 6.1 SgClawProcessHost + +必须实现: + +1. 单例,避免重复创建多个 `sgclaw` 子进程。 +2. `Start()` 创建匿名管道并启动子进程。 +3. `Stop()` 正常关闭并在超时后强制结束。 +4. `OnProcessCrash()` 记录错误并更新状态。 +5. 状态机至少包含 `Idle -> Starting -> Running -> Stopped / Crashed`。 + +建议接口: + +```cpp +class SgClawProcessHost { + public: + bool Start(); + void Stop(); + bool IsRunning() const; + bool SendLine(std::string json_line); +}; +``` + +### 6.2 PipeListener + +必须实现: + +1. 持续读取 `stdout`。 +2. 以换行符切分 `JSON Line`。 +3. 拒绝空行、非 JSON、超过 1MB 的消息。 +4. 能按 `seq` 追踪一次请求的完整生命周期。 +5. 管道断开时通知 `SgClawProcessHost`。 + +### 6.3 CommandRouter 对接 + +必须实现: + +1. `command.action` 到现有浏览器命令的映射表。 +2. 尽量复用现有 `CommandRouter`。 +3. 不允许在 Pipe 层直接写新的页面控制逻辑。 +4. response 必须从实际执行结果构造,不允许伪造成功。 + +建议映射: + +1. `click -> CommandRouter.click` +2. `type -> CommandRouter.type` +3. `navigate -> CommandRouter.navigate` +4. `getText -> CommandRouter.getText` + +### 6.4 MacWhitelistCheck + +必须实现: + +1. action 白名单校验。 +2. expected_domain 与当前页面域名比对。 +3. `rules.json` 加载失败时默认拒绝。 +4. 拒绝时返回统一错误码。 + +建议错误码: + +1. `MAC_ACTION_NOT_ALLOWED` +2. `MAC_DOMAIN_NOT_ALLOWED` +3. `MAC_RULES_LOAD_FAILED` +4. `PIPE_INVALID_JSON` +5. `PIPE_MESSAGE_TOO_LARGE` + +--- + +## 7. 浏览器团队开发顺序 + +### Day 1-2 + +1. 完成 `SgClawProcessHost` 骨架。 +2. 用 dummy 子进程验证启动和退出。 +3. 打通 `stdin/stdout` 读写通道。 + +验收: + +1. 能启动 `echo` 或测试进程。 +2. 能发送一行字符串并收到回写。 + +### Day 3-4 + +1. 完成 `PipeListener`。 +2. 完成 `init -> init_ack` 握手。 +3. 建立 `command` / `response` 解析结构。 + +验收: + +1. 能与 Rust 侧互发 JSON Line。 +2. 能处理 `seq` 对应关系。 + +### Day 5-6 + +1. 接入 `CommandRouter`。 +2. 完成 4 个最小 action。 +3. 完成 `MacWhitelistCheck`。 + +验收: + +1. Rust 发起 `click/type/navigate/getText` 时浏览器真实执行。 +2. 非白名单域名被拒绝。 + +### Day 7 + +1. 完成浏览器侧单元测试。 +2. 提供联调分支和运行说明。 +3. 预留半天与项目团队联调。 + +--- + +## 8. 浏览器团队自测清单 + +- [ ] `Start()` 成功启动真实 `sgclaw` 二进制。 +- [ ] `Start()` 重复调用不会启动多个实例。 +- [ ] `Stop()` 能正常关闭进程。 +- [ ] `init -> init_ack` 成功。 +- [ ] 超过 1MB 的 JSON 消息会被拒绝。 +- [ ] 非 JSON 行会被拒绝。 +- [ ] `click/type/navigate/getText` 能成功返回。 +- [ ] 域名不匹配时返回 `MAC_DOMAIN_NOT_ALLOWED`。 +- [ ] `rules.json` 缺失时默认拒绝。 +- [ ] 日志中能按 `seq` 查到请求和响应。 + +--- + +## 9. 联调输入输出样例 + +### 9.1 手动握手 + +浏览器发: + +```json +{"type":"init","version":"1.0","hmac_seed":"00112233445566778899aabbccddeeff","capabilities":["browser_action"]} +``` + +期待 Rust 回: + +```json +{"type":"init_ack","version":"1.0","agent_id":"00000000-0000-0000-0000-000000000000","supported_actions":["click","type","navigate","getText","getHtml","waitForSelector","pageScreenshot","select","scrollTo","getAomSnapshot","storageSet","storageGet","zombieSpawn","zombieKill"]} +``` + +### 9.2 最小 click 联调 + +Rust 发: + +```json +{"type":"command","seq":1,"action":"click","params":{"selector":"#login-btn"},"security":{"expected_domain":"oa.example.com","hmac":""}} +``` + +浏览器回: + +```json +{"type":"response","seq":1,"success":true,"data":{},"aom_snapshot":[],"timing":{"queue_ms":1,"exec_ms":35}} +``` + +--- + +## 10. 联调日必须提供的东西 + +浏览器团队在联调前必须准备: + +1. 可运行的浏览器分支。 +2. `sgclaw` 子进程启动入口。 +3. `rules.json` 默认测试配置。 +4. 最小测试页面,至少包含一个输入框、一个按钮、一个文本节点。 +5. 一份 action 到 `CommandRouter` 的映射表。 +6. 一份错误码表。 + +--- + +## 11. 周期结束验收标准 + +以下全部满足,浏览器团队本周期完成: + +1. 能在浏览器中稳定启动和停止 `sgclaw`。 +2. `init -> init_ack` 成功率 100%。 +3. `click/type/navigate/getText` 联调通过。 +4. 所有失败场景均返回结构化错误。 +5. 域名和 action 白名单生效。 +6. 与项目团队在同一测试页完成一次端到端演示。 + +--- + +## 12. 依赖与协作方式 + +浏览器团队只依赖以下冻结输入: + +1. Pipe 协议版本:`1.0` +2. 消息结构:`init / init_ack / command / response` +3. 最小 action:`click/type/navigate/getText` +4. 安全字段:`expected_domain`、`hmac` + +除以上四项外,本周期内其他细节不应阻塞浏览器侧开发。 + diff --git a/docs/sgclaw_project_team_kickoff.md b/docs/sgclaw_project_team_kickoff.md new file mode 100644 index 0000000..1beabbe --- /dev/null +++ b/docs/sgclaw_project_team_kickoff.md @@ -0,0 +1,390 @@ +# sgClaw 本项目团队开发启动文档 + +**适用对象**:sgClaw Rust / Agent 项目开发团队(P1a、P1b) +**目标**:项目团队拿到本文档后即可独立启动 Rust 侧开发,并在一个周期后与浏览器团队完成 Pipe 联调。 +**协议版本**:`1.0` +**冻结日期**:`2026-03-24` + +--- + +## 1. 开发目标 + +本项目团队本周期只负责 sgClaw Rust 侧能力,不负责 Chromium 内部实现。 + +本周期结束时,Rust 侧必须具备以下能力: + +1. 可作为浏览器子进程启动。 +2. 通过 `stdin/stdout` 执行双向 `JSON Line` 通信。 +3. 完成 `init -> init_ack` 握手。 +4. 提供 `BrowserPipeTool`,可发送 `click/type/navigate/getText`。 +5. 能等待并解析浏览器侧 `response`。 +6. 能执行本地 `MAC Policy` 初步校验。 + +本周期不做: + +1. 不切回 HTTP/TCP 演示通道。 +2. 不依赖浏览器团队未完成的 UI。 +3. 不把 pipe 协议和业务 skill 混在一起推进。 +4. 不要求本周期完成完整 15 action。 + +--- + +## 2. Rust 团队负责的交付物 + +本周期交付以下文件或等价模块: + +1. `src/main.rs` +2. `src/pipe/protocol.rs` +3. `src/pipe/handshake.rs` +4. `src/pipe/browser_tool.rs` +5. `src/pipe/mod.rs` +6. `src/security/mac_policy.rs` +7. `src/security/hmac.rs` +8. `tests/pipe_protocol_test.rs` +9. `tests/pipe_handshake_test.rs` +10. `tests/browser_tool_test.rs` +11. `tests/integration/handshake_flow_test.rs` + +建议本周期目录保持如下: + +```text +src/ + main.rs + lib.rs + pipe/ + mod.rs + protocol.rs + handshake.rs + browser_tool.rs + security/ + mod.rs + hmac.rs + mac_policy.rs +tests/ + pipe_protocol_test.rs + pipe_handshake_test.rs + browser_tool_test.rs + integration/ + handshake_flow_test.rs +resources/ + rules.json +``` + +--- + +## 3. 冻结边界 + +### 3.1 本周期团队边界 + +P1a 负责: + +1. Pipe 协议结构体。 +2. 握手。 +3. `BrowserPipeTool`。 +4. HMAC 计算。 +5. response 关联和超时处理。 + +P1b 负责: + +1. 将 `BrowserPipeTool` 作为工具注册到后续 `AgentRuntime`。 +2. 但本周期联调不阻塞于完整 ReAct Loop。 + +本周期联调最小成功标准是: + +1. Rust 能发命令。 +2. 浏览器能执行并返回。 +3. Rust 能按 `seq` 收到正确 response。 + +### 3.2 进程与日志约束 + +1. `stdin` 只读协议消息。 +2. `stdout` 只写协议消息。 +3. 所有日志必须写到 `stderr`。 +4. 遇到协议错误时返回结构化错误或退出,不允许把调试日志写进 `stdout`。 + +--- + +## 4. 冻结协议 + +### 4.1 Browser -> sgClaw + +`init` + +```json +{"type":"init","version":"1.0","hmac_seed":"0123456789abcdef","capabilities":["browser_action"]} +``` + +`response` + +```json +{"type":"response","seq":1,"success":true,"data":{},"aom_snapshot":[],"timing":{"queue_ms":1,"exec_ms":20}} +``` + +### 4.2 sgClaw -> Browser + +`init_ack` + +```json +{"type":"init_ack","version":"1.0","agent_id":"uuid-v4","supported_actions":["click","type","navigate","getText","getHtml","waitForSelector","pageScreenshot","select","scrollTo","getAomSnapshot","storageSet","storageGet","zombieSpawn","zombieKill"]} +``` + +`command` + +```json +{ + "type":"command", + "seq":1, + "action":"click", + "params":{"selector":"#submit"}, + "security":{ + "expected_domain":"oa.example.com", + "hmac":"" + } +} +``` + +### 4.3 必须满足的协议规则 + +1. 编码为 UTF-8。 +2. 每行一个完整 JSON。 +3. 单消息最大 `1 MB`。 +4. `seq` 从 `1` 开始递增。 +5. 每个 `command.seq` 对应唯一 `response.seq`。 +6. `version` 固定为 `1.0`。 + +--- + +## 5. Rust 侧实现要求 + +### 5.1 main.rs + +本周期 `main` 的目标很简单: + +1. 初始化日志到 `stderr`。 +2. 用 `stdin/stdout` 执行握手。 +3. 初始化 `BrowserPipeTool` 所需对象。 +4. 保持进程存活,等待命令结果和后续任务。 + +如果当前代码还保留演示版 HTTP 入口,本周期必须恢复到 pipe 入口优先。 + +### 5.2 handshake.rs + +必须实现: + +1. 从 `stdin` 读取第一条 `init`。 +2. 校验 `version`。 +3. 从 `hmac_seed` 派生会话级 HMAC key。 +4. 生成 `agent_id`。 +5. 向 `stdout` 回写 `init_ack`。 + +失败条件: + +1. 第一条消息不是 `init`。 +2. `version` 不匹配。 +3. `hmac_seed` 非法。 + +### 5.3 protocol.rs + +必须定义: + +1. `BrowserMessage` +2. `AgentMessage` +3. `SecurityFields` +4. `Timing` +5. `Action` + +本周期最小 `Action` 必须覆盖: + +1. `click` +2. `type` +3. `navigate` +4. `getText` + +建议保留剩余 action 枚举,为后续扩展留口。 + +### 5.4 browser_tool.rs + +必须实现: + +1. 输入参数反序列化为 `Action`。 +2. 调用本地 `MAC Policy` 做前置校验。 +3. 分配递增 `seq`。 +4. 计算 `security.hmac`。 +5. 向 `stdout` 写出 `command`。 +6. 等待同 `seq` 的 `response`。 +7. 超时返回错误。 + +建议超时: + +1. 握手超时:`5s` +2. 单 action 响应超时:`30s` + +### 5.5 mac_policy.rs + +本周期最小校验: + +1. action 白名单。 +2. 域名白名单。 +3. storage key 前缀约束可后置。 +4. 熔断器可后置,但接口要预留。 + +`rules.json` 建议格式: + +```json +{ + "version": "1.0", + "domains": { + "allowed": ["oa.example.com", "erp.example.com", "hr.example.com"] + }, + "pipe_actions": { + "allowed": ["click", "type", "navigate", "getText"], + "blocked": ["eval", "executeJsInPage"] + } +} +``` + +--- + +## 6. 本周期开发顺序 + +### Day 1-2 + +1. 固定协议结构体。 +2. 写 `pipe_protocol_test`。 +3. 写 `pipe_handshake_test`。 +4. 恢复 `stdin/stdout` 入口。 + +验收: + +1. 能独立运行进程并手动喂一条 `init`。 +2. 能正确输出 `init_ack`。 + +### Day 3-4 + +1. 完成 `BrowserPipeTool`。 +2. 完成 HMAC 计算。 +3. 完成基于 `seq` 的 response 匹配。 +4. 与本地 mock 浏览器进程联通。 + +验收: + +1. Rust 能发出 `click/type/navigate/getText` 四类命令。 +2. mock response 能被正确接收。 + +### Day 5-6 + +1. 接入最小 `MAC Policy`。 +2. 完成 integration test。 +3. 准备联调脚本和示例 JSON。 + +验收: + +1. 非白名单 action 在 Rust 侧被前置拒绝。 +2. 域名不合法时直接失败。 + +### Day 7 + +1. 收口测试。 +2. 输出联调说明。 +3. 与浏览器团队联调。 + +--- + +## 7. Rust 团队自测清单 + +- [ ] `protocol.rs` 序列化/反序列化测试通过。 +- [ ] `init -> init_ack` 测试通过。 +- [ ] `version` 不匹配时握手失败。 +- [ ] `hmac_seed` 非法时握手失败。 +- [ ] `click/type/navigate/getText` 命令都能正确编码。 +- [ ] `response.seq` 不匹配时不会误关联。 +- [ ] 单 action 超时能返回错误。 +- [ ] 非白名单 action 被 `MAC Policy` 拒绝。 +- [ ] 日志只出现在 `stderr`。 + +--- + +## 8. 联调输入输出样例 + +### 8.1 手动运行握手 + +输入: + +```json +{"type":"init","version":"1.0","hmac_seed":"00112233445566778899aabbccddeeff","capabilities":["browser_action"]} +``` + +期望输出: + +```json +{"type":"init_ack","version":"1.0","agent_id":"00000000-0000-0000-0000-000000000000","supported_actions":["click","type","navigate","getText","getHtml","waitForSelector","pageScreenshot","select","scrollTo","getAomSnapshot","storageSet","storageGet","zombieSpawn","zombieKill"]} +``` + +### 8.2 最小命令样例 + +输出给浏览器: + +```json +{"type":"command","seq":1,"action":"navigate","params":{"url":"https://oa.example.com/login"},"security":{"expected_domain":"oa.example.com","hmac":""}} +``` + +浏览器回: + +```json +{"type":"response","seq":1,"success":true,"data":{},"aom_snapshot":[],"timing":{"queue_ms":1,"exec_ms":50}} +``` + +--- + +## 9. 联调前必须提供的东西 + +本项目团队在联调前必须准备: + +1. 可运行的 `sgclaw` 可执行文件或 debug 启动方式。 +2. 协议样例文件。 +3. `rules.json` 默认测试配置。 +4. 四个最小 action 的参数样例。 +5. 一份错误码表。 +6. 一份 `stderr` 日志关键字段说明。 + +--- + +## 10. 周期结束验收标准 + +以下全部满足,Rust 团队本周期完成: + +1. `sgclaw` 可以被浏览器作为子进程启动。 +2. `init -> init_ack` 成功率 100%。 +3. 能稳定发送 `click/type/navigate/getText` 四类命令。 +4. 能稳定按 `seq` 收到并解析 response。 +5. Rust 侧前置 `MAC Policy` 生效。 +6. 与浏览器团队在同一测试页面上联调成功。 + +--- + +## 11. 联调日执行顺序 + +联调当天只按下面顺序走,避免双方并发改协议: + +1. 先验证 `init -> init_ack`。 +2. 再验证 `navigate`。 +3. 再验证 `type`。 +4. 再验证 `click`。 +5. 最后验证 `getText`。 +6. 再补失败场景:域名拒绝、非法 action、超时。 + +任何协议字段问题,一律以 `protocol.rs` 和本文件为准,不在联调现场临时改口。 + +--- + +## 12. 对浏览器团队的依赖 + +Rust 团队本周期只依赖浏览器团队提供以下冻结输入: + +1. 浏览器能启动子进程。 +2. 浏览器能收发 JSON Line。 +3. 浏览器支持 4 个最小 action。 +4. 浏览器返回结构化 `response`。 + +浏览器内部如何落到 `CommandRouter`,不属于 Rust 团队阻塞项。 + diff --git a/docs/superpowers/plans/2026-03-25-superrpa-sgclaw-browser-control.md b/docs/superpowers/plans/2026-03-25-superrpa-sgclaw-browser-control.md new file mode 100644 index 0000000..dbebccb --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-superrpa-sgclaw-browser-control.md @@ -0,0 +1,345 @@ +# SuperRPA sgClaw Browser Control 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:** Deliver a two-phase integration where `sgclaw` first drives the existing SuperRPA browser through a minimal fixed-intent demo, then upgrades to a real Agent loop backed by `deepseek-chat`. + +**Architecture:** Keep the browser side thin and reuse-first. Rust owns task understanding, pipe protocol, and sequencing; SuperRPA owns process hosting, secondary security checks, and delegation into existing `CommandRouter`. Phase 1 uses a rule-based planner; Phase 2 swaps in an Agent runtime without changing browser command execution. + +**Tech Stack:** Rust, JSON Line over STDIO, HMAC-SHA256, SuperRPA Chromium C++, existing `CommandRouter`, existing rules services, FunctionsUI bridge, DeepSeek OpenAI-compatible API (`deepseek-chat`). + +--- + +## File Structure + +### sgClaw Repository + +- Create: `src/agent/mod.rs` +- Create: `src/agent/runtime.rs` +- Create: `src/agent/planner.rs` +- Create: `src/llm/mod.rs` +- Create: `src/llm/provider.rs` +- Create: `src/llm/deepseek.rs` +- Create: `src/config/mod.rs` +- Create: `src/config/settings.rs` +- Modify: `src/lib.rs` +- Modify: `src/main.rs` +- Modify: `src/pipe/protocol.rs` +- Modify: `src/pipe/browser_tool.rs` +- Modify: `src/security/hmac.rs` +- Modify: `resources/rules.json` +- Create: `tests/task_protocol_test.rs` +- Create: `tests/planner_test.rs` +- Create: `tests/runtime_task_flow_test.rs` + +### SuperRPA Repository + +- Modify: `src/chrome/browser/superrpa/BUILD.gn` +- Modify: `src/chrome/browser/superrpa/router/command_router.h` +- Modify: `src/chrome/browser/superrpa/router/command_router.cc` +- Modify: `src/chrome/browser/superrpa/sgclaw/sgclaw_command_dispatcher.cc` +- Modify: `src/chrome/browser/superrpa/sgclaw/sgclaw_security_gate.h` +- Modify: `src/chrome/browser/superrpa/sgclaw/sgclaw_security_gate.cc` +- Create or modify: `src/chrome/browser/superrpa/sgclaw/sgclaw_process_host.h` +- Create or modify: `src/chrome/browser/superrpa/sgclaw/sgclaw_process_host.cc` +- Create or modify: `src/chrome/browser/superrpa/sgclaw/pipe_listener.h` +- Create or modify: `src/chrome/browser/superrpa/sgclaw/pipe_listener.cc` +- Modify: `src/chrome/browser/resources/superrpa/devtools/functions/functions.ts` +- Modify: `src/chrome/browser/resources/superrpa/devtools/functions/functions_manifest.ts` +- Modify: `src/chrome/browser/superrpa/rules/rpa_rules_service_factory.cc` +- Test: `test("superrpa_unittests")` + +## Task 1: Align Pipe Contract and Security Baseline + +**Files:** +- Modify: `src/pipe/protocol.rs` +- Modify: `src/security/hmac.rs` +- Modify: `resources/rules.json` +- Create: `tests/task_protocol_test.rs` + +- [ ] **Step 1: Write failing protocol tests for task-level messages** + +Add tests covering `submit_task`, `task_complete`, and exact HMAC canonical string expectations. + +- [ ] **Step 2: Run protocol-focused tests** + +Run: `cargo test task_protocol_test pipe_protocol_test -q` +Expected: FAIL because the task-level messages and canonical signing are missing. + +- [ ] **Step 3: Extend protocol types** + +Add task-scope message variants in `src/pipe/protocol.rs` for: +- browser -> sgclaw `submit_task` +- sgclaw -> browser `task_complete` +- optional `log_entry` + +- [ ] **Step 4: Fix HMAC canonical string** + +Change `src/security/hmac.rs` to sign: + +```text +\n\n\n +``` + +- [ ] **Step 5: Add demo rules isolation** + +Add a clearly marked demo allow entry for Baidu in `resources/rules.json`, with comments in docs explaining it is demo-only. + +- [ ] **Step 6: Re-run protocol tests** + +Run: `cargo test task_protocol_test pipe_protocol_test -q` +Expected: PASS. + +## Task 2: Build Phase 1 Rust Task Flow + +**Files:** +- Create: `src/agent/mod.rs` +- Create: `src/agent/planner.rs` +- Modify: `src/lib.rs` +- Modify: `src/main.rs` +- Create: `tests/planner_test.rs` +- Create: `tests/runtime_task_flow_test.rs` + +- [ ] **Step 1: Write failing planner tests** + +Add tests for parsing: +- `打开百度搜索天气` +- `打开百度搜索电网调度` + +Expected output is an ordered action plan: `navigate`, `type`, `click`. + +- [ ] **Step 2: Run planner tests** + +Run: `cargo test planner_test -q` +Expected: FAIL because no planner exists. + +- [ ] **Step 3: Implement rule-based planner** + +Create `src/agent/planner.rs` with a minimal parser that only accepts the Baidu-search intent family and rejects everything else clearly. + +- [ ] **Step 4: Wire `submit_task` handling into runtime entry** + +Update `src/lib.rs` and `src/main.rs` so the Rust process can receive a task message, execute the planner, call `BrowserPipeTool`, and emit `task_complete`. + +- [ ] **Step 5: Add end-to-end runtime test** + +Use a mock transport to validate: +- receive `submit_task` +- send three browser commands +- consume three responses +- emit `task_complete` + +- [ ] **Step 6: Re-run Rust tests** + +Run: `cargo test -q` +Expected: PASS for planner and runtime task flow. + +## Task 3: Reuse Existing SuperRPA Browser Execution Path + +**Files:** +- Modify: `src/chrome/browser/superrpa/sgclaw/sgclaw_process_host.h` +- Modify: `src/chrome/browser/superrpa/sgclaw/sgclaw_process_host.cc` +- Modify: `src/chrome/browser/superrpa/sgclaw/pipe_listener.h` +- Modify: `src/chrome/browser/superrpa/sgclaw/pipe_listener.cc` +- Modify: `src/chrome/browser/superrpa/BUILD.gn` + +- [ ] **Step 1: Add failing browser-side host/listener tests** + +Cover: +- process start +- init handshake timeout +- JSON Line split and dispatch +- listener rejection of invalid payloads + +- [ ] **Step 2: Implement process host skeleton** + +Add lifecycle states and `Start/Stop/SendLine` using the existing sgclaw area, not a parallel subsystem. + +- [ ] **Step 3: Implement listener** + +Read `stdout`, split lines, reject empty/oversized/invalid JSON, and forward valid messages to sgclaw dispatch code. + +- [ ] **Step 4: Hook build targets** + +Update `src/chrome/browser/superrpa/BUILD.gn` to compile the sgclaw host/listener path inside existing targets. + +- [ ] **Step 5: Run browser unit tests** + +Run the relevant `superrpa_unittests` target for the added cases. +Expected: PASS. + +## Task 4: Reuse CommandRouter and Security Gates + +**Files:** +- Modify: `src/chrome/browser/superrpa/router/command_router.h` +- Modify: `src/chrome/browser/superrpa/router/command_router.cc` +- Modify: `src/chrome/browser/superrpa/sgclaw/sgclaw_command_dispatcher.cc` +- Modify: `src/chrome/browser/superrpa/sgclaw/sgclaw_security_gate.h` +- Modify: `src/chrome/browser/superrpa/sgclaw/sgclaw_security_gate.cc` +- Modify: `src/chrome/browser/superrpa/rules/rpa_rules_service_factory.cc` + +- [ ] **Step 1: Write failing dispatch/security tests** + +Cover: +- allowed Baidu demo task +- blocked non-whitelisted domain +- blocked unsupported action +- HMAC mismatch rejection + +- [ ] **Step 2: Reuse command entrypoints** + +Map sgclaw commands into existing methods: +- `ExecuteNavigate` +- `ExecuteType` +- `ExecuteClick` +- `ExecuteGetText` + +- [ ] **Step 3: Reuse security layers** + +Ensure sgclaw path reads existing rules services and uses `sgclaw_security_gate` for secondary checks before dispatch. + +- [ ] **Step 4: Add demo rules source** + +If needed, gate Baidu allow rules behind profile/demo config rather than broad permanent defaults. + +- [ ] **Step 5: Re-run browser tests** + +Run the focused security/dispatch unit tests. +Expected: PASS. + +## Task 5: Wire FunctionsUI Submission and Result Flow + +**Files:** +- Modify: `src/chrome/browser/resources/superrpa/devtools/functions/functions.ts` +- Modify: `src/chrome/browser/resources/superrpa/devtools/functions/functions_manifest.ts` +- Modify: browser-side bridge code that receives `window.__SUPER_RPA_BRIDGE__` calls + +- [ ] **Step 1: Write failing UI bridge test or manual harness case** + +Cover: +- `sgclaw_start` +- `sgclaw_stop` +- `sgclaw_submit_task` +- result/event propagation + +- [ ] **Step 2: Add bridge entry points** + +Expose minimal callable actions from FunctionsUI to the browser-side sgclaw host. + +- [ ] **Step 3: Surface task lifecycle events** + +Push state, logs, and final result back to FunctionsUI without introducing a new parallel UI subsystem. + +- [ ] **Step 4: Validate manual smoke path** + +Manual test: +1. Open FunctionsUI +2. Start sgclaw +3. Submit `打开百度搜索天气` +4. Observe logs and completion summary + +- [ ] **Step 5: Document the bridge contract** + +Add a short browser-side note describing the exact payloads for start/stop/submit/result. + +## Task 6: Add Phase 2 Agent Runtime with DeepSeek + +**Files:** +- Create: `src/agent/runtime.rs` +- Create: `src/llm/mod.rs` +- Create: `src/llm/provider.rs` +- Create: `src/llm/deepseek.rs` +- Create: `src/config/mod.rs` +- Create: `src/config/settings.rs` +- Modify: `src/pipe/browser_tool.rs` +- Modify: `src/lib.rs` +- Create: `tests/deepseek_provider_test.rs` +- Create: `tests/agent_runtime_test.rs` + +- [ ] **Step 1: Write failing provider tests** + +Cover: +- config loading from env +- request shape for DeepSeek compatible chat API +- model default = `deepseek-chat` + +- [ ] **Step 2: Implement provider abstraction** + +Add a minimal provider trait and DeepSeek implementation using: +- `base_url=https://api.deepseek.com` +- model `deepseek-chat` +- API key from environment or config file, never hardcoded + +- [ ] **Step 3: Write failing runtime tests** + +Cover: +- tool registration for `browser_action` +- one think-act-observe cycle +- final summary generation after successful browser actions + +- [ ] **Step 4: Implement Agent runtime** + +Create a minimal `AgentRuntime` that can: +- receive task text +- call provider +- parse tool call +- invoke `BrowserPipeTool` +- emit `task_complete` + +- [ ] **Step 5: Keep Phase 1 fallback** + +Retain the rule-based planner as a fallback path for offline/demo use and for controlled debugging. + +- [ ] **Step 6: Re-run Rust tests** + +Run: `cargo test -q` +Expected: PASS including provider and runtime suites. + +## Task 7: Final Cross-Repo Acceptance and Low-Context Docs + +**Files:** +- Modify: `README.md` +- Create: `docs/superpowers/acceptance/2026-03-25-superrpa-sgclaw-browser-control.md` +- Modify: `docs/浏览器对接标准.md` +- Modify: `docs/sgclaw_project_team_kickoff.md` + +- [ ] **Step 1: Write acceptance checklist** + +Cover: +- handshake +- `submit_task` +- Baidu search success +- HMAC mismatch failure +- non-whitelisted domain rejection + +- [ ] **Step 2: Create low-context handoff docs** + +Write one short acceptance doc that links only the required files and commands for each phase. + +- [ ] **Step 3: Run final smoke tests** + +Rust repo: +`cargo test -q` + +Browser repo: +run focused `superrpa_unittests` + +Manual: +submit `打开百度搜索天气` + +- [ ] **Step 4: Update top-level docs** + +Update README and browser contract docs so the next contributor can find: +- Phase 1 demo loop +- Phase 2 Agent loop +- exact integration points + +- [ ] **Step 5: Commit in small slices** + +Suggested commit order: +1. `feat: align sgclaw pipe contract for task flow` +2. `feat: add phase1 baidu demo planner` +3. `feat: wire superrpa sgclaw process host and dispatcher` +4. `feat: add functionsui sgclaw task bridge` +5. `feat: add deepseek-backed agent runtime` +6. `docs: add acceptance and integration notes` diff --git a/docs/superpowers/specs/2026-03-25-superrpa-sgclaw-browser-control-design.md b/docs/superpowers/specs/2026-03-25-superrpa-sgclaw-browser-control-design.md new file mode 100644 index 0000000..3c3e3c5 --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-superrpa-sgclaw-browser-control-design.md @@ -0,0 +1,107 @@ +# SuperRPA sgClaw Browser Control Design + +## Goal + +Build `sgclaw` in two phases so it can control the existing SuperRPA browser with minimal new surface area. + +- Phase 1: deliver a demo-safe closed loop for a fixed instruction like `打开百度搜索天气`. +- Phase 2: upgrade that loop into a real Agent flow backed by `deepseek-chat`. + +The design must maximize reuse of existing SuperRPA browser interfaces and minimize working context for future contributors. + +## Scope + +### In Scope + +- Reuse SuperRPA `CommandRouter` as the browser execution entry. +- Reuse existing browser rule and security infrastructure where possible. +- Keep the Rust side responsible for task understanding, sequencing, and pipe protocol. +- Keep the browser side responsible for process hosting, security re-check, and command dispatch. +- Use layered docs so contributors only read the smallest necessary document. + +### Out of Scope + +- New browser automation APIs parallel to `CommandRouter` +- Full SkillLoader / Memory / MCP work in Phase 1 +- Broad action-set expansion beyond `click`, `type`, `navigate`, `getText` + +## Existing Integration Points + +### sgClaw Repository + +- Pipe and security baseline already exist in [`src/pipe/protocol.rs`](/home/zyl/projects/sgClaw/src/pipe/protocol.rs), [`src/pipe/handshake.rs`](/home/zyl/projects/sgClaw/src/pipe/handshake.rs), [`src/pipe/browser_tool.rs`](/home/zyl/projects/sgClaw/src/pipe/browser_tool.rs), and [`src/security/mac_policy.rs`](/home/zyl/projects/sgClaw/src/security/mac_policy.rs). + +### SuperRPA Repository + +- Browser command entry: `src/chrome/browser/superrpa/router/command_router.h/.cc` +- Existing sgclaw dispatch/security area: `src/chrome/browser/superrpa/sgclaw/sgclaw_command_dispatcher.cc`, `src/chrome/browser/superrpa/sgclaw/sgclaw_security_gate.h/.cc` +- FunctionsUI front-end entry: `src/chrome/browser/resources/superrpa/devtools/functions/functions.ts` +- Rules and whitelist sources: `src/chrome/browser/superrpa/rules/*`, `src/chrome/browser/superrpa/zombie/resource_controller.*` + +## Recommended Architecture + +Use a thin-adapter design. + +1. Rust owns `submit_task`, planning, pipe messages, response correlation, and final task completion. +2. SuperRPA owns `sgclaw` process lifecycle, JSON Line I/O, secondary security validation, and delegation into existing `CommandRouter`. +3. Phase 1 uses a rule-based planner for one narrow intent family: `打开百度搜索X`. +4. Phase 2 replaces that planner with a real Agent runtime using `deepseek-chat`, but keeps the same `BrowserPipeTool` contract so browser-side code stays thin. + +This preserves the browser’s existing abstractions and avoids duplicating action logic. + +## Phase Design + +### Phase 1: Minimal Demo Loop + +- Add task-level messages on top of the existing pipe. +- Accept a `submit_task` instruction from the browser bridge. +- Parse only one pattern family: open Baidu, enter query, click search. +- Return `task_complete` with summary and step log. +- Allow Baidu only in demo rules, not as a permanent broad whitelist expansion. + +### Phase 2: Real Agent Loop + +- Add `agent/runtime.rs` and provider abstraction. +- Register `BrowserPipeTool` as `browser_action`. +- Default provider is DeepSeek with `base_url=https://api.deepseek.com` and model `deepseek-chat`. +- Keep provider config externalized through environment variables and settings files. + +## Security + +- HMAC must be aligned to the browser contract exactly: `\n\n\n`. +- Rust validates before send; browser validates again before dispatch. +- `rules.json` remains the source for domain/action allow rules. +- Demo-only domains like `baidu.com` must be clearly isolated in a demo profile or demo rules file. + +## Context Control Strategy + +Use four small docs instead of one large narrative: + +1. This design doc: goals, boundaries, architecture. +2. Browser contract doc: exact message shapes and file paths. +3. Plan doc: execution order and concrete files. +4. Acceptance doc: smoke tests and failure matrix. + +Each implementation task should point only to the doc section it needs. + +## Testing Strategy + +- Rust unit tests for protocol, planner, HMAC, and runtime message handling +- Rust integration tests for `submit_task -> command -> response -> task_complete` +- SuperRPA unit tests for process host, listener, security gate, and dispatch mapping +- Cross-repo smoke test for `打开百度搜索天气` + +## Acceptance Criteria + +### Phase 1 + +- Start `sgclaw` from SuperRPA +- Send `submit_task` +- Navigate to Baidu and search a keyword through existing browser actions +- Surface logs and final result back to FunctionsUI + +### Phase 2 + +- Execute the same flow through `deepseek-chat` +- Keep the same browser contract and command mapping +- Expose provider/model config without code changes diff --git a/resources/rules.json b/resources/rules.json new file mode 100644 index 0000000..daa9e82 --- /dev/null +++ b/resources/rules.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "domains": { + "allowed": ["oa.example.com", "erp.example.com", "hr.example.com"] + }, + "pipe_actions": { + "allowed": ["click", "type", "navigate", "getText"], + "blocked": ["eval", "executeJsInPage"] + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..71d2aab --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,37 @@ +pub mod pipe; +pub mod security; + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use pipe::{perform_handshake, BrowserPipeTool, PipeError, StdioTransport, Transport}; +use security::MacPolicy; + +fn default_rules_path() -> PathBuf { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join("resources") + .join("rules.json") +} + +pub fn run() -> Result<(), PipeError> { + let transport = Arc::new(StdioTransport::new(std::io::stdin(), std::io::stdout())); + let handshake = perform_handshake(transport.as_ref(), Duration::from_secs(5))?; + let mac_policy = MacPolicy::load_from_path(default_rules_path())?; + let _browser_tool = BrowserPipeTool::new(transport.clone(), mac_policy, handshake.session_key) + .with_response_timeout(Duration::from_secs(30)); + + eprintln!("sgclaw ready: agent_id={}", handshake.agent_id); + + loop { + match transport.recv_timeout(Duration::from_secs(3600)) { + Ok(message) => { + eprintln!("ignoring unsolicited browser message: {:?}", message); + } + Err(PipeError::Timeout) => continue, + Err(PipeError::PipeClosed) => return Ok(()), + Err(err) => return Err(err), + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..1354314 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,10 @@ +use std::process::ExitCode; + +fn main() -> ExitCode { + if let Err(err) = sgclaw::run() { + eprintln!("sgclaw failed: {err}"); + return ExitCode::FAILURE; + } + + ExitCode::SUCCESS +} diff --git a/src/pipe/browser_tool.rs b/src/pipe/browser_tool.rs new file mode 100644 index 0000000..ffaaa05 --- /dev/null +++ b/src/pipe/browser_tool.rs @@ -0,0 +1,103 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use serde_json::Value; + +use crate::pipe::protocol::{Action, AgentMessage, BrowserMessage, SecurityFields, Timing}; +use crate::pipe::{PipeError, Transport}; +use crate::security::{sign_command, MacPolicy}; + +#[derive(Debug, Clone, PartialEq)] +pub struct CommandOutput { + pub seq: u64, + pub success: bool, + pub data: Value, + pub aom_snapshot: Vec, + pub timing: Timing, +} + +pub struct BrowserPipeTool { + transport: Arc, + mac_policy: MacPolicy, + session_key: Vec, + next_seq: AtomicU64, + response_timeout: Duration, +} + +impl BrowserPipeTool { + pub fn new(transport: Arc, mac_policy: MacPolicy, session_key: Vec) -> Self { + Self { + transport, + mac_policy, + session_key, + next_seq: AtomicU64::new(1), + response_timeout: Duration::from_secs(30), + } + } + + pub fn with_response_timeout(mut self, response_timeout: Duration) -> Self { + self.response_timeout = response_timeout; + self + } + + pub fn invoke( + &self, + action: Action, + params: Value, + expected_domain: &str, + ) -> Result { + self.mac_policy.validate(&action, expected_domain)?; + + let seq = self.next_seq.fetch_add(1, Ordering::Relaxed); + let hmac = sign_command(&self.session_key, seq, &action, ¶ms, expected_domain)?; + let command = AgentMessage::Command { + seq, + action, + params, + security: SecurityFields { + expected_domain: expected_domain.to_string(), + hmac, + }, + }; + + self.transport.send(&command)?; + + let started = Instant::now(); + loop { + let Some(remaining) = self.response_timeout.checked_sub(started.elapsed()) else { + return Err(PipeError::Timeout); + }; + + match self.transport.recv_timeout(remaining)? { + BrowserMessage::Response { + seq: response_seq, + success, + data, + aom_snapshot, + timing, + } if response_seq == seq => { + return Ok(CommandOutput { + seq: response_seq, + success, + data, + aom_snapshot, + timing, + }); + } + BrowserMessage::Response { + seq: response_seq, .. + } => { + return Err(PipeError::Protocol(format!( + "received response seq {response_seq} while waiting for {seq}" + ))); + } + BrowserMessage::Init { .. } => { + return Err(PipeError::UnexpectedMessage( + "received duplicate init after handshake".to_string(), + )); + } + } + } + } +} diff --git a/src/pipe/handshake.rs b/src/pipe/handshake.rs new file mode 100644 index 0000000..08803b6 --- /dev/null +++ b/src/pipe/handshake.rs @@ -0,0 +1,53 @@ +use std::time::Duration; + +use uuid::Uuid; + +use crate::pipe::protocol::{supported_actions, AgentMessage, BrowserMessage, PROTOCOL_VERSION}; +use crate::pipe::{PipeError, Transport}; +use crate::security::derive_session_key; + +#[derive(Debug, Clone)] +pub struct HandshakeResult { + pub agent_id: String, + pub session_key: Vec, + pub capabilities: Vec, +} + +pub fn perform_handshake( + transport: &T, + timeout: Duration, +) -> Result { + let init = transport.recv_timeout(timeout)?; + + match init { + BrowserMessage::Init { + version, + hmac_seed, + capabilities, + } => { + if version != PROTOCOL_VERSION { + return Err(PipeError::Protocol(format!( + "unsupported protocol version: {version}" + ))); + } + + let session_key = derive_session_key(&hmac_seed)?; + let agent_id = Uuid::new_v4().to_string(); + let ack = AgentMessage::InitAck { + version: PROTOCOL_VERSION.to_string(), + agent_id: agent_id.clone(), + supported_actions: supported_actions(), + }; + transport.send(&ack)?; + + Ok(HandshakeResult { + agent_id, + session_key, + capabilities, + }) + } + other => Err(PipeError::UnexpectedMessage(format!( + "expected init as first message, got {other:?}" + ))), + } +} diff --git a/src/pipe/mod.rs b/src/pipe/mod.rs new file mode 100644 index 0000000..cb7c8de --- /dev/null +++ b/src/pipe/mod.rs @@ -0,0 +1,125 @@ +pub mod browser_tool; +pub mod handshake; +pub mod protocol; + +pub use browser_tool::{BrowserPipeTool, CommandOutput}; +pub use handshake::{perform_handshake, HandshakeResult}; +pub use protocol::{ + supported_actions, Action, AgentMessage, BrowserMessage, SecurityFields, Timing, +}; + +use std::io::{BufRead, BufReader, Read, Write}; +use std::sync::{mpsc, Mutex}; +use std::time::Duration; + +use thiserror::Error; + +const MAX_MESSAGE_BYTES: usize = 1024 * 1024; + +#[derive(Debug, Error)] +pub enum PipeError { + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + #[error("timeout while waiting for browser message")] + Timeout, + #[error("pipe closed")] + PipeClosed, + #[error("protocol error: {0}")] + Protocol(String), + #[error("unexpected message: {0}")] + UnexpectedMessage(String), + #[error("message too large: {0} bytes")] + MessageTooLarge(usize), + #[error(transparent)] + Security(#[from] crate::security::SecurityError), +} + +pub trait Transport: Send + Sync { + fn send(&self, message: &AgentMessage) -> Result<(), PipeError>; + fn recv_timeout(&self, timeout: Duration) -> Result; +} + +pub struct StdioTransport { + rx: Mutex>>, + writer: Mutex>, +} + +impl StdioTransport { + pub fn new(reader: R, writer: W) -> Self + where + R: Read + Send + 'static, + W: Write + Send + 'static, + { + let (tx, rx) = mpsc::channel(); + + std::thread::spawn(move || { + let mut reader = BufReader::new(reader); + + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => break, + Ok(_) => { + let line = line.trim_end_matches(&['\r', '\n'][..]); + if line.is_empty() { + let _ = tx.send(Err(PipeError::Protocol( + "received empty JSON line".to_string(), + ))); + continue; + } + + if line.as_bytes().len() > MAX_MESSAGE_BYTES { + let _ = tx.send(Err(PipeError::MessageTooLarge(line.len()))); + continue; + } + + let parsed = + serde_json::from_str::(line).map_err(PipeError::from); + let _ = tx.send(parsed); + } + Err(err) => { + let _ = tx.send(Err(PipeError::Io(err))); + break; + } + } + } + }); + + Self { + rx: Mutex::new(rx), + writer: Mutex::new(Box::new(writer)), + } + } +} + +impl Transport for StdioTransport { + fn send(&self, message: &AgentMessage) -> Result<(), PipeError> { + let payload = serde_json::to_vec(message)?; + if payload.len() > MAX_MESSAGE_BYTES { + return Err(PipeError::MessageTooLarge(payload.len())); + } + + let mut writer = self + .writer + .lock() + .map_err(|_| PipeError::Protocol("writer lock poisoned".to_string()))?; + writer.write_all(&payload)?; + writer.write_all(b"\n")?; + writer.flush()?; + Ok(()) + } + + fn recv_timeout(&self, timeout: Duration) -> Result { + let rx = self + .rx + .lock() + .map_err(|_| PipeError::Protocol("receiver lock poisoned".to_string()))?; + match rx.recv_timeout(timeout) { + Ok(result) => result, + Err(mpsc::RecvTimeoutError::Timeout) => Err(PipeError::Timeout), + Err(mpsc::RecvTimeoutError::Disconnected) => Err(PipeError::PipeClosed), + } + } +} diff --git a/src/pipe/protocol.rs b/src/pipe/protocol.rs new file mode 100644 index 0000000..8493ba5 --- /dev/null +++ b/src/pipe/protocol.rs @@ -0,0 +1,115 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +pub const PROTOCOL_VERSION: &str = "1.0"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum BrowserMessage { + Init { + version: String, + hmac_seed: String, + #[serde(default)] + capabilities: Vec, + }, + Response { + seq: u64, + success: bool, + #[serde(default = "default_object")] + data: Value, + #[serde(default)] + aom_snapshot: Vec, + timing: Timing, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AgentMessage { + InitAck { + version: String, + agent_id: String, + supported_actions: Vec, + }, + Command { + seq: u64, + action: Action, + params: Value, + security: SecurityFields, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Action { + Click, + Type, + Navigate, + GetText, + GetHtml, + WaitForSelector, + PageScreenshot, + Select, + ScrollTo, + GetAomSnapshot, + StorageSet, + StorageGet, + ZombieSpawn, + ZombieKill, +} + +impl Action { + pub fn as_str(&self) -> &'static str { + match self { + Action::Click => "click", + Action::Type => "type", + Action::Navigate => "navigate", + Action::GetText => "getText", + Action::GetHtml => "getHtml", + Action::WaitForSelector => "waitForSelector", + Action::PageScreenshot => "pageScreenshot", + Action::Select => "select", + Action::ScrollTo => "scrollTo", + Action::GetAomSnapshot => "getAomSnapshot", + Action::StorageSet => "storageSet", + Action::StorageGet => "storageGet", + Action::ZombieSpawn => "zombieSpawn", + Action::ZombieKill => "zombieKill", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SecurityFields { + pub expected_domain: String, + pub hmac: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Timing { + pub queue_ms: u64, + pub exec_ms: u64, +} + +pub fn supported_actions() -> Vec { + vec![ + Action::Click, + Action::Type, + Action::Navigate, + Action::GetText, + Action::GetHtml, + Action::WaitForSelector, + Action::PageScreenshot, + Action::Select, + Action::ScrollTo, + Action::GetAomSnapshot, + Action::StorageSet, + Action::StorageGet, + Action::ZombieSpawn, + Action::ZombieKill, + ] +} + +fn default_object() -> Value { + json!({}) +} diff --git a/src/security/hmac.rs b/src/security/hmac.rs new file mode 100644 index 0000000..26b1542 --- /dev/null +++ b/src/security/hmac.rs @@ -0,0 +1,48 @@ +use hmac::{Hmac, Mac}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::pipe::Action; +use crate::security::SecurityError; + +type HmacSha256 = Hmac; + +pub fn derive_session_key(hmac_seed: &str) -> Result, SecurityError> { + let seed = hex::decode(hmac_seed)?; + if seed.is_empty() { + return Err(SecurityError::InvalidSeed( + "hmac_seed must not be empty".to_string(), + )); + } + + let mut hasher = Sha256::new(); + hasher.update(seed); + hasher.update(b"sgclaw-session-v1"); + Ok(hasher.finalize().to_vec()) +} + +pub fn sign_command( + session_key: &[u8], + seq: u64, + action: &Action, + params: &Value, + expected_domain: &str, +) -> Result { + if session_key.is_empty() { + return Err(SecurityError::InvalidSeed( + "session key must not be empty".to_string(), + )); + } + + let mut mac = HmacSha256::new_from_slice(session_key) + .map_err(|err| SecurityError::Hmac(err.to_string()))?; + mac.update(seq.to_string().as_bytes()); + mac.update(b"|"); + mac.update(action.as_str().as_bytes()); + mac.update(b"|"); + mac.update(expected_domain.as_bytes()); + mac.update(b"|"); + mac.update(serde_json::to_string(params)?.as_bytes()); + + Ok(hex::encode(mac.finalize().into_bytes())) +} diff --git a/src/security/mac_policy.rs b/src/security/mac_policy.rs new file mode 100644 index 0000000..ed628ee --- /dev/null +++ b/src/security/mac_policy.rs @@ -0,0 +1,111 @@ +use std::fs; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::pipe::Action; +use crate::security::SecurityError; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MacPolicy { + pub version: String, + pub domains: DomainRules, + pub pipe_actions: PipeActionRules, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DomainRules { + pub allowed: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PipeActionRules { + pub allowed: Vec, + #[serde(default)] + pub blocked: Vec, +} + +impl MacPolicy { + pub fn load_from_path(path: impl AsRef) -> Result { + let contents = fs::read_to_string(path)?; + let policy: Self = serde_json::from_str(&contents)?; + policy.validate_rules()?; + Ok(policy) + } + + pub fn from_json_str(contents: &str) -> Result { + let policy: Self = serde_json::from_str(contents)?; + policy.validate_rules()?; + Ok(policy) + } + + pub fn validate(&self, action: &Action, expected_domain: &str) -> Result<(), SecurityError> { + let action_name = action.as_str(); + if self + .pipe_actions + .blocked + .iter() + .any(|blocked| blocked == action_name) + { + return Err(SecurityError::ActionNotAllowed(action_name.to_string())); + } + + if !self + .pipe_actions + .allowed + .iter() + .any(|allowed| allowed == action_name) + { + return Err(SecurityError::ActionNotAllowed(action_name.to_string())); + } + + let normalized = normalize_domain(expected_domain); + if normalized.is_empty() { + return Err(SecurityError::DomainNotAllowed(expected_domain.to_string())); + } + + if !self + .domains + .allowed + .iter() + .map(|domain| normalize_domain(domain)) + .any(|allowed| allowed == normalized) + { + return Err(SecurityError::DomainNotAllowed(normalized)); + } + + Ok(()) + } + + fn validate_rules(&self) -> Result<(), SecurityError> { + if self.version.trim().is_empty() { + return Err(SecurityError::InvalidRules( + "rules version must not be empty".to_string(), + )); + } + if self.domains.allowed.is_empty() { + return Err(SecurityError::InvalidRules( + "at least one allowed domain is required".to_string(), + )); + } + if self.pipe_actions.allowed.is_empty() { + return Err(SecurityError::InvalidRules( + "at least one allowed action is required".to_string(), + )); + } + Ok(()) + } +} + +fn normalize_domain(raw: &str) -> String { + raw.trim() + .trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or_default() + .split(':') + .next() + .unwrap_or_default() + .to_ascii_lowercase() +} diff --git a/src/security/mod.rs b/src/security/mod.rs new file mode 100644 index 0000000..b82727e --- /dev/null +++ b/src/security/mod.rs @@ -0,0 +1,27 @@ +mod hmac; +mod mac_policy; + +pub use hmac::{derive_session_key, sign_command}; +pub use mac_policy::MacPolicy; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SecurityError { + #[error("invalid hmac seed: {0}")] + InvalidSeed(String), + #[error("action is not allowed: {0}")] + ActionNotAllowed(String), + #[error("domain is not allowed: {0}")] + DomainNotAllowed(String), + #[error("invalid rules: {0}")] + InvalidRules(String), + #[error("hmac error: {0}")] + Hmac(String), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Hex(#[from] hex::FromHexError), +} diff --git a/superrpa_sgclaw_doc_paths_20260325.zip b/superrpa_sgclaw_doc_paths_20260325.zip new file mode 100644 index 0000000..2cd9dd3 Binary files /dev/null and b/superrpa_sgclaw_doc_paths_20260325.zip differ diff --git a/tests/browser_tool_test.rs b/tests/browser_tool_test.rs new file mode 100644 index 0000000..f43241d --- /dev/null +++ b/tests/browser_tool_test.rs @@ -0,0 +1,84 @@ +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use common::MockTransport; +use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing}; +use sgclaw::security::MacPolicy; + +fn test_policy() -> MacPolicy { + MacPolicy::from_json_str( + r#"{ + "version": "1.0", + "domains": { "allowed": ["oa.example.com", "erp.example.com"] }, + "pipe_actions": { + "allowed": ["click", "type", "navigate", "getText"], + "blocked": ["eval", "executeJsInPage"] + } + }"#, + ) + .unwrap() +} + +#[test] +fn browser_tool_signs_and_sends_command_then_waits_for_response() { + let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response { + seq: 1, + success: true, + data: serde_json::json!({"text": "ok"}), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 20, + }, + }])); + let tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let result = tool + .invoke( + Action::Click, + serde_json::json!({ "selector": "#submit" }), + "oa.example.com", + ) + .unwrap(); + let sent = transport.sent_messages(); + + assert_eq!(result.seq, 1); + assert_eq!(result.data, serde_json::json!({"text": "ok"})); + assert_eq!(sent.len(), 1); + assert!(matches!( + &sent[0], + AgentMessage::Command { + seq, + action, + params, + security + } if *seq == 1 + && action == &Action::Click + && params == &serde_json::json!({"selector": "#submit"}) + && security.expected_domain == "oa.example.com" + && !security.hmac.is_empty() + )); +} + +#[test] +fn browser_tool_rejects_action_when_mac_policy_blocks_it() { + let transport = Arc::new(MockTransport::new(vec![])); + let tool = BrowserPipeTool::new(transport, test_policy(), vec![1, 2, 3, 4]); + + let err = tool + .invoke( + Action::GetHtml, + serde_json::json!({ "selector": "body" }), + "oa.example.com", + ) + .unwrap_err(); + + assert!(err.to_string().contains("action is not allowed")); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..f79a5c6 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,38 @@ +use std::collections::VecDeque; +use std::sync::Mutex; +use std::time::Duration; + +use sgclaw::pipe::{AgentMessage, BrowserMessage, PipeError, Transport}; + +pub struct MockTransport { + incoming: Mutex>, + sent: Mutex>, +} + +impl MockTransport { + pub fn new(messages: Vec) -> Self { + Self { + incoming: Mutex::new(VecDeque::from(messages)), + sent: Mutex::new(Vec::new()), + } + } + + pub fn sent_messages(&self) -> Vec { + self.sent.lock().unwrap().clone() + } +} + +impl Transport for MockTransport { + fn send(&self, message: &AgentMessage) -> Result<(), PipeError> { + self.sent.lock().unwrap().push(message.clone()); + Ok(()) + } + + fn recv_timeout(&self, _timeout: Duration) -> Result { + self.incoming + .lock() + .unwrap() + .pop_front() + .ok_or(PipeError::Timeout) + } +} diff --git a/tests/handshake_flow_test.rs b/tests/handshake_flow_test.rs new file mode 100644 index 0000000..a5f821c --- /dev/null +++ b/tests/handshake_flow_test.rs @@ -0,0 +1,2 @@ +#[path = "integration/handshake_flow_test.rs"] +mod handshake_flow_test; diff --git a/tests/integration/handshake_flow_test.rs b/tests/integration/handshake_flow_test.rs new file mode 100644 index 0000000..b0a3278 --- /dev/null +++ b/tests/integration/handshake_flow_test.rs @@ -0,0 +1,89 @@ +use std::io::{Cursor, Result as IoResult, Write}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use sgclaw::pipe::{ + perform_handshake, Action, AgentMessage, BrowserPipeTool, StdioTransport, Timing, +}; +use sgclaw::security::MacPolicy; + +#[derive(Clone, Default)] +struct SharedBuffer { + inner: Arc>>, +} + +impl SharedBuffer { + fn snapshot(&self) -> String { + String::from_utf8(self.inner.lock().unwrap().clone()).unwrap() + } +} + +impl Write for SharedBuffer { + fn write(&mut self, buf: &[u8]) -> IoResult { + self.inner.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> IoResult<()> { + Ok(()) + } +} + +#[test] +fn handshake_and_command_flow_work_over_json_line_transport() { + let reader = Cursor::new( + concat!( + r#"{"type":"init","version":"1.0","hmac_seed":"0123456789abcdef","capabilities":["browser_action"]}"#, + "\n", + r#"{"type":"response","seq":1,"success":true,"data":{"text":"提交成功"},"aom_snapshot":[],"timing":{"queue_ms":2,"exec_ms":38}}"#, + "\n" + ) + .as_bytes() + .to_vec(), + ); + let writer = SharedBuffer::default(); + let captured = writer.clone(); + let transport = Arc::new(StdioTransport::new(reader, writer)); + + let handshake = perform_handshake(transport.as_ref(), Duration::from_secs(1)).unwrap(); + let policy = MacPolicy::from_json_str( + r#"{ + "version": "1.0", + "domains": { "allowed": ["oa.example.com"] }, + "pipe_actions": { + "allowed": ["click", "type", "navigate", "getText"], + "blocked": [] + } + }"#, + ) + .unwrap(); + let tool = BrowserPipeTool::new(transport, policy, handshake.session_key) + .with_response_timeout(Duration::from_secs(1)); + + let result = tool + .invoke( + Action::Click, + serde_json::json!({ "selector": "#submit" }), + "oa.example.com", + ) + .unwrap(); + let written = captured.snapshot(); + + assert_eq!( + result.timing, + Timing { + queue_ms: 2, + exec_ms: 38 + } + ); + assert!(written.contains(r#""type":"init_ack""#)); + assert!(written.contains(r#""type":"command""#)); + assert!(written.contains(r#""action":"click""#)); + + let lines: Vec<&str> = written.lines().collect(); + let command: AgentMessage = serde_json::from_str(lines[1]).unwrap(); + assert!(matches!( + command, + AgentMessage::Command { seq, .. } if seq == 1 + )); +} diff --git a/tests/pipe_handshake_test.rs b/tests/pipe_handshake_test.rs new file mode 100644 index 0000000..84d5f5d --- /dev/null +++ b/tests/pipe_handshake_test.rs @@ -0,0 +1,41 @@ +mod common; + +use std::time::Duration; + +use common::MockTransport; +use sgclaw::pipe::{perform_handshake, AgentMessage, BrowserMessage}; + +#[test] +fn handshake_reads_init_and_writes_init_ack() { + let transport = MockTransport::new(vec![BrowserMessage::Init { + version: "1.0".to_string(), + hmac_seed: "0123456789abcdef".to_string(), + capabilities: vec!["browser_action".to_string()], + }]); + + let result = perform_handshake(&transport, Duration::from_secs(5)).unwrap(); + let sent = transport.sent_messages(); + + assert_eq!(result.capabilities, vec!["browser_action"]); + assert_eq!(sent.len(), 1); + assert!(matches!( + &sent[0], + AgentMessage::InitAck { + version, + agent_id, + supported_actions + } if version == "1.0" && !agent_id.is_empty() && supported_actions.len() >= 4 + )); +} + +#[test] +fn handshake_rejects_version_mismatch() { + let transport = MockTransport::new(vec![BrowserMessage::Init { + version: "9.9".to_string(), + hmac_seed: "0123456789abcdef".to_string(), + capabilities: vec![], + }]); + + let err = perform_handshake(&transport, Duration::from_secs(5)).unwrap_err(); + assert!(err.to_string().contains("unsupported protocol version")); +} diff --git a/tests/pipe_protocol_test.rs b/tests/pipe_protocol_test.rs new file mode 100644 index 0000000..6613a81 --- /dev/null +++ b/tests/pipe_protocol_test.rs @@ -0,0 +1,59 @@ +use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, SecurityFields, Timing}; + +#[test] +fn browser_init_round_trip_uses_frozen_wire_format() { + let raw = r#"{"type":"init","version":"1.0","hmac_seed":"0123456789abcdef","capabilities":["browser_action"]}"#; + let message: BrowserMessage = serde_json::from_str(raw).unwrap(); + + assert!(matches!( + message, + BrowserMessage::Init { + ref version, + ref hmac_seed, + ref capabilities + } if version == "1.0" + && hmac_seed == "0123456789abcdef" + && *capabilities == vec!["browser_action".to_string()] + )); + + assert_eq!(serde_json::to_string(&message).unwrap(), raw); +} + +#[test] +fn command_serializes_action_and_security_fields() { + let message = AgentMessage::Command { + seq: 1, + action: Action::GetText, + params: serde_json::json!({ "selector": "#submit" }), + security: SecurityFields { + expected_domain: "oa.example.com".to_string(), + hmac: "abc123".to_string(), + }, + }; + + let raw = serde_json::to_string(&message).unwrap(); + + assert!(raw.contains(r#""type":"command""#)); + assert!(raw.contains(r#""action":"getText""#)); + assert!(raw.contains(r#""expected_domain":"oa.example.com""#)); +} + +#[test] +fn response_deserializes_timing_and_payload() { + let raw = r#"{"type":"response","seq":7,"success":true,"data":{"text":"提交成功"},"aom_snapshot":[],"timing":{"queue_ms":2,"exec_ms":38}}"#; + let message: BrowserMessage = serde_json::from_str(raw).unwrap(); + + assert_eq!( + message, + BrowserMessage::Response { + seq: 7, + success: true, + data: serde_json::json!({"text": "提交成功"}), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 2, + exec_ms: 38, + }, + } + ); +}