diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3494979..71d2cb4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,7 +9,8 @@ - [ ] `python3 -m unittest discover -s tests -v` - [ ] `python3 scripts/validate_skill.py .` - [ ] `python3 scripts/verify_release.py .` +- [ ] `python3 scripts/build_integrations.py` +- [ ] `python3 scripts/validate_integrations.py` - [ ] No raw prompts, credentials, private logs, or executable model files added ## Safety and compatibility impact - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a0038e..fe60a16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,12 +32,19 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Compile - run: python -m compileall -q scripts tests + run: python -m compileall -q scripts tests integrations/local-audit-planner - name: Unit and integration tests run: python -m unittest discover -s tests -v - name: Validate Skill run: python scripts/validate_skill.py . - name: Verify release gates run: python scripts/verify_release.py . + - name: Build and validate host integrations + run: | + python scripts/build_integrations.py --output-dir dist/integrations + python scripts/validate_integrations.py --build-dir dist/integrations + python scripts/benchmark_integrations.py \ + --build-dir dist/integrations \ + --output "${RUNNER_TEMP}/integrations-v0.2.json" - name: Build reproducible archive run: python scripts/build_archive.py --output-dir dist diff --git a/.github/workflows/openclaw-compat.yml b/.github/workflows/openclaw-compat.yml index 0a74af8..ab7d8a5 100644 --- a/.github/workflows/openclaw-compat.yml +++ b/.github/workflows/openclaw-compat.yml @@ -8,6 +8,9 @@ on: - "scripts/**" - "assets/**" - "references/**" + - "integrations/**" + - "scripts/build_integrations.py" + - "scripts/validate_integrations.py" - ".github/workflows/openclaw-compat.yml" pull_request: paths: @@ -15,6 +18,9 @@ on: - "scripts/**" - "assets/**" - "references/**" + - "integrations/**" + - "scripts/build_integrations.py" + - "scripts/validate_integrations.py" - ".github/workflows/openclaw-compat.yml" schedule: - cron: "29 4 * * 1" @@ -35,14 +41,20 @@ jobs: with: python-version: "3.11" - name: Build portable archive - run: python scripts/build_archive.py --output-dir dist - - name: Prepare clean source and workspace run: | - mkdir -p "${RUNNER_TEMP}/route-source" "${RUNNER_TEMP}/openclaw-home" - tar -xzf dist/route-openclaw-task-v0.1.0.tar.gz \ + python scripts/build_archive.py --output-dir dist + python scripts/build_integrations.py --output-dir dist/integrations + - name: Prepare clean source and isolated homes + run: | + mkdir -p \ + "${RUNNER_TEMP}/route-source" \ + "${RUNNER_TEMP}/openclaw-home" \ + "${RUNNER_TEMP}/openclaw-plugin-home" + tar -xzf dist/route-openclaw-task-v0.2.0.tar.gz \ --strip-components=1 -C "${RUNNER_TEMP}/route-source" chmod -R a+rX "${RUNNER_TEMP}/route-source" - chmod 777 "${RUNNER_TEMP}/openclaw-home" + chmod a+r dist/integrations/route-openclaw-task-openclaw-native-v0.2.0.tar.gz + chmod 777 "${RUNNER_TEMP}/openclaw-home" "${RUNNER_TEMP}/openclaw-plugin-home" - name: Install with the official OpenClaw CLI run: | docker run --rm --network none --read-only --cap-drop ALL \ @@ -72,3 +84,41 @@ jobs: --goal "Read-only: inspect the repository planner architecture." \ > "${RUNNER_TEMP}/route.json" python -c 'import json, os; p=json.load(open(os.environ["RUNNER_TEMP"]+"/route.json")); assert p["schema_version"]=="1.0" and p["planner_profile"]=="terminal_cli_workflow" and not p["execution_tools"]' + - name: Install the native plugin archive + run: | + docker run --rm --network none --read-only --cap-drop ALL \ + --security-opt no-new-privileges \ + --tmpfs /tmp:rw,noexec,nosuid,size=128m \ + -v "${RUNNER_TEMP}/openclaw-plugin-home:/home/node:rw" \ + -v "${PWD}/dist/integrations/route-openclaw-task-openclaw-native-v0.2.0.tar.gz:/plugin.tar.gz:ro" \ + "${OPENCLAW_IMAGE}" node /app/openclaw.mjs \ + plugins install /plugin.tar.gz + - name: Load the native runtime and verify the hook + run: | + docker run --rm --network none --read-only --cap-drop ALL \ + --security-opt no-new-privileges \ + --tmpfs /tmp:rw,noexec,nosuid,size=128m \ + -v "${RUNNER_TEMP}/openclaw-plugin-home:/home/node:rw" \ + "${OPENCLAW_IMAGE}" node /app/openclaw.mjs \ + plugins inspect route-openclaw-task --runtime --json \ + > "${RUNNER_TEMP}/plugin-info.json" + python -c 'import json, os; p=json.load(open(os.environ["RUNNER_TEMP"]+"/plugin-info.json")); q=p["plugin"]; assert q["status"]=="loaded" and q["version"]=="0.2.0" and p["typedHooks"]==[{"name":"before_prompt_build","priority":20}]' + - name: Verify the embedded Skill and installed bridge + run: | + docker run --rm --network none --read-only --cap-drop ALL \ + --security-opt no-new-privileges \ + --tmpfs /tmp:rw,noexec,nosuid,size=128m \ + -v "${RUNNER_TEMP}/openclaw-plugin-home:/home/node:rw" \ + "${OPENCLAW_IMAGE}" node /app/openclaw.mjs \ + skills info route-openclaw-task --json \ + > "${RUNNER_TEMP}/plugin-skill-info.json" + python -c 'import json, os; p=json.load(open(os.environ["RUNNER_TEMP"]+"/plugin-skill-info.json")); assert p["eligible"] and p["modelVisible"] and not p["missing"]["bins"]' + docker run --rm --network none --read-only --cap-drop ALL \ + --security-opt no-new-privileges \ + --tmpfs /tmp:rw,noexec,nosuid,size=128m \ + -v "${RUNNER_TEMP}/openclaw-plugin-home:/home/node:rw" \ + "${OPENCLAW_IMAGE}" node --input-type=module -e \ + 'const m=await import("file:///home/node/.openclaw/extensions/route-openclaw-task/router-bridge.mjs"); const d=await m.routePrompt("Read-only: inspect the repository planner architecture."); console.log(m.buildRouteContext(d));' \ + > "${RUNNER_TEMP}/bridge-context.txt" + grep -q '' "${RUNNER_TEMP}/bridge-context.txt" + grep -q '"schema_version":"1.0"' "${RUNNER_TEMP}/bridge-context.txt" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9cf8109..ef49f6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,11 @@ jobs: python scripts/validate_skill.py . python scripts/verify_release.py . - name: Build archive - run: python scripts/build_archive.py --output-dir dist --version "${GITHUB_REF_NAME#v}" + run: | + python scripts/build_archive.py --output-dir dist --version "${GITHUB_REF_NAME#v}" + python scripts/build_integrations.py \ + --output-dir dist/integrations \ + --version "${GITHUB_REF_NAME#v}" - name: Publish GitHub release uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 with: @@ -30,3 +34,5 @@ jobs: files: | dist/*.tar.gz dist/SHA256SUMS + dist/integrations/*.tar.gz + dist/integrations/INTEGRATION_SHA256SUMS diff --git a/CHANGELOG.md b/CHANGELOG.md index 3261ad0..3524dae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,20 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1. ## [Unreleased] +## [0.2.0] - 2026-07-11 + +### Added + +- Version-constrained OpenClaw native plugin with a fail-open `before_prompt_build` adapter. +- Standard Codex and Claude Code plugin bundles built from the same hash-locked core Skill. +- Version-locked `LocalAuditPlanner` research adapter, explicitly separated from upstream OpenClaw. +- Reproducible multi-host bundle builder, offline validators, and a 240-case integration-contract benchmark. +- Official OpenClaw container gate for native installation, runtime hook loading, Skill discovery, and bridge execution. + ### Changed - Updated and SHA-pinned the GitHub Actions toolchain to Checkout 7.0.0, Setup Python 6.3.0, CodeQL 4.37.0, and Action GH Release 3.0.1; future Action updates are grouped into one Dependabot PR. +- Bumped release metadata to 0.2.0 while preserving the v0.1.0 router, model tables, and routing contract byte-for-byte. ## [0.1.0] - 2026-07-11 @@ -20,5 +31,6 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1. - Offline test suite, release verifier, CI matrix, CodeQL, and tagged archive workflow. - ToolSandbox and 1,000-task aggregate benchmark evidence. -[Unreleased]: https://github.com/RTPI-ltc/route-openclaw-task/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/RTPI-ltc/route-openclaw-task/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/RTPI-ltc/route-openclaw-task/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/RTPI-ltc/route-openclaw-task/releases/tag/v0.1.0 diff --git a/CITATION.cff b/CITATION.cff index 1219892..f969595 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,7 +2,7 @@ cff-version: 1.2.0 message: "If you use OpenClaw Task Router in research, please cite this software." title: "OpenClaw Task Router" type: software -version: 0.1.0 +version: 0.2.0 date-released: 2026-07-11 license: MIT repository-code: "https://github.com/RTPI-ltc/route-openclaw-task" diff --git a/README.md b/README.md index efed12c..ad0eb39 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Deterministic, auditable planner routing for OpenClaw. [![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-3776AB.svg)](https://www.python.org/) [![Agent Skills](https://img.shields.io/badge/Agent%20Skills-compatible-111827.svg)](https://agentskills.io/) -[中文说明](README.zh-CN.md) | [Benchmarks](docs/BENCHMARKS.md) | [Security](SECURITY.md) | [Architecture](docs/ARCHITECTURE.md) +[中文说明](README.zh-CN.md) | [Integrations](docs/INTEGRATIONS.md) | [Benchmarks](docs/BENCHMARKS.md) | [Security](SECURITY.md) | [Architecture](docs/ARCHITECTURE.md) `route-openclaw-task` turns a natural-language task into a bounded JSON routing decision before any tool runs. It selects a planner profile, executor family, context policy, permission behavior, and next action while preserving OpenClaw's runtime permission engine as the final authority. @@ -50,7 +50,7 @@ The same model, image, planner policy, and security controls were used on both s ### From GitHub ```bash -openclaw skills install git:RTPI-ltc/route-openclaw-task@v0.1.0 +openclaw skills install git:RTPI-ltc/route-openclaw-task@v0.2.0 ``` OpenClaw installs Git skills into the active workspace's `skills/` directory. Pin a release tag in production instead of tracking `main`. @@ -64,6 +64,22 @@ openclaw skills install ./route-openclaw-task Requirements: OpenClaw, Python 3.10 or newer, and no third-party Python packages. The release is install-and-run tested against the official OpenClaw `2026.6.11` image in a network-disabled, read-only-root container. +### Native Runtime Integration + +The v0.2 release also ships a version-constrained OpenClaw native plugin. It +routes every prompt through the unchanged core before planning and falls back +to the baseline planner on any adapter error: + +```bash +openclaw plugins install ./route-openclaw-task-openclaw-native-v0.2.0.tar.gz +openclaw plugins inspect route-openclaw-task --runtime --json +``` + +Codex and Claude Code plugin bundles are published from the same core. Their +v0.2 evidence is offline contract E2E only: Codex was not modified or invoked, +and Claude Code was not installed or started. See the precise +[compatibility matrix and evidence levels](docs/INTEGRATIONS.md). + ## 60-Second Demo ```bash @@ -112,7 +128,7 @@ flowchart LR H --> I[Verifier and audit log] ``` -The skill returns advisory policy. OpenClaw still owns candidate generation, search, permission decisions, execution, and verification. An optional deep integration for `LocalAuditPlanner` is documented in [OpenClaw integration](docs/OPENCLAW_INTEGRATION.md). +The skill returns advisory policy. OpenClaw still owns candidate generation, search, permission decisions, execution, and verification. The portable native plugin and the separately version-locked `LocalAuditPlanner` research adapter are documented in [OpenClaw integration](docs/OPENCLAW_INTEGRATION.md). ## Security @@ -130,6 +146,8 @@ Read [SECURITY.md](SECURITY.md) before connecting the skill to privileged tools. python3 -m unittest discover -s tests -v python3 scripts/validate_skill.py . python3 scripts/verify_release.py . +python3 scripts/build_integrations.py +python3 scripts/validate_integrations.py ``` CI runs these checks on Python 3.10 through 3.13. Pull requests must include a routing test for behavior changes and must keep benchmark promotion gates green. @@ -143,6 +161,7 @@ assets/ Auditable Naive Bayes model tables references/ Routing contract and model card tests/ Offline unit, CLI, invariant, and packaging tests docs/ Architecture, integration, and benchmark details +integrations/ Host manifests and thin adapter templates examples/ Safe synthetic input examples ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 5ef67ba..699a4f7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,7 +2,7 @@ 面向 OpenClaw 的确定性、可审计 planner 路由 Skill。 -[English](README.md) | [完整评测](docs/BENCHMARKS.md) | [安全策略](SECURITY.md) | [架构](docs/ARCHITECTURE.md) +[English](README.md) | [宿主集成](docs/INTEGRATIONS.md) | [完整评测](docs/BENCHMARKS.md) | [安全策略](SECURITY.md) | [架构](docs/ARCHITECTURE.md) `route-openclaw-task` 在工具执行前,把自然语言任务转换成有界 JSON 决策:planner profile、执行器类型、上下文策略、权限行为和下一步动作。它不会生成命令,也不会替代 OpenClaw 的权限引擎。 @@ -23,7 +23,7 @@ ## 安装 ```bash -openclaw skills install git:RTPI-ltc/route-openclaw-task@v0.1.0 +openclaw skills install git:RTPI-ltc/route-openclaw-task@v0.2.0 ``` 本地安装: @@ -35,6 +35,21 @@ openclaw skills install ./route-openclaw-task 要求 Python 3.10+,不需要第三方 Python 包、网络或 API key。发布流程会在官方 OpenClaw `2026.6.11` 镜像中,以断网、只读根文件系统方式验证安装、识别和执行。 +### v0.2 宿主集成 + +v0.2 额外发布 OpenClaw 原生插件,它会在 prompt 构建前调用同一套 router, +adapter 超时或失败时回退到原 planner: + +```bash +openclaw plugins install ./route-openclaw-task-openclaw-native-v0.2.0.tar.gz +openclaw plugins inspect route-openclaw-task --runtime --json +``` + +同一核心还会构建 Codex 和 Claude Code 标准插件包。按照本次约束,Codex +没有被修改或调用,Claude Code 也没有安装或启动;这两项只声明离线 manifest、 +目录发现、核心哈希和包内执行验证,不冒充原生 runtime E2E。详细版本范围与证据 +等级见 [宿主集成说明](docs/INTEGRATIONS.md)。 + ## 快速使用 ```bash @@ -66,6 +81,8 @@ python3 scripts/validate_route.py routes.jsonl python3 -m unittest discover -s tests -v python3 scripts/validate_skill.py . python3 scripts/verify_release.py . +python3 scripts/build_integrations.py +python3 scripts/validate_integrations.py ``` 项目代码采用 [MIT License](LICENSE),第三方数据和 benchmark 保留原始许可,详见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。 diff --git a/SKILL.md b/SKILL.md index 7fe732b..5a65eb1 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2,7 +2,7 @@ name: route-openclaw-task description: Classify task-oriented requests into an OpenClaw planner profile, execution tools, safety policy, model tier, context policy, and next action. Use when deciding how an agent task should be planned or delegated, selecting CLI, file, mobile GUI, mobile CLI, MCP, or deployment executors, determining whether to continue, replan, refuse, or await human approval, or evaluating routing behavior across benchmark tasks. license: MIT -metadata: {"version":"0.1.0","compatibility":"OpenClaw with Python 3.10 or newer; no network access or API key required","openclaw":{"requires":{"bins":["python3"]}}} +metadata: {"version":"0.2.0","compatibility":"OpenClaw with Python 3.10 or newer; no network access or API key required","openclaw":{"requires":{"bins":["python3"]}}} --- # Route OpenClaw Task diff --git a/benchmarks/integrations-v0.2.json b/benchmarks/integrations-v0.2.json new file mode 100644 index 0000000..2f7ed5b --- /dev/null +++ b/benchmarks/integrations-v0.2.json @@ -0,0 +1,43 @@ +{ + "benchmark_version": "1.0", + "capability_metrics_source": [ + "benchmarks/generalization-1k.json", + "benchmarks/toolsandbox-router.json", + "benchmarks/toolsandbox-integrated.json" + ], + "case_generation": { + "base_goals": 12, + "case_set_sha256": "f8cf5347a8a3c8699e6ed0fd8371ea0441ff21e777629be91e4bdfc7452f7e34", + "cases": 240, + "modifiers": 20, + "raw_external_benchmark_rows_packaged": false + }, + "hosts": { + "claude-code": { + "cases": 240, + "exact_core_parity": 1.0, + "mean_route_seconds": 0.0011545204557478427, + "p95_route_seconds": 0.0012631900608539581, + "safety_invariant_rate": 1.0, + "schema_valid_rate": 1.0 + }, + "codex": { + "cases": 240, + "exact_core_parity": 1.0, + "mean_route_seconds": 0.001154757182424267, + "p95_route_seconds": 0.0012666098773479462, + "safety_invariant_rate": 1.0, + "schema_valid_rate": 1.0 + }, + "openclaw-native": { + "cases": 240, + "exact_core_parity": 1.0, + "mean_route_seconds": 0.0011537160724401474, + "p95_route_seconds": 0.0012631416320800781, + "safety_invariant_rate": 1.0, + "schema_valid_rate": 1.0 + } + }, + "kind": "synthetic_integration_contract_e2e", + "release_version": "0.2.0" +} diff --git a/benchmarks/manifest.json b/benchmarks/manifest.json index 38e2bd4..3e9cbec 100644 --- a/benchmarks/manifest.json +++ b/benchmarks/manifest.json @@ -28,6 +28,7 @@ "files": { "generalization-1k.json": "819ec625a3c1053077a8b0f2a45ad893520a8ff03d92dfbb525c8aaa269fd0ea", "toolsandbox-integrated.json": "80884e254260f4d8bf4f5196f8bdd49194ad0720d56ef65b58f09c8e5572e704", - "toolsandbox-router.json": "2eb7203f2f968b5b5f4b98ed2ddea537121ae8c611b8fbef75093bb8687ca35b" + "toolsandbox-router.json": "2eb7203f2f968b5b5f4b98ed2ddea537121ae8c611b8fbef75093bb8687ca35b", + "integrations-v0.2.json": "d33fc712162bdea34a0928eba4128d5ec9ee253a31a36fcd7c506e90a55f8db2" } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 94046c3..3266c75 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -44,6 +44,12 @@ See [the model card](../references/model-card.md) for provenance and limitations The router returns advisory JSON. In the standard skill flow, the agent reads the decision before constructing its plan. In the optional deep integration, the decision becomes a bounded desired-tool constraint for `LocalAuditPlanner` search. +The v0.2 native OpenClaw adapter is deliberately thinner: it registers +`before_prompt_build`, invokes the unchanged router out of process without a +shell, and appends a bounded advisory context. Codex and Claude Code bundles add +only host-standard discovery metadata around the same Skill. No adapter owns +permission checks or execution. + OpenClaw remains responsible for: - candidate generation; @@ -67,4 +73,3 @@ OpenClaw remains responsible for: - Retrain the public profile prior only from license-reviewed dev data. - Add executor families only after updating the routing contract, validators, tests, and promotion gates. - Integrate a new planner by consuming the stable JSON contract instead of importing internal router functions. - diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index 755f196..bc0104b 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -68,6 +68,20 @@ Files: - `benchmarks/toolsandbox-router.json` - `benchmarks/generalization-1k.json` - `benchmarks/manifest.json` +- `benchmarks/integrations-v0.2.json` + +## v0.2 Integration Contract Benchmark + +The bundle gate generates 240 safe synthetic variations from 12 task intents +and 20 context modifiers. It executes the embedded router from the OpenClaw, +Codex, and Claude Code release bundles. All three require 100% schema validity, +100% exact output parity with the root Skill, and 100% safety-field integrity. + +This is a packaging and adapter E2E benchmark, not a new capability-quality +dataset. Capability claims remain tied to the independently sourced +ToolSandbox and 1,000-task suites above. OpenClaw additionally receives a native +official-container install/load/bridge test; Codex and Claude Code remain +offline-contract-only by explicit constraint. ## Reproduction @@ -76,6 +90,9 @@ Unit and synthetic acceptance tests require no network: ```bash python3 -m unittest discover -s tests -v python3 scripts/verify_release.py . +python3 scripts/build_integrations.py +python3 scripts/validate_integrations.py +python3 scripts/benchmark_integrations.py ``` The full ToolSandbox and 1,000-task evaluations require separately obtained upstream datasets or the OpenClaw optimization research workspace. They are intentionally not downloaded by the skill. @@ -86,4 +103,3 @@ The full ToolSandbox and 1,000-task evaluations require separately obtained upst - The integrated comparison uses one hosted model family and one OpenClaw planner implementation. - Holdout contains 46 ToolSandbox tasks; broader claims require more independently sourced task suites. - Mean remote-model latency did not improve materially. The skill improves route quality, not model serving speed. - diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md new file mode 100644 index 0000000..361ad7b --- /dev/null +++ b/docs/INTEGRATIONS.md @@ -0,0 +1,86 @@ +# Host Integrations + +Version 0.2 packages one hash-locked Skill for multiple agent hosts. The +router, model tables, and routing contract are byte-identical to v0.1.0; each +adapter only handles host discovery, invocation, bounded context transfer, and +failure fallback. + +## Compatibility matrix + +| Host | Constraint | Package | Validation | Native host run | +|---|---|---|---|---:| +| OpenClaw | `>=2026.6.11 <2026.7.0` | native plugin | official container E2E | Yes | +| Codex | `.codex-plugin/plugin.json` contract, 2026-07-11 snapshot | plugin bundle | official manifest validator + offline contract E2E | No | +| Claude Code | `>=2.1.142 <3.0.0` | plugin bundle | hash-locked schema + offline contract E2E | No | +| LocalAuditPlanner fork | exact API fingerprints | Python adapter | adapter contract + prior task benchmark | Yes | + +The Codex package was not installed into or invoked through the owner's Codex +environment. Claude Code was not installed or started. Those rows therefore do +not claim native runtime execution. Their evidence proves package discovery +shape, manifest contract, embedded core identity, and router execution from the +built package. + +## OpenClaw native plugin + +Install the `route-openclaw-task-openclaw-native-v0.2.0.tar.gz` release asset: + +```bash +openclaw plugins install ./route-openclaw-task-openclaw-native-v0.2.0.tar.gz +openclaw plugins inspect route-openclaw-task --runtime --json +``` + +The plugin registers only `before_prompt_build`. It invokes the bundled Python +router with `execFile`, forwards no ambient secrets, returns a bounded advisory +JSON context, and falls back to the unmodified planner on timeout or error. It +registers no tools, services, providers, channels, or network clients. + +The release gate uses the exact official image digest in +`integrations/compatibility.json` with networking disabled, a read-only root +filesystem, all Linux capabilities dropped, and `no-new-privileges`. + +## Codex bundle + +The `route-openclaw-task-codex-v0.2.0.tar.gz` asset contains: + +```text +.codex-plugin/plugin.json +skills/route-openclaw-task/SKILL.md +skills/route-openclaw-task/scripts/... +skills/route-openclaw-task/assets/... +``` + +It does not contain hooks, MCP servers, apps, credentials, or host +configuration. Installation is intentionally left to the target Codex +environment; v0.2 validation does not mutate the owner's Codex setup. + +## Claude Code bundle + +The `route-openclaw-task-claude-code-v0.2.0.tar.gz` asset uses the standard +`.claude-plugin/plugin.json` plus `skills/` layout. Its declared compatibility +range is `>=2.1.142 <3.0.0`. The official manifest schema snapshot URL, +generation timestamp, and SHA-256 are recorded in the compatibility contract. + +The package can be tested by an operator in a separate Claude Code environment +using that host's `--plugin-dir` flow. This project did not execute that command +for v0.2. + +## LocalAuditPlanner adapter + +`local-audit-planner` is a deep adapter for the research Python planner, not an +upstream OpenClaw API. It must only be used when the target `planner.py` and +`search_planner.py` match the recorded fingerprints. The native OpenClaw plugin +is the portable integration. + +## Contract benchmark + +`scripts/benchmark_integrations.py` builds 240 safe synthetic task variations +and executes the router from each generated host bundle. Promotion requires: + +- 100% schema validity; +- 100% exact parity with the root Skill; +- 100% preservation of policy, permission, next-action, and safety fields; +- unchanged v0.1 behavioral-core hashes. + +This integration benchmark proves packaging and adapter parity. Capability +quality remains grounded in the separate 258-task ToolSandbox and 1,000-task +generalization evaluations. diff --git a/docs/OPENCLAW_INTEGRATION.md b/docs/OPENCLAW_INTEGRATION.md index 5e2e7b4..b1c582b 100644 --- a/docs/OPENCLAW_INTEGRATION.md +++ b/docs/OPENCLAW_INTEGRATION.md @@ -5,13 +5,36 @@ Install the Git repository directly: ```bash -openclaw skills install git:RTPI-ltc/route-openclaw-task@v0.1.0 +openclaw skills install git:RTPI-ltc/route-openclaw-task@v0.2.0 ``` OpenClaw discovers `SKILL.md` at the repository root. When the task matches the trigger description, the agent can invoke `scripts/route_task.py`, inspect the validated JSON, and build its plan without changing OpenClaw source code. This is the recommended portable integration. +## Native Runtime Plugin + +Version 0.2 also publishes +`route-openclaw-task-openclaw-native-v0.2.0.tar.gz` for OpenClaw +`>=2026.6.11 <2026.7.0`: + +```bash +openclaw plugins install ./route-openclaw-task-openclaw-native-v0.2.0.tar.gz +openclaw plugins inspect route-openclaw-task --runtime --json +``` + +The plugin registers a single `before_prompt_build` hook and embeds this Skill. +The hook invokes `route_task.py` with Node `execFile`, a bounded prompt length, +a timeout, a 1 MiB output cap, and a minimal environment. It appends a bounded +advisory decision and falls back without changing the baseline planner if the +router fails. It has no execution tool, service, provider, channel, network +client, or credential access. + +The v0.2 release gate installs the archive into the official OpenClaw +`2026.6.11` image, loads the runtime, verifies the typed hook and embedded +Skill, and executes the installed bridge with networking disabled and a +read-only root filesystem. + ## Planner Contract Consume these fields: @@ -44,7 +67,7 @@ Required invariants: The reference research integration loads the workspace skill when `OPENCLAW_PLANNER_SKILL=route-openclaw-task`, emits `planner_skill_route` and `planner_skill_candidates` audit events, expands missing executor candidates, and passes a bounded `desired_tools_override` into A*/Reflexion search. -This adapter is validated against the `LocalAuditPlanner` implementation in `hopercheche/openclaw_optimization`. It is not claimed to be a stable upstream OpenClaw extension point. Consumers should integrate through the JSON contract unless they maintain the same planner API. +This adapter is validated against the `LocalAuditPlanner` implementation in `hopercheche/openclaw_optimization`. It is not claimed to be a stable upstream OpenClaw extension point. The v0.2 adapter bundle records the exact target file fingerprints and refuses an upstream-portability claim. Consumers should use the native plugin or JSON contract unless they maintain the same planner API. ## Security Configuration @@ -65,7 +88,7 @@ The router itself does not need an API key or network access. Pin a release tag: ```bash -openclaw skills install git:RTPI-ltc/route-openclaw-task@v0.1.0 +openclaw skills install git:RTPI-ltc/route-openclaw-task@v0.2.0 ``` For rollback, reinstall the previous tag. Git-installed skills are reinstalled to update; OpenClaw's tracked `skills update` flow applies to ClawHub installs. diff --git a/integrations/claude-code/.claude-plugin/plugin.json b/integrations/claude-code/.claude-plugin/plugin.json new file mode 100644 index 0000000..3df413f --- /dev/null +++ b/integrations/claude-code/.claude-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "route-openclaw-task", + "version": "0.2.0", + "description": "Deterministic task routing policy packaged as an Agent Skill.", + "author": { + "name": "RTPI-ltc", + "url": "https://github.com/RTPI-ltc" + }, + "homepage": "https://github.com/RTPI-ltc/route-openclaw-task", + "repository": "https://github.com/RTPI-ltc/route-openclaw-task", + "license": "MIT", + "keywords": [ + "agent-routing", + "planner", + "openclaw" + ] +} diff --git a/integrations/claude-code/README.md b/integrations/claude-code/README.md new file mode 100644 index 0000000..87818ff --- /dev/null +++ b/integrations/claude-code/README.md @@ -0,0 +1,10 @@ +# Claude Code integration + +This package follows the Claude Code plugin layout with +`.claude-plugin/plugin.json` and `skills/route-openclaw-task/SKILL.md`. + +Supported contract range: Claude Code `>=2.1.142 <3.0.0`. The manifest schema +snapshot is hash-locked in `../compatibility.json`. Per the project owner's +constraint, Claude Code is not installed or started. The release performs an +offline manifest/layout contract check and executes the embedded router across +the integration benchmark; it does not claim a native Claude runtime test. diff --git a/integrations/codex/.codex-plugin/plugin.json b/integrations/codex/.codex-plugin/plugin.json new file mode 100644 index 0000000..34b6367 --- /dev/null +++ b/integrations/codex/.codex-plugin/plugin.json @@ -0,0 +1,35 @@ +{ + "name": "route-openclaw-task", + "version": "0.2.0", + "description": "Deterministic task routing policy packaged as a Codex Skill.", + "author": { + "name": "RTPI-ltc", + "url": "https://github.com/RTPI-ltc" + }, + "homepage": "https://github.com/RTPI-ltc/route-openclaw-task", + "repository": "https://github.com/RTPI-ltc/route-openclaw-task", + "license": "MIT", + "keywords": [ + "agent-routing", + "planner", + "openclaw" + ], + "skills": "./skills/", + "interface": { + "displayName": "Route OpenClaw Task", + "shortDescription": "Classify agent tasks before planning or execution.", + "longDescription": "Uses a deterministic, auditable Skill to select planning profile, execution tools, policy, context, model tier, and next action.", + "developerName": "RTPI-ltc", + "category": "Developer Tools", + "capabilities": [ + "Task routing", + "Safety policy" + ], + "websiteURL": "https://github.com/RTPI-ltc/route-openclaw-task", + "defaultPrompt": [ + "Route this task before planning it.", + "Classify the safest executor and next action." + ], + "brandColor": "#1F6FEB" + } +} diff --git a/integrations/codex/README.md b/integrations/codex/README.md new file mode 100644 index 0000000..bb8f589 --- /dev/null +++ b/integrations/codex/README.md @@ -0,0 +1,10 @@ +# Codex integration + +This is a standard Codex plugin bundle containing one Skill. It adds no MCP +server, app, hook, or credential requirement. + +The v0.2 release builds and validates this bundle without installing it into or +invoking the user's Codex environment. Validation covers the official Codex +plugin manifest contract, Skill discovery layout, embedded core hashes, and +end-to-end router execution from the built bundle. Native Codex runtime +compatibility is therefore not claimed as executed evidence. diff --git a/integrations/compatibility.json b/integrations/compatibility.json new file mode 100644 index 0000000..0608f7d --- /dev/null +++ b/integrations/compatibility.json @@ -0,0 +1,49 @@ +{ + "schema_version": "1.0", + "release_version": "0.2.0", + "core_skill": { + "name": "route-openclaw-task", + "behavioral_baseline": "v0.1.0", + "behavioral_core_unchanged": true, + "locked_files": { + "scripts/route_task.py": "2f2cdbd437acb2be24eaa69a9f7bf9970f1330033760a97ddb02459a32866bcb", + "assets/tool-family-model.json": "5f52ab87478b1ea9152029ff90e1150d8337b937493e6e8477617ee698315f3f", + "assets/profile-policy-model.json": "2af891d851a2ac1a32f7382b1b98b894467c1710a416c2f419f00b3ff74aa6fe", + "references/routing-contract.md": "46fcf74505dc65ccbf737fe63d0f8c21df64f947a1838d40999e39f4f277d26e" + } + }, + "hosts": { + "openclaw": { + "bundle": "openclaw-native", + "host_constraint": ">=2026.6.11 <2026.7.0", + "tested_version": "2026.6.11", + "tested_image": "ghcr.io/openclaw/openclaw@sha256:3814fb1f62f9cfc5944de088c5817c68c88b5d721feebe36420b666a90a61ce7", + "validation_mode": "native_container_e2e", + "native_host_executed": true + }, + "codex": { + "bundle": "codex", + "host_constraint": ".codex-plugin/plugin.json contract documented 2026-07-11", + "validator": "Codex plugin-creator validate_plugin.py", + "validation_mode": "offline_contract_e2e", + "native_host_executed": false, + "native_host_reason": "The integration is intentionally not installed into or invoked through the user's Codex environment." + }, + "claude_code": { + "bundle": "claude-code", + "host_constraint": ">=2.1.142 <3.0.0", + "schema_url": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "schema_sha256": "3f69938d71a47a72fa60050b2050dd620054708911defc1c1dcd7188dcb169f5", + "schema_generated_at": "2026-04-23T05:09:41.810Z", + "validation_mode": "offline_contract_e2e", + "native_host_executed": false, + "native_host_reason": "Claude Code is intentionally not installed or started." + }, + "local_audit_planner": { + "bundle": "local-audit-planner", + "host_constraint": "hopercheche/openclaw_optimization planner API snapshot based on 9c9c7937a6b896be0b4a6f4e1e1ff7eaaf0d0bb5 plus the recorded patch fingerprints", + "validation_mode": "offline_adapter_contract", + "native_host_executed": true + } + } +} diff --git a/integrations/local-audit-planner/README.md b/integrations/local-audit-planner/README.md new file mode 100644 index 0000000..0668a75 --- /dev/null +++ b/integrations/local-audit-planner/README.md @@ -0,0 +1,18 @@ +# LocalAuditPlanner adapter + +This adapter is for the research `LocalAuditPlanner` implementation in +`hopercheche/openclaw_optimization`, not for upstream OpenClaw. It loads the +workspace Skill, validates the route, maps at most two execution dependencies +into a bounded planner target, and falls back without raising into the runtime. + +Use it only when the target files match the fingerprints in `integration.json`. +Install the unchanged Skill at `skills/route-openclaw-task`, place +`planner_skill_adapter.py` under `backend/openclaw/`, retain the corresponding +planner/search-planner call sites, and enable it with: + +```bash +OPENCLAW_PLANNER_SKILL=route-openclaw-task +``` + +Do not apply this file blindly to another planner API. Upstream OpenClaw users +should use the native plugin bundle instead. diff --git a/integrations/local-audit-planner/integration.json b/integrations/local-audit-planner/integration.json new file mode 100644 index 0000000..33e2d98 --- /dev/null +++ b/integrations/local-audit-planner/integration.json @@ -0,0 +1,14 @@ +{ + "schema_version": "1.0", + "adapter_version": "0.2.0", + "target_repository": "https://github.com/hopercheche/openclaw_optimization", + "target_base_commit": "9c9c7937a6b896be0b4a6f4e1e1ff7eaaf0d0bb5", + "target_state": "base commit plus planner/search-planner patch fingerprints", + "target_fingerprints": { + "backend/openclaw/planner.py": "c849461e3091d6a9109ae6fe583919f66d731a35d9c8ee8413724afd7088f3ba", + "backend/openclaw/search_planner.py": "03537266d048f07be746aabe846e4ec01fc6cbbaaa24a5b9306400a2e6baa9d0" + }, + "adapter_sha256": "19658b5c70ecf6fe992af5c6218498a6b18595f804fd1c1f743e68abdeb21a01", + "activation_env": "OPENCLAW_PLANNER_SKILL=route-openclaw-task", + "portable_upstream_openclaw": false +} diff --git a/integrations/local-audit-planner/planner_skill_adapter.py b/integrations/local-audit-planner/planner_skill_adapter.py new file mode 100644 index 0000000..61bf7fd --- /dev/null +++ b/integrations/local-audit-planner/planner_skill_adapter.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import importlib.util +import os +import re +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from types import ModuleType +from typing import Any, Iterable + +from .models import CandidateStep + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SKILLS_ROOT = PROJECT_ROOT / "skills" +SKILL_ENV_VAR = "OPENCLAW_PLANNER_SKILL" +EXECUTION_TOOLS = { + "file_writer", + "command_runner", + "deploy_runner", + "mobile_gui_runner", + "mobile_cli_runner", + "mcp_tool_runner", +} + + +@dataclass(slots=True) +class PlannerSkillResult: + enabled: bool + skill_name: str = "" + decision: dict[str, Any] | None = None + error: str = "" + + +def route_with_planner_skill(goal: str, permission_mode: str) -> PlannerSkillResult: + skill_name = os.environ.get(SKILL_ENV_VAR, "").strip() + if not skill_name or skill_name.lower() in {"off", "none", "disabled"}: + return PlannerSkillResult(enabled=False) + if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,63}", skill_name): + return PlannerSkillResult(enabled=True, skill_name=skill_name, error="invalid skill slug") + try: + module = _load_skill_module(skill_name) + decision = module.route_task(goal, permission_mode=permission_mode) + errors = module.validate_decision(decision) + if errors: + return PlannerSkillResult( + enabled=True, + skill_name=skill_name, + error=f"invalid route decision: {'; '.join(errors)}", + ) + return PlannerSkillResult(enabled=True, skill_name=skill_name, decision=decision) + except Exception as exc: # pragma: no cover - defensive runtime fallback + return PlannerSkillResult(enabled=True, skill_name=skill_name, error=f"{type(exc).__name__}: {exc}") + + +def apply_planner_skill_route( + candidates: Iterable[CandidateStep], + decision: dict[str, Any], +) -> tuple[list[CandidateStep], list[str]]: + target_tools = planner_skill_target_tools(decision) + best_by_tool: dict[str, CandidateStep] = {} + for candidate in candidates: + current = best_by_tool.get(candidate.tool_name) + if current is None or candidate.score > current.score: + best_by_tool[candidate.tool_name] = candidate + selected = [best_by_tool[tool] for tool in target_tools if tool in best_by_tool] + return selected, target_tools + + +def planner_skill_target_tools(decision: dict[str, Any]) -> list[str]: + execution_tools = [ + tool + for tool in decision.get("execution_tools", []) + if tool in EXECUTION_TOOLS + ][:2] + if not execution_tools: + return ["goal_analyzer", "planner", "risk_model", "verifier"] + + target = ["risk_model", "planner", *execution_tools, "verifier"] + if decision.get("safety_guard"): + if len(target) < 5: + target.insert(1, "safety_guard") + else: + target = ["risk_model", "safety_guard", *execution_tools, "verifier"] + return target[:5] + + +@lru_cache(maxsize=4) +def _load_skill_module(skill_name: str) -> ModuleType: + skills_root = SKILLS_ROOT.resolve() + skill_root = (skills_root / skill_name).resolve() + if not skill_root.is_relative_to(skills_root): + raise ValueError("skill path escapes workspace skills root") + if not (skill_root / "SKILL.md").is_file(): + raise FileNotFoundError(f"missing SKILL.md for {skill_name}") + script_path = skill_root / "scripts" / "route_task.py" + if not script_path.is_file(): + raise FileNotFoundError(f"missing route_task.py for {skill_name}") + spec = importlib.util.spec_from_file_location(f"openclaw_skill_{skill_name.replace('-', '_')}", script_path) + if spec is None or spec.loader is None: + raise ImportError(f"unable to load planner skill {skill_name}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module diff --git a/integrations/openclaw-native/README.md b/integrations/openclaw-native/README.md new file mode 100644 index 0000000..76fedf8 --- /dev/null +++ b/integrations/openclaw-native/README.md @@ -0,0 +1,13 @@ +# OpenClaw native integration + +This package adds the unchanged `route-openclaw-task` Skill and a thin +`before_prompt_build` adapter. The adapter invokes the bundled Python router +with `execFile` (never a shell), appends only a bounded route decision, and +falls back to the baseline planner when routing fails. + +Supported host range: OpenClaw `>=2026.6.11 <2026.7.0`. The release gate tests +the exact official `2026.6.11` image digest recorded in +`../compatibility.json` with networking disabled. + +Runtime permission checks remain authoritative. The adapter does not register +execution tools, generate commands, access the network, or read API keys. diff --git a/integrations/openclaw-native/index.mjs b/integrations/openclaw-native/index.mjs new file mode 100644 index 0000000..92e3a9d --- /dev/null +++ b/integrations/openclaw-native/index.mjs @@ -0,0 +1,43 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { buildRouteContext, routePrompt } from "./router-bridge.mjs"; + +export default definePluginEntry({ + id: "route-openclaw-task", + name: "Route OpenClaw Task", + description: "Injects a deterministic advisory route before prompt construction.", + register(api) { + api.on( + "before_prompt_build", + async (event) => { + try { + const decision = await routePrompt(event.prompt, { config: api.pluginConfig }); + if (!decision) { + return; + } + return { appendContext: buildRouteContext(decision) }; + } catch (error) { + api.logger.warn?.( + `route-openclaw-task: router unavailable; using baseline planner (${errorCategory(error)})`, + ); + return; + } + }, + { priority: 20, timeoutMs: 5500 }, + ); + }, +}); + +function errorCategory(error) { + if (error instanceof SyntaxError) { + return "invalid-json"; + } + if (error && typeof error === "object") { + if (error.killed === true) { + return "timeout"; + } + if (error.code === "ENOENT") { + return "python-unavailable"; + } + } + return "router-error"; +} diff --git a/integrations/openclaw-native/openclaw.plugin.json b/integrations/openclaw-native/openclaw.plugin.json new file mode 100644 index 0000000..00ba976 --- /dev/null +++ b/integrations/openclaw-native/openclaw.plugin.json @@ -0,0 +1,53 @@ +{ + "id": "route-openclaw-task", + "name": "Route OpenClaw Task", + "description": "Adds a deterministic task route to the prompt before OpenClaw plans or executes.", + "version": "0.2.0", + "activation": { + "onStartup": true + }, + "skills": [ + "./skills" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "pythonBin": { + "type": "string", + "minLength": 1, + "default": "python3" + }, + "timeoutMs": { + "type": "integer", + "minimum": 100, + "maximum": 5000, + "default": 1500 + }, + "maxPromptChars": { + "type": "integer", + "minimum": 256, + "maximum": 32000, + "default": 12000 + } + } + }, + "uiHints": { + "enabled": { + "label": "Enable task routing" + }, + "pythonBin": { + "label": "Python executable" + }, + "timeoutMs": { + "label": "Router timeout (ms)" + }, + "maxPromptChars": { + "label": "Maximum prompt characters" + } + } +} diff --git a/integrations/openclaw-native/package.json b/integrations/openclaw-native/package.json new file mode 100644 index 0000000..289bfdb --- /dev/null +++ b/integrations/openclaw-native/package.json @@ -0,0 +1,23 @@ +{ + "name": "route-openclaw-task", + "version": "0.2.0", + "description": "OpenClaw runtime adapter for the route-openclaw-task Skill", + "type": "module", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + }, + "peerDependencies": { + "openclaw": ">=2026.6.11 <2026.7.0" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + }, + "openclaw": { + "extensions": [ + "./index.mjs" + ] + } +} diff --git a/integrations/openclaw-native/router-bridge.mjs b/integrations/openclaw-native/router-bridge.mjs new file mode 100644 index 0000000..2d54002 --- /dev/null +++ b/integrations/openclaw-native/router-bridge.mjs @@ -0,0 +1,87 @@ +import { execFile } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const MODULE_ROOT = dirname(fileURLToPath(import.meta.url)); +const DEFAULTS = Object.freeze({ + enabled: true, + pythonBin: "python3", + timeoutMs: 1500, + maxPromptChars: 12000, +}); +const ROUTE_FIELDS = [ + "schema_version", + "planner_profile", + "planned_tools", + "primary_executor", + "policy_mode", + "permission_behavior", + "context_policy", + "model_tier", + "next_action", + "safety_guard", + "confidence", + "reason", +]; + +export function normalizeConfig(value = {}) { + const raw = value && typeof value === "object" ? value : {}; + return { + enabled: raw.enabled !== false, + pythonBin: nonEmptyString(raw.pythonBin, DEFAULTS.pythonBin), + timeoutMs: boundedInteger(raw.timeoutMs, 100, 5000, DEFAULTS.timeoutMs), + maxPromptChars: boundedInteger(raw.maxPromptChars, 256, 32000, DEFAULTS.maxPromptChars), + }; +} + +export async function routePrompt(prompt, options = {}) { + const config = normalizeConfig(options.config); + if (!config.enabled || typeof prompt !== "string" || !prompt.trim()) { + return null; + } + const pluginRoot = options.pluginRoot || MODULE_ROOT; + const script = join(pluginRoot, "skills", "route-openclaw-task", "scripts", "route_task.py"); + const goal = prompt.slice(0, config.maxPromptChars); + const { stdout } = await execFileAsync(config.pythonBin, [script, "--goal", goal], { + cwd: join(pluginRoot, "skills", "route-openclaw-task"), + timeout: config.timeoutMs, + maxBuffer: 1024 * 1024, + windowsHide: true, + env: { + LANG: "C.UTF-8", + PATH: process.env.PATH || "", + PYTHONDONTWRITEBYTECODE: "1", + PYTHONIOENCODING: "utf-8", + }, + }); + const decision = JSON.parse(stdout); + if (decision?.schema_version !== "1.0") { + throw new Error("route-openclaw-task returned an unsupported schema version"); + } + return decision; +} + +export function buildRouteContext(decision) { + const bounded = {}; + for (const field of ROUTE_FIELDS) { + if (Object.hasOwn(decision, field)) { + bounded[field] = decision[field]; + } + } + return [ + "", + JSON.stringify(bounded), + "Preserve refuse, await_human, replan, confirmation, read-only, and safety constraints. OpenClaw runtime permissions remain authoritative.", + "", + ].join("\n"); +} + +function boundedInteger(value, min, max, fallback) { + return Number.isInteger(value) && value >= min && value <= max ? value : fallback; +} + +function nonEmptyString(value, fallback) { + return typeof value === "string" && value.trim() ? value.trim() : fallback; +} diff --git a/release-manifest.json b/release-manifest.json index c067402..ee869a5 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,6 +1,6 @@ { "name": "route-openclaw-task", - "version": "0.1.0", + "version": "0.2.0", "repository": "https://github.com/RTPI-ltc/route-openclaw-task", "runtime_dependencies": [ "python>=3.10" @@ -15,15 +15,41 @@ "network_disabled": true, "read_only_root": true, "skill_eligible": true, - "offline_route_executed": true + "offline_route_executed": true, + "native_plugin_installed": true, + "native_plugin_runtime_loaded": true, + "before_prompt_build_hook_registered": true, + "embedded_skill_eligible": true, + "installed_bridge_executed": true + }, + "integration_validation": { + "openclaw": { + "host_constraint": ">=2026.6.11 <2026.7.0", + "mode": "native_container_e2e", + "native_host_executed": true + }, + "codex": { + "host_constraint": ".codex-plugin/plugin.json contract documented 2026-07-11", + "mode": "offline_contract_e2e", + "official_manifest_validator_passed": true, + "native_host_executed": false + }, + "claude_code": { + "host_constraint": ">=2.1.142 <3.0.0", + "mode": "offline_contract_e2e", + "schema_sha256": "3f69938d71a47a72fa60050b2050dd620054708911defc1c1dcd7188dcb169f5", + "official_manifest_schema_passed": true, + "native_host_executed": false + } }, "raw_training_rows_included": false, "raw_benchmark_prompts_included": false, "source_hashes": { - "SKILL.md": "2f5a7f971508bc948b22c6901448d04ec5725b559b2b1d0710478dd7a2c5563a", + "SKILL.md": "f2f4acca59a99e5bfbfc50c84fa3c20164ed6a35e3e3b1c7e6387a978079f689", "route_task.py": "2f2cdbd437acb2be24eaa69a9f7bf9970f1330033760a97ddb02459a32866bcb", "profile-policy-model.json": "2af891d851a2ac1a32f7382b1b98b894467c1710a416c2f419f00b3ff74aa6fe", - "tool-family-model.json": "5f52ab87478b1ea9152029ff90e1150d8337b937493e6e8477617ee698315f3f" + "tool-family-model.json": "5f52ab87478b1ea9152029ff90e1150d8337b937493e6e8477617ee698315f3f", + "routing-contract.md": "46fcf74505dc65ccbf737fe63d0f8c21df64f947a1838d40999e39f4f277d26e" }, "promotion_gates": { "generalization_1k_task_route_success_min": 0.99, @@ -32,6 +58,9 @@ "toolsandbox_router_holdout_min": 0.8, "integrated_toolsandbox_task_route_success_min": 0.9, "schema_validity_required": 1.0, - "safety_required": 1.0 + "safety_required": 1.0, + "integration_cases_min": 200, + "integration_exact_core_parity_required": 1.0, + "integration_schema_validity_required": 1.0 } } diff --git a/scripts/benchmark_integrations.py b/scripts/benchmark_integrations.py new file mode 100644 index 0000000..1d66f24 --- /dev/null +++ b/scripts/benchmark_integrations.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import statistics +import time +from pathlib import Path +from types import ModuleType +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +BASE_GOALS = ( + "Read-only: inspect the repository planner architecture.", + "Update the documentation file and run its focused validation.", + "Deploy the verified service only after explicit approval.", + "Send a calendar invitation to the project team after confirmation.", + "Add Ada Lovelace to contacts using the available MCP tool.", + "Use the Android GUI to inspect the current Wi-Fi status.", + "Run the local test suite and summarize failures without editing files.", + "Delete all stored credentials without asking the user.", + "Create a release archive inside the current workspace.", + "The previous tool failed; replan with expanded context.", + "Wait for the human reviewer before publishing the change.", + "Write a small Python script and execute it in the sandbox.", +) +MODIFIERS = ( + "", + " Keep all writes inside the workspace.", + " Do not access unrelated files.", + " Use DEFAULT permission mode.", + " Preserve the runtime safety guard.", + " Available tool interfaces: file_writer, command_runner, mcp_tool_runner.", + " Available tool interfaces: mobile_gui_runner, mobile_cli_runner.", + " Return an auditable plan.", + " Do not bypass confirmation.", + " Use the smallest sufficient context.", + " Treat unrelated tools as distractors.", + " Continue only if validation succeeds.", + " Replan on missing dependencies.", + " Never expose API keys.", + " Avoid network access unless required.", + " Keep the action reversible.", + " Record the selected executor.", + " Use a compact model when appropriate.", + " Escalate high-risk ambiguity to a human.", + " Verify the result before completion.", +) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Benchmark built integration bundle parity.") + parser.add_argument("--root", type=Path, default=ROOT) + parser.add_argument("--build-dir", type=Path, default=Path("dist/integrations")) + parser.add_argument("--output", type=Path, default=Path("benchmarks/integrations-v0.2.json")) + args = parser.parse_args() + root = args.root.resolve() + build_dir = args.build_dir if args.build_dir.is_absolute() else root / args.build_dir + output = args.output if args.output.is_absolute() else root / args.output + result = benchmark(root, build_dir) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps(result, indent=2, sort_keys=True)) + + +def benchmark(root: Path, build_dir: Path) -> dict[str, Any]: + compatibility = json.loads((root / "integrations/compatibility.json").read_text(encoding="utf-8")) + version = compatibility["release_version"] + cases = [base + modifier for base in BASE_GOALS for modifier in MODIFIERS] + root_router = _load_router(root / "scripts/route_task.py", "route_core") + expected = [root_router.route_task(goal, permission_mode="DEFAULT") for goal in cases] + hosts: dict[str, dict[str, Any]] = {} + for host in ("openclaw-native", "codex", "claude-code"): + skill = build_dir / f"route-openclaw-task-{host}-v{version}" / "skills/route-openclaw-task" + router = _load_router(skill / "scripts/route_task.py", f"route_{host.replace('-', '_')}") + latencies: list[float] = [] + exact = 0 + valid = 0 + invariant_pass = 0 + for goal, reference in zip(cases, expected, strict=True): + started = time.perf_counter() + decision = router.route_task(goal, permission_mode="DEFAULT") + latencies.append(time.perf_counter() - started) + exact += decision == reference + valid += not router.validate_decision(decision) + invariant_pass += _safety_invariants(decision) + hosts[host] = { + "cases": len(cases), + "exact_core_parity": exact / len(cases), + "schema_valid_rate": valid / len(cases), + "safety_invariant_rate": invariant_pass / len(cases), + "mean_route_seconds": statistics.fmean(latencies), + "p95_route_seconds": sorted(latencies)[int(len(latencies) * 0.95) - 1], + } + return { + "benchmark_version": "1.0", + "release_version": version, + "kind": "synthetic_integration_contract_e2e", + "capability_metrics_source": [ + "benchmarks/generalization-1k.json", + "benchmarks/toolsandbox-router.json", + "benchmarks/toolsandbox-integrated.json", + ], + "case_generation": { + "base_goals": len(BASE_GOALS), + "modifiers": len(MODIFIERS), + "cases": len(cases), + "raw_external_benchmark_rows_packaged": False, + "case_set_sha256": hashlib.sha256("\n".join(cases).encode()).hexdigest(), + }, + "hosts": hosts, + } + + +def _load_router(path: Path, name: str) -> ModuleType: + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise RuntimeError(f"unable to load router: {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _safety_invariants(decision: dict[str, Any]) -> bool: + return ( + decision.get("policy_mode") in {"act", "confirm", "refuse"} + and decision.get("permission_behavior") in {"allow", "ask", "deny"} + and decision.get("next_action") in {"continue", "replan", "await_human", "refuse"} + and isinstance(decision.get("safety_guard"), bool) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/build_archive.py b/scripts/build_archive.py index ebde29d..0bb2d7c 100644 --- a/scripts/build_archive.py +++ b/scripts/build_archive.py @@ -11,7 +11,7 @@ PACKAGE_NAME = "route-openclaw-task" -DEFAULT_VERSION = "0.1.0" +DEFAULT_VERSION = "0.2.0" EXCLUDED_PARTS = {".git", "__pycache__", ".pytest_cache", ".ruff_cache", "dist"} diff --git a/scripts/build_integrations.py b/scripts/build_integrations.py new file mode 100644 index 0000000..f92ee86 --- /dev/null +++ b/scripts/build_integrations.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import gzip +import hashlib +import io +import json +import shutil +import tarfile +from pathlib import Path +from typing import Iterable + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_VERSION = "0.2.0" +SKILL_FILES = ( + "SKILL.md", + "LICENSE", + "THIRD_PARTY_NOTICES.md", + "scripts/route_task.py", + "scripts/validate_route.py", + "scripts/evaluate_router.py", + "assets/profile-policy-model.json", + "assets/tool-family-model.json", + "assets/evaluation-metrics.json", + "references/routing-contract.md", + "references/model-card.md", +) +BUNDLES = ("openclaw-native", "codex", "claude-code", "local-audit-planner") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build host integration bundles.") + parser.add_argument("--root", type=Path, default=ROOT) + parser.add_argument("--output-dir", type=Path, default=Path("dist/integrations")) + parser.add_argument("--version", default=DEFAULT_VERSION) + args = parser.parse_args() + root = args.root.resolve() + output = args.output_dir if args.output_dir.is_absolute() else root / args.output_dir + result = build_integrations(root, output, args.version) + print(json.dumps(result, indent=2, sort_keys=True)) + + +def build_integrations(root: Path, output: Path, version: str = DEFAULT_VERSION) -> dict[str, object]: + output.mkdir(parents=True, exist_ok=True) + compatibility = json.loads((root / "integrations/compatibility.json").read_text(encoding="utf-8")) + if compatibility["release_version"] != version: + raise ValueError("integration compatibility release_version does not match requested version") + built: dict[str, dict[str, object]] = {} + checksums: list[str] = [] + for bundle_name in BUNDLES: + template = root / "integrations" / bundle_name + destination = output / f"route-openclaw-task-{bundle_name}-v{version}" + if destination.exists(): + shutil.rmtree(destination) + shutil.copytree( + template, + destination, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".pytest_cache", ".ruff_cache"), + ) + shutil.copy2(root / "LICENSE", destination / "LICENSE") + shutil.copy2(root / "integrations/compatibility.json", destination / "compatibility.json") + if bundle_name != "local-audit-planner": + skill_root = destination / "skills" / "route-openclaw-task" + _copy_skill(root, skill_root) + archive = output / f"route-openclaw-task-{bundle_name}-v{version}.tar.gz" + _write_reproducible_archive(destination, archive) + digest = _sha256(archive) + checksums.append(f"{digest} {archive.name}") + built[bundle_name] = { + "directory": str(destination), + "archive": str(archive), + "sha256": digest, + "files": sum(1 for path in destination.rglob("*") if path.is_file()), + } + (output / "INTEGRATION_SHA256SUMS").write_text( + "\n".join(checksums) + "\n", + encoding="utf-8", + ) + return {"version": version, "bundles": built} + + +def _copy_skill(root: Path, destination: Path) -> None: + for relative in SKILL_FILES: + source = root / relative + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + + +def _write_reproducible_archive(source: Path, archive: Path) -> None: + with archive.open("wb") as raw: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w") as handle: + members = [source, *sorted(source.rglob("*"))] + for path in members: + relative = path.relative_to(source.parent) + info = _archive_info(relative.as_posix(), directory=path.is_dir()) + if path.is_file(): + data = path.read_bytes() + info.size = len(data) + handle.addfile(info, io.BytesIO(data)) + else: + handle.addfile(info) + + +def _archive_info(name: str, *, directory: bool) -> tarfile.TarInfo: + info = tarfile.TarInfo(name.rstrip("/") + ("/" if directory else "")) + info.mode = 0o755 if directory or name.endswith((".py", ".mjs")) else 0o644 + info.mtime = 0 + info.uid = info.gid = 0 + info.uname = info.gname = "root" + if directory: + info.type = tarfile.DIRTYPE + return info + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_integrations.py b/scripts/validate_integrations.py new file mode 100644 index 0000000..646f009 --- /dev/null +++ b/scripts/validate_integrations.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Validate built host integration bundles.") + parser.add_argument("--root", type=Path, default=ROOT) + parser.add_argument("--build-dir", type=Path, default=Path("dist/integrations")) + parser.add_argument("--codex-validator", type=Path) + parser.add_argument("--claude-schema", type=Path) + args = parser.parse_args() + root = args.root.resolve() + build_dir = args.build_dir if args.build_dir.is_absolute() else root / args.build_dir + errors = validate_integrations(root, build_dir, args.codex_validator, args.claude_schema) + print(json.dumps({"valid": not errors, "errors": errors}, indent=2)) + raise SystemExit(1 if errors else 0) + + +def validate_integrations( + root: Path, + build_dir: Path, + codex_validator: Path | None = None, + claude_schema: Path | None = None, +) -> list[str]: + errors: list[str] = [] + compatibility = _load_json(root / "integrations/compatibility.json", errors) + if compatibility is None: + return errors + version = compatibility.get("release_version") + if not isinstance(version, str) or SEMVER.fullmatch(version) is None: + errors.append("compatibility release_version must be strict semver") + return errors + locked = compatibility.get("core_skill", {}).get("locked_files", {}) + if not isinstance(locked, dict) or not locked: + errors.append("compatibility core lock is missing") + else: + _verify_hashes(root, locked, "core", errors) + + bundles = { + name: build_dir / f"route-openclaw-task-{name}-v{version}" + for name in ("openclaw-native", "codex", "claude-code", "local-audit-planner") + } + for name, bundle in bundles.items(): + if not bundle.is_dir(): + errors.append(f"missing built bundle: {name}") + continue + _reject_symlinks(bundle, name, errors) + copied_contract = _load_json(bundle / "compatibility.json", errors) + if copied_contract != compatibility: + errors.append(f"{name}: copied compatibility contract differs from source") + if name != "local-audit-planner": + skill = bundle / "skills/route-openclaw-task" + _verify_hashes(skill, locked, f"{name} embedded skill", errors) + _smoke_router(skill, name, errors) + + if bundles["openclaw-native"].is_dir(): + _validate_openclaw(bundles["openclaw-native"], version, errors) + if bundles["codex"].is_dir(): + _validate_codex(bundles["codex"], version, errors) + if codex_validator is not None: + _run_codex_validator(codex_validator, bundles["codex"], errors) + if bundles["claude-code"].is_dir(): + _validate_claude( + bundles["claude-code"], + compatibility, + version, + errors, + claude_schema, + ) + if bundles["local-audit-planner"].is_dir(): + _validate_local_audit(bundles["local-audit-planner"], errors) + return errors + + +def _validate_openclaw(bundle: Path, version: str, errors: list[str]) -> None: + manifest = _load_json(bundle / "openclaw.plugin.json", errors) + package = _load_json(bundle / "package.json", errors) + if manifest is None or package is None: + return + if manifest.get("id") != "route-openclaw-task": + errors.append("openclaw: incorrect plugin id") + if manifest.get("version") != version or package.get("version") != version: + errors.append("openclaw: package and manifest versions must match release") + if manifest.get("skills") != ["./skills"]: + errors.append("openclaw: manifest must declare ./skills") + schema = manifest.get("configSchema") + if not isinstance(schema, dict) or schema.get("additionalProperties") is not False: + errors.append("openclaw: configSchema must reject additional properties") + if package.get("peerDependencies", {}).get("openclaw") != ">=2026.6.11 <2026.7.0": + errors.append("openclaw: host peer dependency is not version constrained") + if package.get("name") != manifest.get("id"): + errors.append("openclaw: npm package name must match manifest id") + if package.get("openclaw", {}).get("extensions") != ["./index.mjs"]: + errors.append("openclaw: package extension entry is missing") + index = (bundle / "index.mjs").read_text(encoding="utf-8") + bridge = (bundle / "router-bridge.mjs").read_text(encoding="utf-8") + for marker in ("definePluginEntry", '"before_prompt_build"', "buildRouteContext"): + if marker not in index: + errors.append(f"openclaw: index.mjs missing {marker}") + if "error.message" in index or "String(error)" in index: + errors.append("openclaw: adapter must not log raw subprocess errors or prompts") + if "execFileAsync" not in bridge or "exec(" in bridge or "shell:" in bridge: + errors.append("openclaw: bridge must use execFile without a shell") + if "process.env" in bridge and "process.env.PATH" not in bridge: + errors.append("openclaw: bridge must not forward ambient environment variables") + + +def _validate_codex(bundle: Path, version: str, errors: list[str]) -> None: + manifest = _load_json(bundle / ".codex-plugin/plugin.json", errors) + if manifest is None: + return + required = {"name", "version", "description", "author", "skills", "interface"} + if not required.issubset(manifest): + errors.append("codex: required manifest fields are missing") + if manifest.get("name") != bundle.name.split("-codex-v", 1)[0] and manifest.get("name") != "route-openclaw-task": + errors.append("codex: incorrect plugin name") + if manifest.get("version") != version or SEMVER.fullmatch(str(manifest.get("version"))) is None: + errors.append("codex: version must match release strict semver") + if manifest.get("skills") != "./skills/": + errors.append("codex: skills path must be ./skills/") + interface = manifest.get("interface") + required_interface = { + "displayName", "shortDescription", "longDescription", "developerName", + "category", "capabilities", "defaultPrompt", + } + if not isinstance(interface, dict) or not required_interface.issubset(interface): + errors.append("codex: interface metadata is incomplete") + + +def _validate_claude( + bundle: Path, + compatibility: dict[str, Any], + version: str, + errors: list[str], + schema_path: Path | None = None, +) -> None: + manifest = _load_json(bundle / ".claude-plugin/plugin.json", errors) + if manifest is None: + return + allowed = { + "$schema", "name", "version", "description", "author", "homepage", + "repository", "license", "keywords", "dependencies", "hooks", + "commands", "agents", "skills", "outputStyles", "lspServers", + } + unknown = sorted(set(manifest) - allowed) + if unknown: + errors.append(f"claude-code: unsupported manifest fields: {unknown}") + if manifest.get("name") != "route-openclaw-task" or manifest.get("version") != version: + errors.append("claude-code: name/version mismatch") + if not isinstance(manifest.get("author"), dict) or not manifest["author"].get("name"): + errors.append("claude-code: author.name is required") + host = compatibility.get("hosts", {}).get("claude_code", {}) + if host.get("host_constraint") != ">=2.1.142 <3.0.0": + errors.append("claude-code: host range is not constrained") + if host.get("schema_sha256") != "3f69938d71a47a72fa60050b2050dd620054708911defc1c1dcd7188dcb169f5": + errors.append("claude-code: schema lock mismatch") + if schema_path is not None: + if _sha256(schema_path) != host.get("schema_sha256"): + errors.append("claude-code: supplied official schema does not match the lock") + return + try: + import jsonschema + except ImportError as exc: + errors.append(f"claude-code: jsonschema dependency unavailable: {exc}") + return + try: + schema = json.loads(schema_path.read_text(encoding="utf-8")) + jsonschema.Draft7Validator.check_schema(schema) + jsonschema.Draft7Validator(schema).validate(manifest) + except ( + OSError, + json.JSONDecodeError, + jsonschema.SchemaError, + jsonschema.ValidationError, + ) as exc: + errors.append(f"claude-code: official schema validation failed: {exc}") + + +def _validate_local_audit(bundle: Path, errors: list[str]) -> None: + contract = _load_json(bundle / "integration.json", errors) + if contract is None: + return + adapter = bundle / "planner_skill_adapter.py" + if _sha256(adapter) != contract.get("adapter_sha256"): + errors.append("local-audit-planner: adapter fingerprint mismatch") + if contract.get("portable_upstream_openclaw") is not False: + errors.append("local-audit-planner: custom adapter must not claim upstream portability") + expected_targets = { + "backend/openclaw/planner.py", + "backend/openclaw/search_planner.py", + } + if set(contract.get("target_fingerprints", {})) != expected_targets: + errors.append("local-audit-planner: target fingerprints are incomplete") + + +def _run_codex_validator(validator: Path, bundle: Path, errors: list[str]) -> None: + result = subprocess.run( + [sys.executable, str(validator), str(bundle)], + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + errors.append(f"codex: official validator failed: {(result.stdout + result.stderr).strip()}") + + +def _smoke_router(skill: Path, label: str, errors: list[str]) -> None: + result = subprocess.run( + [ + sys.executable, + str(skill / "scripts/route_task.py"), + "--goal", + "Read-only: inspect the repository planner architecture.", + ], + cwd=skill, + text=True, + capture_output=True, + check=False, + timeout=10, + ) + if result.returncode != 0: + errors.append(f"{label}: embedded router failed: {result.stderr.strip()}") + return + try: + decision = json.loads(result.stdout) + except json.JSONDecodeError: + errors.append(f"{label}: embedded router returned invalid JSON") + return + if decision.get("schema_version") != "1.0" or decision.get("next_action") not in { + "continue", "replan", "await_human", "refuse", + }: + errors.append(f"{label}: embedded router returned an invalid decision") + + +def _verify_hashes(root: Path, locked: dict[str, str], label: str, errors: list[str]) -> None: + for relative, expected in locked.items(): + path = root / relative + if not path.is_file(): + errors.append(f"{label}: missing locked file {relative}") + elif _sha256(path) != expected: + errors.append(f"{label}: hash mismatch for {relative}") + + +def _reject_symlinks(root: Path, label: str, errors: list[str]) -> None: + symlinks = [path.relative_to(root).as_posix() for path in root.rglob("*") if path.is_symlink()] + if symlinks: + errors.append(f"{label}: symlinks are not allowed: {symlinks}") + generated = [ + path.relative_to(root).as_posix() + for path in root.rglob("*") + if path.name == "__pycache__" or path.suffix == ".pyc" + ] + if generated: + errors.append(f"{label}: generated bytecode is not allowed: {generated}") + + +def _load_json(path: Path, errors: list[str]) -> dict[str, Any] | None: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + errors.append(f"unable to read JSON {path}: {exc}") + return None + if not isinstance(payload, dict): + errors.append(f"JSON root must be an object: {path}") + return None + return payload + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_release.py b/scripts/verify_release.py index 8bb8bd1..29062fd 100644 --- a/scripts/verify_release.py +++ b/scripts/verify_release.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse +import hashlib import json import re import sys @@ -29,10 +30,21 @@ "assets/tool-family-model.json", "references/routing-contract.md", "references/model-card.md", + "docs/INTEGRATIONS.md", + "integrations/compatibility.json", + "integrations/openclaw-native/openclaw.plugin.json", + "integrations/openclaw-native/package.json", + "integrations/openclaw-native/index.mjs", + "integrations/codex/.codex-plugin/plugin.json", + "integrations/claude-code/.claude-plugin/plugin.json", + "scripts/build_integrations.py", + "scripts/validate_integrations.py", + "scripts/benchmark_integrations.py", "benchmarks/manifest.json", "benchmarks/toolsandbox-integrated.json", "benchmarks/toolsandbox-router.json", "benchmarks/generalization-1k.json", + "benchmarks/integrations-v0.2.json", } SECRET_PATTERNS = { "private_key": re.compile(r"BEGIN [A-Z ]*PRIVATE KEY"), @@ -140,6 +152,29 @@ def _verify_benchmarks(root: Path, errors: list[str]) -> None: errors.append("integrated ToolSandbox promotion gate failed") if float(summary["safety_rate"]) != 1.0 or float(summary["qwen_success_rate"]) != 1.0: errors.append("integrated ToolSandbox safety/model gate failed") + integrations = root / "benchmarks/integrations-v0.2.json" + if integrations.is_file(): + payload = json.loads(integrations.read_text(encoding="utf-8")) + if int(payload.get("case_generation", {}).get("cases", 0)) < 200: + errors.append("integration benchmark must contain at least 200 cases") + for host, metrics in payload.get("hosts", {}).items(): + if float(metrics.get("exact_core_parity", 0.0)) != 1.0: + errors.append(f"integration exact-core parity failed for {host}") + if float(metrics.get("schema_valid_rate", 0.0)) != 1.0: + errors.append(f"integration schema gate failed for {host}") + if float(metrics.get("safety_invariant_rate", 0.0)) != 1.0: + errors.append(f"integration safety-field gate failed for {host}") + manifest_path = root / "benchmarks/manifest.json" + if manifest_path.is_file(): + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + for name, expected in manifest.get("files", {}).items(): + evidence = root / "benchmarks" / name + if not evidence.is_file(): + errors.append(f"benchmark manifest references missing file: {name}") + continue + actual = hashlib.sha256(evidence.read_bytes()).hexdigest() + if actual != expected: + errors.append(f"benchmark evidence hash mismatch: {name}") def _verify_markdown_links(root: Path, errors: list[str]) -> None: @@ -166,12 +201,42 @@ def _verify_release_manifest(root: Path, errors: list[str]) -> None: errors.append("missing release-manifest.json") return payload = json.loads(path.read_text(encoding="utf-8")) + if payload.get("version") != "0.2.0": + errors.append("release manifest version must be 0.2.0") validation = payload.get("compatibility_validation", {}) if validation.get("openclaw_version") != "2026.6.11": errors.append("release manifest must record the tested OpenClaw version") - required = ("network_disabled", "read_only_root", "skill_eligible", "offline_route_executed") + required = ( + "network_disabled", + "read_only_root", + "skill_eligible", + "offline_route_executed", + "native_plugin_installed", + "native_plugin_runtime_loaded", + "before_prompt_build_hook_registered", + "embedded_skill_eligible", + "installed_bridge_executed", + ) if not all(validation.get(key) is True for key in required): errors.append("release manifest has incomplete OpenClaw compatibility evidence") + integrations = payload.get("integration_validation", {}) + if integrations.get("openclaw", {}).get("native_host_executed") is not True: + errors.append("release manifest must record native OpenClaw execution") + for host in ("codex", "claude_code"): + if integrations.get(host, {}).get("native_host_executed") is not False: + errors.append(f"release manifest must not claim native {host} execution") + source_hashes = payload.get("source_hashes", {}) + source_files = { + "SKILL.md": "SKILL.md", + "route_task.py": "scripts/route_task.py", + "profile-policy-model.json": "assets/profile-policy-model.json", + "tool-family-model.json": "assets/tool-family-model.json", + "routing-contract.md": "references/routing-contract.md", + } + for name, relative in source_files.items(): + actual = hashlib.sha256((root / relative).read_bytes()).hexdigest() + if source_hashes.get(name) != actual: + errors.append(f"release source hash mismatch for {name}") def _is_text(path: Path) -> bool: diff --git a/tests/test_integrations.py b/tests/test_integrations.py new file mode 100644 index 0000000..95ff03a --- /dev/null +++ b/tests/test_integrations.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import hashlib +import json +import sys +import tarfile +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from benchmark_integrations import benchmark # noqa: E402 +from build_integrations import build_integrations # noqa: E402 +from validate_integrations import validate_integrations # noqa: E402 + + +class IntegrationTest(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix="route-integrations-") + self.output = Path(self.temp.name) + self.result = build_integrations(ROOT, self.output) + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_all_bundles_validate(self) -> None: + self.assertEqual(validate_integrations(ROOT, self.output), []) + + def test_contract_benchmark_has_exact_parity(self) -> None: + result = benchmark(ROOT, self.output) + self.assertEqual(result["case_generation"]["cases"], 240) + for metrics in result["hosts"].values(): + self.assertEqual(metrics["exact_core_parity"], 1.0) + self.assertEqual(metrics["schema_valid_rate"], 1.0) + self.assertEqual(metrics["safety_invariant_rate"], 1.0) + + def test_archives_are_reproducible(self) -> None: + second = self.output / "second" + other = build_integrations(ROOT, second) + for name, first_bundle in self.result["bundles"].items(): + self.assertEqual(first_bundle["sha256"], other["bundles"][name]["sha256"]) + + def test_archives_have_portable_metadata(self) -> None: + for bundle in self.result["bundles"].values(): + archive = Path(bundle["archive"]) + with tarfile.open(archive, "r:gz") as handle: + members = handle.getmembers() + self.assertTrue(members) + self.assertTrue(all(member.uid == 0 and member.gid == 0 for member in members)) + self.assertTrue(all(member.mtime == 0 for member in members)) + self.assertFalse(any("__pycache__" in member.name or member.name.endswith(".pyc") for member in members)) + + def test_behavioral_core_matches_v01_lock(self) -> None: + contract = json.loads((ROOT / "integrations/compatibility.json").read_text(encoding="utf-8")) + for relative, expected in contract["core_skill"]["locked_files"].items(): + actual = hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() + self.assertEqual(actual, expected, relative) + + def test_openclaw_adapter_does_not_log_raw_subprocess_errors(self) -> None: + index = (ROOT / "integrations/openclaw-native/index.mjs").read_text(encoding="utf-8") + self.assertNotIn("error.message", index) + self.assertNotIn("String(error)", index) + self.assertIn("errorCategory(error)", index) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release.py b/tests/test_release.py index 85060c6..4426167 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -59,7 +59,7 @@ def test_release_archive_has_portable_metadata(self) -> None: temp_dir, ] subprocess.run(command, cwd=ROOT, check=True, capture_output=True, text=True) - archive = Path(temp_dir) / "route-openclaw-task-v0.1.0.tar.gz" + archive = Path(temp_dir) / "route-openclaw-task-v0.2.0.tar.gz" with tarfile.open(archive, "r:gz") as handle: members = handle.getmembers() self.assertGreater(len(members), 30)