chore: seed sgclaw rust baseline
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
.worktrees/
|
.worktrees/
|
||||||
|
target/
|
||||||
|
|||||||
28
AGENTS.md
Normal file
28
AGENTS.md
Normal file
@@ -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.
|
||||||
577
Cargo.lock
generated
Normal file
577
Cargo.lock
generated
Normal file
@@ -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"
|
||||||
13
Cargo.toml
Normal file
13
Cargo.toml
Normal file
@@ -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"] }
|
||||||
16
README.md
16
README.md
@@ -1,3 +1,19 @@
|
|||||||
# sgClaw
|
# sgClaw
|
||||||
|
|
||||||
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
|
||||||
|
```
|
||||||
|
|||||||
396
docs/browser_team_kickoff.md
Normal file
396
docs/browser_team_kickoff.md
Normal file
@@ -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":"<hex>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段要求:
|
||||||
|
|
||||||
|
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":"<hex>"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器回:
|
||||||
|
|
||||||
|
```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`
|
||||||
|
|
||||||
|
除以上四项外,本周期内其他细节不应阻塞浏览器侧开发。
|
||||||
|
|
||||||
390
docs/sgclaw_project_team_kickoff.md
Normal file
390
docs/sgclaw_project_team_kickoff.md
Normal file
@@ -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":"<hex>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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":"<hex>"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器回:
|
||||||
|
|
||||||
|
```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 团队阻塞项。
|
||||||
|
|
||||||
@@ -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
|
||||||
|
<seq>\n<action>\n<stable_json(params)>\n<expected_domain>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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`
|
||||||
@@ -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: `<seq>\n<action>\n<stable_json(params)>\n<expected_domain>`.
|
||||||
|
- 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
|
||||||
10
resources/rules.json
Normal file
10
resources/rules.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/lib.rs
Normal file
37
src/lib.rs
Normal file
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/main.rs
Normal file
10
src/main.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
103
src/pipe/browser_tool.rs
Normal file
103
src/pipe/browser_tool.rs
Normal file
@@ -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<Value>,
|
||||||
|
pub timing: Timing,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BrowserPipeTool<T: Transport> {
|
||||||
|
transport: Arc<T>,
|
||||||
|
mac_policy: MacPolicy,
|
||||||
|
session_key: Vec<u8>,
|
||||||
|
next_seq: AtomicU64,
|
||||||
|
response_timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Transport> BrowserPipeTool<T> {
|
||||||
|
pub fn new(transport: Arc<T>, mac_policy: MacPolicy, session_key: Vec<u8>) -> 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<CommandOutput, PipeError> {
|
||||||
|
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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/pipe/handshake.rs
Normal file
53
src/pipe/handshake.rs
Normal file
@@ -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<u8>,
|
||||||
|
pub capabilities: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn perform_handshake<T: Transport>(
|
||||||
|
transport: &T,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<HandshakeResult, PipeError> {
|
||||||
|
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:?}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/pipe/mod.rs
Normal file
125
src/pipe/mod.rs
Normal file
@@ -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<BrowserMessage, PipeError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StdioTransport {
|
||||||
|
rx: Mutex<mpsc::Receiver<Result<BrowserMessage, PipeError>>>,
|
||||||
|
writer: Mutex<Box<dyn Write + Send>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StdioTransport {
|
||||||
|
pub fn new<R, W>(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::<BrowserMessage>(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<BrowserMessage, PipeError> {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/pipe/protocol.rs
Normal file
115
src/pipe/protocol.rs
Normal file
@@ -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<String>,
|
||||||
|
},
|
||||||
|
Response {
|
||||||
|
seq: u64,
|
||||||
|
success: bool,
|
||||||
|
#[serde(default = "default_object")]
|
||||||
|
data: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
aom_snapshot: Vec<Value>,
|
||||||
|
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<Action>,
|
||||||
|
},
|
||||||
|
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<Action> {
|
||||||
|
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!({})
|
||||||
|
}
|
||||||
48
src/security/hmac.rs
Normal file
48
src/security/hmac.rs
Normal file
@@ -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<Sha256>;
|
||||||
|
|
||||||
|
pub fn derive_session_key(hmac_seed: &str) -> Result<Vec<u8>, 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<String, SecurityError> {
|
||||||
|
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()))
|
||||||
|
}
|
||||||
111
src/security/mac_policy.rs
Normal file
111
src/security/mac_policy.rs
Normal file
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PipeActionRules {
|
||||||
|
pub allowed: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub blocked: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MacPolicy {
|
||||||
|
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, SecurityError> {
|
||||||
|
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<Self, SecurityError> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
27
src/security/mod.rs
Normal file
27
src/security/mod.rs
Normal file
@@ -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),
|
||||||
|
}
|
||||||
BIN
superrpa_sgclaw_doc_paths_20260325.zip
Normal file
BIN
superrpa_sgclaw_doc_paths_20260325.zip
Normal file
Binary file not shown.
84
tests/browser_tool_test.rs
Normal file
84
tests/browser_tool_test.rs
Normal file
@@ -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"));
|
||||||
|
}
|
||||||
38
tests/common/mod.rs
Normal file
38
tests/common/mod.rs
Normal file
@@ -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<VecDeque<BrowserMessage>>,
|
||||||
|
sent: Mutex<Vec<AgentMessage>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockTransport {
|
||||||
|
pub fn new(messages: Vec<BrowserMessage>) -> Self {
|
||||||
|
Self {
|
||||||
|
incoming: Mutex::new(VecDeque::from(messages)),
|
||||||
|
sent: Mutex::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sent_messages(&self) -> Vec<AgentMessage> {
|
||||||
|
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<BrowserMessage, PipeError> {
|
||||||
|
self.incoming
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.pop_front()
|
||||||
|
.ok_or(PipeError::Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
2
tests/handshake_flow_test.rs
Normal file
2
tests/handshake_flow_test.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#[path = "integration/handshake_flow_test.rs"]
|
||||||
|
mod handshake_flow_test;
|
||||||
89
tests/integration/handshake_flow_test.rs
Normal file
89
tests/integration/handshake_flow_test.rs
Normal file
@@ -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<Mutex<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<usize> {
|
||||||
|
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
|
||||||
|
));
|
||||||
|
}
|
||||||
41
tests/pipe_handshake_test.rs
Normal file
41
tests/pipe_handshake_test.rs
Normal file
@@ -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"));
|
||||||
|
}
|
||||||
59
tests/pipe_protocol_test.rs
Normal file
59
tests/pipe_protocol_test.rs
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user