122 Commits
Author SHA1 Message Date
rookiestar28 822661ec81 fix(governance): bind acceptance gates to clean commits
Add the lightweight high-risk closeout helper and its regression coverage. Reject tracked, staged, or untracked public changes before and after full local validation so accepted results map to a reproducible commit.
2026-08-09 03:26:10 +08:00
rookiestar28 c649331ef5 chore(release): bump version to 1.0.7 2026-08-08 16:27:32 +08:00
rookiestar28 f086f7a0e8 fix(deps): update nanoid to patched release 2026-08-08 16:21:43 +08:00
rookiestar28 c46cae810f chore(release): bump version to 1.0.6 2026-08-03 18:23:36 +08:00
rookiestar28 397c9a1cbe style(tests): apply classifier test formatting 2026-08-03 18:18:38 +08:00
rookiestar28 71f399369c fix(ci): classify bootstrap route changes as high risk 2026-08-03 18:14:37 +08:00
rookiestar28 87c4c2df08 fix(startup): restore packaged route imports 2026-08-03 16:41:28 +08:00
rookiestar28 ec50c09b93 fix(tests): stabilize lifecycle snapshot assertions 2026-08-01 19:53:27 +08:00
rookiestar28 8970d6ff28 fix(ci): stabilize harness retry accounting 2026-08-01 19:43:44 +08:00
rookiestar28 76f535aa1a chore(release): bump version to 1.0.5 2026-07-31 17:02:34 +08:00
rookiestar28 9f6287f947 docs: align dependency hardening guidance 2026-07-31 16:49:52 +08:00
rookiestar28 816231c49f fix(deps): harden frontend dependency validation 2026-07-31 16:00:18 +08:00
rookiestar28 975843ac1a chore(release): bump version to 1.0.4 2026-07-31 14:56:39 +08:00
rookiestar28 b6760ee595 docs: summarize recent hardening and host alignment 2026-07-31 14:25:56 +08:00
rookiestar28 7d7a1c412f docs(audio): clarify native TTS ownership 2026-07-31 10:13:57 +08:00
rookiestar28 55d320db3d docs(workflows): clarify host workspace ownership 2026-07-31 09:52:14 +08:00
rookiestar28 e89b1c85a8 docs(nodes): document native media input flows 2026-07-31 09:30:29 +08:00
rookiestar28 a68dbfa433 fix(job-monitor): recognize advanced 3d results 2026-07-31 09:09:33 +08:00
rookiestar28 e5c1f48448 refactor(services): package bootstrap and posture domains 2026-07-31 08:44:04 +08:00
rookiestar28 22b4a341c2 fix(parameter-lab): correlate host queue receipts 2026-07-31 08:01:17 +08:00
rookiestar28 37f2507d37 fix(parameter-lab): bound experiment inputs 2026-07-31 07:03:52 +08:00
rookiestar28 c05436944d fix(security): contain posture evaluation errors 2026-07-31 06:42:01 +08:00
rookiestar28 3fafa42c93 feat(security): centralize effective startup posture 2026-07-31 06:27:51 +08:00
rookiestar28 8c175f47ab feat(startup): model bootstrap lifecycle outcomes 2026-07-31 05:40:58 +08:00
rookiestar28 5babb01a56 fix(compat): detect current desktop bridge 2026-07-31 05:34:55 +08:00
rookiestar28 85afda3277 fix(inventory): exclude dataset user data 2026-07-31 05:13:01 +08:00
rookiestar28 fe5bf6c684 feat(architecture): enforce production dependency boundaries 2026-07-31 04:59:02 +08:00
rookiestar28 351b418b83 fix(security): keep environment templates untracked 2026-07-31 04:24:09 +08:00
rookiestar28 291d537214 feat(compat): model desktop host generations 2026-07-31 04:22:38 +08:00
rookiestar28 570c4d0dc5 fix(ci): stabilize frontend retry assertions 2026-07-28 12:57:13 +08:00
rookiestar28 f1f221bb6d chore(release): bump version to 1.0.2 2026-07-11 18:39:05 +08:00
rookiestar28 76cbaa404f fix(ci): restrict static analysis to tracked sources 2026-07-11 18:20:47 +08:00
rookiestar28 c1aca449c5 fix(tests): stabilize contract digests across line endings 2026-07-11 17:25:34 +08:00
rookiestar28 a28316ed38 docs: refresh maintainability and verification guidance 2026-07-11 16:55:28 +08:00
rookiestar28 06a25f7393 test(coverage): promote governed floor to 55 percent 2026-07-11 10:29:57 +08:00
rookiestar28 bc06b56a5b refactor(frontend): decompose settings and API clients 2026-07-11 10:07:55 +08:00
rookiestar28 11faa8a789 refactor(connectors): decompose Slack and Feishu adapters 2026-07-11 08:51:33 +08:00
rookiestar28 b8b8d9c180 refactor(connector): decompose command router 2026-07-11 08:32:35 +08:00
rookiestar28 746d9a1352 refactor(config): decompose API handlers 2026-07-11 08:14:32 +08:00
rookiestar28 fdda687141 refactor(api): decompose route ownership 2026-07-11 07:52:08 +08:00
rookiestar28 b68d951fd0 fix(robustness): harden exception boundaries 2026-07-11 07:25:15 +08:00
rookiestar28 10c8f2e4aa test(performance): add deterministic scale baselines 2026-07-11 06:57:07 +08:00
rookiestar28 ed98ed6568 chore(quality): enforce incremental static analysis 2026-07-11 06:33:08 +08:00
rookiestar28 0f23b04a32 chore(release): bump version to 1.0.0 2026-07-11 03:27:40 +08:00
rookiestar28 486e94d2e0 docs: refresh jobs and output guidance 2026-07-11 02:33:23 +08:00
rookiestar28 fd82dd7fef feat(ui): preview bounded text outputs 2026-07-11 00:24:40 +08:00
rookiestar28 02b0c4d2d6 chore(compat): refresh host reference anchors 2026-07-10 23:57:10 +08:00
rookiestar28 79f4a722f0 fix(connector): render bounded jobs summaries 2026-07-10 16:59:40 +08:00
rookiestar28 c8df8b1886 feat(api): expose bounded jobs read model 2026-07-10 16:42:31 +08:00
rookiestar28 5efc587c3f fix(api): secure jobs listing contract 2026-07-10 16:21:41 +08:00
rookiestar28 c04dbde73d bump version to v0.9.8 2026-07-08 14:23:41 +08:00
rookiestar28 f10c632bc5 docs: refresh public update notes 2026-07-08 03:44:20 +08:00
rookiestar28 ed9c2bcda4 fix(ui): preserve host-shaped widget ids 2026-07-08 03:40:02 +08:00
rookiestar28 6a35056631 fix(ui): show HDR outputs as fallback links
Detect .exr and .hdr image outputs before normal Job Monitor thumbnail rendering, and show explicit source-preview fallback tiles instead of broken image elements.

Keep normal image thumbnails plus existing media and asset fallback behavior intact, and document the HDR fallback posture.

Validation: targeted Vitest, R107 Playwright, doc-contract checks, and the full Windows test gate passed.
2026-07-08 03:22:10 +08:00
rookiestar28 a22be165d8 fix(connectors): harden media response headers
Route signed connector media through a shared MIME-aware response helper so dangerous active content downloads with octet-stream and nosniff headers while safe images remain inline-compatible.

Preserve media token, expiry, and path-boundary checks for LINE and WhatsApp media routes.

Validation: targeted connector media tests passed; full Windows test gate passed.
2026-07-08 03:11:33 +08:00
rookiestar28 886e91c491 fix(outputs): make asset hashes optional
Keep filename-backed output references previewable when hosts omit hash metadata, while preserving the explicit no-go path for asset-only references.

Update public docs and generated OpenAPI contract to describe optional hash metadata and the ComfyUI asset hashing flag.

Validation: targeted backend/unit/E2E checks passed; full Windows test gate passed.
2026-07-08 02:58:39 +08:00
rookiestar28 c612a67053 docs(compatibility): refresh host reference anchors
Update active ComfyUI and standalone frontend compatibility anchors while preserving desktop as a lagging host surface.

Refresh host-surface expectations and compatibility governance tests.

Validation: Windows full test gate passed with Playwright 39 passed.
2026-07-08 02:45:39 +08:00
rookiestar28 ea1dbbe315 docs: refresh recent update notes 2026-06-24 16:19:03 +08:00
rookiestar28 db1cc91bdc test(compat): pin residual host contracts 2026-06-24 13:37:33 +08:00
rookiestar28 4f64294b85 fix(connector): target job cancellation requests 2026-06-24 13:21:30 +08:00
rookiestar28 ba3c64bdd1 chore(compat): refresh host reference anchors 2026-06-24 13:12:38 +08:00
rookiestar28 035290fb47 docs: refresh host alignment documentation 2026-06-12 20:20:27 +08:00
rookiestar28 fbbb8642b8 bump version to v0.9.7 2026-06-12 17:22:41 +08:00
rookiestar28 c4b9d2b271 feat(queue): add ComfyUI usage source attribution 2026-06-12 16:47:38 +08:00
rookiestar28 c6cfbdb606 feat(models): align managed folder type parity 2026-06-12 16:35:44 +08:00
rookiestar28 a99ec20fa5 feat(history): support media-aware output refs 2026-06-12 16:22:02 +08:00
rookiestar28 223514c9b4 fix(history): accept ComfyUI asset hash aliases 2026-06-12 16:06:44 +08:00
rookiestar28 b3bf098382 chore(compat): refresh ComfyUI host anchors 2026-06-12 15:59:54 +08:00
rookiestar28 620549ea96 docs: document Atlas Cloud provider setup 2026-06-04 15:51:20 +08:00
rookiestar28 b32a6a9010 docs: refresh runtime hygiene references 2026-06-04 15:29:15 +08:00
rookiestar28 a46a7db84b fix(tools): report sandbox failures deterministically 2026-06-04 15:08:32 +08:00
lucaszhu-hueandClaude Opus 4.8 bd2741aad0 docs: document Atlas Cloud as an OpenAI-compatible LLM provider
Atlas Cloud exposes an OpenAI-compatible API, so it works through the existing
`custom` provider (OPENCLAW_LLM_PROVIDER=custom + OPENCLAW_LLM_BASE_URL) with no
code changes. Document it under "Configure an LLM key", noting that its public
HTTPS host passes the default SSRF-safe egress validation (no insecure override).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:58:07 +08:00
rookiestar28 9784328e40 fix(tooling): resolve package-owned tool allowlist 2026-06-04 14:55:36 +08:00
rookiestar28 db9067a380 chore(packaging): document hygiene boundaries 2026-06-04 14:40:09 +08:00
rookiestar28 4cfa9fba39 Merge pull request #15 from rookiestar28/dependabot/npm_and_yarn/npm_and_yarn-3ac77625be
chore(deps-dev): bump vitest from 3.2.4 to 4.1.0 in the npm_and_yarn group across 1 directory
2026-06-02 15:09:07 +08:00
dependabot[bot] fba88fc5e4 chore(deps-dev): bump vitest
Bumps the npm_and_yarn group with 1 update in the / directory: [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `vitest` from 3.2.4 to 4.1.0
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.0/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.0
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-01 22:19:24 +00:00
rookiestar28 18a5341697 bump version to v0.9.5 2026-05-31 08:38:00 +08:00
rookiestar28 853d272dd1 docs(readme): surface latest update first 2026-05-31 08:34:57 +08:00
rookiestar28 ab4449f6de test(e2e): wait for admin console binding 2026-05-31 08:24:59 +08:00
rookiestar28 e03c4d527e docs(release): refresh host alignment notes 2026-05-31 08:11:18 +08:00
rookiestar28 fd21de18db test(host): stamp desktop parity metadata 2026-05-31 07:22:45 +08:00
rookiestar28 92ba574b99 feat(models): support current folder keys 2026-05-31 07:17:19 +08:00
rookiestar28 99a425c6c5 fix(assets): accept hash aliases for previews 2026-05-31 07:12:14 +08:00
rookiestar28 4ddade280c fix(queue): reconcile active prompts after reconnect 2026-05-31 07:06:20 +08:00
rookiestar28 552f1a079f docs(compatibility): refresh host matrix anchors 2026-05-31 07:00:01 +08:00
rookiestar28 8f08abd229 test(e2e): harden openclaw entry retry harness 2026-05-22 15:33:10 +08:00
rookiestar28 bdf1a0a9ad style: apply supply-chain checker formatting 2026-05-13 10:45:02 +08:00
rookiestar28 2c98df607e chore: harden supply chain and bump to v0.9.3 2026-05-13 10:26:49 +08:00
rookiestar28 89f1e923f0 docs: update supply-chain hardening notes 2026-05-13 10:22:11 +08:00
rookiestar28 bba8055c3f security: harden supply-chain CI gates 2026-05-13 10:17:57 +08:00
rookiestar28 06f395b4ec Merge pull request #14 from rookiestar28/dependabot/npm_and_yarn/npm_and_yarn-06160b2e2d
chore(deps-dev): bump postcss from 8.5.8 to 8.5.14 in the npm_and_yarn group across 1 directory
2026-05-09 15:53:05 +08:00
dependabot[bot] bc9283f27c chore(deps-dev): bump postcss
Bumps the npm_and_yarn group with 1 update in the / directory: [postcss](https://github.com/postcss/postcss).


Updates `postcss` from 8.5.8 to 8.5.14
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.14)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.14
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-08 16:30:16 +00:00
rookiestar28 0102aabbad bump version to v0.9.2 2026-05-08 20:23:43 +08:00
rookiestar28 12405e5a17 feat(comfyui): refresh host compatibility handling 2026-05-08 20:16:46 +08:00
rookiestar28 c6aef620bc docs: refresh connector public documentation 2026-05-04 20:59:16 +08:00
rookiestar28 d1e8b0e92b feat(connector): add reply visibility policy 2026-05-04 15:26:28 +08:00
rookiestar28 ce934d00a9 feat(connector): unify replay lifecycle 2026-05-04 12:11:51 +08:00
rookiestar28 f6d92b2d60 chore: modified 2026-05-03 02:18:38 +08:00
rookiestar28 fc7c296e65 docs: rename 2026-04-28 20:53:55 +08:00
rookiestar28 55fbe67b2a fix(openapi): regenerate public API spec 2026-04-28 20:43:28 +08:00
rookiestar28 4de75e70d4 docs: refresh public runtime and security documentation 2026-04-28 20:33:58 +08:00
rookiestar28 1d95cd864f feat(security): isolate internal prompt content 2026-04-28 19:59:42 +08:00
rookiestar28 1777c03926 feat(security): validate sidecar secret refs 2026-04-28 19:52:46 +08:00
rookiestar28 05bc4edab7 feat(runtime): report startup warmup status 2026-04-28 19:42:34 +08:00
rookiestar28 71fbfc9c52 feat(scheduler): normalize delivery targets 2026-04-28 19:24:48 +08:00
rookiestar28 6e5ce4fa07 feat(connector): preserve Telegram topic delivery 2026-04-28 19:08:04 +08:00
rookiestar28 02362d47f4 feat(security): scope private LLM network access 2026-04-28 18:58:38 +08:00
rookiestar28 a59663bfb8 docs: regenerate OpenAPI spec
Regenerate the OpenAPI artifact after the public preflight API contract update so commit and push guards remain in sync.
2026-04-26 20:45:52 +08:00
rookiestar28 95d9a305f4 docs: update preflight compatibility guidance
Document refreshed host compatibility anchors, inactive-branch preflight suppression, Explorer rendering, and the public preflight API contract.
2026-04-26 20:15:41 +08:00
rookiestar28 6f5d8c06e9 feat(preflight): align inactive branch diagnostics
Refresh host compatibility anchors and governance expectations for current ComfyUI, frontend, and desktop references.

Add inactive branch suppression for workflow portability and preflight diagnostics, including Explorer rendering and regression coverage.

Validation: powershell -File scripts/run_full_tests_windows.ps1 passed.
2026-04-26 20:06:03 +08:00
rookiestar28 da5fb1fcbd docs(readme): refresh current feature notes 2026-04-26 18:54:27 +08:00
rookiestar28 c0bd987ec1 refactor(nodes): align node categories with canonical naming 2026-04-26 18:46:18 +08:00
rookiestar28 062e0f2e11 test(coverage): add hotspot readiness governance 2026-04-26 18:39:47 +08:00
rookiestar28 2ee8245ff1 chore(test): add exception boundary governance 2026-04-26 18:14:48 +08:00
rookiestar28 d49e1d416f feat(api): add legacy compatibility governance 2026-04-26 18:05:31 +08:00
rookiestar28 8660ece6c1 feat(frontend): add shared DOM wiring helpers 2026-04-26 17:52:34 +08:00
rookiestar28 0a959f96aa feat(connector): add Slack interactive callback handling 2026-04-26 17:40:45 +08:00
rookiestar28 1f8e11205f docs: update testing SOP 2026-04-26 13:43:00 +08:00
rookiestar28 4e3a30272e chore: modified 2026-04-26 13:04:03 +08:00
rookiestar28 157ef81505 fix(tests): restore python 3.10 tomllib compatibility 2026-04-24 02:12:13 +08:00
rookiestar28 a6e2669858 docs(api): regenerate openapi spec 2026-04-24 01:55:35 +08:00
rookiestar28 404f19175f style(tests): normalize formatter output 2026-04-24 01:45:03 +08:00
298 changed files with 49914 additions and 7509 deletions
+34 -6
View File
@@ -30,6 +30,9 @@ jobs:
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install import deps
run: |
python -m pip install --upgrade pip
@@ -60,6 +63,9 @@ jobs:
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install preflight deps
run: |
python -m pip install --upgrade pip
@@ -69,7 +75,7 @@ jobs:
python scripts/preflight_check.py --strict
- name: Install Node deps
run: |
npm install
npm ci
- name: Install Playwright browsers
run: |
npx playwright install chromium
@@ -89,6 +95,9 @@ jobs:
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install test deps
run: |
python -m pip install --upgrade pip
@@ -97,10 +106,14 @@ jobs:
# CRITICAL: Python 3.10 coverage reads pyproject.toml only when the
# TOML extra is present; do not downgrade this back to plain coverage.
python -m pip install -r requirements.txt
python -m pip install -r requirements-quality.txt
python -m pip install numpy pillow aiohttp "coverage[toml]"
- name: R120 preflight
run: |
python scripts/preflight_check.py --strict
- name: Static-analysis policy
run: |
python scripts/verify_static_analysis_policy.py
- name: Run MAE hard-guarantee suites
env:
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_mae
@@ -131,6 +144,9 @@ jobs:
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install test deps
run: |
python -m pip install --upgrade pip
@@ -163,6 +179,9 @@ jobs:
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install test deps
run: |
python -m pip install --upgrade pip
@@ -183,14 +202,17 @@ jobs:
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Frontend Audit (npm)
run: |
# Audit only production dependencies, ignore dev
npm audit --production
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Frontend Audit (npm)
run: |
# Development tooling is part of the build/test trust boundary.
npm ci
npm audit --audit-level=high
- name: Install backend deps
run: |
python -m pip install --upgrade pip
@@ -214,6 +236,9 @@ jobs:
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install test deps
run: |
python -m pip install --upgrade pip
@@ -246,6 +271,9 @@ jobs:
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install test deps
run: |
python -m pip install --upgrade pip
+26
View File
@@ -0,0 +1,26 @@
name: Dependency Review
on:
pull_request:
paths:
- "package.json"
- "package-lock.json"
- "requirements.txt"
- "pyproject.toml"
- ".github/workflows/dependency-review.yml"
permissions:
contents: read
pull-requests: read
jobs:
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Dependency Review
uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
comment-summary-in-pr: always
+4
View File
@@ -31,6 +31,10 @@ jobs:
# Install black/isort explicitly so CI doesn't fail due to missing tools
# if a hook is configured to run via system python.
pip install pre-commit black==24.1.1 isort==5.13.2
pip install -r requirements-quality.txt
- name: Verify static-analysis policy directly
run: python scripts/verify_static_analysis_policy.py
- name: Run all pre-commit hooks
run: pre-commit run --all-files --show-diff-on-failure
+2 -1
View File
@@ -9,6 +9,7 @@ on:
- "pyproject.toml"
permissions:
contents: read
issues: write
jobs:
@@ -32,6 +33,6 @@ jobs:
echo "Skipping registry publish because pyproject version is unchanged."
- name: Publish Custom Node
if: steps.publish_guard.outputs.should_publish == 'true'
uses: Comfy-Org/publish-node-action@main
uses: Comfy-Org/publish-node-action@d2366e7abb6ab16f3bb03e3520ae25c8cf749bc9
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+55 -20
View File
@@ -1,37 +1,72 @@
__pycache__/
*.py[cod]
*.pyd
.planning/
.env
.venv/
.venv-wsl/
venv/
env/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.tox/
htmlcov/
.coverage
.coverage.*
coverage.xml
*.cover
reference/
AGEN*.md
scripts/sync_split.py
tests_output.txt
node_modules/
playwright-report/
test-results/
playwright/.cache/
openclaw_state/
moltbot_state/
.tmp/
*.log
test_output.txt
test_auth_out.txt
connector_state.json*
*.swp
# Agent/local project exclusions
.pla*/
reference/
REFERENCE/
.reference/
ROA*.md
roa*.md
AG*.md
# Secrets and local environment
.env
.env.*
*.env
!.env.example
!.env.sample
# Python
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
.se*/
.mypy_cache/
.ruff_cache/
.coverage
coverage.xml
htmlcov/
.venv/
.venv-*/
venv/
ENV/
# Node / frontend tests
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
playwright-report/
test-results/
coverage/
# Build, cache, temp, and local tool output
dist/
build/
.cache/
.tmp/
tmp/
temp/
*.log
# OS and editor files
.DS_Store
Thumbs.db
.vscode/
.idea/
*.swp
+16
View File
@@ -41,6 +41,22 @@ repos:
language: python
pass_filenames: false
always_run: true
- id: static-analysis-policy
name: incremental Ruff/Mypy static-analysis policy
# Keep isolated pins aligned with requirements-quality.txt and policy JSON.
entry: python -B scripts/verify_static_analysis_policy.py
language: python
additional_dependencies:
- ruff==0.15.20
- mypy==2.2.0
pass_filenames: false
always_run: true
- id: production-dependency-boundary
name: production dependency boundary contract
entry: python -B scripts/verify_production_dependencies.py
language: python
pass_filenames: false
always_run: true
# Secret detection
- repo: https://github.com/Yelp/detect-secrets
+240 -52
View File
@@ -66,12 +66,14 @@ This project is designed to make **ComfyUI a reliable automation target** with a
- Admin writes, webhook ingress, and bridge worker paths are protected as explicit trust boundaries rather than convenience-only localhost helpers.
- Connector ingress keeps allowlist and policy checks as first-class controls, with degraded/public posture handled deliberately instead of silently widening access.
- Interactive connector actions are treated as a security boundary too: callback-capable platforms use signed envelopes, timestamp/replay guards, dedupe, and explicit policy mapping instead of trusting button actions as implicit admin intent.
- Outbound egress is constrained: callback delivery and custom LLM base URLs stay behind SSRF-safe validation, exact-host policy, and explicit insecure overrides.
- Connector reply visibility is policy-driven: silent, internal, tool-only, and no-mention text replies can be suppressed without bypassing allowlists, replay checks, approvals, or action-button delivery.
- Outbound egress is constrained: callback delivery and custom LLM base URLs stay behind SSRF-safe validation, exact-host policy, scoped private-network allowance, and explicit insecure overrides.
- Secret handling stays server-side: browser storage is not used for secrets, local secret-manager integration is opt-in, and secrets-at-rest / token lifecycle controls are treated as operational boundaries.
- Multi-tenant mode is isolation-first: tenant mismatches fail closed across config, secret sources, connector installations, approvals, visibility, and execution budgets.
- Connector multi-workspace and multi-account bindings are secret-ref-only and fail-closed by design, so tenant/binding mismatches degrade to explicit rejection paths instead of silently reusing the wrong installation context.
- Operator-facing payloads default to redaction for provider reasoning-like content, while audit trails, diagnostics, and runtime guardrails remain explicit and tamper-evident.
- Verification is part of the security model: route drift checks, coverage governance, adversarial gates, and doctor/compatibility diagnostics are all wired into CI-parity workflows.
- Operator-facing and audit payloads default to redaction for provider reasoning-like content and explicitly marked internal maintenance/helper prompt material, while diagnostics and runtime guardrails remain explicit and tamper-evident.
- Verification is part of the security model: route drift checks, supply-chain hardening checks, coverage governance, adversarial gates, and doctor/compatibility diagnostics are all wired into CI-parity workflows.
- Repository automation is hardened around deterministic dependency installs, restricted workflow permissions, pinned high-risk publish actions, and PR-time dependency review for dependency manifest changes.
Deployment profiles and hardening references:
- [Security Deployment Guide](docs/security_deployment_guide.md)
@@ -79,7 +81,7 @@ Deployment profiles and hardening references:
- [Security Checklist](docs/security_checklist.md)
- [Runtime Hardening and Startup](docs/runtime_hardening_and_startup.md)
- [Threat Model](docs/release/threat_model.md)
- [R69 Frontend Migration Decision](docs/r69_ui_framework_migration_decision.md)
- [Frontend Migration Decision](docs/ui_framework_migration_decision.md)
</details>
@@ -89,58 +91,89 @@ Deployment profiles and hardening references:
<details>
<summary><strong>Packaging boundaries, node portability guidance, config ownership seams, and connector extraction diagnostics aligned with the current runtime</strong></summary>
<summary><strong>Startup, security posture, and architecture boundaries hardened</strong></summary>
- Made the supported packaging model explicit: the ComfyUI custom node pack remains the primary artifact, the embedded operator platform is the first-class runtime identity, and the connector stays an optional attached subsystem rather than a separate published package.
- Added a stable node portability contract so inventory/preflight diagnostics can expose OpenClaw node metadata and deterministic replacement hints when a workflow depends on nodes that are not available in the current host.
- Consolidated the remaining high-churn package-boundary import hotspots onto shared import-fallback helpers so minimal or partially optional environments degrade predictably instead of crashing on module import.
- Split runtime-config ownership into focused storage, policy, and operator-projection seams while keeping the public runtime-config facade and precedence contract stable for existing callers and operators.
- Added admin-only connector extraction diagnostics at `/openclaw/connector/extraction-contract` (with legacy `/moltbot/*` parity) so maintainers can query the current no-split recommendation, seam families, and blockers from one machine-readable source of truth.
- Added an executable production dependency boundary check that detects forbidden ownership
direction, cycles, and dynamic-import drift without importing application modules.
- Patched high-severity transitive frontend development dependencies and made local full-test,
pre-push, and CI security paths rebuild the exact lockfile tree before blocking high/critical
findings across production and development dependencies.
- Startup health now exposes typed, redacted phase, readiness, retry, fatal, timing, and optional
warmup outcomes.
- Process-static deployment and security decisions now resolve once into an immutable,
secret-free posture snapshot reused across startup and authorization boundaries.
- Bootstrap lifecycle, route registration, and effective posture implementations now live in
focused owner packages while legacy import identities remain compatible.
- The public systemd environment template now follows `.env.example` conventions, while
secret-bearing deployment environment files remain excluded from version control.
</details>
<details>
<summary><strong>Verification governance, config bootstrap hygiene, and connector env hardening aligned with the current runtime</strong></summary>
<summary><strong>Host alignment, Parameter Lab, and native workflow ownership refreshed</strong></summary>
- Promoted the staged coverage-ratchet baseline to the enforced `45%` floor, added retained review-cycle evidence for hotspot families, and wired backend coverage collection through one shared local/CI helper instead of ad hoc `fail_under` edits.
- Added focused connector and config/bootstrap hotspot regressions, reviewed the governed hotspot-family coverage summaries, and retired the temporary promotion-gap exceptions now that both promotion-blocking families are represented by explicit review evidence.
- Added fail-closed test-debt governance for no-skip modules and mutation-survivor allowlist entries, with explicit `reason` and `review_after` metadata now enforced by the standard full-test flow.
- Hardened pack metadata/version fallback parsing and made config/bootstrap imports side-effect-safe, so pack version fallback stays deterministic and importing config helpers no longer creates the state directory or log file before first real use.
- Added bounded connector numeric env parsing for delivery, media, timeout, rate-limit, command-length, OAuth TTL, and bind-port settings, so malformed values degrade to documented defaults or clamps with warnings instead of crashing startup.
- Legacy fixed-bundle Desktop and current managed-install Comfy-Desktop are modeled separately;
current bridge presence is detected without granting privileged capability access.
- ComfyUI's `datasets` user-data root is excluded from model inventory and Model Manager
destinations.
- Parameter Lab now accepts only bounded scalar values and correlates queued runs through exact
request-ID receipts, failing explicitly on unsupported or ambiguous host queue shapes.
- Advanced 3D `result` references are recognized as bounded output links without inspecting
later metadata or rendering binary content.
- Native ComfyUI video/webcam inputs, audio and text-to-speech flows, and the Graph/Workflows
workspace remain host-owned instead of being duplicated by OpenClaw.
</details>
<details>
<summary><strong>Output contract, outbound egress handling, Security Doctor structure, and audit verification tooling aligned with the current runtime</strong></summary>
<summary><strong>Maintainability, scale safeguards, and verification governance strengthened</strong></summary>
- Kept `/history` + `/view` as the supported runtime output contract for current operator flows, and made asset-service-only refs stay explicit as a bounded fallback state instead of silently guessing a direct `/api/assets` fetch path.
- Consolidated outbound safe HTTP execution behind one shared `safe_io` executor seam so local-provider checks, connector callbacks, and redirect handling now follow the same SSRF-safe validation, pinning, and redirect re-check rules.
- Split Security Doctor internals into focused endpoint, runtime, connector, report, and remediation modules while keeping the operator-facing doctor API and remediation workflow unchanged.
- Added retained audit-chain verification tooling, including a persisted `audit.log.key` sidecar when no environment key is provided, so operators can verify the current audit log plus retained rotations after restart or log rotation.
- Added pinned incremental Ruff and Mypy enforcement that blocks new production-code debt in
local and CI validation without requiring an unsafe repository-wide rewrite.
- Added deterministic scale baselines for large jobs history, connector summaries, and frontend
output normalization, with exact payload and call-count budgets plus advisory timing evidence.
- Hardened selected exception boundaries so cancellation, compatibility fallback, status mapping,
and redacted diagnostics remain explicit instead of being swallowed by broad catches.
- Split the largest API route/config, connector command, Slack/Feishu adapter, and frontend
Settings/API hotspots into focused owner modules while preserving public routes, patch seams,
security checks, DOM behavior, and host compatibility.
- Promoted the governed backend coverage floor to 55% using consecutive release-cycle evidence,
all-hotspot regression ownership, atomic config checks, and fail-closed evidence validation.
</details>
<details>
<summary><strong>Provider URL parity and CI harness resilience tightened for local LLM defaults and Playwright bootstrap stability</strong></summary>
<summary><strong>Secure jobs visibility, host compatibility, output previews, and graph guards refreshed</strong></summary>
- Fixed the built-in `Ollama (Local)` provider default so OpenClaw's OpenAI-compatible requests now target the correct `/v1` surface by default, and existing loopback-root overrides are normalized onto the same bounded path instead of failing on `/models` or `/chat/completions` at the daemon root.
- Added a provider URL contract matrix that pins built-in provider defaults, adapter endpoint assembly, and bounded Ollama normalization in one regression lane so future `LM Studio`, `Ollama`, and custom OpenAI-compatible drift is caught before release.
- Hardened the shared Playwright harness bootstrap so a single transient `openclaw.js` module-fetch failure in CI is retried once instead of failing the whole UI load, while still surfacing real import/runtime errors as hard test failures.
- `GET /openclaw/jobs` now provides an Admin-only, versioned jobs view with bounded
status/workflow filtering, sorting, pagination, and privacy-minimized summaries across
pending, in-progress, completed, failed, and cancelled work.
- Jobs listing distinguishes an authoritative empty snapshot from unsupported or
unavailable host contracts and never returns raw prompts, workflows, execution errors,
tracebacks, current inputs/outputs, tenant/client/trace identifiers, or reasoning text.
- Authorized connector operators can use `/jobs` (plus `jobs` or `queue`) for a bounded
authoritative summary. The connector validates the response contract, displays only
aggregate counts and short job IDs, and uses a coarse queue-count fallback only for
explicit host-contract/backend unavailability.
- Published host compatibility notes now pin the current ComfyUI, standalone frontend, and Desktop reference anchors while keeping Desktop embedded-frontend lag explicit.
- Output previews keep filename-backed refs first-class, accept optional `asset_hash` / `hash` metadata when present, and leave asset-service-only identifiers as explicit fallback states.
- LINE and WhatsApp connector media URLs now force dangerous active content such as SVG/HTML/JS/CSS/XML to download with no-sniff response headers while preserving safe image delivery.
- Job Monitor now treats HDR `.exr` and `.hdr` image outputs as explicit source-preview fallback links instead of normal thumbnails, matching the current host expectation without bundling a HDR viewer.
- Parameter Lab and graph-helper coverage now preserve non-numeric node IDs and promoted-widget source metadata, while structured color/box widget inputs stay out of missing-model diagnostics.
</details>
<details>
<summary><strong>PNG Info sidebar workflow added with ComfyUI metadata extraction, better large-image handling, and lower-noise operator alerts</strong></summary>
<summary><strong>Targeted connector cancellation and host contract guard coverage refreshed</strong></summary>
- Added a new `PNG Info` sidebar tab with drag-and-drop, file picker, scoped paste, preview rendering, prompt copy actions, structured summary cards, and raw metadata inspection for saved generation images.
- Added backend metadata parsing for A1111 infotext and ComfyUI `prompt` / `workflow` metadata, including prompt/sampler/model/size extraction from standard ComfyUI graphs and a larger dedicated payload ceiling for original metadata-bearing images.
- Improved operator-facing UX by making large-image failures explain the metadata-preservation constraint more clearly, letting the PNG Info input area scroll with the rest of the content, and moving prompt copy surfaces to the top of the information area.
- Reduced noise in ComfyUI prompt extraction so generic custom `CLIPTextEncode*` nodes now prefer explicit prompt-bearing keys instead of surfacing parser/config strings as if they were prompt text.
- Tightened queue-monitor alert sensitivity so sidebar startup races no longer generate persistent disconnect noise unless the backend stays unavailable long enough to look like a real incident.
- Connector `/stop`, `/cancel`, and `/interrupt` commands now keep no-argument global interrupt explicit while routing supplied job IDs through targeted ComfyUI job cancellation.
- Single-job cancellation on older hosts can fall back only to targeted interrupt; multi-job cancellation failures no longer degrade into a global interrupt.
- Compatibility guard coverage now documents SaveImage-style output refs, 3D preview refs, typed asset dimensions, grouped asset behavior, sidebar registration fallback, and the OpenClaw Node.js runtime policy.
- OpenClaw keeps its own package/test harness on Node.js `>=18.0.0` while documenting that standalone ComfyUI frontend development may require a newer Node engine.
</details>
@@ -161,6 +194,9 @@ See full update history: [docs/release/recent_updates.md](docs/release/recent_up
- [Basic operations](#basic-operations)
- [Reverse proxy and exposure notes](#reverse-proxy-and-exposure-notes)
- [Nodes](#nodes)
- [Native Media Inputs](#native-media-inputs)
- [Native Audio and Text-to-Speech](#native-audio-and-text-to-speech)
- [Workflow Workspace Ownership](#workflow-workspace-ownership)
- [Node Portability and Workflow Fallback](#node-portability-and-workflow-fallback)
- [Extension UI](#extension-ui)
- [Sidebar Modules](#sidebar-modules)
@@ -172,6 +208,7 @@ See full update history: [docs/release/recent_updates.md](docs/release/recent_up
- [LLM Failover](#llm-failover)
- [Advanced Security and Runtime Setup](#advanced-security-and-runtime-setup)
- [State Directory & Logs](#state-directory--logs)
- [Package and Tool Runtime Hygiene](#package-and-tool-runtime-hygiene)
- [Audit Chain Verification](#audit-chain-verification)
- [Troubleshooting](#troubleshooting)
- [Tests](#tests)
@@ -211,12 +248,42 @@ Notes:
- Recommended: set API keys via environment variables.
- Optional: for single-user localhost setups, you can store a provider API key from the Settings tab (UI Key Store (Advanced)).
- This writes to the server-side secret store (`{STATE_DIR}/secrets.json`).
- This writes to the encrypted server-side secret store (`{STATE_DIR}/secrets.enc.json`).
- Environment variables always take priority over stored keys.
- Built-in local-provider defaults use loopback-only OpenAI-compatible URLs:
- `Ollama (Local)` -> `http://127.0.0.1:11434/v1`
- `LM Studio (Local)` -> `http://localhost:1234/v1`
#### Hosted OpenAI-compatible provider: Atlas Cloud
> 🎁 **[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=comfyui-openclaw)** is a full-modal AI inference platform with an OpenAI-compatible API (DeepSeek, Qwen, GLM, Kimi, MiniMax, …). Use it through the `custom` provider:
```bash
OPENCLAW_LLM_PROVIDER=custom
OPENCLAW_LLM_BASE_URL=https://api.atlascloud.ai/v1
OPENCLAW_LLM_MODEL=deepseek-ai/deepseek-v4-pro
OPENCLAW_LLM_API_KEY=your_atlascloud_api_key
```
`api.atlascloud.ai` is a public HTTPS host, so it passes the default SSRF-safe egress validation with no `OPENCLAW_LLM_ALLOW_PRIVATE_NETWORK` or insecure override needed. `deepseek-ai/deepseek-v4-pro` is a reasoning model; any other Atlas chat model id works the same way.
<details>
<summary>All Atlas Cloud chat models (59)</summary>
- **Anthropic (Claude):** `anthropic/claude-haiku-4.5-20251001`, `anthropic/claude-opus-4.8`, `anthropic/claude-sonnet-4.6`
- **OpenAI (GPT):** `openai/gpt-5.4`, `openai/gpt-5.5`
- **Google (Gemini):** `google/gemini-3.1-flash-lite`, `google/gemini-3.1-pro-preview`, `google/gemini-3.5-flash`
- **Qwen:** `qwen/qwen2.5-7b-instruct`, `Qwen/Qwen3-235B-A22B-Instruct-2507`, `qwen/qwen3-235b-a22b-thinking-2507`, `qwen/qwen3-30b-a3b`, `Qwen/Qwen3-30B-A3B-Instruct-2507`, `qwen/qwen3-30b-a3b-thinking-2507`, `qwen/qwen3-32b`, `qwen/qwen3-8b`, `Qwen/Qwen3-Coder`, `qwen/qwen3-coder-next`, `qwen/qwen3-max-2026-01-23`, `Qwen/Qwen3-Next-80B-A3B-Instruct`, `Qwen/Qwen3-Next-80B-A3B-Thinking`, `Qwen/Qwen3-VL-235B-A22B-Instruct`, `qwen/qwen3-vl-235b-a22b-thinking`, `qwen/qwen3-vl-30b-a3b-instruct`, `qwen/qwen3-vl-30b-a3b-thinking`, `qwen/qwen3-vl-8b-instruct`, `qwen/qwen3.5-122b-a10b`, `qwen/qwen3.5-27b`, `qwen/qwen3.5-35b-a3b`, `qwen/qwen3.5-397b-a17b`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-plus`
- **DeepSeek:** `deepseek-ai/deepseek-ocr`, `deepseek-ai/deepseek-r1-0528`, `deepseek-ai/DeepSeek-V3-0324`, `deepseek-ai/DeepSeek-V3.1`, `deepseek-ai/DeepSeek-V3.1-Terminus`, `deepseek-ai/deepseek-v3.2`, `deepseek-ai/DeepSeek-V3.2-Exp`, `deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro`
- **Moonshot (Kimi):** `moonshotai/Kimi-K2-Instruct`, `moonshotai/Kimi-K2-Instruct-0905`, `moonshotai/Kimi-K2-Thinking`, `moonshotai/kimi-k2.5`, `moonshotai/kimi-k2.6`
- **Zhipu (GLM):** `zai-org/GLM-4.6`, `zai-org/glm-4.7`, `zai-org/glm-5`, `zai-org/glm-5-turbo`, `zai-org/glm-5.1`, `zai-org/glm-5v-turbo`
- **MiniMax:** `MiniMaxAI/MiniMax-M2`, `minimaxai/minimax-m2.1`, `minimaxai/minimax-m2.5`, `minimaxai/minimax-m2.7`
- **xAI:** `xai/grok-4.3`
- **Kwaipilot:** `kwaipilot/kat-coder-pro-v2`
- **Other:** `owl`
</details>
### 2 Configure webhook auth (required for `/webhook*`)
Webhooks are **deny-by-default** unless auth is configured:
@@ -300,7 +367,7 @@ Notes:
- On Windows, if a port fails with bind errors (for example WinError 10013), choose a different port outside excluded ranges.
- If write actions are denied remotely, verify both `OPENCLAW_ADMIN_TOKEN` and `OPENCLAW_ALLOW_REMOTE_ADMIN=1`.
- Remote Admin being reachable from LAN does not imply LAN-hosted custom LLM targets are allowed. SSRF rules for `base_url` remain separate and stricter.
- Remote Admin being reachable from LAN does not imply LAN-hosted custom LLM targets are allowed. SSRF rules for `base_url` remain separate and require either the scoped LLM private-network setting for the configured target or the broader insecure override.
### Basic operations
@@ -340,14 +407,63 @@ Nodes are exported as `Moltbot*` class names for compatibility, but appear as `o
- `openclaw: Image to Prompt`
- `openclaw: Batch Variants`
The current node category is `openclaw`; serialized workflows that still reference the legacy `Moltbot*` class names continue to load through retained compatibility aliases.
See `web/docs/` for node usage notes.
### Native Media Inputs
Current supported ComfyUI hosts already provide the media-ingestion nodes needed by
OpenClaw image consumers:
- For video frames, connect ComfyUI's `Load Video` node to `Get Video Components` and use
its `images` output. File selection and upload stay inside the ComfyUI input boundary.
- For a camera snapshot, use ComfyUI's `Webcam Capture` node. Camera permission and
secure-context requirements are handled by the host frontend; the captured frame is
uploaded to ComfyUI temporary storage and returned as an `IMAGE`.
OpenClaw intentionally does not register duplicate video-decoder or camera-capture nodes.
This keeps media decoding, browser device permission, and native `VIDEO` / `IMAGE`
compatibility owned by ComfyUI and avoids adding a second ffmpeg or backend-device access
path.
### Native Audio and Text-to-Speech
Use native ComfyUI audio workflows for text-to-speech generation. Connect a host-provided
voice selector and TTS node to the standard `AUDIO` flow, then use ComfyUI's native audio
preview or save nodes. Provider availability, authentication, voice/model choice, credits,
and output format remain owned by the host workflow and its installed nodes.
OpenClaw Jobs can observe audio results exposed by ComfyUI history, but the remote chat
connector remains a text command-and-control surface. It does not capture microphone
input, synthesize speech independently, persist connector audio or transcripts, or return
workflow audio as chat voice attachments. Start an approved audio workflow through the
existing remote controls when needed, then inspect or play its result in ComfyUI or
OpenClaw Jobs.
### Workflow Workspace Ownership
The ComfyUI Graph Canvas is the authoritative workspace for loading, arranging,
inspecting, saving, and switching workflows. Use the host Workflows sidebar and tabs,
drafts, and subgraphs so graph migrations, custom-node registration, active and modified
state, and Desktop compatibility remain owned by ComfyUI.
OpenClaw complements that workspace: Explorer, preflight, checkpoints, and portability or
rewrite tools diagnose and guard workflows; Parameter Lab reads and replays bounded values
through the active host graph; Jobs observes execution and results.
OpenClaw intentionally does not create a second canvas, workflow store, serializer, or
remote graph editor. Keeping one graph owner avoids divergent drafts, subgraph identifiers,
widget state, and execution context.
### Node Portability and Workflow Fallback
Current builds expose a stable portability contract for the shipped OpenClaw nodes so workflow diagnostics can distinguish "custom node missing" from a generic import/runtime failure:
- inventory/preflight surfaces can expose package-level node portability metadata for `openclaw:*` nodes alongside the normal node inventory view
- when a workflow references an unavailable OpenClaw node, current diagnostics prefer deterministic replacement guidance instead of an opaque missing-node failure
- muted or bypassed root nodes and subgraph branches are reported as suppressed diagnostics when the submitted workflow shape exposes enough frontend metadata, so inactive branches do not become actionable missing-node/model failures
- model-name inputs are checked against current ComfyUI folder keys such as `text_encoders` and `diffusion_models`, with legacy `clip` and `unet` aliases preserved for older workflow metadata
- the compatibility class exports (`Moltbot*`) remain in place for existing workflows, but portability guidance is anchored on the canonical `openclaw:*` node identities
If you are moving a workflow between hosts, treat the portability metadata and replacement hints as the supported migration path before attempting ad hoc node renames. The troubleshooting guide covers the operator-facing interpretation of those signals.
@@ -360,15 +476,29 @@ The frontend lives in `web/` and is served by ComfyUI as an extension panel. It
Current sidebar composition keeps `web/openclaw_ui.js` as the shell root and routes specialized browser logic through focused modules:
- ComfyUI host sidebar registration: `web/openclaw_sidebar_registration.js`
- actions and submit/cancel wiring: `web/openclaw_actions.js`
- queue polling and transient banners: `web/openclaw_queue_monitor.js` and `web/openclaw_banner_manager.js`
- persistent operator notifications: `web/openclaw_notification_center.js`
- tab registration/remount behavior: `web/openclaw_tabs.js`
- API transport/session core: `web/openclaw_api.js`, with config, generation, resource, model,
and event route families owned by `web/openclaw_api_*.js` modules behind the same singleton
- Settings composition and async generation lifecycle: `web/tabs/settings_tab.js`, with status,
LLM, secrets, logs, DOM, and lifecycle ownership in focused `settings_tab_*.js` modules
- shared error + compatibility helpers: `web/openclaw_utils.js`
New shell/tab wiring should use the shared text-safe DOM helpers in `web/openclaw_utils.js` instead of duplicating ad hoc element construction in individual tabs.
Canonical DOM/class ownership is now centered on `openclaw-*`; legacy `moltbot-*` class compatibility is still supported through shared runtime aliasing instead of duplicated markup in each tab template.
The sidebar now also resolves and stamps its active host surface (`standalone_frontend` vs desktop-embedded host) at mount time so frontend-host drift is explicit and testable instead of inferred from runtime accidents.
The sidebar now also resolves and stamps its active host surface (`standalone_frontend`, legacy
`desktop`, or current managed-install `comfy_desktop`) and reference metadata at mount time, so
Desktop `0.9.4` embedded-frontend lag against standalone frontend `1.49.1` is explicit and
testable. The current Comfy-Desktop `1.0.32-rc.1` reference keeps hosted component versions
installation-specific and recognizes `window.__comfyDesktop2` as presence metadata only; bridge
detection does not authorize privileged capability calls.
Sidebar registration prefers ComfyUI's current sidebar store API and falls back to the deprecated frontend facade when running on older host bundles. Hosts without either sidebar API use the legacy menu fallback instead of failing extension setup.
### Sidebar Modules
@@ -378,18 +508,18 @@ The OpenClaw sidebar includes these built-in tabs. Some tabs are capability-gate
| Tab | What it does | Related docs |
| --- | --- | --- |
| `Settings` | Health/config/log visibility, provider/model setup, model connectivity checks, and optional localhost key storage. | [Quick Start](#quick-start-minimal), [LLM config](#llm-config-non-secret), [Troubleshooting](#troubleshooting) |
| `Jobs` | Tracks prompt IDs, consumes deterministic event/task cursor metadata for polling, and shows output previews for recent jobs across classic history refs and asset-backed output refs through the same `/view` contract; refs that only expose asset-service identifiers stay explicit as an operator-visible fallback state instead of silently upgrading to `/api/assets`. | [Observability](#observability-read-only), [Remote Control (Connector)](#remote-control-connector) |
| `Settings` | Health/config/log visibility, provider/model setup, model connectivity checks, and optional localhost key storage. | [Quick Start](#quick-start-minimal), [API Overview](#api-overview), [Troubleshooting](#troubleshooting) |
| `Jobs` | Tracks prompt IDs, consumes deterministic event/task cursor metadata for polling, and shows recent outputs across classic history refs, optional `asset_hash`/`hash`-backed refs when host metadata is present, and current previewable media groups (`images`, `video`, `audio`, `3d`, bounded inline or file-backed `text`). Allowlisted text files use a bounded same-origin `/view` reader and literal text rendering with an explicit source-link fallback; asset-service-only refs stay explicit instead of silently upgrading to `/api/assets`. | [API Overview](#api-overview), [Remote Control (Connector)](#remote-control-connector) |
| `Planner` | Uses assist endpoint to generate structured prompt plans (positive/negative/params). | [Configure an LLM key](#1-configure-an-llm-key-for-plannerrefinervision-helpers), [Nodes](#nodes) |
| `Refiner` | Refines existing prompts with optional image context and issue/goal input. | [Configure an LLM key](#1-configure-an-llm-key-for-plannerrefinervision-helpers), [Nodes](#nodes) |
| `Variants` | Local helper for generating batch variant parameter JSON (seed/range-style sweeps). | [Nodes](#nodes), [Operator UX Features](#operator-ux-features) |
| `Library` | Manages reusable prompt/params presets and provides pack-oriented library operations in one place. | [Presets](#presets-admin), [Packs](#packs-admin) |
| `Approvals` | Lists approval gates and supports approve/reject operations, including the same approval objects now surfaced through Slack and Feishu interactive connector actions. | [Triggers + approvals](#triggers--approvals-admin), [Remote Control (Connector)](#remote-control-connector) |
| `Explorer` | Inventory/preflight diagnostics and snapshot/checkpoint troubleshooting workflows, including snapshot-first inventory refresh state (`snapshot_ts`, `scan_state`, `stale`, `last_error`). | [Operator UX Features](#operator-ux-features), [Troubleshooting](#troubleshooting) |
| `Packs` | Dedicated pack lifecycle tab for import/export/delete under admin boundary. | [Packs](#packs-admin) |
| `Library` | Manages reusable prompt/params presets and provides pack-oriented library operations in one place. | [Templates](#templates), [API Overview](#api-overview) |
| `Approvals` | Lists approval gates and supports approve/reject operations, including the same approval objects now surfaced through Slack and Feishu interactive connector actions. | [API Overview](#api-overview), [Remote Control (Connector)](#remote-control-connector) |
| `Explorer` | Inventory/preflight diagnostics and snapshot/checkpoint troubleshooting workflows, including snapshot-first inventory refresh state (`snapshot_ts`, `scan_state`, `stale`, `last_error`) and suppressed inactive-branch findings. | [Operator UX Features](#operator-ux-features), [Troubleshooting](#troubleshooting) |
| `Packs` | Dedicated pack lifecycle tab for import/export/delete under admin boundary. | [API Overview](#api-overview) |
| `PNG Info` | Inspects saved generation images through drag-and-drop, file picker, or scoped paste, parses A1111 infotext plus ComfyUI `prompt` / `workflow` metadata, shows extracted prompt and generation fields when recoverable, and keeps raw metadata visible for operator inspection. | [API Overview](#api-overview), [Troubleshooting](#troubleshooting) |
| `Model Manager` | Searches model catalog/install records, queues managed downloads, monitors task lifecycle, and imports completed tasks into the managed install root with the same trusted download/import contract used by the backend model manager APIs. | [Model manager](#model-manager-admin-f54), [API Overview](#api-overview) |
| `Parameter Lab` | Runs bounded sweep/compare experiments, stores history, and replays parameters back into the graph. | [Operator UX Features](#operator-ux-features) |
| `Model Manager` | Searches model catalog/install records, queues managed downloads, monitors task lifecycle, and imports completed tasks into the managed install root with current ComfyUI folder-key normalization, including `gligen`, `latent_upscale_models`, `hypernetworks`, `photomaker`, `model_patches`, `geometry_estimation`, and `detection`, plus legacy type aliases. User-managed `datasets` remain outside model inventory and install destinations. | [API Overview](#api-overview), [Troubleshooting](#troubleshooting) |
| `Parameter Lab` | Runs bounded sweep/compare experiments, stores history, and replays parameters back into the graph while preserving non-numeric host node IDs. | [Operator UX Features](#operator-ux-features) |
## Operator UX Features
@@ -424,7 +554,12 @@ Parameter Lab now supports experiment history and run replay:
- `History` lists saved experiments from local state.
- `Load` opens stored experiment details and run statuses.
- `Replay` applies a selected run's parameter values back into the active workflow graph.
- `Replay` applies a selected run's parameter values back into the active workflow graph without coercing string or non-numeric host node IDs.
- Sweep and compare inputs accept bounded strings, booleans, and finite numbers; structured values
and unsupported sweep strategies fail validation instead of being guessed or silently coerced.
- Queued runs use request-ID-correlated receipts to bind the exact host prompt ID. Unsupported,
malformed, busy, or ambiguous host queue boundaries fail explicitly rather than borrowing a
globally recent prompt.
This makes iterative tuning and backtracking faster without manually retyping prior parameter sets.
@@ -461,9 +596,12 @@ Base path notes:
Main API families:
- Observability: health, capabilities, logs, traces, event feeds
- Jobs visibility: Admin-only, bounded and privacy-minimized list/filter/sort/pagination
over the in-process ComfyUI queue and history snapshot
- Admin diagnostics: preflight inventory snapshot/status, doctor-facing readiness views
- Config + LLM: effective config, provider tests, model lists, assist planner/refiner
- Connector diagnostics: installation state, resolution, callback/tenant binding evidence, audit views, and extraction seam metadata
- Connector diagnostics: installation state, resolution, callback/tenant binding evidence, audit views, extraction seam metadata, and static service-env SecretRef propagation policy
- External tools: admin-gated, feature-flagged allowlist execution with sandbox/path diagnostics
- Webhooks + events: validate, submit, callback delivery, SSE/polling status
- Admin operations: approvals, schedules, presets, rewrite recipes
- Model Manager + Packs: search, download/import lifecycle, pack import/export
@@ -479,10 +617,16 @@ Primary references:
Key operational notes:
- Observability remains token-gated for remote access and redacts provider reasoning-like content by default.
- Observability remains token-gated for remote access and redacts provider reasoning-like content plus marked internal maintenance/helper content by default.
- `GET /openclaw/jobs` is Admin-only and returns contract version 1 with bounded job
summaries and pagination. Treat HTTP 200 with `jobs: []` as an authoritative empty
snapshot, HTTP 501 as an unsupported host contract, and HTTP 503 as backend
unavailability; neither failure is an empty success.
- Event/model-download polling and preflight inventory are snapshot/cursor-driven contracts; clients should consume `snapshot_ts`, `scan_state`, `stale`, and cursor metadata instead of assuming full-refresh polling.
- Output/history-facing consumers should keep using the bounded `/history` + `/view` contract; refs that only upstream asset services can resolve remain explicit `asset_api_required` compatibility states.
- Connector diagnostics expose redacted token references only, and `/openclaw/connector/extraction-contract` is structural packaging metadata rather than a live installation-health feed.
- Model Manager and preflight consumers should use current ComfyUI folder keys for model types where possible, including `text_encoders`, `diffusion_models`, `gligen`, `latent_upscale_models`, `hypernetworks`, `photomaker`, `model_patches`, `geometry_estimation`, and `detection`; compatibility aliases such as `clip`, `unet`, `ckpt`, and plural legacy names are normalized before lookup/import.
- Output/history-facing consumers should keep using the bounded `/history` + `/view` contract; current previewable output groups include `images`, `video`, `audio`, `3d`, and bounded `text`, while optional hash-backed refs are used only when host metadata is present and refs that only upstream asset services can resolve remain explicit `asset_api_required` compatibility states.
- Queue submissions add stable ComfyUI usage-source attribution (`comfyui-openclaw`) when callers do not provide one; callers that already supply `extra_data.comfy_usage_source` keep ownership of that value.
- Connector diagnostics expose redacted token references only, and `/openclaw/connector/extraction-contract` is structural packaging metadata and static SecretRef policy rather than a live installation-health, environment, or token-status feed.
## Advanced Security and Runtime Setup
@@ -493,6 +637,7 @@ Start here:
- [Runtime hardening and startup](docs/runtime_hardening_and_startup.md)
- [Security deployment guide](docs/security_deployment_guide.md)
- [Security checklist](docs/security_checklist.md)
- [Feature flags](docs/release/feature_flags.md)
- [Config and secrets contract](docs/release/config_secrets_contract.md)
Architecture and boundary decisions:
@@ -596,6 +741,30 @@ Logs:
- set `OPENCLAW_LOG_FORMAT=json` (or `OPENCLAW_STRUCTURED_LOGS=1`) before startup
- default behavior remains plain text logs (no structured log emission unless opt-in)
## Package and Tool Runtime Hygiene
OpenClaw keeps package-owned resources, runtime state, and local validation artifacts separate:
- package-owned defaults, such as `data/tools_allowlist.json`, are read from the installed custom-node pack
- custom external-tool allowlists should be supplied explicitly with `OPENCLAW_TOOLS_CONFIG_PATH`
- runtime cache and external-tool sandbox scratch space belong under the configured state directory (`cache/` and `tool_sandbox/`)
- repo-local generated folders such as `.tmp/`, `.venv/`, and `node_modules/` are validation/development artifacts and are regenerated from project metadata or lockfiles
- OpenClaw does not automatically repair, migrate, or delete runtime dependency caches
External tools remain opt-in and admin-gated:
- `OPENCLAW_ENABLE_EXTERNAL_TOOLS=true` enables the route family
- `GET /openclaw/tools` lists the allowlisted tools
- `POST /openclaw/tools/{name}/run` executes a named allowlisted tool with validated args
- hardened runtime posture fails closed on missing sandbox runtime or ambiguous sandbox policy
- service-level tool execution results include stable diagnostics for common local failures such as `sandbox_runtime_unavailable`, `interpreter_missing`, `timeout`, and `workspace_violation`
Operational references:
- [Runtime hardening and startup](docs/runtime_hardening_and_startup.md)
- [Troubleshooting guide](docs/troubleshooting.md)
- [Config and secrets contract](docs/release/config_secrets_contract.md)
## Audit Chain Verification
Operators can verify retained audit-log continuity with:
@@ -626,8 +795,9 @@ Quick jumps:
- backend not loaded / route 404 startup failures
- Operator Doctor usage
- Jobs preview fallback for asset-api-only output refs
- Jobs preview and media fallback for asset-api-only output refs
- audit chain verification after restart or rotation
- external-tool allowlist, sandbox runtime, interpreter, timeout, or workspace diagnostics
- webhook auth not configured
- loopback LLM SSRF validation errors
- Remote Admin vs private-LAN LLM target behavior
@@ -647,7 +817,11 @@ The SOP already defines:
- the docs-only exception for strictly documentation/planning/SOP changes
- one-command full test scripts for Windows and Linux/WSL
- the CI-parity backend coverage and governance workflow
- supply-chain hardening checks, lockfile-driven frontend dependency installation, and blocking
high/critical audits across production and development dependencies
- pinned incremental Ruff/Mypy debt enforcement and deterministic scale regression baselines
- the CI-parity backend coverage workflow with the governed 55% floor, retained release-cycle
evidence, and required hotspot ownership
## Updating
@@ -660,16 +834,25 @@ OpenClaw includes a standalone **Connector** process that allows you to control
The connector currently remains an **optional attached subsystem inside this repo/package boundary**. Current builds expose extraction diagnostics for maintainers, but do **not** treat a standalone connector package or separate-repo distribution as a supported release shape.
- **Status & Queue**: Check job progress remotely.
- **Status & Queue**: Authorized operators can use `/jobs` for a bounded authoritative
jobs summary; public `/status` remains a coarse health/queue view and does not receive
the Admin-only jobs payload.
- **Run Jobs**: Submit templates via chat commands.
- **Targeted cancellation**: `/stop`, `/cancel`, and `/interrupt` without job IDs send an explicit global interrupt; supplying one or more job IDs requests targeted ComfyUI job cancellation.
- **Approvals**: Approve/Reject paused workflows from your phone.
- **Secure**: Outbound-only for Telegram/Discord. LINE/WhatsApp/WeChat/KakaoTalk/Slack require inbound HTTPS (webhook), while Slack can also use Socket Mode and Feishu can run in either webhook or long-connection mode with a dedicated callback ingress path.
- **Telegram topics**: Forum topic commands keep their topic context for immediate replies and delayed result delivery.
- **Replay-safe actions and replies**: Duplicate or retried platform events are acknowledged without re-running completed actions; retryable failures before delivery commit can be retried.
- **Reply visibility policy**: Direct-message, group/channel, thread, internal, and tool-only contexts share one visible/suppressed text decision; suppressed text is treated as a successful no-op and action/approval controls stay visible.
- **WeChat encrypted mode**: Official Account encrypted webhook mode is supported when AES settings are configured.
- **KakaoTalk response safety**: QuickReply limits and safe fallback handling are enforced for reliable payload behavior.
- **Slack multi-workspace mode**: Workspace installs can be handled through connector-managed OAuth install/callback routes with per-workspace token binding and fail-closed health diagnostics.
- **Slack multi-workspace and interactive mode**: Workspace installs can be handled through connector-managed OAuth install/callback routes with per-workspace token binding, fail-closed health diagnostics, and signed interactive callback handling for action payloads.
- **Feishu/Lark multi-account mode**: Connector-managed account/workspace bindings support tenant-aware installation resolution, interactive approval cards, and signed callback handling without exposing raw app secrets or widening command trust implicitly.
- **Bounded connector numeric envs**: Delivery/media/time-budget settings, bind ports, rate limits, and command-length knobs now clamp or fall back to documented defaults with warnings instead of crashing connector startup on malformed values.
- **Packaging diagnostics**: Admin operators/maintainers can inspect `/openclaw/connector/extraction-contract` for the current in-repo recommendation plus the minimum seam families required before any future split.
- **Startup diagnostics**: `/openclaw/health` reports startup readiness, optional warmup degradation, and fatal startup details without making optional warmups block baseline API availability.
- **SecretRef service boundaries**: Connector service-env planning preserves only supported env-backed credential references and rejects raw secrets, legacy marker strings, unsupported envs, and runtime-only auth tokens.
- **Internal prompt isolation**: Operator-visible and audit payloads remove explicitly marked internal maintenance/helper prompt content before normal reasoning redaction.
- **Packaging diagnostics**: Admin operators/maintainers can inspect `/openclaw/connector/extraction-contract` for the current in-repo recommendation, the static service-env SecretRef propagation policy, and the minimum seam families required before any future split.
- [See Setup Guide (`docs/connector.md`)](docs/connector.md)
@@ -677,6 +860,11 @@ The connector currently remains an **optional attached subsystem inside this rep
Read [SECURITY.md](docs/SECURITY.md) before exposing any endpoint beyond localhost. The project is designed to be secure-by-default (deny-by-default auth, SSRF protections, redaction, bounded outputs), but unsafe deployment can still create risk.
Repository maintenance workflows also include supply-chain controls: CI/local validation scans
declared dependencies and selected workspace persistence surfaces for known malicious indicators,
frontend installs are lockfile-driven, high/critical findings across production and development
dependencies block acceptance, and dependency manifest changes receive PR-time review.
### Security Deployment Guide
- [Security Deployment Guide](docs/security_deployment_guide.md)
+11 -1
View File
@@ -53,7 +53,17 @@ def _bootstrap_openclaw_routes() -> None:
from .services.route_bootstrap import register_routes_once
else:
from services.route_bootstrap import register_routes_once
except Exception:
except Exception as exc:
try:
if __package__:
from .services.startup_lifecycle import mark_bootstrap_import_failed
else:
from services.startup_lifecycle import mark_bootstrap_import_failed
mark_bootstrap_import_failed(exc)
except Exception:
# IMPORTANT: diagnostics must not mask the original compatibility fallback.
pass
return
register_routes_once()
+75 -804
View File
@@ -166,6 +166,29 @@ except Exception:
logger = logging.getLogger("ComfyUI-OpenClaw.api.config")
(
ConfigHandlerDependencies,
config_get_response,
config_put_response,
) = import_attrs_dual(
__package__,
"..api.config_projection_handlers",
"api.config_projection_handlers",
("ConfigHandlerDependencies", "config_get_response", "config_put_response"),
)
(llm_models_response,) = import_attrs_dual(
__package__,
"..api.config_model_handlers",
"api.config_model_handlers",
("llm_models_response",),
)
(llm_chat_response, llm_test_response) = import_attrs_dual(
__package__,
"..api.config_llm_handlers",
"api.config_llm_handlers",
("llm_chat_response", "llm_test_response"),
)
(
_MODEL_LIST_CACHE,
_MODEL_LIST_MAX_ENTRIES,
@@ -258,6 +281,46 @@ except ImportError:
]
def _handler_dependencies():
"""Capture established facade patch seams for owned config handlers."""
return ConfigHandlerDependencies(
web=web,
logger=logger,
provider_catalog=PROVIDER_CATALOG,
pack_version=PACK_VERSION,
require_observability_access=require_observability_access,
require_admin_token=require_admin_token,
require_same_origin_if_no_token=require_same_origin_if_no_token,
resolve_token_info=resolve_token_info,
emit_audit_event=emit_audit_event,
check_rate_limit=check_rate_limit,
build_rate_limit_response=build_rate_limit_response,
get_client_ip=get_client_ip,
is_loopback=is_loopback,
get_admin_token=get_admin_token,
get_apply_semantics=get_apply_semantics,
get_effective_config=get_effective_config,
get_llm_egress_controls=get_llm_egress_controls,
get_runtime_guardrails=get_runtime_guardrails,
get_settings_schema=get_settings_schema,
is_loopback_client=is_loopback_client,
update_config=update_config,
tenant_boundary_error=TenantBoundaryError,
request_tenant_scope=request_tenant_scope,
runtime_only_code=CODE_RUNTIME_ONLY_PERSIST_FORBIDDEN,
payload_contains_runtime_guardrails=payload_contains_runtime_guardrails,
model_cache_get=_cache_get,
format_llm_ssrf_error=_format_llm_ssrf_error,
llm_insecure_override_enabled=_llm_insecure_override_enabled,
fetch_remote_model_list=fetch_remote_model_list,
get_stale_cached_models=get_stale_cached_models,
resolve_model_list_target=resolve_model_list_target,
validate_model_list_target=validate_model_list_target,
llm_client=LLMClient,
)
@endpoint_metadata(
auth=AuthTier.OBSERVABILITY,
risk=RiskTier.LOW,
@@ -272,73 +335,8 @@ async def config_get_handler(request: web.Request) -> web.Response:
Returns effective config, sources, and provider catalog.
Enforced by S14 Access Control.
"""
if web is None:
raise RuntimeError("aiohttp not available")
# S14: Access Control
allowed, error = require_observability_access(request)
if not allowed:
return web.json_response({"ok": False, "error": error}, status=403)
# S17: Rate Limit
if not check_rate_limit(request, "admin"):
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
effective, sources = get_effective_config(tenant_id=tenant.tenant_id)
guardrails = get_runtime_guardrails()
if guardrails.get("status") != "ok":
emit_audit_event(
action="runtime.guardrails",
target="runtime_guardrails",
outcome="warn",
token_info=token_info,
status_code=200,
details={
"tenant_id": tenant.tenant_id,
"code": guardrails.get("code"),
"violations": guardrails.get("violations", []),
},
request=request,
)
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"config": effective,
"sources": sources,
"runtime_guardrails": guardrails,
"providers": PROVIDER_CATALOG,
# R70: Settings schema for frontend type coercion / validation
"schema": get_settings_schema(),
# Simplified UX: writes are controlled by admin access policy, not a separate env "enable" flag.
"write_enabled": True,
}
)
except TenantBoundaryError as e:
return web.json_response(
{"ok": False, "error": e.code, "message": str(e)},
status=403,
)
except Exception as e:
logger.exception("Error getting config")
return web.json_response(
{
"ok": False,
"error": str(e),
},
status=500,
)
# CRITICAL: owned implementation performs require_observability_access before reads.
return await config_get_response(request, _handler_dependencies())
@endpoint_metadata(
@@ -357,210 +355,11 @@ async def llm_models_handler(request: web.Request) -> web.Response:
Security:
- admin boundary
- loopback-only unless OPENCLAW_ALLOW_REMOTE_ADMIN=1
- SSRF policy enforced via OPENCLAW_LLM_ALLOWED_HOSTS / OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST
- SSRF policy enforced via LLM egress controls, including scoped private-network allowance
"""
if web is None:
raise RuntimeError("aiohttp not available")
# S17: Rate Limit
if not check_rate_limit(request, "admin"):
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
# Admin boundary
allowed, err = require_admin_token(request)
if not allowed:
emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=403,
details={
"tenant_id": tenant.tenant_id,
"reason": err or "unauthorized",
},
request=request,
)
return web.json_response(
{
"ok": False,
"error": err or "Unauthorized",
},
status=403,
)
# Optional loopback check (match config_put behavior)
import os
allow_remote = (
os.environ.get("OPENCLAW_ALLOW_REMOTE_ADMIN")
or os.environ.get("MOLTBOT_ALLOW_REMOTE_ADMIN")
or ""
).lower()
if allow_remote not in ("1", "true", "yes", "on"):
remote = request.remote or ""
if not is_loopback_client(remote):
return web.json_response(
{
"ok": False,
"error": "Remote admin access denied. Set OPENCLAW_ALLOW_REMOTE_ADMIN=1 (or legacy MOLTBOT_ALLOW_REMOTE_ADMIN=1) to allow.",
},
status=403,
)
provider_override = (request.query.get("provider") or "").strip().lower()
effective, _sources = get_effective_config(tenant_id=tenant.tenant_id)
try:
target = resolve_model_list_target(
provider_override,
effective,
tenant.tenant_id,
)
except ValueError as e:
return web.json_response(
{"ok": False, "error": str(e)},
status=400,
)
except TypeError as e:
return web.json_response(
{"ok": False, "error": str(e)},
status=400,
)
# R60: Check bounded TTL+LRU cache
cached_entry = _cache_get(target.cache_key)
if cached_entry:
_ts, models = cached_entry
if isinstance(models, list):
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": target.provider,
"models": models,
"cached": True,
}
)
# CRITICAL:
# Local providers (e.g. ollama/lmstudio) intentionally work without API keys.
# Do not change this gate back to `if not api_key`, or local model-list loading
# will regress with false 400 errors.
if target.requires_api_key and not target.api_key:
return web.json_response(
{
"ok": False,
"error": f"No API key configured for provider '{target.provider}'.",
},
status=400,
)
# SSRF policy
try:
controls = get_llm_egress_controls(target.provider, target.base_url)
validate_model_list_target(
target,
controls,
allow_insecure_base_url=_llm_insecure_override_enabled(),
)
except Exception as e:
return web.json_response(
{"ok": False, "error": _format_llm_ssrf_error(e)},
status=403,
)
# Fetch /models
try:
try:
from ..services.safe_io import SSRFError
except ImportError:
from services.safe_io import SSRFError # type: ignore
models = fetch_remote_model_list(
target,
controls,
pack_version=PACK_VERSION,
allow_insecure_base_url=_llm_insecure_override_enabled(),
)
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": target.provider,
"models": models,
"cached": False,
}
)
except SSRFError as e:
return web.json_response(
{"ok": False, "error": _format_llm_ssrf_error(e)},
status=403,
)
except RuntimeError as e:
# safe_request_json raises RuntimeError for HTTP errors (non-200) contextually
# check if it looks like an HTTP error
str_e = str(e)
if "HTTP" in str_e:
# Fallback: serve stale cache entry (if any) on fetch failure
stale = get_stale_cached_models(target.cache_key)
if stale:
_ts, models = stale
warning = f"Using cached list (refresh failed: {str_e})"
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": target.provider,
"models": models,
"cached": True,
"warning": warning,
}
)
return web.json_response(
{"ok": False, "error": f"Upstream error: {str_e}"}, status=502
)
raise
except Exception as e:
stale = get_stale_cached_models(target.cache_key)
if stale:
# IMPORTANT:
# Test path intentionally injects network failures to verify cache fallback.
# Keep this as warning (no traceback) to avoid noisy false-alarm logs.
logger.warning(
"Model list refresh failed, serving cached list: %s", e
)
_ts, models = stale
warning = f"Using cached list (refresh failed: {str(e)})"
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": target.provider,
"models": models,
"cached": True,
"warning": warning,
}
)
logger.exception("Failed to fetch model list")
return web.json_response({"ok": False, "error": str(e)}, status=500)
except TenantBoundaryError as e:
return web.json_response(
{"ok": False, "error": e.code, "message": str(e)},
status=403,
)
# CRITICAL: owned implementation performs require_admin_token( before network access.
# CRITICAL S65: fetch_remote_model_list remains the safe_request_json egress owner.
return await llm_models_response(request, _handler_dependencies())
@endpoint_metadata(
@@ -576,182 +375,8 @@ async def config_put_handler(request: web.Request) -> web.Response:
PUT /moltbot/config
Updates non-secret LLM config. Protected by admin boundary (S13) + CSRF (S26+).
"""
if web is None:
raise RuntimeError("aiohttp not available")
# S26+: CSRF protection for convenience mode
admin_token_configured = bool(get_admin_token())
resp = require_same_origin_if_no_token(request, admin_token_configured)
if resp:
return resp
# S17: Rate Limit
if not check_rate_limit(request, "admin"):
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
# R99/S46: resolve identity context for non-repudiation audits.
token_info = resolve_token_info(request)
# Still enforce admin requirement (which checks hierarchy)
allowed, err = require_admin_token(request)
if not allowed:
emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": err or "admin_token_required"},
request=request,
)
return web.json_response(
{
"ok": False,
"error": err or "Unauthorized",
},
status=403,
)
# S13: Optional loopback check
import os
allow_remote = (
os.environ.get("OPENCLAW_ALLOW_REMOTE_ADMIN")
or os.environ.get("MOLTBOT_ALLOW_REMOTE_ADMIN")
or ""
).lower()
if allow_remote not in ("1", "true", "yes", "on"):
# Use S14 is_loopback which handles ipv6/mapped
remote = get_client_ip(request)
if not is_loopback(remote):
emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": "remote_admin_denied", "remote": remote},
request=request,
)
return web.json_response(
{
"ok": False,
"error": "Remote admin access denied. Set OPENCLAW_ALLOW_REMOTE_ADMIN=1 (or legacy MOLTBOT_ALLOW_REMOTE_ADMIN=1) to allow.",
},
status=403,
)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response(
{
"ok": False,
"error": "Invalid JSON body",
},
status=400,
)
# S66: Runtime guardrails are ENV-driven + runtime-only and must never be
# persisted via config writes (prevents config drift / silent downgrade paths).
if payload_contains_runtime_guardrails(body):
emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=400,
details={
"tenant_id": tenant.tenant_id,
"reason": "runtime_guardrails_runtime_only",
"code": CODE_RUNTIME_ONLY_PERSIST_FORBIDDEN,
},
request=request,
)
return web.json_response(
{
"ok": False,
"error": "runtime_guardrails are runtime-only (ENV-driven) and cannot be persisted via /config",
"code": CODE_RUNTIME_ONLY_PERSIST_FORBIDDEN,
},
status=400,
)
# Extract LLM config updates
updates = body.get("llm", body) # Support both { llm: {...} } and {...}
if not isinstance(updates, dict):
return web.json_response(
{
"ok": False,
"error": "Expected object with config fields",
},
status=400,
)
success, errors = update_config(updates, tenant_id=tenant.tenant_id)
# R99: Standardized Audit Emission
emit_audit_event(
action="config.update",
target="config.json",
outcome="allow" if success else "error",
token_info=token_info,
status_code=200 if success else 400,
details=(
{"tenant_id": tenant.tenant_id, "errors": errors}
if errors
else {"tenant_id": tenant.tenant_id}
),
request=request,
)
if not success:
return web.json_response(
{
"ok": False,
"errors": errors,
},
status=400,
)
# Return updated config
effective, sources = get_effective_config(tenant_id=tenant.tenant_id)
# R53: Calculate apply semantics
apply_info = get_apply_semantics(list(updates.keys()))
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"config": effective,
"sources": sources,
"apply": apply_info,
}
)
except TenantBoundaryError as e:
emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": e.code},
request=request,
)
return web.json_response(
{"ok": False, "error": e.code, "message": str(e)},
status=403,
)
# CRITICAL: owned implementation performs require_admin_token( before mutation.
return await config_put_response(request, _handler_dependencies())
@endpoint_metadata(
@@ -767,215 +392,8 @@ async def llm_test_handler(request: web.Request) -> web.Response:
POST /moltbot/llm/test
Tests LLM connection. Protected by admin boundary (S13) + CSRF (S26+).
"""
if web is None:
raise RuntimeError("aiohttp not available")
try:
from ..services.async_utils import run_in_thread
except ImportError:
from services.async_utils import run_in_thread
try:
# IMPORTANT: use package-relative import in ComfyUI runtime.
# CRITICAL: Missing this import causes NameError in provider error handling.
from ..services.provider_errors import ProviderHTTPError
except ImportError:
from services.provider_errors import ProviderHTTPError # type: ignore
# S26+: CSRF protection for convenience mode
admin_token_configured = bool(get_admin_token())
resp = require_same_origin_if_no_token(request, admin_token_configured)
if resp:
return resp
# S17: Rate Limit
if not check_rate_limit(request, "admin"):
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
token_info = resolve_token_info(request)
# S13: Validate admin boundary
allowed, err = require_admin_token(request)
if not allowed:
emit_audit_event(
action="llm.test_connection",
target="llm",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": err or "unauthorized"},
request=request,
)
return web.json_response(
{
"ok": False,
"error": err or "Unauthorized",
},
status=403,
)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
# IMPORTANT (Settings UX / provider mismatch):
# - The Settings UI allows selecting provider/model/base_url without persisting config immediately.
# - If this endpoint only uses effective config, "Test Connection" can misleadingly test the
# previous provider (often "openai") and report: "API key not configured for provider 'openai'"
# even when the UI is set to Gemini and a Gemini key is stored.
# Therefore, accept optional overrides in the JSON body.
#
# Contract:
# - Empty body -> test effective config
# - Body may include: provider, model, base_url, timeout_sec, max_retries
try:
body = await request.json()
if body is None:
body = {}
except Exception:
body = {}
if body and not isinstance(body, dict):
return web.json_response(
{"ok": False, "error": "Expected JSON object body (or empty body)"},
status=400,
)
provider = (
body.get("provider") if isinstance(body.get("provider"), str) else None
)
model = body.get("model") if isinstance(body.get("model"), str) else None
base_url = (
body.get("base_url") if isinstance(body.get("base_url"), str) else None
)
timeout_val = body.get("timeout_sec")
timeout_sec = None
if (
isinstance(timeout_val, (int, float, str))
and str(timeout_val).strip() != ""
):
try:
timeout_sec = int(timeout_val)
except Exception:
return web.json_response(
{"ok": False, "error": "timeout_sec must be an integer"},
status=400,
)
retries_val = body.get("max_retries")
max_retries = None
if (
isinstance(retries_val, (int, float, str))
and str(retries_val).strip() != ""
):
try:
max_retries = int(retries_val)
except Exception:
return web.json_response(
{"ok": False, "error": "max_retries must be an integer"},
status=400,
)
# Initialize client (uses effective config by default; overrides if provided)
client = LLMClient(
provider=provider,
base_url=base_url,
model=model,
timeout=timeout_sec,
max_retries=max_retries,
)
# Run test in a worker thread since LLMClient is sync
result = await run_in_thread(
client.complete,
system="You are a test assistant.",
user_message="Respond with exactly: OK",
max_tokens=10,
)
# Check result
if result and "text" in result:
emit_audit_event(
action="llm.test_connection",
target=f"{client.provider}:{client.model}",
outcome="allow",
token_info=token_info,
status_code=200,
details={
"tenant_id": tenant.tenant_id,
"provider": client.provider,
"model": client.model,
},
request=request,
)
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"message": "Connection successful",
"response": result["text"].strip(),
"provider": client.provider,
"model": client.model,
}
)
emit_audit_event(
action="llm.test_connection",
target=f"{client.provider}:{client.model}",
outcome="error",
token_info=token_info,
status_code=500,
details={
"tenant_id": tenant.tenant_id,
"provider": client.provider,
"model": client.model,
"error": "Empty response",
},
request=request,
)
return web.json_response(
{
"ok": False,
"error": "Empty or invalid response from LLM",
}
)
except TenantBoundaryError as e:
emit_audit_event(
action="llm.test_connection",
target="llm",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": e.code},
request=request,
)
return web.json_response(
{"ok": False, "error": e.code, "message": str(e)},
status=403,
)
except Exception as e:
logger.exception("LLM test failed")
emit_audit_event(
action="llm.test_connection",
target="llm",
outcome="error",
token_info=token_info,
status_code=500,
details={"error": str(e)},
request=request,
)
return web.json_response(
{
"ok": False,
"error": str(e),
},
status=500,
)
# CRITICAL: owned implementation performs require_admin_token( before provider access.
return await llm_test_response(request, _handler_dependencies())
@endpoint_metadata(
@@ -992,152 +410,5 @@ async def llm_chat_handler(request: web.Request) -> web.Response:
Run a simple chat completion using server-side LLM config + keys.
This endpoint is intended for the connector; no prompt content is logged.
"""
if web is None:
raise RuntimeError("aiohttp not available")
try:
from ..services.async_utils import run_in_thread
except ImportError:
from services.async_utils import run_in_thread
try:
# IMPORTANT: use package-relative import in ComfyUI runtime.
# CRITICAL: Missing this import causes NameError in provider error handling.
from ..services.provider_errors import ProviderHTTPError
except ImportError:
from services.provider_errors import ProviderHTTPError # type: ignore
# S28: CSRF protection for convenience mode (no admin token configured)
admin_token_configured = bool(get_admin_token())
resp = require_same_origin_if_no_token(request, admin_token_configured)
if resp:
return resp
# S17: Rate Limit
if not check_rate_limit(request, "admin"):
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
# NOTE: Keep this server-side. Connector cannot access UI-stored secrets directly.
# This endpoint ensures keys are resolved via backend config + secret store.
# S13: Validate admin boundary (or loopback if no admin token configured)
token_info = resolve_token_info(request)
allowed, err = require_admin_token(request)
if not allowed:
return web.json_response(
{
"ok": False,
"error": err or "Unauthorized",
},
status=403,
)
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
return web.json_response(
{"ok": False, "error": "Expected JSON object body"},
status=400,
)
system = body.get("system") if isinstance(body.get("system"), str) else ""
user_message = (
body.get("user_message")
if isinstance(body.get("user_message"), str)
else body.get("message") if isinstance(body.get("message"), str) else ""
)
temperature = (
body.get("temperature")
if isinstance(body.get("temperature"), (int, float))
else 0.7
)
max_tokens = (
body.get("max_tokens") if isinstance(body.get("max_tokens"), int) else 1024
)
if not user_message:
return web.json_response(
{"ok": False, "error": "missing_user_message"},
status=400,
)
# S29: Debug-level structured log — metadata only, never raw prompt content.
logger.debug(
"llm_chat: has_system=%s msg_len=%d temperature=%.2f max_tokens=%d",
bool(system),
len(user_message),
temperature,
max_tokens,
)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
client = LLMClient()
def _run():
return client.complete(
system=system,
user_message=user_message,
temperature=temperature,
max_tokens=max_tokens,
)
result = await run_in_thread(_run)
text = ""
if isinstance(result, dict):
text = result.get("text") or ""
return web.json_response(
{"ok": True, "tenant_id": tenant.tenant_id, "text": text}
)
except TenantBoundaryError as e:
return web.json_response(
{"ok": False, "error": e.code, "message": str(e)},
status=403,
)
except ValueError as e:
# Common: missing API key for selected provider
return web.json_response(
{"ok": False, "error": str(e)},
status=400,
)
except ProviderHTTPError as e:
# IMPORTANT (recurring support issue):
# Do not swallow provider errors into a generic "llm_request_failed" without context.
# The connector can safely surface *redacted* provider messages (no prompt content)
# so users can fix misconfiguration (401/403/429, SSRF allowlist, etc.) quickly.
payload = {
"ok": False,
"error": f"{e.provider} HTTP {e.status_code}: {e.message}",
"provider": e.provider,
"status_code": e.status_code,
}
if getattr(e, "retry_after", None):
payload["retry_after"] = e.retry_after
return web.json_response(payload, status=e.status_code)
except Exception as e:
# S29: Redact exception message to prevent accidental prompt content leakage.
# Downgraded from error → warning (non-actionable for operators when provider-specific).
try:
from services.redaction import redact_text # type: ignore
except ImportError:
try:
from ..services.redaction import redact_text
except ImportError:
redact_text = str # type: ignore
logger.warning(
"LLM chat request failed: %s: %s",
type(e).__name__,
redact_text(str(e)),
)
return web.json_response(
{"ok": False, "error": "llm_request_failed"},
status=500,
)
# CRITICAL: owned implementation performs require_admin_token( before provider access.
return await llm_chat_response(request, _handler_dependencies())
+283
View File
@@ -0,0 +1,283 @@
"""Owned LLM connection-test and chat handler implementations."""
from __future__ import annotations
from typing import Any
from .config_projection_handlers import ConfigHandlerDependencies
async def llm_test_response(request: Any, deps: ConfigHandlerDependencies) -> Any:
"""Run the existing tenant-scoped, audited LLM connection test."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
try:
from ..services.async_utils import run_in_thread
except ImportError:
from services.async_utils import run_in_thread
admin_token_configured = bool(deps.get_admin_token())
response = deps.require_same_origin_if_no_token(request, admin_token_configured)
if response:
return response
if not deps.check_rate_limit(request, "admin"):
return deps.build_rate_limit_response(
request,
"admin",
web_module=deps.web,
error="Rate limit exceeded",
include_ok=True,
)
token_info = deps.resolve_token_info(request)
allowed, error = deps.require_admin_token(request)
if not allowed:
deps.emit_audit_event(
action="llm.test_connection",
target="llm",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": error or "unauthorized"},
request=request,
)
return deps.web.json_response(
{"ok": False, "error": error or "Unauthorized"}, status=403
)
try:
with deps.request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
try:
body = await request.json()
if body is None:
body = {}
except Exception:
body = {}
if body and not isinstance(body, dict):
return deps.web.json_response(
{"ok": False, "error": "Expected JSON object body (or empty body)"},
status=400,
)
provider = (
body.get("provider") if isinstance(body.get("provider"), str) else None
)
model = body.get("model") if isinstance(body.get("model"), str) else None
base_url = (
body.get("base_url") if isinstance(body.get("base_url"), str) else None
)
timeout_val = body.get("timeout_sec")
timeout_sec = None
if (
isinstance(timeout_val, (int, float, str))
and str(timeout_val).strip() != ""
):
try:
timeout_sec = int(timeout_val)
except (TypeError, ValueError, OverflowError):
return deps.web.json_response(
{"ok": False, "error": "timeout_sec must be an integer"},
status=400,
)
retries_val = body.get("max_retries")
max_retries = None
if (
isinstance(retries_val, (int, float, str))
and str(retries_val).strip() != ""
):
try:
max_retries = int(retries_val)
except (TypeError, ValueError, OverflowError):
return deps.web.json_response(
{"ok": False, "error": "max_retries must be an integer"},
status=400,
)
client = deps.llm_client(
provider=provider,
base_url=base_url,
model=model,
timeout=timeout_sec,
max_retries=max_retries,
)
result = await run_in_thread(
client.complete,
system="You are a test assistant.",
user_message="Respond with exactly: OK",
max_tokens=10,
)
if result and "text" in result:
deps.emit_audit_event(
action="llm.test_connection",
target=f"{client.provider}:{client.model}",
outcome="allow",
token_info=token_info,
status_code=200,
details={
"tenant_id": tenant.tenant_id,
"provider": client.provider,
"model": client.model,
},
request=request,
)
return deps.web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"message": "Connection successful",
"response": result["text"].strip(),
"provider": client.provider,
"model": client.model,
}
)
deps.emit_audit_event(
action="llm.test_connection",
target=f"{client.provider}:{client.model}",
outcome="error",
token_info=token_info,
status_code=500,
details={
"tenant_id": tenant.tenant_id,
"provider": client.provider,
"model": client.model,
"error": "Empty response",
},
request=request,
)
return deps.web.json_response(
{"ok": False, "error": "Empty or invalid response from LLM"}
)
except deps.tenant_boundary_error as exc:
deps.emit_audit_event(
action="llm.test_connection",
target="llm",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": exc.code},
request=request,
)
return deps.web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)}, status=403
)
except Exception as exc:
deps.logger.error("LLM test failed (error_type=%s)", type(exc).__name__)
deps.emit_audit_event(
action="llm.test_connection",
target="llm",
outcome="error",
token_info=token_info,
status_code=500,
details={"error": "llm_test_failed"},
request=request,
)
return deps.web.json_response(
{"ok": False, "error": "llm_test_failed"}, status=500
)
async def llm_chat_response(request: Any, deps: ConfigHandlerDependencies) -> Any:
"""Run server-side tenant-scoped chat without logging prompt content."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
try:
from ..services.async_utils import run_in_thread
except ImportError:
from services.async_utils import run_in_thread
try:
from ..services.provider_errors import ProviderHTTPError
except ImportError:
from services.provider_errors import ProviderHTTPError
admin_token_configured = bool(deps.get_admin_token())
response = deps.require_same_origin_if_no_token(request, admin_token_configured)
if response:
return response
if not deps.check_rate_limit(request, "admin"):
return deps.build_rate_limit_response(
request,
"admin",
web_module=deps.web,
error="Rate limit exceeded",
include_ok=True,
)
token_info = deps.resolve_token_info(request)
allowed, error = deps.require_admin_token(request)
if not allowed:
return deps.web.json_response(
{"ok": False, "error": error or "Unauthorized"}, status=403
)
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
return deps.web.json_response(
{"ok": False, "error": "Expected JSON object body"}, status=400
)
system = body.get("system") if isinstance(body.get("system"), str) else ""
user_message = (
body.get("user_message")
if isinstance(body.get("user_message"), str)
else body.get("message") if isinstance(body.get("message"), str) else ""
)
temperature = (
body.get("temperature")
if isinstance(body.get("temperature"), (int, float))
else 0.7
)
max_tokens = (
body.get("max_tokens") if isinstance(body.get("max_tokens"), int) else 1024
)
if not user_message:
return deps.web.json_response(
{"ok": False, "error": "missing_user_message"}, status=400
)
deps.logger.debug(
"llm_chat: has_system=%s msg_len=%d temperature=%.2f max_tokens=%d",
bool(system),
len(user_message),
temperature,
max_tokens,
)
try:
with deps.request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
client = deps.llm_client()
def _run():
return client.complete(
system=system,
user_message=user_message,
temperature=temperature,
max_tokens=max_tokens,
)
result = await run_in_thread(_run)
text = result.get("text") or "" if isinstance(result, dict) else ""
return deps.web.json_response(
{"ok": True, "tenant_id": tenant.tenant_id, "text": text}
)
except deps.tenant_boundary_error as exc:
return deps.web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)}, status=403
)
except ValueError as exc:
return deps.web.json_response({"ok": False, "error": str(exc)}, status=400)
except ProviderHTTPError as exc:
payload = {
"ok": False,
"error": f"{exc.provider} HTTP {exc.status_code}: {exc.message}",
"provider": exc.provider,
"status_code": exc.status_code,
}
if getattr(exc, "retry_after", None):
payload["retry_after"] = exc.retry_after
return deps.web.json_response(payload, status=exc.status_code)
except Exception as exc:
deps.logger.warning(
"LLM chat request failed: ***REDACTED*** (error_type=%s)",
type(exc).__name__,
)
return deps.web.json_response(
{"ok": False, "error": "llm_request_failed"}, status=500
)
+182
View File
@@ -0,0 +1,182 @@
"""Owned remote model-discovery handler implementation."""
from __future__ import annotations
import os
from typing import Any
from .config_projection_handlers import ConfigHandlerDependencies
async def llm_models_response(request: Any, deps: ConfigHandlerDependencies) -> Any:
"""Serve tenant-isolated bounded provider model discovery."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
if not deps.check_rate_limit(request, "admin"):
return deps.build_rate_limit_response(
request,
"admin",
web_module=deps.web,
error="Rate limit exceeded",
include_ok=True,
)
token_info = deps.resolve_token_info(request)
try:
with deps.request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
allowed, error = deps.require_admin_token(request)
if not allowed:
deps.emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=403,
details={
"tenant_id": tenant.tenant_id,
"reason": error or "unauthorized",
},
request=request,
)
return deps.web.json_response(
{"ok": False, "error": error or "Unauthorized"}, status=403
)
allow_remote = (
os.environ.get("OPENCLAW_ALLOW_REMOTE_ADMIN")
or os.environ.get("MOLTBOT_ALLOW_REMOTE_ADMIN")
or ""
).lower()
if allow_remote not in ("1", "true", "yes", "on"):
remote = request.remote or ""
if not deps.is_loopback_client(remote):
return deps.web.json_response(
{
"ok": False,
"error": "Remote admin access denied. Set OPENCLAW_ALLOW_REMOTE_ADMIN=1 (or legacy MOLTBOT_ALLOW_REMOTE_ADMIN=1) to allow.",
},
status=403,
)
provider_override = (request.query.get("provider") or "").strip().lower()
effective, _sources = deps.get_effective_config(tenant_id=tenant.tenant_id)
try:
target = deps.resolve_model_list_target(
provider_override, effective, tenant.tenant_id
)
except (TypeError, ValueError) as exc:
return deps.web.json_response(
{"ok": False, "error": str(exc)}, status=400
)
cached_entry = deps.model_cache_get(target.cache_key)
if cached_entry:
_timestamp, models = cached_entry
if isinstance(models, list):
return deps.web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": target.provider,
"models": models,
"cached": True,
}
)
# CRITICAL: local providers intentionally work without API keys.
if target.requires_api_key and not target.api_key:
return deps.web.json_response(
{
"ok": False,
"error": f"No API key configured for provider '{target.provider}'.",
},
status=400,
)
try:
controls = deps.get_llm_egress_controls(
target.provider,
target.base_url,
allow_private_network=target.allow_private_network,
)
deps.validate_model_list_target(
target,
controls,
allow_insecure_base_url=deps.llm_insecure_override_enabled(),
)
except Exception as exc:
return deps.web.json_response(
{"ok": False, "error": deps.format_llm_ssrf_error(exc)},
status=403,
)
try:
try:
from ..services.safe_io import SSRFError
except ImportError:
from services.safe_io import SSRFError
models = deps.fetch_remote_model_list(
target,
controls,
pack_version=deps.pack_version,
allow_insecure_base_url=deps.llm_insecure_override_enabled(),
)
return deps.web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": target.provider,
"models": models,
"cached": False,
}
)
except SSRFError as exc:
return deps.web.json_response(
{"ok": False, "error": deps.format_llm_ssrf_error(exc)},
status=403,
)
except RuntimeError as exc:
error_text = str(exc)
if "HTTP" in error_text:
stale = deps.get_stale_cached_models(target.cache_key)
if stale:
_timestamp, models = stale
warning = f"Using cached list (refresh failed: {error_text})"
return deps.web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": target.provider,
"models": models,
"cached": True,
"warning": warning,
}
)
return deps.web.json_response(
{"ok": False, "error": f"Upstream error: {error_text}"},
status=502,
)
raise
except Exception as exc:
stale = deps.get_stale_cached_models(target.cache_key)
if stale:
deps.logger.warning(
"Model list refresh failed, serving cached list: %s", exc
)
_timestamp, models = stale
warning = f"Using cached list (refresh failed: {exc!s})"
return deps.web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": target.provider,
"models": models,
"cached": True,
"warning": warning,
}
)
deps.logger.exception("Failed to fetch model list")
return deps.web.json_response(
{"ok": False, "error": str(exc)}, status=500
)
except deps.tenant_boundary_error as exc:
return deps.web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)}, status=403
)
+244
View File
@@ -0,0 +1,244 @@
"""Owned config projection and mutation handler implementations."""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class ConfigHandlerDependencies:
web: Any
logger: Any
provider_catalog: Any
pack_version: Any
require_observability_access: Any
require_admin_token: Any
require_same_origin_if_no_token: Any
resolve_token_info: Any
emit_audit_event: Any
check_rate_limit: Any
build_rate_limit_response: Any
get_client_ip: Any
is_loopback: Any
get_admin_token: Any
get_apply_semantics: Any
get_effective_config: Any
get_llm_egress_controls: Any
get_runtime_guardrails: Any
get_settings_schema: Any
is_loopback_client: Any
update_config: Any
tenant_boundary_error: Any
request_tenant_scope: Any
runtime_only_code: Any
payload_contains_runtime_guardrails: Any
model_cache_get: Any
format_llm_ssrf_error: Any
llm_insecure_override_enabled: Any
fetch_remote_model_list: Any
get_stale_cached_models: Any
resolve_model_list_target: Any
validate_model_list_target: Any
llm_client: Any
async def config_get_response(request: Any, deps: ConfigHandlerDependencies) -> Any:
"""Return the tenant-scoped effective configuration projection."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
allowed, error = deps.require_observability_access(request)
if not allowed:
return deps.web.json_response({"ok": False, "error": error}, status=403)
if not deps.check_rate_limit(request, "admin"):
return deps.build_rate_limit_response(
request,
"admin",
web_module=deps.web,
error="Rate limit exceeded",
include_ok=True,
)
token_info = deps.resolve_token_info(request)
try:
with deps.request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
effective, sources = deps.get_effective_config(tenant_id=tenant.tenant_id)
guardrails = deps.get_runtime_guardrails()
if guardrails.get("status") != "ok":
deps.emit_audit_event(
action="runtime.guardrails",
target="runtime_guardrails",
outcome="warn",
token_info=token_info,
status_code=200,
details={
"tenant_id": tenant.tenant_id,
"code": guardrails.get("code"),
"violations": guardrails.get("violations", []),
},
request=request,
)
return deps.web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"config": effective,
"sources": sources,
"runtime_guardrails": guardrails,
"providers": deps.provider_catalog,
"schema": deps.get_settings_schema(),
"write_enabled": True,
}
)
except deps.tenant_boundary_error as exc:
return deps.web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)}, status=403
)
except Exception as exc:
deps.logger.error("Error getting config (error_type=%s)", type(exc).__name__)
return deps.web.json_response(
{"ok": False, "error": "config_read_failed"}, status=500
)
async def config_put_response(request: Any, deps: ConfigHandlerDependencies) -> Any:
"""Validate and atomically apply tenant-scoped non-secret config updates."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
admin_token_configured = bool(deps.get_admin_token())
response = deps.require_same_origin_if_no_token(request, admin_token_configured)
if response:
return response
if not deps.check_rate_limit(request, "admin"):
return deps.build_rate_limit_response(
request,
"admin",
web_module=deps.web,
error="Rate limit exceeded",
include_ok=True,
)
token_info = deps.resolve_token_info(request)
allowed, error = deps.require_admin_token(request)
if not allowed:
deps.emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": error or "admin_token_required"},
request=request,
)
return deps.web.json_response(
{"ok": False, "error": error or "Unauthorized"}, status=403
)
allow_remote = (
os.environ.get("OPENCLAW_ALLOW_REMOTE_ADMIN")
or os.environ.get("MOLTBOT_ALLOW_REMOTE_ADMIN")
or ""
).lower()
if allow_remote not in ("1", "true", "yes", "on"):
remote = deps.get_client_ip(request)
if not deps.is_loopback(remote):
deps.emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": "remote_admin_denied", "remote": remote},
request=request,
)
return deps.web.json_response(
{
"ok": False,
"error": "Remote admin access denied. Set OPENCLAW_ALLOW_REMOTE_ADMIN=1 (or legacy MOLTBOT_ALLOW_REMOTE_ADMIN=1) to allow.",
},
status=403,
)
try:
with deps.request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
try:
body = await request.json()
except json.JSONDecodeError:
return deps.web.json_response(
{"ok": False, "error": "Invalid JSON body"}, status=400
)
if deps.payload_contains_runtime_guardrails(body):
deps.emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=400,
details={
"tenant_id": tenant.tenant_id,
"reason": "runtime_guardrails_runtime_only",
"code": deps.runtime_only_code,
},
request=request,
)
return deps.web.json_response(
{
"ok": False,
"error": "runtime_guardrails are runtime-only (ENV-driven) and cannot be persisted via /config",
"code": deps.runtime_only_code,
},
status=400,
)
updates = body.get("llm", body)
if not isinstance(updates, dict):
return deps.web.json_response(
{"ok": False, "error": "Expected object with config fields"},
status=400,
)
success, errors = deps.update_config(updates, tenant_id=tenant.tenant_id)
deps.emit_audit_event(
action="config.update",
target="config.json",
outcome="allow" if success else "error",
token_info=token_info,
status_code=200 if success else 400,
details=(
{"tenant_id": tenant.tenant_id, "errors": errors}
if errors
else {"tenant_id": tenant.tenant_id}
),
request=request,
)
if not success:
return deps.web.json_response(
{"ok": False, "errors": errors}, status=400
)
effective, sources = deps.get_effective_config(tenant_id=tenant.tenant_id)
apply_info = deps.get_apply_semantics(list(updates.keys()))
return deps.web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"config": effective,
"sources": sources,
"apply": apply_info,
}
)
except deps.tenant_boundary_error as exc:
deps.emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": exc.code},
request=request,
)
return deps.web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)}, status=403
)
+445
View File
@@ -0,0 +1,445 @@
"""Owned observability and jobs handler implementations for the API facade."""
from __future__ import annotations
import os
import time
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class RouteHandlerDependencies:
web: Any
pack_name: Any
pack_version: Any
pack_start_time: Any
log_file: Any
metrics: Any
tail_log: Any
require_observability_access: Any
require_admin_token: Any
check_rate_limit: Any
build_rate_limit_response: Any
trace_store: Any
get_executor_diagnostics: Any
redact_text: Any
check_dependency: Callable[[str], bool]
resolve_token_info: Any
emit_audit_event: Any
jobs_request_tenant_scope: Any
normalize_jobs_query: Any
build_jobs_audit_details: Any
safe_job_audit_outcomes: Any
jobs_security_error: Any
tenant_boundary_error: Any
jobs_host_contract_unsupported: Any
jobs_backend_unavailable: Any
read_jobs: Any
ensure_observability_deps_ready: Any
def ensure_observability_deps_ready(
deps: RouteHandlerDependencies,
) -> tuple[bool, str | None]:
"""Reject partially initialized observability handlers deterministically."""
missing: list[str] = []
if not callable(deps.require_observability_access):
missing.append("require_observability_access")
if not callable(deps.check_rate_limit):
missing.append("check_rate_limit")
if not callable(deps.tail_log):
missing.append("tail_log")
if missing:
return (
False,
"Backend not fully initialized (missing route dependencies: "
+ ", ".join(missing)
+ ").",
)
return True, None
async def health_response(request: Any, deps: RouteHandlerDependencies) -> Any:
"""Build the existing partial-failure-tolerant health response."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
try:
from ..services.llm_client import LLMClient
from ..services.providers.keys import requires_api_key
except ImportError:
from services.llm_client import LLMClient
from services.providers.keys import requires_api_key
uptime = time.time() - deps.pack_start_time
provider_info = {
"provider": "unknown",
"key_configured": False,
"model": "unknown",
"base_url": None,
"api_type": None,
}
key_required = True
try:
client = LLMClient()
provider_info = client.get_provider_summary()
key_required = requires_api_key(provider_info.get("provider", "unknown"))
except Exception:
provider_info = {
"provider": "unknown",
"key_configured": False,
"model": "unknown",
"base_url": None,
"api_type": None,
}
key_required = True
try:
from ..services.access_control import is_loopback
_ = is_loopback
token_val = (
os.environ.get("OPENCLAW_OBSERVABILITY_TOKEN")
or os.environ.get("MOLTBOT_OBSERVABILITY_TOKEN")
or ""
).strip()
token_configured = bool(token_val)
except ImportError:
from services.access_control import is_loopback
_ = is_loopback
token_val = (
os.environ.get("OPENCLAW_OBSERVABILITY_TOKEN")
or os.environ.get("MOLTBOT_OBSERVABILITY_TOKEN")
or ""
).strip()
token_configured = bool(token_val)
policy_mode = "token" if token_configured else "loopback_only"
try:
metrics_snapshot = deps.metrics.get_snapshot()
except Exception:
metrics_snapshot = {"errors_captured": 0, "logs_processed": 0}
try:
executor_snapshot = deps.get_executor_diagnostics() or {}
except Exception:
executor_snapshot = {}
try:
if __package__ and "." in __package__:
from ..services.startup_lifecycle import get_startup_diagnostics
else:
from services.startup_lifecycle import get_startup_diagnostics
startup_diagnostics = get_startup_diagnostics()
except Exception:
# SECURITY: keep the public fallback deterministic and content-free even when
# startup diagnostics cannot be imported.
startup_diagnostics = {
"schema_version": 1,
"phase": "package_import",
"state": "fatal",
"reason_code": "bootstrap_import_failed",
"ready": False,
"degraded": False,
"fatal": True,
"attempt": 0,
"max_attempts": 0,
"elapsed_ms": 0,
"phase_elapsed_ms": 0,
"ready_elapsed_ms": None,
"warmups": [],
}
job_stats = {}
try:
from ..services.job_events import get_job_event_store
job_stats = get_job_event_store().stats()
except Exception:
pass
control_plane_info = {}
runtime_profile = "minimal"
try:
try:
from ..services.capabilities import _get_control_plane_info
from ..services.runtime_profile import get_runtime_profile
except ImportError:
from services.capabilities import _get_control_plane_info
from services.runtime_profile import get_runtime_profile
control_plane_info = _get_control_plane_info()
runtime_profile = get_runtime_profile().value
except Exception:
pass
return deps.web.json_response(
{
"ok": True,
"pack": {
"name": deps.pack_name,
"version": deps.pack_version,
"dependencies": {
"aiohttp": deps.check_dependency("aiohttp"),
"watchdog": deps.check_dependency("watchdog"),
},
},
"uptime_sec": uptime,
"config": {
"provider": provider_info.get("provider"),
"model": provider_info.get("model"),
"base_url": provider_info.get("base_url"),
"api_type": provider_info.get("api_type"),
"llm_key_configured": provider_info.get("key_configured", False),
"llm_key_required": key_required,
},
"stats": {
"errors_captured": metrics_snapshot["errors_captured"],
"logs_processed": metrics_snapshot["logs_processed"],
"executors": executor_snapshot,
"observability": job_stats,
},
"startup": startup_diagnostics,
"access_policy": {
"observability": policy_mode,
"token_configured": token_configured,
},
"control_plane": control_plane_info,
"runtime_profile": runtime_profile,
}
)
async def logs_tail_response(request: Any, deps: RouteHandlerDependencies) -> Any:
"""Authorize, bound, filter, and redact the log-tail response."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
ok, init_error = deps.ensure_observability_deps_ready()
if not ok:
return deps.web.json_response({"ok": False, "error": init_error}, status=500)
allowed, error = deps.require_admin_token(request)
if not allowed:
return deps.web.json_response({"ok": False, "error": error}, status=403)
if not deps.check_rate_limit(request, "logs"):
return deps.build_rate_limit_response(
request,
"logs",
web_module=deps.web,
error="Rate limit exceeded",
include_ok=True,
)
try:
line_count = 50
val_n = request.query.get("n")
val_lines = request.query.get("lines")
target_val = val_n if val_n is not None else val_lines
if target_val:
with suppress(ValueError):
line_count = int(target_val)
line_count = min(max(line_count, 1), 500)
trace_id_filter = request.query.get("trace_id")
prompt_id_filter = request.query.get("prompt_id")
content = deps.tail_log(deps.log_file, line_count)
if trace_id_filter or prompt_id_filter:
content = [
line
for line in content
if (trace_id_filter and trace_id_filter in line)
or (prompt_id_filter and prompt_id_filter in line)
]
if deps.redact_text:
content = [deps.redact_text(line) for line in content]
max_bytes = 100_000
if sum(len(line.encode("utf-8")) for line in content) > max_bytes:
truncated: list[str] = []
current_bytes = 0
for line in reversed(content):
line_bytes = len(line.encode("utf-8"))
if current_bytes + line_bytes > max_bytes:
break
truncated.insert(0, line)
current_bytes += line_bytes
content = truncated
return deps.web.json_response(
{
"ok": True,
"content": content,
"filtered": bool(trace_id_filter or prompt_id_filter),
}
)
except Exception as exc:
return deps.web.json_response({"ok": False, "error": str(exc)}, status=500)
def emit_jobs_list_audit(
deps: RouteHandlerDependencies,
*,
request: Any,
token_info: Any,
outcome: str,
status_code: int,
reason: str,
**counts: Any,
) -> None:
safe_outcome = outcome if outcome in deps.safe_job_audit_outcomes else "error"
deps.emit_audit_event(
action="jobs.list",
target="jobs",
outcome=safe_outcome,
token_info=token_info,
status_code=status_code,
details=deps.build_jobs_audit_details(reason, **counts),
request=request,
)
async def jobs_response(request: Any, deps: RouteHandlerDependencies) -> Any:
"""Serve the R213 bounded jobs read model behind its security transaction."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
token_info = deps.resolve_token_info(request)
if not deps.check_rate_limit(request, "admin"):
emit_jobs_list_audit(
deps,
request=request,
token_info=token_info,
outcome="rate_limit",
status_code=429,
reason="jobs_rate_limited",
)
return deps.build_rate_limit_response(
request,
"admin",
web_module=deps.web,
error="jobs_rate_limited",
include_ok=True,
)
# CRITICAL: metadata is descriptive; this guard must precede queue/history access.
allowed, _error = deps.require_admin_token(request)
if not allowed:
emit_jobs_list_audit(
deps,
request=request,
token_info=token_info,
outcome="deny",
status_code=403,
reason="jobs_admin_required",
)
return deps.web.json_response(
{"ok": False, "error": "jobs_admin_required"}, status=403
)
try:
with deps.jobs_request_tenant_scope(request, token_info) as tenant_context:
query = deps.normalize_jobs_query(request.query)
body = deps.read_jobs(query, tenant_id=tenant_context.tenant_id)
scan = body["scan"]
emit_jobs_list_audit(
deps,
request=request,
token_info=token_info,
outcome="allow",
status_code=200,
reason="jobs_listed",
returned_count=len(body["jobs"]),
excluded_count=scan["excluded"],
malformed_count=scan["malformed"],
)
except deps.tenant_boundary_error as exc:
emit_jobs_list_audit(
deps,
request=request,
token_info=token_info,
outcome="deny",
status_code=403,
reason=exc.code,
)
return deps.web.json_response({"ok": False, "error": exc.code}, status=403)
except deps.jobs_security_error:
emit_jobs_list_audit(
deps,
request=request,
token_info=token_info,
outcome="error",
status_code=400,
reason="jobs_query_invalid",
)
return deps.web.json_response(
{"ok": False, "error": "jobs_query_invalid"}, status=400
)
except deps.jobs_host_contract_unsupported:
emit_jobs_list_audit(
deps,
request=request,
token_info=token_info,
outcome="unsupported",
status_code=501,
reason="jobs_host_contract_unsupported",
)
return deps.web.json_response(
{"ok": False, "error": "jobs_host_contract_unsupported"}, status=501
)
except deps.jobs_backend_unavailable:
emit_jobs_list_audit(
deps,
request=request,
token_info=token_info,
outcome="error",
status_code=503,
reason="jobs_backend_unavailable",
)
return deps.web.json_response(
{"ok": False, "error": "jobs_backend_unavailable"}, status=503
)
return deps.web.json_response(body)
async def trace_response(request: Any, deps: RouteHandlerDependencies) -> Any:
"""Authorize and return the redacted operator trace projection."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
ok, init_error = deps.ensure_observability_deps_ready()
if not ok:
return deps.web.json_response({"ok": False, "error": init_error}, status=500)
allowed, error = deps.require_admin_token(request)
if not allowed:
return deps.web.json_response({"ok": False, "error": error}, status=403)
prompt_id = request.match_info.get("prompt_id")
if not prompt_id:
return deps.web.json_response(
{"ok": False, "error": "missing_prompt_id"}, status=400
)
record = deps.trace_store.get(prompt_id)
if not record:
return deps.web.json_response({"ok": False, "error": "not_found"}, status=404)
trace_data = record.to_dict()
try:
from ..services.reasoning_redaction import (
audit_reasoning_reveal,
resolve_reasoning_reveal,
sanitize_operator_payload,
)
from ..services.redaction import redact_json
except ImportError:
from services.reasoning_redaction import (
audit_reasoning_reveal,
resolve_reasoning_reveal,
sanitize_operator_payload,
)
from services.redaction import redact_json
if redact_json:
trace_data = redact_json(trace_data)
reveal = resolve_reasoning_reveal(request, admin_authorized=allowed)
audit_reasoning_reveal(request, target="trace.get", decision=reveal)
trace_data = sanitize_operator_payload(
trace_data, include_reasoning=reveal["allowed"]
)
return deps.web.json_response({"ok": True, "trace": trace_data})
+206
View File
@@ -0,0 +1,206 @@
"""Owned PromptServer route registration and startup orchestration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from functools import wraps
from typing import Any
@dataclass(frozen=True)
class RouteRegistrationDependencies:
build_core_route_specs: Callable[..., Any]
build_assist_route_specs: Callable[..., Any]
build_connector_installation_route_specs: Callable[..., Any]
build_pack_route_specs: Callable[..., Any]
register_route_family: Callable[..., None]
register_dual_route: Callable[..., None]
core_handlers: dict[str, Any]
assist: Any
connector_installation_handlers: dict[str, Any] | None
run_mae_startup_gate: Callable[[Any], None]
def register_dual_route(
server: Any,
method: str,
path: str,
handler: Any,
*,
metrics: Any = None,
legacy_headers_builder: Any = None,
) -> None:
"""Register PromptServer and direct aliases with one legacy wrapper."""
if not callable(handler):
print(
f"[OpenClaw] Warning: Skipping route {method} {path} because handler is missing (None)."
)
return
actual_handler = handler
if path.startswith("/moltbot"):
@wraps(handler)
async def _deprecated_handler(request: Any) -> Any:
try:
if metrics:
metrics.inc("legacy_api_hits")
except Exception:
pass
print(
f"[OpenClaw] DEPRECATION WARNING: Legacy route accessed: {request.path}. Please migrate to /openclaw/* equivalents."
)
response = await handler(request)
if legacy_headers_builder:
headers = legacy_headers_builder(getattr(request, "path", path))
response_headers = getattr(response, "headers", None)
if (
headers
and response_headers is not None
and hasattr(response_headers, "update")
):
response_headers.update(headers)
return response
actual_handler = _deprecated_handler
registrar = (
getattr(server.routes, method.lower(), None)
if method in {"GET", "POST", "PUT", "DELETE"}
else None
)
if registrar is not None:
registrar(path)(actual_handler)
if hasattr(server, "app") and hasattr(server.app, "router"):
for target in (path, "/api" + path):
try:
# IMPORTANT: direct aliases must retain the same legacy wrapper.
server.app.router.add_route(method, target, actual_handler)
except RuntimeError:
pass
except Exception as exc:
print(
f"[OpenClaw] Warning: Failed to register fallback route {target}: {exc}"
)
def run_mae_startup_gate(server: Any, resolve_profile: Callable[[], str]) -> None:
"""Validate the registered OpenClaw route posture for the active profile."""
if not hasattr(server, "app"):
return
try:
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
generate_manifest,
validate_mae_posture,
)
else:
from services.endpoint_manifest import (
generate_manifest,
validate_mae_posture,
)
except Exception as exc:
print(f"[OpenClaw] Warning: S60 MAE gate unavailable: {exc}")
return
profile = resolve_profile()
manifest = generate_manifest(server.app)
scoped_manifest = [
entry for entry in manifest if _is_openclaw_managed_path(entry.get("path", ""))
]
ok, violations = validate_mae_posture(scoped_manifest, profile=profile)
if ok:
return
message = "S60 MAE posture validation failed:\n" + "\n".join(
f"- {item}" for item in violations
)
if profile in {"public", "hardened"}:
raise RuntimeError(message)
print(f"[OpenClaw] Warning: {message}")
def _is_openclaw_managed_path(path: str) -> bool:
if not isinstance(path, str):
return False
return path.startswith(
(
"/openclaw",
"/moltbot",
"/api/openclaw",
"/api/moltbot",
"/bridge",
"/api/bridge",
)
)
def _register_bridge(server: Any) -> None:
try:
try:
from ..api.bridge import register_bridge_routes
from ..services.modules import ModuleCapability, is_module_enabled
except (ImportError, ValueError):
from api.bridge import register_bridge_routes
from services.modules import ModuleCapability, is_module_enabled
if hasattr(server, "app") and is_module_enabled(ModuleCapability.BRIDGE):
register_bridge_routes(server.app)
print("[OpenClaw] Bridge routes registered")
elif not is_module_enabled(ModuleCapability.BRIDGE):
print("[OpenClaw] Bridge module disabled; skipping route registration")
except ImportError:
pass
def _register_packs(
server: Any, prefixes: tuple[str, ...], deps: RouteRegistrationDependencies
) -> None:
try:
try:
from ..api.packs import PacksHandlers
except (ImportError, ValueError):
from api.packs import PacksHandlers
try:
from ..config import DATA_DIR
except (ImportError, ValueError):
from config import DATA_DIR
packs = PacksHandlers(DATA_DIR)
for prefix in prefixes:
deps.register_route_family(
server,
deps.register_dual_route,
deps.build_pack_route_specs(prefix, packs),
)
except ImportError:
pass
def register_route_families(server: Any, deps: RouteRegistrationDependencies) -> None:
"""Register all route families in the frozen R220 exposure order."""
prefixes = ("/openclaw", "/moltbot")
for prefix in prefixes:
deps.register_route_family(
server,
deps.register_dual_route,
deps.build_core_route_specs(prefix, deps.core_handlers),
)
if deps.assist:
for prefix in prefixes:
deps.register_route_family(
server,
deps.register_dual_route,
deps.build_assist_route_specs(prefix, deps.assist),
)
if deps.connector_installation_handlers is not None:
for prefix in prefixes:
deps.register_route_family(
server,
deps.register_dual_route,
deps.build_connector_installation_route_specs(
prefix, deps.connector_installation_handlers
),
)
_register_bridge(server)
deps.run_mae_startup_gate(server)
_register_packs(server, prefixes, deps)
+232 -481
View File
@@ -7,10 +7,8 @@ Registers /openclaw/* endpoints (and legacy /moltbot/*) against ComfyUI PromptSe
# Do not move this import or insert code above it, or ComfyUI route registration will fail.
from __future__ import annotations
import json
import os
import sys
import time
from typing import cast
if __package__ and "." in __package__:
from ..services.import_fallback import import_attrs_dual
@@ -39,6 +37,46 @@ else:
),
)
(
RouteHandlerDependencies,
emit_jobs_list_audit,
health_response,
jobs_response,
logs_tail_response,
owned_ensure_observability_deps_ready,
trace_response,
) = import_attrs_dual(
__package__,
"..api.route_handlers",
"api.route_handlers",
(
"RouteHandlerDependencies",
"emit_jobs_list_audit",
"health_response",
"jobs_response",
"logs_tail_response",
"ensure_observability_deps_ready",
"trace_response",
),
)
(
RouteRegistrationDependencies,
orchestrate_dual_route,
register_route_families,
run_mae_startup_gate,
) = import_attrs_dual(
__package__,
"..api.route_orchestration",
"api.route_orchestration",
(
"RouteRegistrationDependencies",
"register_dual_route",
"register_route_families",
"run_mae_startup_gate",
),
)
# R98 / R64: Endpoint Metadata import via shared helper
(
AuthTier,
@@ -52,6 +90,13 @@ else:
("AuthTier", "RiskTier", "RoutePlane", "endpoint_metadata"),
)
(build_legacy_route_deprecation_headers,) = import_attrs_dual(
__package__,
"..services.legacy_compat",
"services.legacy_compat",
("build_legacy_route_deprecation_headers",),
)
try:
from aiohttp import web # type: ignore
except ModuleNotFoundError: # pragma: no cover (optional for unit tests)
@@ -59,6 +104,11 @@ except ModuleNotFoundError: # pragma: no cover (optional for unit tests)
PACK_NAME = PACK_VERSION = PACK_START_TIME = LOG_FILE = get_api_key = None # type: ignore
metrics = tail_log = require_observability_access = check_rate_limit = trace_store = None # type: ignore
require_admin_token = resolve_token_info = emit_audit_event = None # type: ignore
jobs_request_tenant_scope = normalize_jobs_query = build_jobs_audit_details = None # type: ignore
SAFE_JOB_AUDIT_OUTCOMES = None # type: ignore
JobsSecurityError = TenantBoundaryError = None # type: ignore
JobsHostContractUnsupported = JobsBackendUnavailable = read_jobs = None # type: ignore
get_executor_diagnostics = None # type: ignore
webhook_handler = webhook_submit_handler = webhook_validate_handler = capabilities_handler = preflight_handler = None # type: ignore
pnginfo_handler = None # type: ignore # R168
@@ -269,11 +319,57 @@ if web is not None:
# CRITICAL: These imports MUST remain present.
# If edited out, module-level placeholders stay as None and handlers raise at runtime
# (e.g., TypeError: 'NoneType' object is not callable), producing noisy aiohttp tracebacks.
(require_admin_token, require_observability_access) = import_attrs_dual(
(require_admin_token, require_observability_access, resolve_token_info) = (
import_attrs_dual(
__package__,
"..services.access_control",
"services.access_control",
(
"require_admin_token",
"require_observability_access",
"resolve_token_info",
),
)
)
(emit_audit_event,) = import_attrs_dual(
__package__,
"..services.access_control",
"services.access_control",
("require_admin_token", "require_observability_access"),
"..services.audit",
"services.audit",
("emit_audit_event",),
)
(
JobsSecurityError,
SAFE_JOB_AUDIT_OUTCOMES,
TenantBoundaryError,
build_jobs_audit_details,
jobs_request_tenant_scope,
normalize_jobs_query,
) = import_attrs_dual(
__package__,
"..services.jobs_security",
"services.jobs_security",
(
"JobsSecurityError",
"SAFE_JOB_AUDIT_OUTCOMES",
"TenantBoundaryError",
"build_jobs_audit_details",
"jobs_request_tenant_scope",
"normalize_jobs_query",
),
)
(
JobsBackendUnavailable,
JobsHostContractUnsupported,
read_jobs,
) = import_attrs_dual(
__package__,
"..services.jobs_read_model",
"services.jobs_read_model",
(
"JobsBackendUnavailable",
"JobsHostContractUnsupported",
"read_jobs",
),
)
(tail_log,) = import_attrs_dual(
__package__,
@@ -345,27 +441,47 @@ def check_dependency(module_name: str) -> bool:
return False
def _handler_dependencies():
"""Capture facade patch seams for the owned route implementations."""
return RouteHandlerDependencies(
web=web,
pack_name=PACK_NAME,
pack_version=PACK_VERSION,
pack_start_time=PACK_START_TIME,
log_file=LOG_FILE,
metrics=metrics,
tail_log=tail_log,
require_observability_access=require_observability_access,
require_admin_token=require_admin_token,
check_rate_limit=check_rate_limit,
build_rate_limit_response=build_rate_limit_response,
trace_store=trace_store,
get_executor_diagnostics=get_executor_diagnostics,
redact_text=redact_text,
check_dependency=check_dependency,
resolve_token_info=resolve_token_info,
emit_audit_event=emit_audit_event,
jobs_request_tenant_scope=jobs_request_tenant_scope,
normalize_jobs_query=normalize_jobs_query,
build_jobs_audit_details=build_jobs_audit_details,
safe_job_audit_outcomes=SAFE_JOB_AUDIT_OUTCOMES,
jobs_security_error=JobsSecurityError,
tenant_boundary_error=TenantBoundaryError,
jobs_host_contract_unsupported=JobsHostContractUnsupported,
jobs_backend_unavailable=JobsBackendUnavailable,
read_jobs=read_jobs,
ensure_observability_deps_ready=_ensure_observability_deps_ready,
)
def _ensure_observability_deps_ready() -> tuple[bool, str | None]:
"""
Defensive guard against a recurring class of regressions:
if the import block above is edited incorrectly, the module-level
placeholders stay as None and handlers raise TypeError at runtime.
"""
missing: list[str] = []
if not callable(require_observability_access):
missing.append("require_observability_access")
if not callable(check_rate_limit):
missing.append("check_rate_limit")
if not callable(tail_log):
missing.append("tail_log")
if missing:
return (
False,
"Backend not fully initialized (missing route dependencies: "
+ ", ".join(missing)
+ ").",
)
return True, None
"""Preserve the established initialization-check patch seam."""
return cast(
tuple[bool, str | None],
owned_ensure_observability_deps_ready(_handler_dependencies()),
)
@endpoint_metadata(
@@ -381,135 +497,7 @@ async def health_handler(request: web.Request) -> web.Response:
GET /openclaw/health (legacy: /moltbot/health)
Returns pack status, uptime, dependencies, config presence, and stats.
"""
if web is None:
raise RuntimeError("aiohttp not available")
try:
from ..services.llm_client import LLMClient
from ..services.providers.keys import requires_api_key
except ImportError:
from services.llm_client import LLMClient
from services.providers.keys import requires_api_key
uptime = time.time() - PACK_START_TIME
# Get provider info from LLMClient
provider_info = {
"provider": "unknown",
"key_configured": False,
"model": "unknown",
"base_url": None,
"api_type": None,
}
key_required = True
try:
client = LLMClient()
provider_info = client.get_provider_summary()
key_required = requires_api_key(provider_info.get("provider", "unknown"))
except Exception:
provider_info = {
"provider": "unknown",
"key_configured": False,
"model": "unknown",
"base_url": None,
"api_type": None,
}
key_required = True
# S15: Access Policy Info
try:
from ..services.access_control import is_loopback
token_val = (
os.environ.get("OPENCLAW_OBSERVABILITY_TOKEN")
or os.environ.get("MOLTBOT_OBSERVABILITY_TOKEN")
or ""
).strip()
token_configured = bool(token_val)
except ImportError:
from services.access_control import is_loopback
token_val = (
os.environ.get("OPENCLAW_OBSERVABILITY_TOKEN")
or os.environ.get("MOLTBOT_OBSERVABILITY_TOKEN")
or ""
).strip()
token_configured = bool(token_val)
# Determine basic policy state
policy_mode = "token" if token_configured else "loopback_only"
# Metrics snapshot
# Metrics snapshot (robust even if metrics implementation changes)
try:
m_snapshot = metrics.get_snapshot()
except Exception:
m_snapshot = {"errors_captured": 0, "logs_processed": 0}
try:
executor_snapshot = get_executor_diagnostics() or {}
except Exception:
executor_snapshot = {}
# Job Event Store Stats (Backpressure)
job_stats = {}
try:
from ..services.job_events import get_job_event_store
store = get_job_event_store()
job_stats = store.stats()
except Exception:
pass
# H3 (F55): Include control_plane info for frontend mode badge
cp_info = {}
runtime_prof = "minimal"
try:
try:
from ..services.capabilities import _get_control_plane_info
from ..services.runtime_profile import get_runtime_profile
except ImportError:
from services.capabilities import _get_control_plane_info
from services.runtime_profile import get_runtime_profile
cp_info = _get_control_plane_info()
runtime_prof = get_runtime_profile().value
except Exception:
pass
return web.json_response(
{
"ok": True,
"pack": {
"name": PACK_NAME,
"version": PACK_VERSION,
"dependencies": {
"aiohttp": check_dependency("aiohttp"),
"watchdog": check_dependency("watchdog"),
},
},
"uptime_sec": uptime,
"config": {
"provider": provider_info.get("provider"),
"model": provider_info.get("model"),
"base_url": provider_info.get("base_url"),
"api_type": provider_info.get("api_type"),
"llm_key_configured": provider_info.get("key_configured", False),
"llm_key_required": key_required,
},
"stats": {
"errors_captured": m_snapshot["errors_captured"],
"logs_processed": m_snapshot["logs_processed"],
"executors": executor_snapshot, # R129
"observability": job_stats, # R87
},
# S15: Exposure Detection
"access_policy": {
"observability": policy_mode,
"token_configured": token_configured,
},
# H3 (F55): Control plane mode for frontend badge
"control_plane": cp_info,
"runtime_profile": runtime_prof,
}
)
return await health_response(request, _handler_dependencies())
@endpoint_metadata(
@@ -522,114 +510,46 @@ async def health_handler(request: web.Request) -> web.Response:
)
async def logs_tail_handler(request: web.Request) -> web.Response:
"""GET /moltbot/logs/tail - Returns the last N lines of the log file."""
if web is None:
raise RuntimeError("aiohttp not available")
ok, init_error = _ensure_observability_deps_ready()
if not ok:
return web.json_response({"ok": False, "error": init_error}, status=500)
# S34: Trace/Log data is high sensitivity -> Require Admin Token
allowed, error = require_admin_token(request)
if not allowed:
return web.json_response({"ok": False, "error": error}, status=403)
# S17: Rate Limit
if not check_rate_limit(request, "logs"):
return build_rate_limit_response(
request,
"logs",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
try:
# Default 50 lines, max 500
# Support both 'n' (internal preference) and 'lines' (legacy frontend)
line_count = 50
val_n = request.query.get("n")
val_lines = request.query.get("lines")
target_val = val_n if val_n is not None else val_lines
if target_val:
try:
line_count = int(target_val)
except ValueError:
pass
# Cap at 500
line_count = min(max(line_count, 1), 500)
# R31: Filter parameters
trace_id_filter = request.query.get("trace_id")
prompt_id_filter = request.query.get("prompt_id")
content = tail_log(LOG_FILE, line_count)
# R31: Apply filtering if requested
if trace_id_filter or prompt_id_filter:
filtered_content = []
for line in content:
# Simple substring match (case-sensitive for IDs)
if trace_id_filter and trace_id_filter in line:
filtered_content.append(line)
elif prompt_id_filter and prompt_id_filter in line:
filtered_content.append(line)
content = filtered_content
# S24: Apply redaction to each line
if redact_text:
content = [redact_text(line) for line in content]
# R31: Enforce max bytes limit (100KB total)
MAX_BYTES = 100_000
total_bytes = sum(len(line.encode("utf-8")) for line in content)
if total_bytes > MAX_BYTES:
# Truncate from end to stay under limit
truncated = []
current_bytes = 0
for line in reversed(content):
line_bytes = len(line.encode("utf-8"))
if current_bytes + line_bytes > MAX_BYTES:
break
truncated.insert(0, line)
current_bytes += line_bytes
content = truncated
return web.json_response(
{
"ok": True,
"content": content,
"filtered": bool(trace_id_filter or prompt_id_filter),
}
)
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
# CRITICAL: logs_tail_response performs require_admin_token( before log access.
return await logs_tail_response(request, _handler_dependencies())
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="List jobs",
description="Stub endpoint for job listing.",
description="Admin-authorized versioned bounded in-process jobs read model.",
audit="jobs.list",
plane=RoutePlane.ADMIN,
)
async def jobs_handler(request: web.Request) -> web.Response:
"""
GET /moltbot/jobs
Stub endpoint for job listing (not implemented yet).
GET /openclaw/jobs (legacy: /moltbot/jobs).
This handler preserves the authorization and tenant boundary around the read model.
"""
if web is None:
raise RuntimeError("aiohttp not available")
return web.json_response(
{
"ok": True,
"jobs": [],
"not_implemented": True,
"message": "Job persistence is not yet implemented. This is a stub endpoint.",
}
# CRITICAL: jobs_response performs require_admin_token( before queue/history access.
return await jobs_response(request, _handler_dependencies())
def _emit_jobs_list_audit(
*,
request,
token_info,
outcome: str,
status_code: int,
reason: str,
**counts,
) -> None:
"""Preserve the established facade seam with content-free dependency capture."""
emit_jobs_list_audit(
_handler_dependencies(),
request=request,
token_info=token_info,
outcome=outcome,
status_code=status_code,
reason=reason,
**counts,
)
@@ -643,52 +563,8 @@ async def jobs_handler(request: web.Request) -> web.Response:
)
async def trace_handler(request: web.Request) -> web.Response:
"""GET /moltbot/trace/{prompt_id} - Returns trace_id and redacted timeline."""
if web is None:
raise RuntimeError("aiohttp not available")
ok, init_error = _ensure_observability_deps_ready()
if not ok:
return web.json_response({"ok": False, "error": init_error}, status=500)
# S34: Trace/Log data is high sensitivity -> Require Admin Token
allowed, error = require_admin_token(request)
if not allowed:
return web.json_response({"ok": False, "error": error}, status=403)
prompt_id = request.match_info.get("prompt_id")
if not prompt_id:
return web.json_response(
{"ok": False, "error": "missing_prompt_id"}, status=400
)
rec = trace_store.get(prompt_id)
if not rec:
return web.json_response({"ok": False, "error": "not_found"}, status=404)
# S24: Apply redaction to trace data
trace_data = rec.to_dict()
try:
from ..services.reasoning_redaction import (
audit_reasoning_reveal,
resolve_reasoning_reveal,
sanitize_operator_payload,
)
from ..services.redaction import redact_json
except ImportError:
from services.reasoning_redaction import ( # type: ignore
audit_reasoning_reveal,
resolve_reasoning_reveal,
sanitize_operator_payload,
)
from services.redaction import redact_json
if redact_json:
trace_data = redact_json(trace_data)
reveal = resolve_reasoning_reveal(request, admin_authorized=allowed)
audit_reasoning_reveal(request, target="trace.get", decision=reveal)
trace_data = sanitize_operator_payload(
trace_data, include_reasoning=reveal["allowed"]
)
return web.json_response({"ok": True, "trace": trace_data})
# CRITICAL: trace_response performs require_admin_token( before trace access.
return await trace_response(request, _handler_dependencies())
assist = None
@@ -707,72 +583,33 @@ def register_dual_route(server, method: str, path: str, handler) -> None:
and directly to the aiohttp router with and without /api prefix
to ensure robustness against loading order (R26/F24).
"""
# IMPORTANT: handler MUST be callable. If imports fail, handlers remain None.
# Registering a None handler crashes ComfyUI at startup (aiohttp assertion).
if not callable(handler):
print(
f"[OpenClaw] Warning: Skipping route {method} {path} because handler is missing (None)."
)
return
# Phase 3 Deprecation wrapper for legacy paths
actual_handler = handler
if path.startswith("/moltbot"):
from functools import wraps
@wraps(handler)
async def _deprecated_handler(request: web.Request) -> web.Response:
try:
# Assuming `metrics` is available in scope (from module level imports)
if metrics:
metrics.inc("legacy_api_hits")
except Exception:
pass
print(
f"[OpenClaw] DEPRECATION WARNING: Legacy route accessed: {request.path}. Please migrate to /openclaw/* equivalents."
)
return await handler(request)
actual_handler = _deprecated_handler
# 1. Standard ComfyUI registration
if method == "GET":
server.routes.get(path)(actual_handler)
elif method == "POST":
server.routes.post(path)(actual_handler)
elif method == "PUT":
server.routes.put(path)(actual_handler)
elif method == "DELETE":
server.routes.delete(path)(actual_handler)
# 2. Hardened direct registration
if hasattr(server, "app") and hasattr(server.app, "router"):
# We try to register /api/... and legacy /... explicitly
# This fixes 404s if the extension loads after ComfyUI has compiled routes
targets = [path, "/api" + path]
for t in targets:
try:
server.app.router.add_route(method, t, handler)
except RuntimeError:
# Route likely exists (e.g. added by step 1 or duplicate)
pass
except Exception as e:
print(f"[OpenClaw] Warning: Failed to register fallback route {t}: {e}")
def _is_openclaw_managed_path(path: str) -> bool:
if not isinstance(path, str):
return False
return (
path.startswith("/openclaw")
or path.startswith("/moltbot")
or path.startswith("/api/openclaw")
or path.startswith("/api/moltbot")
or path.startswith("/bridge")
or path.startswith("/api/bridge")
orchestrate_dual_route(
server,
method,
path,
handler,
metrics=metrics,
legacy_headers_builder=build_legacy_route_deprecation_headers,
)
def _resolve_mae_profile() -> str:
try:
if __package__ and "." in __package__:
from ..services.effective_security_posture import (
get_effective_security_posture,
)
else:
from services.effective_security_posture import (
get_effective_security_posture,
)
posture = get_effective_security_posture(required=False)
if posture is not None:
return str(posture.mae_profile)
except ImportError:
# IMPORTANT: dependency-light import mode retains the accepted resolver below.
pass
profile = os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "local").strip().lower()
if profile in {"public", "hardened"}:
return profile
@@ -785,45 +622,16 @@ def _resolve_mae_profile() -> str:
runtime_profile = get_runtime_profile().value
if runtime_profile == "hardened":
return "hardened"
except Exception:
except ImportError:
# IMPORTANT: optional standalone import absence may fall back to the deployment
# profile, but unexpected resolver failures must propagate instead of downgrading
# hardened posture silently.
pass
return profile or "local"
def _run_mae_startup_gate(server) -> None:
if not hasattr(server, "app"):
return
try:
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
generate_manifest,
validate_mae_posture,
)
else:
from services.endpoint_manifest import (
generate_manifest,
validate_mae_posture,
)
except Exception as e:
print(f"[OpenClaw] Warning: S60 MAE gate unavailable: {e}")
return
mae_profile = _resolve_mae_profile()
manifest = generate_manifest(server.app)
scoped_manifest = [
entry for entry in manifest if _is_openclaw_managed_path(entry.get("path", ""))
]
ok, violations = validate_mae_posture(scoped_manifest, profile=mae_profile)
if ok:
return
message = "S60 MAE posture validation failed:\n" + "\n".join(
f"- {item}" for item in violations
)
if mae_profile in {"public", "hardened"}:
raise RuntimeError(message)
print(f"[OpenClaw] Warning: {message}")
run_mae_startup_gate(server, _resolve_mae_profile)
def register_routes(server) -> None:
@@ -835,18 +643,29 @@ def register_routes(server) -> None:
# Must run BEFORE any route or worker registration.
try:
try:
from ..services.effective_security_posture import (
get_effective_security_posture,
resolve_effective_security_posture,
)
from ..services.startup_profile_gate import enforce_startup_gate
except (ImportError, ValueError):
from services.effective_security_posture import (
get_effective_security_posture,
resolve_effective_security_posture,
)
from services.startup_profile_gate import enforce_startup_gate
enforce_startup_gate()
posture = get_effective_security_posture(required=False)
if posture is None:
# Compatibility/direct-test invocation is not the process owner.
posture = resolve_effective_security_posture()
enforce_startup_gate(posture=posture)
except RuntimeError:
# CRITICAL: fail-closed. Never continue route registration after S56
# startup gate failure.
raise
print("[OpenClaw] Registering routes (Shim Alignment R26)...")
prefixes = ["/openclaw", "/moltbot"] # new, legacy
core_handlers = {
"remote_admin_page_handler": remote_admin_page_handler,
"health_handler": health_handler,
@@ -900,95 +719,27 @@ def register_routes(server) -> None:
"select_apply_winner_handler": select_apply_winner_handler,
}
# Core Observability & Config
for prefix in prefixes:
register_route_family(
server,
register_dual_route,
build_core_route_specs(prefix, core_handlers),
)
# F8/F21 Assist Routes
# R84 Boot Boundary: CORE (Planner/Refiner part of core/assist)
if assist:
for prefix in prefixes:
register_route_family(
server,
register_dual_route,
build_assist_route_specs(prefix, assist),
)
# R126: Connector installation diagnostics/read APIs
connector_handlers = None
if connector_installations_list_handler:
connector_installation_handlers = {
connector_handlers = {
"connector_installations_list_handler": connector_installations_list_handler,
"connector_extraction_contract_handler": connector_extraction_contract_handler,
"connector_installation_resolve_handler": connector_installation_resolve_handler,
"connector_installation_audit_handler": connector_installation_audit_handler,
"connector_installation_get_handler": connector_installation_get_handler,
}
for prefix in prefixes:
register_route_family(
server,
register_dual_route,
build_connector_installation_route_specs(
prefix, connector_installation_handlers
),
)
# F10 Bridge Routes (Sidecar)
# R84 Boot Boundary: BRIDGE
# F10 Bridge Routes (Sidecar)
# R84 Boot Boundary: BRIDGE
try:
try:
from ..api.bridge import register_bridge_routes
from ..services.modules import ModuleCapability, is_module_enabled
except (ImportError, ValueError):
from api.bridge import register_bridge_routes
from services.modules import ModuleCapability, is_module_enabled
if hasattr(server, "app") and is_module_enabled(ModuleCapability.BRIDGE):
register_bridge_routes(server.app)
print("[OpenClaw] Bridge routes registered")
elif not is_module_enabled(ModuleCapability.BRIDGE):
print("[OpenClaw] Bridge module disabled; skipping route registration")
except ImportError:
pass
_run_mae_startup_gate(server)
# S8/S23/F11 Asset Packs
# R84 Boot Boundary: REGISTRY_SYNC (Packs management)
try:
try:
from ..api.packs import PacksHandlers
from ..services.modules import ModuleCapability, is_module_enabled
except (ImportError, ValueError):
from api.packs import PacksHandlers
from services.modules import ModuleCapability, is_module_enabled
# Packs are currently treated as part of CORE or REGISTRY_SYNC depending on strictness.
# For now, we bind them to REGISTRY_SYNC if we want to segment them,
# but realistically they are often core local features.
# Let's check REGISTRY_SYNC for import/export features specifically if we wanted to split,
# but keeping them enabled by default for now unless R84 explicitly segments them.
# DESIGN DECISION: Packs are local core features. Registry sync is remote.
# We will keep basic pack routes, but R84 might control remote interactions later.
try:
from ..config import DATA_DIR
except (ImportError, ValueError):
from config import DATA_DIR
packs = PacksHandlers(DATA_DIR)
for prefix in prefixes:
register_route_family(
server,
register_dual_route,
build_pack_route_specs(prefix, packs),
)
except ImportError:
pass
register_route_families(
server,
RouteRegistrationDependencies(
build_core_route_specs=build_core_route_specs,
build_assist_route_specs=build_assist_route_specs,
build_connector_installation_route_specs=build_connector_installation_route_specs,
build_pack_route_specs=build_pack_route_specs,
register_route_family=register_route_family,
register_dual_route=register_dual_route,
core_handlers=core_handlers,
assist=assist,
connector_installation_handlers=connector_handlers,
run_mae_startup_gate=_run_mae_startup_gate,
),
)
+20 -1
View File
@@ -16,12 +16,20 @@ from typing import Optional
# can silently import another pack's module and break auth/approval semantics.
if __package__ and "." in __package__:
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.scheduler.delivery_contract import (
DeliveryContractError,
normalize_schedule_delivery,
)
from ..services.scheduler.models import Schedule, TriggerType
from ..services.scheduler.storage import get_schedule_store
from ..services.templates import is_template_allowed
from ..services.webhook_auth import AuthError
else: # pragma: no cover (test-only import mode)
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.scheduler.delivery_contract import ( # type: ignore
DeliveryContractError,
normalize_schedule_delivery,
)
from services.scheduler.models import Schedule, TriggerType # type: ignore
from services.scheduler.storage import get_schedule_store # type: ignore
from services.templates import is_template_allowed # type: ignore
@@ -31,6 +39,10 @@ logger = logging.getLogger("ComfyUI-OpenClaw.api.schedules")
web = import_aiohttp_web()
def _delivery_error_response(exc: DeliveryContractError) -> web.Response:
return web.json_response({"error": str(exc), "code": exc.code}, status=400)
def _get_scheduler_runner():
if __package__ and "." in __package__:
from ..services.scheduler.runner import get_scheduler_runner
@@ -157,6 +169,8 @@ class ScheduleHandlers:
timezone=data.get("timezone", "local"),
enabled=data.get("enabled", True),
)
except DeliveryContractError as e:
return _delivery_error_response(e)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
@@ -209,7 +223,10 @@ class ScheduleHandlers:
if "inputs" in data:
existing.inputs = data["inputs"]
if "delivery" in data:
existing.delivery = data["delivery"]
try:
existing.delivery = normalize_schedule_delivery(data["delivery"])
except DeliveryContractError as e:
return _delivery_error_response(e)
if "timezone" in data:
existing.timezone = data["timezone"]
if "enabled" in data:
@@ -218,6 +235,8 @@ class ScheduleHandlers:
# Re-validate
try:
existing.validate()
except DeliveryContractError as e:
return _delivery_error_response(e)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
+4
View File
@@ -241,6 +241,7 @@ class ConnectorConfig:
slack_bind_host: str = "127.0.0.1"
slack_bind_port: int = DEFAULT_SLACK_BIND_PORT
slack_webhook_path: str = "/slack/events"
slack_interactions_path: str = "/slack/interactions"
slack_require_mention: bool = True
slack_reply_in_thread: bool = True
slack_mode: str = "events" # F57: events | socket
@@ -484,6 +485,9 @@ def load_config() -> ConnectorConfig:
cfg.slack_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_SLACK_PATH", "/slack/events"
)
cfg.slack_interactions_path = os.environ.get(
"OPENCLAW_CONNECTOR_SLACK_INTERACTIONS_PATH", "/slack/interactions"
)
if (
os.environ.get("OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION", "").lower()
== "false"
+183
View File
@@ -0,0 +1,183 @@
"""Strict, bounded formatter for the connector's authoritative jobs view."""
from __future__ import annotations
import re
from collections import Counter
from collections.abc import Mapping
from typing import Any
JOBS_CONTRACT_VERSION = 1
MAX_RETURNED_JOBS = 200
MAX_SNAPSHOT_TOTAL = 10_000
MAX_JOB_ID_LENGTH = 128
MAX_DISPLAY_JOB_ID_LENGTH = 24
MAX_JOBS_SUMMARY_LENGTH = 1_000
MAX_QUEUE_REMAINING = 1_000_000
MAX_NORMALIZATION_WARNINGS = 2
JOB_STATUSES = (
"pending",
"in_progress",
"completed",
"failed",
"cancelled",
)
STATUS_LABELS = {
"pending": "pending",
"in_progress": "in progress",
"completed": "completed",
"failed": "failed",
"cancelled": "cancelled",
}
_SAFE_JOB_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
class JobsContractError(ValueError):
"""Raised when a connector jobs payload is not safe to render."""
def format_jobs_summary(payload: Any) -> str:
"""Validate contract version 1 and return a deterministic operator summary."""
jobs, pagination = _parse_jobs_payload(payload)
total = pagination["total"]
if total == 0:
return "[Jobs] No jobs in the authoritative snapshot."
counts = Counter(job["status"] for job in jobs)
active = counts["pending"] + counts["in_progress"]
terminal = counts["completed"] + counts["failed"] + counts["cancelled"]
lines = [
"[Jobs] Authoritative snapshot",
f"Snapshot total: {total}; returned page: {len(jobs)}",
(
f"Page states: Active {active} (pending {counts['pending']}, "
f"in progress {counts['in_progress']}); "
f"Terminal {terminal} (completed {counts['completed']}, "
f"failed {counts['failed']}, cancelled {counts['cancelled']})"
),
]
if jobs:
for job in jobs[:5]:
lines.append(
f"- {_short_job_id(job['id'])}{STATUS_LABELS[job['status']]}"
)
if len(jobs) > 5:
lines.append(f"Showing 5 of {len(jobs)} returned jobs.")
else:
lines.append("No jobs are present on this page.")
summary = "\n".join(lines)
if len(summary) > MAX_JOBS_SUMMARY_LENGTH:
raise JobsContractError("jobs summary exceeds the safe display bound")
return summary
def format_queue_fallback(response: Any) -> str:
"""Render only a bounded coarse queue count from the legacy fallback seam."""
remaining = _queue_remaining(response)
if remaining is None:
return "[Jobs fallback] Coarse queue count is unavailable."
return (
f"[Jobs fallback] Queue remaining: {remaining} "
"(coarse count; not an authoritative jobs snapshot)."
)
def _parse_jobs_payload(payload: Any) -> tuple[list[dict[str, str]], dict[str, Any]]:
if not isinstance(payload, Mapping) or payload.get("ok") is not True:
raise JobsContractError("jobs response must be a successful mapping")
version = payload.get("contract_version")
if isinstance(version, bool) or version != JOBS_CONTRACT_VERSION:
raise JobsContractError("unsupported jobs contract version")
raw_jobs = payload.get("jobs")
pagination = payload.get("pagination")
if not isinstance(raw_jobs, list) or len(raw_jobs) > MAX_RETURNED_JOBS:
raise JobsContractError("jobs list is malformed or oversized")
if not isinstance(pagination, Mapping):
raise JobsContractError("jobs pagination is missing")
if not isinstance(payload.get("source"), Mapping) or not isinstance(
payload.get("scan"), Mapping
):
raise JobsContractError("jobs source diagnostics are missing")
parsed_jobs = [_parse_job(item) for item in raw_jobs]
parsed_pagination = _parse_pagination(pagination, returned=len(parsed_jobs))
return parsed_jobs, parsed_pagination
def _parse_job(item: Any) -> dict[str, str]:
if not isinstance(item, Mapping):
raise JobsContractError("job summary must be a mapping")
job_id = item.get("id")
status = item.get("status")
if (
not isinstance(job_id, str)
or not job_id
or len(job_id) > MAX_JOB_ID_LENGTH
or _SAFE_JOB_ID.fullmatch(job_id) is None
):
raise JobsContractError("job id is outside the safe display contract")
if not isinstance(status, str) or status not in JOB_STATUSES:
raise JobsContractError("job status is unsupported")
return {"id": job_id, "status": status}
def _parse_pagination(
pagination: Mapping[str, Any], *, returned: int
) -> dict[str, Any]:
offset = _bounded_int(pagination.get("offset"), minimum=0, maximum=10_000)
limit = _bounded_int(pagination.get("limit"), minimum=1, maximum=MAX_RETURNED_JOBS)
total = _bounded_int(pagination.get("total"), minimum=0, maximum=MAX_SNAPSHOT_TOTAL)
has_more = pagination.get("has_more")
warnings = pagination.get("warnings")
if not isinstance(has_more, bool):
raise JobsContractError("jobs has_more must be boolean")
if not isinstance(warnings, list) or len(warnings) > MAX_NORMALIZATION_WARNINGS:
raise JobsContractError("jobs warnings are malformed")
if returned > limit or offset + returned > total:
raise JobsContractError("jobs pagination counts are inconsistent")
if has_more != (offset + returned < total):
raise JobsContractError("jobs has_more is inconsistent")
return {
"offset": offset,
"limit": limit,
"total": total,
"has_more": has_more,
}
def _bounded_int(value: Any, *, minimum: int, maximum: int) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise JobsContractError("jobs count must be an integer")
if value < minimum or value > maximum:
raise JobsContractError("jobs count is outside the safe bound")
return value
def _short_job_id(job_id: str) -> str:
if len(job_id) <= MAX_DISPLAY_JOB_ID_LENGTH:
return job_id
return job_id[: MAX_DISPLAY_JOB_ID_LENGTH - 3] + "..."
def _queue_remaining(response: Any) -> int | None:
if not isinstance(response, Mapping) or response.get("ok") is not True:
return None
data = response.get("data")
if not isinstance(data, Mapping):
return None
exec_info = data.get("exec_info")
if not isinstance(exec_info, Mapping):
return None
remaining = exec_info.get("queue_remaining")
if (
isinstance(remaining, bool)
or not isinstance(remaining, int)
or remaining < 0
or remaining > MAX_QUEUE_REMAINING
):
return None
return remaining
+66
View File
@@ -0,0 +1,66 @@
"""Safe response helpers for connector-served local media."""
from __future__ import annotations
import mimetypes
from pathlib import Path
from typing import Any
DANGEROUS_CONTENT_TYPES = {
"text/html",
"text/html-sandboxed",
"application/xhtml+xml",
"text/javascript",
"application/javascript",
"application/x-javascript",
"application/ecmascript",
"text/css",
"image/svg+xml",
"application/xml",
"text/xml",
"message/rfc822",
}
def is_dangerous_content_type(content_type: str | None) -> bool:
"""Return True for browser-renderable active content types."""
if not content_type:
return False
normalized = content_type.split(";", 1)[0].strip().lower()
if normalized in DANGEROUS_CONTENT_TYPES:
return True
return normalized.endswith("+xml") or normalized.endswith("/xml")
def _content_disposition_filename(name: str) -> str:
safe_name = name.replace("\r", "").replace("\n", "")
safe_name = safe_name.replace("\\", "\\\\").replace('"', '\\"')
return f'filename="{safe_name}"'
def build_connector_media_response(web: Any, path: Path):
"""Build a hardened FileResponse for signed connector media files."""
content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
disposition = _content_disposition_filename(path.name)
# IMPORTANT: connector media is user-controlled. Dangerous active content
# must download instead of rendering inline in the local media origin.
if is_dangerous_content_type(content_type):
content_type = "application/octet-stream"
disposition = f"attachment; {_content_disposition_filename(path.name)}"
return web.FileResponse(
path,
headers={
"Content-Disposition": disposition,
"Content-Type": content_type,
"X-Content-Type-Options": "nosniff",
},
)
__all__ = [
"DANGEROUS_CONTENT_TYPES",
"build_connector_media_response",
"is_dangerous_content_type",
]
+13 -4
View File
@@ -7,6 +7,7 @@ import json
import logging
import uuid
from typing import Optional
from urllib.parse import quote
from .config import ConnectorConfig
@@ -68,7 +69,7 @@ class OpenClawClient:
async with session.request(
method, url, headers=self.headers, json=json_data, timeout=timeout
) as resp:
result = {"ok": resp.status in (200, 201, 202)}
result = {"ok": resp.status in (200, 201, 202), "status": resp.status}
try:
data = await resp.json()
@@ -166,9 +167,17 @@ class OpenClawClient:
}
return await self._request("POST", "/openclaw/triggers/fire", data)
async def interrupt_output(self) -> dict:
# Remediation: Cancel -> Interrupt (Global)
return await self._request("POST", "/api/interrupt", {})
async def cancel_job(self, job_id: str) -> dict:
encoded_job_id = quote(str(job_id), safe="")
return await self._request("POST", f"/api/jobs/{encoded_job_id}/cancel", {})
async def cancel_jobs(self, job_ids: list[str]) -> dict:
return await self._request("POST", "/api/jobs/cancel", {"job_ids": job_ids})
async def interrupt_output(self, prompt_id: Optional[str] = None) -> dict:
# No prompt_id means explicit global interrupt. A prompt_id is targeted.
payload = {"prompt_id": str(prompt_id)} if prompt_id else {}
return await self._request("POST", "/api/interrupt", payload)
async def get_view(
self, filename: str, subfolder: str = "", type: str = "output"
@@ -0,0 +1,421 @@
"""Owned Feishu card, response, and media-delivery mixin."""
# ruff: noqa: UP006, UP035, UP045 -- preserve frozen facade annotations.
from __future__ import annotations
import json
import secrets
from dataclasses import dataclass
from typing import Any, Dict, Optional
from services.safe_io import STANDARD_OUTBOUND_POLICY, SafeIOHTTPError
from ..reply_visibility import decide_reply_visibility
from .feishu_installation_manager import FeishuBinding
# mypy: disable-error-code="attr-defined,no-any-return"
@dataclass
class FeishuDeliveryTarget:
channel_id: str
reply_to_message_id: str = ""
workspace_id: str = ""
account_id: str = ""
class FeishuDeliveryMixin:
def _build_card_button_value(
self,
button: Dict[str, Any],
*,
target: FeishuDeliveryTarget,
binding: FeishuBinding,
signing_secret: str,
) -> Dict[str, Any]:
contract = self._callback_contract_for_binding(
binding=binding,
signing_secret=signing_secret,
)
command_text = str(button.get("value", "") or "").strip()
callback_payload = {
"label": str(button.get("label", "") or "").strip(),
"command": command_text,
"approval_id": str(button.get("approval_id", "") or "").strip(),
"workspace_id": target.workspace_id or binding.workspace_id,
"account_id": target.account_id or binding.account_id,
"channel_id": target.channel_id,
"message_id": target.reply_to_message_id,
}
envelope = contract.build_envelope(
request_id=secrets.token_hex(12),
workspace_id=callback_payload["workspace_id"],
action_type=self._adapter_infer_callback_action_type(command_text, button),
payload=callback_payload,
)
return {
"callback_envelope": dict(envelope.__dict__),
"payload": callback_payload,
}
def _build_interactive_card(
self,
target: FeishuDeliveryTarget,
text: str,
buttons: list[dict],
*,
binding: FeishuBinding,
secrets: Dict[str, str],
) -> Dict[str, Any]:
signing_secret = str(
secrets.get("app_secret", "") or binding.app_secret or ""
).strip()
if not signing_secret:
raise RuntimeError("feishu_callback_signing_secret_missing")
actions = []
for button in buttons[:6]:
command_text = str(button.get("value", "") or "").strip()
if not command_text:
continue
actions.append(
{
"tag": "button",
"type": str(button.get("style", "") or "default"),
"text": {
"tag": "plain_text",
"content": str(button.get("label", "") or "OpenClaw"),
},
"value": self._build_card_button_value(
button,
target=target,
binding=binding,
signing_secret=signing_secret,
),
}
)
return {
"config": {"wide_screen_mode": True},
"header": {
"template": "blue",
"title": {"tag": "plain_text", "content": "OpenClaw"},
},
"elements": [
{"tag": "markdown", "content": text or "OpenClaw"},
{"tag": "action", "actions": actions},
],
}
async def _send_interactive_reply(
self,
target: FeishuDeliveryTarget,
text: str,
buttons: list[dict],
) -> None:
resolution, binding, secrets = self._resolve_delivery_binding(
workspace_id=target.workspace_id,
account_id=target.account_id,
)
if binding is None or not resolution.ok:
self._adapter_logger().warning(
"Feishu interactive reply dropped: no workspace binding available (%s / %s)",
target.workspace_id or "no-workspace",
target.account_id or "no-account",
)
return
token = await self._get_tenant_access_token(
binding=binding,
workspace_id=target.workspace_id,
account_id=target.account_id,
)
api_base = self._adapter_resolve_domain_base(binding.domain)
card = self._build_interactive_card(
target,
text,
buttons,
binding=binding,
secrets=secrets,
)
payload = {
"content": json.dumps(card, ensure_ascii=False),
"msg_type": "interactive",
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json; charset=utf-8",
}
if target.reply_to_message_id:
url = (
f"{api_base}/open-apis/im/v1/messages/"
f"{target.reply_to_message_id}/reply"
)
else:
url = f"{api_base}/open-apis/im/v1/messages?receive_id_type=chat_id"
payload["receive_id"] = target.channel_id
try:
data = self._adapter_safe_request_json(
method="POST",
url=url,
json_body=payload,
headers=headers,
content_type="application/json; charset=utf-8",
timeout_sec=15,
allow_hosts=self._adapter_allowed_api_hosts(binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "interactive_reply"},
)
self._adapter_logger().warning(
"Feishu interactive reply failed: status=%s", exc.status_code
)
return
if data.get("code", 0) != 0:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=str(data.get("msg", "unknown") or "unknown"),
status_code=200,
details={"phase": "interactive_reply"},
)
self._adapter_logger().warning(
"Feishu interactive reply failed: %s", data.get("msg", "unknown")
)
async def _send_reply(
self,
target: FeishuDeliveryTarget,
text: str,
*,
delivery_context: Optional[Dict[str, Any]] = None,
) -> None:
ctx = dict(delivery_context or {})
if target.workspace_id:
ctx.setdefault("workspace_id", target.workspace_id)
if target.account_id:
ctx.setdefault("account_id", target.account_id)
if target.reply_to_message_id:
ctx.setdefault("thread_id", target.reply_to_message_id)
decision = decide_reply_visibility(
delivery_context=ctx,
platform="feishu",
channel_kind=str(ctx.get("chat_type", "") or ""),
in_thread=bool(target.reply_to_message_id),
text=text,
)
if decision.suppressed:
self._adapter_logger().info(
"Suppressed Feishu reply channel=%s reason=%s",
target.channel_id,
decision.reason,
)
return
resolution, binding, _ = self._resolve_delivery_binding(
workspace_id=target.workspace_id,
account_id=target.account_id,
)
if binding is None or not resolution.ok:
self._adapter_logger().warning(
"Feishu reply dropped: no workspace binding available (%s / %s)",
target.workspace_id or "no-workspace",
target.account_id or "no-account",
)
return
token = await self._get_tenant_access_token(
binding=binding,
workspace_id=target.workspace_id,
account_id=target.account_id,
)
api_base = self._adapter_resolve_domain_base(binding.domain)
payload = {
"content": json.dumps({"text": text}, ensure_ascii=False),
"msg_type": "text",
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json; charset=utf-8",
}
if target.reply_to_message_id:
url = (
f"{api_base}/open-apis/im/v1/messages/"
f"{target.reply_to_message_id}/reply"
)
else:
url = f"{api_base}/open-apis/im/v1/messages?receive_id_type=chat_id"
payload["receive_id"] = target.channel_id
try:
data = self._adapter_safe_request_json(
method="POST",
url=url,
json_body=payload,
headers=headers,
content_type="application/json; charset=utf-8",
timeout_sec=15,
allow_hosts=self._adapter_allowed_api_hosts(binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "reply"},
)
self._adapter_logger().warning(
"Feishu reply failed: status=%s", exc.status_code
)
return
if data.get("code", 0) != 0:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=str(data.get("msg", "unknown") or "unknown"),
status_code=200,
details={"phase": "reply"},
)
self._adapter_logger().warning(
"Feishu reply failed: %s",
data.get("msg", "unknown"),
)
async def send_message(
self,
channel_id: str,
text: str,
delivery_context: Optional[Dict[str, Any]] = None,
):
ctx = dict(delivery_context or {})
await self._send_reply(
self._adapter_delivery_target(
channel_id=channel_id,
reply_to_message_id=str(ctx.get("thread_id", "") or "").strip(),
workspace_id=str(ctx.get("workspace_id", "") or "").strip(),
account_id=str(ctx.get("account_id", "") or "").strip(),
),
text,
delivery_context=ctx,
)
async def send_image(
self,
channel_id: str,
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[Dict[str, Any]] = None,
):
ctx = dict(delivery_context or {})
resolution, binding, _ = self._resolve_delivery_binding(
workspace_id=str(ctx.get("workspace_id", "") or "").strip(),
account_id=str(ctx.get("account_id", "") or "").strip(),
)
if binding is None or not resolution.ok:
self._adapter_logger().warning(
"Feishu image dropped: no workspace binding available (%s / %s)",
str(ctx.get("workspace_id", "") or "").strip() or "no-workspace",
str(ctx.get("account_id", "") or "").strip() or "no-account",
)
return
token = await self._get_tenant_access_token(
binding=binding,
workspace_id=str(ctx.get("workspace_id", "") or "").strip(),
account_id=str(ctx.get("account_id", "") or "").strip(),
)
api_base = self._adapter_resolve_domain_base(binding.domain)
upload_headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
upload_body, upload_content_type = self._adapter_build_multipart_form(
fields={"image_type": "message"},
file_field="image",
filename=filename,
file_bytes=image_data,
file_content_type="image/png",
)
try:
upload_payload = self._adapter_safe_request_json(
method="POST",
url=f"{api_base}/open-apis/im/v1/images",
raw_body=upload_body,
headers=upload_headers,
content_type=upload_content_type,
timeout_sec=30,
allow_hosts=self._adapter_allowed_api_hosts(binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "image_upload"},
)
self._adapter_logger().warning(
"Feishu image upload failed: status=%s", exc.status_code
)
return
image_key = str(
(upload_payload.get("data") or {}).get("image_key", "") or ""
).strip()
if upload_payload.get("code", 0) != 0 or not image_key:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=str(upload_payload.get("msg", "unknown") or "unknown"),
status_code=200,
details={"phase": "image_upload"},
)
self._adapter_logger().warning(
"Feishu image upload failed: %s",
upload_payload.get("msg", "unknown"),
)
return
message_payload = {
"content": json.dumps({"image_key": image_key}, ensure_ascii=False),
"msg_type": "image",
}
thread_id = str(ctx.get("thread_id", "") or "").strip()
if thread_id:
send_url = f"{api_base}/open-apis/im/v1/messages/{thread_id}/reply"
else:
send_url = f"{api_base}/open-apis/im/v1/messages?receive_id_type=chat_id"
message_payload["receive_id"] = channel_id
try:
self._adapter_safe_request_json(
method="POST",
url=send_url,
json_body=message_payload,
headers={
"Accept": "application/json",
"Authorization": f"Bearer {token}",
},
content_type="application/json; charset=utf-8",
timeout_sec=30,
allow_hosts=self._adapter_allowed_api_hosts(binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "image_send"},
)
self._adapter_logger().warning(
"Feishu image send failed: status=%s", exc.status_code
)
if caption:
await self.send_message(
channel_id,
caption,
delivery_context=ctx,
)
@@ -0,0 +1,462 @@
"""Owned Feishu webhook ingress and callback transaction mixin."""
# ruff: noqa: UP006, UP035, UP045 -- preserve frozen facade annotations.
from __future__ import annotations
import json
import secrets
import time
from typing import Any, Dict, Optional, Tuple
from services.connector_callback_contract import (
CallbackActorContext,
CallbackDecisionCode,
ConnectorCallbackContract,
)
from ..contract import CommandRequest
from .feishu_installation_manager import FeishuBinding
# mypy: disable-error-code="attr-defined,index,no-any-return"
class FeishuIngressMixin:
async def handle_event(self, request):
_, web = self._adapter_import_aiohttp_web()
try:
body = await request.read()
except Exception:
return self._adapter_make_response(web, status=400, text="Bad request")
if len(body) > self._adapter_max_body_bytes():
return self._adapter_make_response(
web, status=413, text="Payload too large"
)
try:
payload = json.loads(body or b"{}")
except json.JSONDecodeError:
return self._adapter_make_response(web, status=400, text="Bad JSON")
if self._is_challenge(payload):
if not self._verify_request_token(payload):
return self._adapter_make_response(
web, status=401, text="Invalid verification token"
)
return self._adapter_make_json_response(
web, {"challenge": str(payload.get("challenge", "") or "")}
)
if not self._verify_request_token(payload):
return self._adapter_make_response(
web, status=401, text="Invalid verification token"
)
try:
await self.process_event_payload(payload)
except ValueError as exc:
safe_code = self._adapter_safe_external_error_code("event_rejected", exc)
self._adapter_logger().warning("Feishu event rejected: %s", safe_code)
return self._adapter_make_response(
web,
status=400,
text=safe_code,
)
return self._adapter_make_response(web, status=200, text="OK")
async def handle_callback(self, request):
_, web = self._adapter_import_aiohttp_web()
try:
body = await request.read()
except Exception:
return self._adapter_make_response(web, status=400, text="Bad request")
if len(body) > self._adapter_max_body_bytes():
return self._adapter_make_response(
web, status=413, text="Payload too large"
)
try:
payload = json.loads(body or b"{}")
except json.JSONDecodeError:
return self._adapter_make_response(web, status=400, text="Bad JSON")
try:
response = await self.process_callback_payload(payload)
except ValueError as exc:
safe_code = self._adapter_safe_external_error_code("callback_rejected", exc)
self._adapter_logger().warning("Feishu callback rejected: %s", safe_code)
return self._adapter_make_json_response(
web,
{
"ok": False,
"error": safe_code,
},
status=403,
)
return self._adapter_make_json_response(web, response)
def _is_challenge(self, payload: Dict[str, Any]) -> bool:
return bool(
payload.get("challenge")
and str(payload.get("type", "") or "").strip().lower() == "url_verification"
)
def _verify_request_token(self, payload: Dict[str, Any]) -> bool:
try:
self._resolve_inbound_binding(payload)
return True
except ValueError:
return False
def _extract_callback_action(self, payload: Dict[str, Any]) -> Tuple[
Dict[str, Any],
Dict[str, Any],
Dict[str, Any],
Dict[str, Any],
str,
str,
]:
header = payload.get("header") or {}
event = payload.get("event") or {}
action = payload.get("action") or event.get("action") or {}
if not action and isinstance(event.get("actions"), list):
first_action = event.get("actions")[0] if event.get("actions") else {}
if isinstance(first_action, dict):
action = first_action
if not isinstance(action, dict):
raise ValueError("invalid_callback_action")
raw_value = action.get("value") or {}
if isinstance(raw_value, str):
raw_value = self._adapter_json_loads_safe(raw_value)
if not isinstance(raw_value, dict):
raise ValueError("invalid_callback_value")
envelope = raw_value.get("callback_envelope") or {}
callback_payload = raw_value.get("payload") or {}
if not isinstance(envelope, dict) or not isinstance(callback_payload, dict):
raise ValueError("invalid_callback_envelope")
workspace_id = str(
header.get("tenant_key")
or event.get("tenant_key")
or callback_payload.get("workspace_id")
or ""
).strip()
account_id = str(callback_payload.get("account_id", "") or "").strip()
return header, event, envelope, callback_payload, workspace_id, account_id
def _callback_contract_for_binding(
self,
*,
binding: FeishuBinding,
signing_secret: str,
) -> ConnectorCallbackContract:
cache_key = self._cache_key_for_binding(binding)
if (
self._callback_contracts.get(cache_key) is not None
and self._callback_contract_secrets.get(cache_key) == signing_secret
):
return self._callback_contracts[cache_key]
contract = ConnectorCallbackContract(
signing_secret=signing_secret,
installation_registry=self._installation_manager.registry,
action_policy_map=self._adapter_callback_policy_map(),
)
self._callback_contracts[cache_key] = contract
self._callback_contract_secrets[cache_key] = signing_secret
return contract
def _actor_context_for_callback(
self,
*,
actor_id: str,
actor_open_id: str,
channel_id: str,
message_id: str,
workspace_id: str,
account_id: str,
command_text: str,
) -> Tuple[CallbackActorContext, CommandRequest]:
request = CommandRequest(
platform="feishu",
sender_id=actor_id or actor_open_id,
channel_id=channel_id or actor_id or actor_open_id,
username=actor_id or actor_open_id,
message_id=message_id or f"cb-{secrets.token_hex(4)}",
text=command_text,
timestamp=time.time(),
workspace_id=workspace_id,
thread_id=message_id,
metadata={
"account_id": account_id,
"sender_open_id": actor_open_id,
"interactive_callback": True,
},
)
actor = CallbackActorContext(
is_admin=self.router._is_admin(request.sender_id),
is_trusted=self.router._is_trusted(request),
user_id=request.sender_id,
tenant_id=workspace_id or request.workspace_id or "",
)
return actor, request
def _build_callback_response(
self,
*,
ok: bool,
text: str,
response_type: str = "info",
card: Optional[Dict[str, Any]] = None,
duplicate: bool = False,
decision_code: str = "",
) -> Dict[str, Any]:
response = {
"ok": ok,
"duplicate": duplicate,
"decision_code": decision_code,
"toast": {
"type": response_type,
"content": text[:500] if text else "",
},
}
if card is not None:
response["card"] = card
return response
def _build_request(
self,
payload: Dict[str, Any],
*,
binding: FeishuBinding,
bot_open_id: str,
) -> Optional[CommandRequest]:
header = payload.get("header") or {}
if (
str(header.get("event_type", "") or "").strip()
not in self._adapter_supported_event_types()
):
return None
event = payload.get("event") or {}
message = event.get("message") or {}
sender = event.get("sender") or {}
sender_id = sender.get("sender_id") or {}
mentions = self._adapter_normalize_mentions(message)
sender_user_id = str(sender_id.get("user_id", "") or "").strip()
sender_open_id = str(sender_id.get("open_id", "") or "").strip()
chat_id = str(message.get("chat_id", "") or "").strip()
chat_type = str(message.get("chat_type", "") or "").strip().lower()
message_id = str(message.get("message_id", "") or "").strip()
workspace_id = (
str(header.get("tenant_key", "") or "").strip() or binding.workspace_id
)
if not sender_user_id and not sender_open_id:
return None
if not chat_id or not message_id:
return None
if sender_open_id and bot_open_id and sender_open_id == bot_open_id:
return None
raw_text = self._adapter_parse_message_text(message)
if not raw_text:
return None
mentioned_bot = False
if bot_open_id:
for mention in mentions:
open_id = str(((mention.get("id") or {}).get("open_id")) or "").strip()
if open_id and open_id == bot_open_id:
mentioned_bot = True
break
text = self._adapter_strip_bot_mention(raw_text, mentions, bot_open_id)
if (
chat_type == "group"
and self.config.feishu_require_mention
and not mentioned_bot
):
return None
effective_sender = sender_user_id or sender_open_id
return CommandRequest(
platform="feishu",
sender_id=effective_sender,
channel_id=chat_id,
username=effective_sender,
message_id=message_id,
text=text,
timestamp=time.time(),
workspace_id=workspace_id,
thread_id=(
str(message.get("root_id", "") or "").strip()
or (message_id if self.config.feishu_reply_in_thread else "")
),
metadata={
"account_id": binding.account_id,
"chat_type": chat_type,
"mentioned_bot": mentioned_bot,
"message_type": str(message.get("message_type", "") or "").strip(),
"sender_open_id": sender_open_id,
},
)
async def process_event_payload(
self,
payload: Dict[str, Any],
*,
binding: Optional[FeishuBinding] = None,
) -> None:
header = payload.get("header") or {}
event_id = str(header.get("event_id", "") or "").strip()
if not event_id:
raise ValueError("Missing event_id")
if not self._replay_guard.check_and_record(event_id):
return
effective_binding = binding or self._resolve_inbound_binding(payload)
bot_open_id = self._cached_bot_open_id(effective_binding)
message = (payload.get("event") or {}).get("message") or {}
chat_type = str(message.get("chat_type", "") or "").strip().lower()
if not bot_open_id and chat_type == "group":
bot_open_id = await self._fetch_bot_open_id(
binding=effective_binding, allow_degrade=True
)
request = self._build_request(
payload,
binding=effective_binding,
bot_open_id=bot_open_id,
)
if request is None:
return
if self._user_allowlist.entries:
user_result = self._user_allowlist.evaluate(str(request.sender_id))
if user_result.decision == "deny":
return
if self._chat_allowlist.entries:
chat_result = self._chat_allowlist.evaluate(str(request.channel_id))
if chat_result.decision == "deny":
return
response = await self.router.handle(request)
resp_text = str(getattr(response, "text", "") or "").strip()
buttons = getattr(response, "buttons", []) or []
target = self._adapter_delivery_target(
channel_id=request.channel_id,
reply_to_message_id=request.thread_id,
workspace_id=request.workspace_id,
account_id=str(request.metadata.get("account_id", "") or ""),
)
if buttons:
await self._send_interactive_reply(target, resp_text, buttons)
elif resp_text:
await self._send_reply(
target,
resp_text,
delivery_context={
"workspace_id": request.workspace_id,
"thread_id": request.thread_id,
"account_id": str(request.metadata.get("account_id", "") or ""),
"chat_type": str(request.metadata.get("chat_type", "") or ""),
"mentioned_bot": bool(request.metadata.get("mentioned_bot")),
},
)
async def process_callback_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
_, _, envelope_dict, callback_payload, workspace_id, account_id = (
self._extract_callback_action(payload)
)
resolution, binding, secrets = self._resolve_delivery_binding(
workspace_id=workspace_id,
account_id=account_id,
)
if binding is None or not resolution.ok:
raise ValueError(resolution.reject_reason or "missing_binding")
signing_secret = str(
secrets.get("app_secret", "") or binding.app_secret or ""
).strip()
if not signing_secret:
raise ValueError("missing_callback_signing_secret")
contract = self._callback_contract_for_binding(
binding=binding,
signing_secret=signing_secret,
)
event = payload.get("event") or {}
operator = payload.get("operator") or event.get("operator") or {}
operator_id = operator.get("operator_id") or operator.get("sender_id") or {}
actor_id = str(
operator.get("user_id")
or operator_id.get("user_id")
or callback_payload.get("actor_user_id")
or ""
).strip()
actor_open_id = str(
operator.get("open_id")
or operator_id.get("open_id")
or callback_payload.get("actor_open_id")
or ""
).strip()
command_text = str(callback_payload.get("command", "") or "").strip()
actor, request = self._actor_context_for_callback(
actor_id=actor_id,
actor_open_id=actor_open_id,
channel_id=str(
payload.get("open_chat_id")
or event.get("open_chat_id")
or callback_payload.get("channel_id")
or ""
).strip(),
message_id=str(
payload.get("open_message_id")
or event.get("open_message_id")
or callback_payload.get("message_id")
or ""
).strip(),
workspace_id=workspace_id or binding.workspace_id,
account_id=binding.account_id,
command_text=command_text,
)
decision = contract.evaluate(
platform="feishu",
envelope_dict=envelope_dict,
payload=callback_payload,
actor=actor,
)
if decision.decision_code == CallbackDecisionCode.REJECT_REPLAY.value:
return self._build_callback_response(
ok=True,
text="Action already processed.",
response_type="info",
duplicate=True,
decision_code=decision.decision_code,
)
if not decision.ok and not decision.requires_approval:
raise ValueError(decision.message or decision.decision_code)
request.text = (
self._adapter_force_approval_command(request.text)
if decision.requires_approval
else request.text
)
request_id = str(envelope_dict.get("request_id", "") or "")
contract.acknowledge_request(request_id)
try:
response = await self.router.handle(request)
except Exception:
# IMPORTANT: failures before route completion remain retryable.
# After router.handle returns, the action may already have side effects,
# so completion failures must not release the claim for rerouting.
contract.release_request_retryable(
request_id, reason="feishu_callback_failed_before_commit"
)
raise
contract.complete_request(request_id)
response_text = str(getattr(response, "text", "") or "").strip() or (
"Action processed."
)
response_buttons = getattr(response, "buttons", []) or []
card = None
if response_buttons:
card = self._build_interactive_card(
self._adapter_delivery_target(
channel_id=request.channel_id,
reply_to_message_id=request.thread_id,
workspace_id=request.workspace_id,
account_id=binding.account_id,
),
response_text,
response_buttons,
binding=binding,
secrets=secrets,
)
return self._build_callback_response(
ok=True,
text=response_text,
response_type="success",
card=card,
decision_code=decision.decision_code,
)
@@ -0,0 +1,198 @@
"""Owned Feishu installation, tenant-token, and bot-identity mixin."""
# ruff: noqa: UP006, UP035, UP045 -- preserve frozen facade annotations.
from __future__ import annotations
import time
from typing import Any, Dict, Optional, Tuple
from services.connector_installation_registry import InstallationResolution
from services.safe_io import STANDARD_OUTBOUND_POLICY, SafeIOHTTPError
from .feishu_installation_manager import FeishuBinding
# mypy: disable-error-code="attr-defined,no-any-return"
class FeishuInstallationMixin:
def _resolve_inbound_binding(self, payload: Dict[str, Any]) -> FeishuBinding:
header = payload.get("header") or {}
verification_token = (
str(payload.get("token", "") or "").strip()
or str(header.get("token", "") or "").strip()
or str(((payload.get("event") or {}).get("token")) or "").strip()
)
workspace_id = str(header.get("tenant_key", "") or "").strip()
return self._installation_manager.resolve_inbound_binding(
verification_token=verification_token,
workspace_id=workspace_id,
account_id=self._bound_account_id,
)
def _cache_key_for_binding(self, binding: FeishuBinding) -> str:
return binding.installation_id or binding.account_id
def _cached_bot_open_id(self, binding: FeishuBinding) -> str:
return (
self._bot_open_ids.get(self._cache_key_for_binding(binding), "")
or self._bot_open_id
)
def _resolve_delivery_binding(
self, *, workspace_id: str = "", account_id: str = ""
) -> Tuple[InstallationResolution, Optional[FeishuBinding], Dict[str, str]]:
return self._installation_manager.resolve_binding(
workspace_id=workspace_id,
account_id=account_id or self._bound_account_id,
)
async def _get_tenant_access_token(
self,
*,
binding: Optional[FeishuBinding] = None,
workspace_id: str = "",
account_id: str = "",
) -> str:
resolution, effective_binding, secrets = self._resolve_delivery_binding(
workspace_id=workspace_id,
account_id=account_id or (binding.account_id if binding else ""),
)
if effective_binding is None or not resolution.ok:
raise RuntimeError(
f"feishu_binding_resolution_failed:{resolution.reject_reason or 'missing_binding'}"
)
cache_key = self._cache_key_for_binding(effective_binding)
if self._tenant_access_tokens.get(
cache_key
) and self._tenant_access_token_expires_at.get(cache_key, 0.0) > (
time.time() + 30
):
return self._tenant_access_tokens[cache_key]
app_secret = str(
secrets.get("app_secret", "") or effective_binding.app_secret
).strip()
payload = {
"app_id": effective_binding.app_id,
"app_secret": app_secret,
}
url = f"{self._adapter_resolve_domain_base(effective_binding.domain)}/open-apis/auth/v3/tenant_access_token/internal"
try:
data = self._adapter_safe_request_json(
method="POST",
url=url,
json_body=payload,
headers={"Accept": "application/json"},
content_type="application/json; charset=utf-8",
timeout_sec=15,
allow_hosts=self._adapter_allowed_api_hosts(effective_binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "tenant_access_token"},
)
raise RuntimeError(
f"feishu_token_fetch_failed:{exc.status_code}:{exc.reason}"
) from exc
if data.get("code", 0) != 0:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=str(data.get("msg", "unknown") or "unknown"),
status_code=200,
details={"phase": "tenant_access_token"},
)
raise RuntimeError(
f"feishu_token_fetch_failed:200:{data.get('msg', 'unknown')}"
)
token = str(data.get("tenant_access_token", "") or "").strip()
if not token:
raise RuntimeError("feishu_token_fetch_failed:missing_token")
expire = int(
data.get("expire", self._adapter_token_ttl_sec())
or self._adapter_token_ttl_sec()
)
self._tenant_access_tokens[cache_key] = token
self._tenant_access_token_expires_at[cache_key] = time.time() + max(60, expire)
if resolution.installation is not None:
self._installation_manager.mark_resolution_success(
resolution.installation.installation_id,
effective_binding.workspace_id,
)
return token
async def _fetch_bot_open_id(
self,
*,
binding: Optional[FeishuBinding] = None,
workspace_id: str = "",
account_id: str = "",
allow_degrade: bool = False,
) -> str:
resolution, effective_binding, _ = self._resolve_delivery_binding(
workspace_id=workspace_id,
account_id=account_id or (binding.account_id if binding else ""),
)
if effective_binding is None or not resolution.ok:
return ""
cache_key = self._cache_key_for_binding(effective_binding)
if self._bot_open_ids.get(cache_key):
return self._bot_open_ids[cache_key]
token = await self._get_tenant_access_token(binding=effective_binding)
url = f"{self._adapter_resolve_domain_base(effective_binding.domain)}/open-apis/bot/v3/info"
try:
data = self._adapter_safe_request_json(
method="GET",
url=url,
headers={
"Accept": "application/json",
"Authorization": f"Bearer {token}",
},
timeout_sec=15,
allow_hosts=self._adapter_allowed_api_hosts(effective_binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "bot_info"},
)
if allow_degrade:
return ""
return ""
if data.get("code", 0) != 0:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=str(data.get("msg", "unknown") or "unknown"),
status_code=200,
details={"phase": "bot_info"},
)
return ""
bot_open_id = str(
(((data.get("data") or {}).get("bot") or {}).get("open_id")) or ""
).strip()
if bot_open_id:
self._bot_open_ids[cache_key] = bot_open_id
self._bot_open_id = bot_open_id
return bot_open_id
async def prime_bot_identity(self) -> None:
try:
await self._fetch_bot_open_id(
account_id=self._bound_account_id
or str(self.config.feishu_account_id or "").strip()
or str(self.config.feishu_default_account_id or "").strip(),
workspace_id=str(self.config.feishu_workspace_id or "").strip(),
allow_degrade=True,
)
except Exception as exc:
self._adapter_logger().debug("Feishu bot identity fetch failed: %s", exc)
File diff suppressed because it is too large Load Diff
+54 -6
View File
@@ -28,6 +28,11 @@ from ..contract import CommandRequest, CommandResponse
from ..router import CommandRouter
from ..security_profile import AllowlistPolicy, ReplayGuard
try:
from services.connector_replay_lifecycle import ConnectorReplayLifecycle
except ImportError: # pragma: no cover
ConnectorReplayLifecycle = None # type: ignore
logger = logging.getLogger(__name__)
@@ -99,6 +104,13 @@ class KakaoWebhookServer:
window_sec=self.REPLAY_WINDOW_SEC,
max_entries=self.NONCE_CACHE_SIZE,
)
if ConnectorReplayLifecycle is None: # pragma: no cover
self._replay_lifecycle = None
else:
self._replay_lifecycle = ConnectorReplayLifecycle(
ttl_sec=self.REPLAY_WINDOW_SEC,
max_entries=self.NONCE_CACHE_SIZE,
)
# S32: Allowlist (soft-deny via AllowlistPolicy primitive)
self._user_allowlist = AllowlistPolicy(config.kakao_allowed_users, strict=False)
@@ -165,10 +177,24 @@ class KakaoWebhookServer:
# We use a hash of the body bytes as the "nonce" for deduplication.
# This prevents re-transmitting the exact same request.
content_hash = hashlib.sha256(body_bytes).hexdigest()
if not self._replay_guard.check_and_record(content_hash):
logger.warning(f"Replay rejected for Kakao hash: {content_hash}")
# Return 200 to stop Kakao retries
return _make_response(web, status=200, text="OK")
lifecycle_key = f"kakao:webhook:{content_hash}"
if self._replay_lifecycle is None: # pragma: no cover
if not self._replay_guard.check_and_record(content_hash):
logger.warning(f"Replay rejected for Kakao hash: {content_hash}")
return _make_response(web, status=200, text="OK")
else:
claim = self._replay_lifecycle.claim(
lifecycle_key,
metadata={"platform": "kakao"},
)
if not claim.accepted:
logger.warning(
"Replay rejected for Kakao hash: %s code=%s state=%s",
content_hash,
claim.code,
claim.record.state,
)
return _make_response(web, status=200, text="OK")
# Normalization
# userRequest.user.id is the opaque user ID (botUserKey)
@@ -180,6 +206,10 @@ class KakaoWebhookServer:
if not sender_id:
# Not a valid user request (maybe a ping?)
if self._replay_lifecycle is not None:
self._replay_lifecycle.fail_terminal(
lifecycle_key, reason="invalid_payload_no_user_id"
)
return self._build_error_response("Invalid Payload: No User ID")
# S32: Allowlist
@@ -208,6 +238,17 @@ class KakaoWebhookServer:
try:
resp = await self.router.handle(req)
except Exception as e:
# IMPORTANT: router failures happen before Kakao response delivery and
# must remain retryable; successful router returns are never rerouted.
if self._replay_lifecycle is not None:
self._replay_lifecycle.release_retryable(
lifecycle_key, reason="kakao_router_failed_before_commit"
)
logger.exception(f"Error handling Kakao command: {e}")
return self._build_error_response("Internal Error")
try:
# IMPORTANT:
# Router mocks in unit tests may return non-string `.text` values.
# Normalize defensively to avoid turning a valid routing flow into
@@ -230,13 +271,20 @@ class KakaoWebhookServer:
# Skipping complex media upload for F44 scope unless specifically required.
if resp_text or buttons:
return self._build_response(resp_text, quick_replies=buttons)
response = self._build_response(resp_text, quick_replies=buttons)
else:
# No response content (e.g. valid command but no output intended?)
# Kakao requires *some* response payload or it treats as error.
# We'll return a simple valid JSON to ack.
return self._build_response("Command processed.")
response = self._build_response("Command processed.")
if self._replay_lifecycle is not None:
self._replay_lifecycle.commit_success(lifecycle_key, reason="routed")
return response
except Exception as e:
if self._replay_lifecycle is not None:
self._replay_lifecycle.fail_terminal(
lifecycle_key, reason="kakao_response_build_failed"
)
logger.exception(f"Error handling Kakao command: {e}")
return self._build_error_response("Internal Error")
+2 -1
View File
@@ -13,6 +13,7 @@ from typing import Optional
from ..config import ConnectorConfig
from ..contract import CommandRequest, CommandResponse
from ..media_response import build_connector_media_response
from ..router import CommandRouter
from ..security_profile import AllowlistPolicy, ReplayGuard, verify_hmac_signature
from ..transport_contract import RelayResponseClassifier
@@ -175,7 +176,7 @@ class LINEWebhookServer:
if not path:
return web.Response(status=404, text="Media Not Found or Expired")
return web.FileResponse(path)
return build_connector_media_response(web, path)
async def _process_event(self, event: dict):
"""Convert LINE event to CommandRequest and route."""
@@ -0,0 +1,345 @@
"""Owned Slack response and media-delivery mixin."""
# ruff: noqa: SIM117, UP006, UP035, UP045 -- preserve frozen behavior/signatures.
from typing import Any, Dict, Optional
from ..reply_visibility import decide_reply_visibility
# mypy: disable-error-code="attr-defined,no-any-return"
class SlackDeliveryMixin:
async def _send_interactive_reply(
self,
*,
channel_id: str,
text: str,
buttons: list[dict],
thread_ts: str = "",
delivery_context: Optional[Dict[str, Any]] = None,
) -> None:
"""Send a Slack Block Kit message with bounded button actions."""
try:
import aiohttp as _aiohttp
except ImportError:
self._adapter_logger().warning(
"aiohttp not available; cannot send Slack interactive reply"
)
return
ctx = dict(delivery_context or {})
if not thread_ts:
thread_ts = str(ctx.get("thread_id", "") or "").strip()
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
str(ctx.get("workspace_id", "") or "").strip()
)
if not bot_token:
self._adapter_logger().warning(
"Slack interactive reply dropped: no workspace token available (workspace=%s)",
workspace_id or "legacy",
)
return
elements: list[dict] = []
for idx, button in enumerate(buttons[:5]):
value = str(button.get("value", "") or "").strip()
if not value:
continue
label = str(button.get("label", "") or "OpenClaw").strip()[:75]
action_id = str(
button.get("action_type")
or button.get("action_id")
or f"openclaw.{idx}"
).strip()[:255]
element: Dict[str, Any] = {
"type": "button",
"text": {"type": "plain_text", "text": label or "OpenClaw"},
"value": value[:2000],
"action_id": action_id or f"openclaw.{idx}",
}
style = self._adapter_style_to_slack(str(button.get("style", "") or ""))
if style:
element["style"] = style
elements.append(element)
if not elements:
if text:
await self._send_reply(
channel_id=channel_id,
text=text,
thread_ts=thread_ts,
delivery_context=ctx,
)
return
payload: Dict[str, Any] = {
"channel": channel_id,
"text": text or "OpenClaw",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (text or "OpenClaw")[:3000],
},
},
{"type": "actions", "elements": elements},
],
}
if thread_ts:
payload["thread_ts"] = thread_ts
headers = {
"Authorization": f"Bearer {bot_token}",
"Content-Type": "application/json; charset=utf-8",
}
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
"https://slack.com/api/chat.postMessage",
json=payload,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status != 200:
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=f"http_{resp.status}",
status_code=resp.status,
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
"interactive": True,
},
)
return
data = await resp.json()
if not data.get("ok"):
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=str(data.get("error", "unknown")),
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
"interactive": True,
},
)
elif installation_id:
self._installation_manager.mark_installation_health(
installation_id,
health_code="ok",
reason="chat_post_message_interactive_ok",
details={"workspace_id": workspace_id},
)
except Exception as e:
self._adapter_logger().warning("Slack interactive reply failed: %s", e)
async def _send_reply(
self,
channel_id: str,
text: str,
thread_ts: str = "",
delivery_context: Optional[Dict[str, Any]] = None,
) -> None:
"""Send a message via Slack Web API (chat.postMessage)."""
ctx = dict(delivery_context or {})
if not thread_ts:
thread_ts = str(ctx.get("thread_id", "") or "").strip()
decision = decide_reply_visibility(
delivery_context=ctx,
platform="slack",
channel_kind=self._adapter_channel_kind(channel_id),
in_thread=bool(thread_ts),
text=text,
)
if decision.suppressed:
self._adapter_logger().info(
"Suppressed Slack reply channel=%s reason=%s",
channel_id,
decision.reason,
)
return
try:
import aiohttp as _aiohttp
except ImportError:
self._adapter_logger().warning(
"aiohttp not available; cannot send Slack reply"
)
return
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
str(ctx.get("workspace_id", "") or "").strip()
)
if not bot_token:
self._adapter_logger().warning(
"Slack reply dropped: no workspace token available (workspace=%s)",
workspace_id or "legacy",
)
return
url = "https://slack.com/api/chat.postMessage"
headers = {
"Authorization": f"Bearer {bot_token}",
"Content-Type": "application/json; charset=utf-8",
}
payload: Dict[str, Any] = {
"channel": channel_id,
"text": text,
}
if thread_ts:
payload["thread_ts"] = thread_ts
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
url,
json=payload,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status != 200:
body = await resp.text()
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=f"http_{resp.status}",
status_code=resp.status,
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
},
)
self._adapter_logger().warning(
f"Slack API error: status={resp.status} body={body[:200]}"
)
else:
data = await resp.json()
if not data.get("ok"):
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=str(data.get("error", "unknown")),
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
},
)
self._adapter_logger().warning(
f"Slack API error: {data.get('error', 'unknown')}"
)
elif installation_id:
self._installation_manager.mark_installation_health(
installation_id,
health_code="ok",
reason="chat_post_message_ok",
details={"workspace_id": workspace_id},
)
except Exception as e:
self._adapter_logger().warning(f"Slack reply failed: {e}")
# ------------------------------------------------------------------
# Platform contract: send_message / send_image
# ------------------------------------------------------------------
async def send_message(
self,
channel_id: str,
text: str,
delivery_context: Optional[Dict[str, Any]] = None,
):
"""Platform contract: send text message."""
await self._send_reply(
channel_id=channel_id,
text=text,
delivery_context=delivery_context,
)
async def send_image(
self,
channel_id: str,
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[Dict[str, Any]] = None,
):
"""Platform contract: send image (Slack files.upload)."""
try:
import aiohttp as _aiohttp
except ImportError:
self._adapter_logger().warning(
"aiohttp not available; cannot upload Slack image"
)
return
ctx = dict(delivery_context or {})
thread_ts = str(ctx.get("thread_id", "") or "").strip()
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
str(ctx.get("workspace_id", "") or "").strip()
)
if not bot_token:
self._adapter_logger().warning(
"Slack image dropped: no workspace token available (workspace=%s)",
workspace_id or "legacy",
)
return
url = "https://slack.com/api/files.upload"
headers = {
"Authorization": f"Bearer {bot_token}",
}
data = _aiohttp.FormData()
data.add_field("file", image_data, filename=filename, content_type="image/png")
data.add_field("channels", channel_id)
if caption:
data.add_field("initial_comment", caption)
if thread_ts:
data.add_field("thread_ts", thread_ts)
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
url,
data=data,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status != 200:
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=f"http_{resp.status}",
status_code=resp.status,
details={
"workspace_id": workspace_id,
"path": "files.upload",
},
)
self._adapter_logger().warning(
f"Slack file upload error: status={resp.status}"
)
else:
resp_data = await resp.json()
if not resp_data.get("ok"):
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=str(resp_data.get("error", "unknown")),
details={
"workspace_id": workspace_id,
"path": "files.upload",
},
)
self._adapter_logger().warning(
f"Slack file upload error: {resp_data.get('error')}"
)
elif installation_id:
self._installation_manager.mark_installation_health(
installation_id,
health_code="ok",
reason="files_upload_ok",
details={"workspace_id": workspace_id},
)
except Exception as e:
self._adapter_logger().warning(f"Slack image upload failed: {e}")
@@ -0,0 +1,497 @@
"""Owned Slack signed-ingress and interaction transaction mixin."""
import json
import time
from typing import Any, Dict, Optional
from urllib.parse import parse_qs
from ..contract import CommandRequest
# ruff: noqa: SIM102, UP006, UP035, UP045 -- preserve frozen behavior/signatures.
# mypy: disable-error-code="attr-defined,no-any-return"
class SlackIngressMixin:
async def handle_event(self, request):
"""POST handler for Slack Events API."""
_, web = self._adapter_import_aiohttp_web()
try:
body_bytes = await request.read()
except Exception:
return self._adapter_make_response(web, status=400, text="Bad request")
# -- Step 1: Signature verification (fail-closed) --
timestamp = ""
signature = ""
if hasattr(request, "headers"):
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
signature = request.headers.get("X-Slack-Signature", "")
if not self._adapter_verify_slack_signature(
signing_secret=self.config.slack_signing_secret or "",
timestamp=timestamp,
body=body_bytes,
signature=signature,
):
self._adapter_logger().warning(
"Slack signature verification failed (rejected)"
)
return self._adapter_make_response(
web, status=401, text="Invalid signature"
)
# -- Step 2: Parse payload --
try:
payload = json.loads(body_bytes)
except json.JSONDecodeError:
return self._adapter_make_response(web, status=400, text="Bad JSON")
# -- Step 3: url_verification challenge (Webhook only) --
if payload.get("type") == "url_verification":
challenge = payload.get("challenge", "")
return self._adapter_make_json_response(web, {"challenge": challenge})
# -- Step 4: Process event --
try:
await self.process_event_payload(payload)
except ValueError:
return self._adapter_make_response(web, status=400, text="Bad Request")
return self._adapter_make_response(web, status=200, text="OK")
async def handle_interaction(self, request):
"""POST handler for Slack Block Kit interactivity callbacks."""
_, web = self._adapter_import_aiohttp_web()
try:
body_bytes = await request.read()
except Exception:
return self._adapter_make_response(web, status=400, text="Bad request")
timestamp = ""
signature = ""
if hasattr(request, "headers"):
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
signature = request.headers.get("X-Slack-Signature", "")
if not self._adapter_verify_slack_signature(
signing_secret=self.config.slack_signing_secret or "",
timestamp=timestamp,
body=body_bytes,
signature=signature,
):
self._adapter_logger().warning(
"Slack interaction signature verification failed (rejected)"
)
return self._adapter_make_response(
web, status=401, text="Invalid signature"
)
parsed = parse_qs(body_bytes.decode("utf-8"), keep_blank_values=True)
raw_payload = (parsed.get("payload") or [""])[0]
if not raw_payload:
return self._adapter_make_response(web, status=400, text="Missing payload")
try:
payload = json.loads(raw_payload)
except json.JSONDecodeError:
return self._adapter_make_response(web, status=400, text="Bad payload")
if not isinstance(payload, dict):
return self._adapter_make_response(web, status=400, text="Bad payload")
try:
routed = await self.process_interaction_payload(payload)
except ValueError:
return self._adapter_make_response(web, status=400, text="Bad Request")
except Exception as exc:
safe_text = self._adapter_safe_external_error_text(
"Slack interaction failed", exc
)
self._adapter_logger().warning("Slack interaction failed: %s", safe_text)
return self._adapter_make_response(web, status=500, text=safe_text)
# Slack requires a fast acknowledgement for interactivity requests.
# Keep the external response bounded; detailed action results are routed
# through the existing reply/deferred-response surfaces.
return self._adapter_make_json_response(
web, {"ok": True, "routed": bool(routed)}
)
async def process_event_payload(self, payload: Dict[str, Any]) -> None:
"""
Shared event processing path for both webhook and socket mode transports.
"""
if payload.get("type") != "event_callback":
return
event = payload.get("event", {})
event_id = payload.get("event_id", "")
event_type = event.get("type", "")
workspace_id = self._installation_manager.extract_workspace_id(payload)
if event_type in ("app_uninstalled", "tokens_revoked", "app_rate_limited"):
if workspace_id:
self._handle_lifecycle_event(workspace_id, event_type)
return
# -- Step 5: Replay / dedupe guard --
if not event_id:
self._adapter_logger().warning("Slack event missing event_id (rejected)")
raise ValueError("Missing event_id")
if not self._replay_guard.check_and_record(event_id):
self._adapter_logger().debug(
f"Slack duplicate event_id={event_id} (accepted, no-op)"
)
return
# -- Step 6: Bot-loop prevention --
# Resolve bot user ID from authorizations or cache.
bot_user_id = self._get_bot_user_id(payload, workspace_id)
sender_id = event.get("user", "")
if sender_id and bot_user_id and sender_id == bot_user_id:
return
if event.get("bot_id"):
return
subtype = event.get("subtype", "")
if subtype and subtype not in ("", "file_share"):
return
# -- Step 7: Event normalization --
text = event.get("text", "").strip()
channel_id = event.get("channel", "")
thread_ts = event.get("thread_ts", "")
message_ts = event.get("ts", "")
if event_type not in ("message", "app_mention"):
return
if not text or not sender_id:
return
# S67: Require mention in group channels.
is_dm = channel_id.startswith("D")
mentioned_bot = event_type == "app_mention" or (
bool(bot_user_id) and f"<@{bot_user_id}>" in text
)
if not is_dm and self.config.slack_require_mention:
if event_type != "app_mention":
if bot_user_id and f"<@{bot_user_id}>" not in text:
return
if bot_user_id:
text = text.replace(f"<@{bot_user_id}>", "").strip()
# -- Step 8: Allowlist checks (S67) --
if self._user_allowlist.entries:
user_result = self._user_allowlist.evaluate(sender_id)
if user_result.decision == "deny":
self._adapter_logger().warning(
f"Slack user {sender_id} denied by allowlist"
)
return
if self._channel_allowlist.entries and channel_id:
chan_result = self._channel_allowlist.evaluate(channel_id)
if chan_result.decision == "deny":
self._adapter_logger().warning(
f"Slack channel {channel_id} denied by allowlist"
)
return
# -- Step 9: Build CommandRequest and route --
req = CommandRequest(
platform="slack",
sender_id=sender_id,
channel_id=channel_id,
username=sender_id,
message_id=event_id,
text=text,
timestamp=float(message_ts) if message_ts else time.time(),
workspace_id=workspace_id,
thread_id=thread_ts
or (message_ts if self.config.slack_reply_in_thread else ""),
)
try:
resp = await self.router.handle(req)
resp_text = getattr(resp, "text", "")
if not isinstance(resp_text, str):
resp_text = str(resp_text) if resp_text is not None else ""
buttons = getattr(resp, "buttons", []) or []
if resp_text or buttons:
if buttons:
await self._send_interactive_reply(
channel_id=channel_id,
text=resp_text or "OpenClaw",
buttons=buttons,
thread_ts=req.thread_id,
delivery_context={
"workspace_id": workspace_id,
"thread_id": req.thread_id,
"channel_kind": self._adapter_channel_kind(channel_id),
"mentioned": mentioned_bot,
},
)
else:
await self._send_reply(
channel_id=channel_id,
text=resp_text,
thread_ts=req.thread_id,
delivery_context={
"workspace_id": workspace_id,
"thread_id": req.thread_id,
"channel_kind": self._adapter_channel_kind(channel_id),
"mentioned": mentioned_bot,
},
)
except Exception as e:
self._adapter_logger().error(
"Slack event handling failed (error_type=%s)", type(e).__name__
)
async def process_interaction_payload(self, payload: Dict[str, Any]) -> bool:
interaction_type = str(payload.get("type", "") or "").strip()
if interaction_type not in self._adapter_interaction_types():
return False
request = self._build_interaction_request(payload)
if request is None:
return False
replay_key = self._interaction_replay_key(payload, request)
if self._interaction_lifecycle is None: # pragma: no cover
if not self._replay_guard.check_and_record(replay_key):
self._adapter_logger().debug(
"Slack duplicate interaction %s (accepted, no-op)", replay_key
)
return False
claim = None
else:
claim = self._interaction_lifecycle.claim(
replay_key,
metadata={
"platform": "slack",
"workspace_id": request.workspace_id,
"interaction_type": str(payload.get("type", "") or ""),
},
)
if claim is not None and not claim.accepted:
self._adapter_logger().debug(
"Slack duplicate interaction %s state=%s code=%s (accepted, no-op)",
replay_key,
claim.record.state,
claim.code,
)
return False
# IMPORTANT: interactive run-like payloads must be routed through the same
# approval semantics as text commands. Untrusted users get approval forced
# before CommandRouter sees the request, avoiding a parallel bypass path.
if request.text.startswith("/run") and not (
self.router._is_admin(request) or self.router._is_trusted(request)
):
request.text = self._adapter_force_approval_command(request.text)
try:
response = await self.router.handle(request)
except Exception:
# IMPORTANT: only failures before router completion are retryable.
# Once router.handle returns, duplicate user actions must not reroute.
if self._interaction_lifecycle is not None:
self._interaction_lifecycle.release_retryable(
replay_key, reason="slack_interaction_failed_before_commit"
)
raise
if self._interaction_lifecycle is not None:
self._interaction_lifecycle.commit_success(replay_key, reason="routed")
response_text = str(getattr(response, "text", "") or "").strip()
response_buttons = getattr(response, "buttons", []) or []
if response_text or response_buttons:
if response_buttons:
await self._send_interactive_reply(
channel_id=request.channel_id,
text=response_text or "Action processed.",
buttons=response_buttons,
thread_ts=request.thread_id,
delivery_context={
"workspace_id": request.workspace_id,
"thread_id": request.thread_id,
},
)
elif response_text:
await self._send_reply(
channel_id=request.channel_id,
text=response_text,
thread_ts=request.thread_id,
delivery_context={
"workspace_id": request.workspace_id,
"thread_id": request.thread_id,
},
)
return True
def _build_interaction_request(
self, payload: Dict[str, Any]
) -> Optional[CommandRequest]:
interaction_type = str(payload.get("type", "") or "").strip()
command_text = self._extract_interaction_command(payload)
if not command_text:
return None
team = payload.get("team") or {}
user = payload.get("user") or {}
container = payload.get("container") or {}
channel = payload.get("channel") or {}
view = payload.get("view") or {}
message = payload.get("message") or {}
action = self._first_action(payload)
workspace_id = self._adapter_first_non_empty(
team.get("id"),
payload.get("team_id"),
(
payload.get("enterprise", {}).get("id")
if isinstance(payload.get("enterprise"), dict)
else ""
),
)
sender_id = self._adapter_first_non_empty(
user.get("id"), payload.get("user_id")
)
channel_id = self._adapter_first_non_empty(
channel.get("id"),
container.get("channel_id"),
payload.get("channel_id"),
)
message_id = self._adapter_first_non_empty(
view.get("id"),
action.get("action_ts"),
container.get("message_ts"),
payload.get("trigger_id"),
f"slack-interaction-{int(time.time())}",
)
thread_id = self._adapter_first_non_empty(
container.get("thread_ts"),
message.get("thread_ts") if isinstance(message, dict) else "",
container.get("message_ts"),
)
if not thread_id and self.config.slack_reply_in_thread:
thread_id = self._adapter_first_non_empty(
container.get("message_ts"), message.get("ts")
)
return CommandRequest(
platform="slack",
sender_id=sender_id,
channel_id=channel_id or sender_id,
username=self._adapter_first_non_empty(
user.get("username"), user.get("name"), sender_id
),
message_id=message_id,
text=command_text,
timestamp=time.time(),
workspace_id=workspace_id,
thread_id=thread_id,
metadata={
"interactive_callback": True,
"interaction_type": interaction_type,
"action_id": self._adapter_first_non_empty(
action.get("action_id"), view.get("callback_id")
),
"response_url": str(payload.get("response_url", "") or ""),
},
)
def _extract_interaction_command(self, payload: Dict[str, Any]) -> str:
interaction_type = str(payload.get("type", "") or "").strip()
if interaction_type == "block_actions":
action = self._first_action(payload)
selected = action.get("selected_option") or {}
value = self._adapter_first_non_empty(
action.get("value"),
selected.get("value") if isinstance(selected, dict) else "",
action.get("action_id"),
)
parsed = self._adapter_json_loads_safe(value)
return self._adapter_first_non_empty(
parsed.get("command"), parsed.get("value"), value
)
if interaction_type == "view_submission":
view = payload.get("view") or {}
private_meta = self._adapter_first_non_empty(view.get("private_metadata"))
parsed = self._adapter_json_loads_safe(private_meta)
if parsed:
return self._adapter_first_non_empty(
parsed.get("command"), parsed.get("value")
)
if private_meta:
return private_meta
state = (view.get("state") or {}).get("values") or {}
return self._extract_command_from_view_state(state)
if interaction_type == "workflow_step_execute":
workflow_step = payload.get("workflow_step") or {}
inputs = workflow_step.get("inputs") or {}
command = inputs.get("command") or {}
if isinstance(command, dict):
return self._adapter_first_non_empty(command.get("value"))
return self._adapter_first_non_empty(workflow_step.get("callback_id"))
return ""
def _extract_command_from_view_state(self, state: Dict[str, Any]) -> str:
if not isinstance(state, dict):
return ""
for block_value in state.values():
if not isinstance(block_value, dict):
continue
for action_value in block_value.values():
if not isinstance(action_value, dict):
continue
candidate = self._adapter_first_non_empty(
action_value.get("value"),
(
(action_value.get("selected_option") or {}).get("value")
if isinstance(action_value.get("selected_option"), dict)
else ""
),
)
parsed = self._adapter_json_loads_safe(candidate)
command = self._adapter_first_non_empty(
parsed.get("command"), parsed.get("value"), candidate
)
if command:
return command
return ""
def _first_action(self, payload: Dict[str, Any]) -> Dict[str, Any]:
actions = payload.get("actions") or []
if isinstance(actions, list) and actions and isinstance(actions[0], dict):
return actions[0]
return {}
def _interaction_replay_key(
self, payload: Dict[str, Any], request: CommandRequest
) -> str:
action = self._first_action(payload)
key_parts = [
"interaction",
str(payload.get("type", "") or ""),
request.workspace_id,
request.sender_id,
request.channel_id,
request.message_id,
str(payload.get("trigger_id", "") or ""),
str(action.get("action_id", "") or ""),
str(action.get("action_ts", "") or ""),
request.text,
]
return ":".join(key_parts)
# ------------------------------------------------------------------
# Slack Web API reply
# ------------------------------------------------------------------
@@ -0,0 +1,171 @@
"""Owned Slack installation, OAuth, and workspace-identity mixin."""
# ruff: noqa: UP006, UP035, UP045 -- preserve frozen facade annotations.
from typing import Any, Dict, Optional, Tuple
# mypy: disable-error-code="attr-defined,has-type,no-any-return"
class SlackInstallationMixin:
async def handle_oauth_install(self, request):
_, web = self._adapter_import_aiohttp_web()
if not self._installation_manager.can_handle_oauth():
return self._adapter_make_response(
web, status=503, text="Slack OAuth not configured"
)
state = self._installation_manager.issue_install_state()
return self._adapter_make_redirect_response(
web, self._installation_manager.build_install_url(state)
)
async def handle_oauth_callback(self, request):
_, web = self._adapter_import_aiohttp_web()
if not self._installation_manager.can_handle_oauth():
return self._adapter_make_response(
web, status=503, text="Slack OAuth not configured"
)
query = getattr(request, "query", {}) or {}
if query.get("error"):
return self._adapter_make_response(
web,
status=400,
text=f"Slack OAuth rejected: {query.get('error')}",
)
state = str(query.get("state", "") or "").strip()
code = str(query.get("code", "") or "").strip()
if not state or not code:
return self._adapter_make_response(
web, status=400, text="Missing OAuth callback fields"
)
if not self._installation_manager.consume_install_state(state):
return self._adapter_make_response(
web, status=400, text="Invalid or replayed OAuth state"
)
try:
payload = await self._installation_manager.exchange_code(code)
installation = self._installation_manager.upsert_from_oauth_payload(payload)
return self._adapter_make_response(
web,
status=200,
text=(
"Slack installation complete for "
f"{installation.workspace_id} ({installation.installation_id})."
),
)
except Exception as exc:
safe_text = self._adapter_safe_external_error_text(
"Slack OAuth processing failed", exc
)
self._adapter_logger().warning("Slack OAuth callback failed: %s", safe_text)
return self._adapter_make_response(
web,
status=502,
text=safe_text,
)
def _get_bot_user_id(self, payload: Dict[str, Any], workspace_id: str) -> str:
candidate = ""
if workspace_id and workspace_id in self._bot_user_ids:
return self._bot_user_ids[workspace_id]
if self._bot_user_id:
return self._bot_user_id
authorizations = payload.get("authorizations", [])
if authorizations and isinstance(authorizations, list):
candidate = str((authorizations[0] or {}).get("user_id", "") or "").strip()
if candidate:
self._bot_user_id = candidate
if workspace_id:
self._bot_user_ids[workspace_id] = candidate
return candidate
if workspace_id:
workspace_resolution, _ = (
self._installation_manager.resolve_workspace_tokens(workspace_id)
)
candidate = self._installation_manager.bot_user_id_for_installation(
workspace_resolution.installation if workspace_resolution.ok else None
)
if candidate:
self._bot_user_ids[workspace_id] = candidate
if self._bot_user_id is None:
self._bot_user_id = candidate
return candidate
def _resolve_workspace_credentials(
self, workspace_id: str
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
workspace_id = str(workspace_id or "").strip()
if workspace_id:
resolution, tokens = self._installation_manager.resolve_workspace_tokens(
workspace_id
)
if resolution.ok and resolution.installation is not None:
bot_token = tokens.get("bot_token")
if bot_token:
self._installation_manager.mark_resolution_success(
resolution.installation.installation_id, workspace_id
)
return (
resolution.installation.installation_id,
bot_token,
workspace_id,
)
self._adapter_logger().warning(
"Slack workspace %s resolved without bot token secret", workspace_id
)
return (
resolution.installation.installation_id,
None,
workspace_id,
)
if (
not self._installation_manager.oauth_enabled
and self.config.slack_bot_token
):
return (None, self.config.slack_bot_token, workspace_id)
self._adapter_logger().warning(
"Slack workspace resolution failed for %s: %s (%s)",
workspace_id,
resolution.reject_reason,
resolution.health_code,
)
return (None, None, workspace_id)
if self.config.slack_bot_token:
return (None, self.config.slack_bot_token, "")
return (None, None, workspace_id)
def _handle_lifecycle_event(self, workspace_id: str, event_type: str) -> None:
installation_id = self._installation_manager.installation_id_for_workspace(
workspace_id
)
try:
if event_type == "app_uninstalled":
self._installation_manager.mark_installation_health(
installation_id,
health_code="revoked",
reason="slack_app_uninstalled",
details={"workspace_id": workspace_id},
)
self._installation_manager.uninstall_installation(
installation_id, reason="slack_app_uninstalled"
)
elif event_type == "tokens_revoked":
self._installation_manager.mark_installation_health(
installation_id,
health_code="invalid_token",
reason="slack_tokens_revoked",
details={"workspace_id": workspace_id},
)
elif event_type == "app_rate_limited":
self._installation_manager.mark_installation_health(
installation_id,
health_code="degraded",
reason="slack_app_rate_limited",
details={"workspace_id": workspace_id},
)
except ValueError:
self._adapter_logger().warning(
"Slack lifecycle event for unbound workspace %s (%s)",
workspace_id,
event_type,
)
+123 -502
View File
@@ -33,16 +33,33 @@ import hmac
import json
import logging
import time
from typing import Any, Dict, Optional, Tuple
from typing import Any, Dict, Optional
from ..config import ConnectorConfig
from ..contract import CommandRequest, CommandResponse
from ..router import CommandRouter
from ..security_profile import AllowlistPolicy, ReplayGuard
from .slack_delivery_handlers import SlackDeliveryMixin
from .slack_ingress_handlers import SlackIngressMixin
from .slack_installation_handlers import SlackInstallationMixin
from .slack_installation_manager import SlackInstallationManager
try:
from services.connector_replay_lifecycle import ConnectorReplayLifecycle
except ImportError: # pragma: no cover
ConnectorReplayLifecycle = None # type: ignore
logger = logging.getLogger(__name__)
_SLACK_INTERACTION_TYPES = frozenset(
{"block_actions", "view_submission", "workflow_step_execute"}
)
def _slack_channel_kind(channel_id: str) -> str:
if str(channel_id or "").startswith("D"):
return "dm"
return "group"
# -- aiohttp compat layer (same pattern as kakao/whatsapp/wechat) -----------
@@ -103,6 +120,40 @@ def _safe_external_error_text(default: str, _exc: Exception) -> str:
return default
def _json_loads_safe(raw: Any) -> Dict[str, Any]:
if isinstance(raw, dict):
return raw
if not isinstance(raw, str):
return {}
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except (TypeError, ValueError):
return {}
def _first_non_empty(*values: Any) -> str:
for value in values:
text = str(value or "").strip()
if text:
return text
return ""
def _force_approval_command(command_text: str) -> str:
normalized = str(command_text or "").strip()
if normalized.startswith("/run") and "--approval" not in normalized:
return f"{normalized} --approval"
return normalized
def _style_to_slack(style: str) -> str:
normalized = str(style or "").strip().lower()
if normalized in {"primary", "danger"}:
return normalized
return "primary" if normalized in {"approve", "success"} else ""
# -- Slack signature verification -------------------------------------------
# Maximum acceptable clock skew for timestamp validation (5 minutes).
@@ -152,7 +203,11 @@ def verify_slack_signature(
# -- Slack adapter ----------------------------------------------------------
class SlackWebhookServer:
class SlackWebhookServer(
SlackInstallationMixin,
SlackIngressMixin,
SlackDeliveryMixin,
):
"""
F56 -- Slack Events API adapter.
@@ -179,6 +234,13 @@ class SlackWebhookServer:
window_sec=self.REPLAY_WINDOW_SEC,
max_entries=self.NONCE_CACHE_SIZE,
)
if ConnectorReplayLifecycle is None: # pragma: no cover
self._interaction_lifecycle = None
else:
self._interaction_lifecycle = ConnectorReplayLifecycle(
ttl_sec=self.REPLAY_WINDOW_SEC,
max_entries=self.NONCE_CACHE_SIZE,
)
# S67: Allowlists (fail-closed when configured)
self._user_allowlist = AllowlistPolicy(config.slack_allowed_users, strict=False)
@@ -188,9 +250,63 @@ class SlackWebhookServer:
self._installation_manager = SlackInstallationManager(config)
# Bot user ID (resolved on first event or set from config)
self._bot_user_id: Optional[str] = None
self._bot_user_id: Optional[str] = None # type: ignore[assignment]
self._bot_user_ids: Dict[str, str] = {}
# IMPORTANT: resolve facade globals at call time; integration suites and
# minimal-host shims patch these security/protocol seams directly.
@staticmethod
def _adapter_import_aiohttp_web():
return _import_aiohttp_web()
@staticmethod
def _adapter_make_response(*args, **kwargs):
return _make_response(*args, **kwargs)
@staticmethod
def _adapter_make_json_response(*args, **kwargs):
return _make_json_response(*args, **kwargs)
@staticmethod
def _adapter_make_redirect_response(*args, **kwargs):
return _make_redirect_response(*args, **kwargs)
@staticmethod
def _adapter_safe_external_error_text(*args, **kwargs):
return _safe_external_error_text(*args, **kwargs)
@staticmethod
def _adapter_verify_slack_signature(*args, **kwargs):
return verify_slack_signature(*args, **kwargs)
@staticmethod
def _adapter_json_loads_safe(*args, **kwargs):
return _json_loads_safe(*args, **kwargs)
@staticmethod
def _adapter_first_non_empty(*args, **kwargs):
return _first_non_empty(*args, **kwargs)
@staticmethod
def _adapter_force_approval_command(*args, **kwargs):
return _force_approval_command(*args, **kwargs)
@staticmethod
def _adapter_style_to_slack(*args, **kwargs):
return _style_to_slack(*args, **kwargs)
@staticmethod
def _adapter_channel_kind(*args, **kwargs):
return _slack_channel_kind(*args, **kwargs)
@staticmethod
def _adapter_interaction_types():
return _SLACK_INTERACTION_TYPES
@staticmethod
def _adapter_logger():
return logger
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
@@ -225,6 +341,9 @@ class SlackWebhookServer:
self.app = web.Application()
self.app.router.add_post(self.config.slack_webhook_path, self.handle_event)
self.app.router.add_post(
self.config.slack_interactions_path, self.handle_interaction
)
if self._installation_manager.can_handle_oauth():
self.app.router.add_get(
self.config.slack_oauth_install_path, self.handle_oauth_install
@@ -249,501 +368,3 @@ class SlackWebhookServer:
# ------------------------------------------------------------------
# Event handler
# ------------------------------------------------------------------
async def handle_oauth_install(self, request):
_, web = _import_aiohttp_web()
if not self._installation_manager.can_handle_oauth():
return _make_response(web, status=503, text="Slack OAuth not configured")
state = self._installation_manager.issue_install_state()
return _make_redirect_response(
web, self._installation_manager.build_install_url(state)
)
async def handle_oauth_callback(self, request):
_, web = _import_aiohttp_web()
if not self._installation_manager.can_handle_oauth():
return _make_response(web, status=503, text="Slack OAuth not configured")
query = getattr(request, "query", {}) or {}
if query.get("error"):
return _make_response(
web,
status=400,
text=f"Slack OAuth rejected: {query.get('error')}",
)
state = str(query.get("state", "") or "").strip()
code = str(query.get("code", "") or "").strip()
if not state or not code:
return _make_response(web, status=400, text="Missing OAuth callback fields")
if not self._installation_manager.consume_install_state(state):
return _make_response(
web, status=400, text="Invalid or replayed OAuth state"
)
try:
payload = await self._installation_manager.exchange_code(code)
installation = self._installation_manager.upsert_from_oauth_payload(payload)
return _make_response(
web,
status=200,
text=(
"Slack installation complete for "
f"{installation.workspace_id} ({installation.installation_id})."
),
)
except Exception as exc:
safe_text = _safe_external_error_text("Slack OAuth processing failed", exc)
logger.warning("Slack OAuth callback failed: %s", safe_text)
return _make_response(
web,
status=502,
text=safe_text,
)
def _get_bot_user_id(self, payload: Dict[str, Any], workspace_id: str) -> str:
candidate = ""
if workspace_id and workspace_id in self._bot_user_ids:
return self._bot_user_ids[workspace_id]
if self._bot_user_id:
return self._bot_user_id
authorizations = payload.get("authorizations", [])
if authorizations and isinstance(authorizations, list):
candidate = str((authorizations[0] or {}).get("user_id", "") or "").strip()
if candidate:
self._bot_user_id = candidate
if workspace_id:
self._bot_user_ids[workspace_id] = candidate
return candidate
if workspace_id:
workspace_resolution, _ = (
self._installation_manager.resolve_workspace_tokens(workspace_id)
)
candidate = self._installation_manager.bot_user_id_for_installation(
workspace_resolution.installation if workspace_resolution.ok else None
)
if candidate:
self._bot_user_ids[workspace_id] = candidate
if self._bot_user_id is None:
self._bot_user_id = candidate
return candidate
def _resolve_workspace_credentials(
self, workspace_id: str
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
workspace_id = str(workspace_id or "").strip()
if workspace_id:
resolution, tokens = self._installation_manager.resolve_workspace_tokens(
workspace_id
)
if resolution.ok and resolution.installation is not None:
bot_token = tokens.get("bot_token")
if bot_token:
self._installation_manager.mark_resolution_success(
resolution.installation.installation_id, workspace_id
)
return (
resolution.installation.installation_id,
bot_token,
workspace_id,
)
logger.warning(
"Slack workspace %s resolved without bot token secret", workspace_id
)
return (
resolution.installation.installation_id,
None,
workspace_id,
)
if (
not self._installation_manager.oauth_enabled
and self.config.slack_bot_token
):
return (None, self.config.slack_bot_token, workspace_id)
logger.warning(
"Slack workspace resolution failed for %s: %s (%s)",
workspace_id,
resolution.reject_reason,
resolution.health_code,
)
return (None, None, workspace_id)
if self.config.slack_bot_token:
return (None, self.config.slack_bot_token, "")
return (None, None, workspace_id)
def _handle_lifecycle_event(self, workspace_id: str, event_type: str) -> None:
installation_id = self._installation_manager.installation_id_for_workspace(
workspace_id
)
try:
if event_type == "app_uninstalled":
self._installation_manager.mark_installation_health(
installation_id,
health_code="revoked",
reason="slack_app_uninstalled",
details={"workspace_id": workspace_id},
)
self._installation_manager.uninstall_installation(
installation_id, reason="slack_app_uninstalled"
)
elif event_type == "tokens_revoked":
self._installation_manager.mark_installation_health(
installation_id,
health_code="invalid_token",
reason="slack_tokens_revoked",
details={"workspace_id": workspace_id},
)
elif event_type == "app_rate_limited":
self._installation_manager.mark_installation_health(
installation_id,
health_code="degraded",
reason="slack_app_rate_limited",
details={"workspace_id": workspace_id},
)
except ValueError:
logger.warning(
"Slack lifecycle event for unbound workspace %s (%s)",
workspace_id,
event_type,
)
async def handle_event(self, request):
"""POST handler for Slack Events API."""
_, web = _import_aiohttp_web()
try:
body_bytes = await request.read()
except Exception:
return _make_response(web, status=400, text="Bad request")
# -- Step 1: Signature verification (fail-closed) --
timestamp = ""
signature = ""
if hasattr(request, "headers"):
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
signature = request.headers.get("X-Slack-Signature", "")
if not verify_slack_signature(
signing_secret=self.config.slack_signing_secret or "",
timestamp=timestamp,
body=body_bytes,
signature=signature,
):
logger.warning("Slack signature verification failed (rejected)")
return _make_response(web, status=401, text="Invalid signature")
# -- Step 2: Parse payload --
try:
payload = json.loads(body_bytes)
except json.JSONDecodeError:
return _make_response(web, status=400, text="Bad JSON")
# -- Step 3: url_verification challenge (Webhook only) --
if payload.get("type") == "url_verification":
challenge = payload.get("challenge", "")
return _make_json_response(web, {"challenge": challenge})
# -- Step 4: Process event --
try:
await self.process_event_payload(payload)
except ValueError:
return _make_response(web, status=400, text="Bad Request")
return _make_response(web, status=200, text="OK")
async def process_event_payload(self, payload: Dict[str, Any]) -> None:
"""
Shared event processing path for both webhook and socket mode transports.
"""
if payload.get("type") != "event_callback":
return
event = payload.get("event", {})
event_id = payload.get("event_id", "")
event_type = event.get("type", "")
workspace_id = self._installation_manager.extract_workspace_id(payload)
if event_type in ("app_uninstalled", "tokens_revoked", "app_rate_limited"):
if workspace_id:
self._handle_lifecycle_event(workspace_id, event_type)
return
# -- Step 5: Replay / dedupe guard --
if not event_id:
logger.warning("Slack event missing event_id (rejected)")
raise ValueError("Missing event_id")
if not self._replay_guard.check_and_record(event_id):
logger.debug(f"Slack duplicate event_id={event_id} (accepted, no-op)")
return
# -- Step 6: Bot-loop prevention --
# Resolve bot user ID from authorizations or cache.
bot_user_id = self._get_bot_user_id(payload, workspace_id)
sender_id = event.get("user", "")
if sender_id and bot_user_id and sender_id == bot_user_id:
return
if event.get("bot_id"):
return
subtype = event.get("subtype", "")
if subtype and subtype not in ("", "file_share"):
return
# -- Step 7: Event normalization --
text = event.get("text", "").strip()
channel_id = event.get("channel", "")
thread_ts = event.get("thread_ts", "")
message_ts = event.get("ts", "")
if event_type not in ("message", "app_mention"):
return
if not text or not sender_id:
return
# S67: Require mention in group channels.
is_dm = channel_id.startswith("D")
if not is_dm and self.config.slack_require_mention:
if event_type != "app_mention":
if bot_user_id and f"<@{bot_user_id}>" not in text:
return
if bot_user_id:
text = text.replace(f"<@{bot_user_id}>", "").strip()
# -- Step 8: Allowlist checks (S67) --
if self._user_allowlist.entries:
user_result = self._user_allowlist.evaluate(sender_id)
if user_result.decision == "deny":
logger.warning(f"Slack user {sender_id} denied by allowlist")
return
if self._channel_allowlist.entries and channel_id:
chan_result = self._channel_allowlist.evaluate(channel_id)
if chan_result.decision == "deny":
logger.warning(f"Slack channel {channel_id} denied by allowlist")
return
# -- Step 9: Build CommandRequest and route --
req = CommandRequest(
platform="slack",
sender_id=sender_id,
channel_id=channel_id,
username=sender_id,
message_id=event_id,
text=text,
timestamp=float(message_ts) if message_ts else time.time(),
workspace_id=workspace_id,
thread_id=thread_ts
or (message_ts if self.config.slack_reply_in_thread else ""),
)
try:
resp = await self.router.handle(req)
resp_text = getattr(resp, "text", "")
if not isinstance(resp_text, str):
resp_text = str(resp_text) if resp_text is not None else ""
if resp_text:
await self._send_reply(
channel_id=channel_id,
text=resp_text,
thread_ts=req.thread_id,
delivery_context={
"workspace_id": workspace_id,
"thread_id": req.thread_id,
},
)
except Exception as e:
logger.exception(f"Error handling Slack event: {e}")
# ------------------------------------------------------------------
# Slack Web API reply
# ------------------------------------------------------------------
async def _send_reply(
self,
channel_id: str,
text: str,
thread_ts: str = "",
delivery_context: Optional[Dict[str, Any]] = None,
) -> None:
"""Send a message via Slack Web API (chat.postMessage)."""
try:
import aiohttp as _aiohttp
except ImportError:
logger.warning("aiohttp not available; cannot send Slack reply")
return
ctx = dict(delivery_context or {})
if not thread_ts:
thread_ts = str(ctx.get("thread_id", "") or "").strip()
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
str(ctx.get("workspace_id", "") or "").strip()
)
if not bot_token:
logger.warning(
"Slack reply dropped: no workspace token available (workspace=%s)",
workspace_id or "legacy",
)
return
url = "https://slack.com/api/chat.postMessage"
headers = {
"Authorization": f"Bearer {bot_token}",
"Content-Type": "application/json; charset=utf-8",
}
payload: Dict[str, Any] = {
"channel": channel_id,
"text": text,
}
if thread_ts:
payload["thread_ts"] = thread_ts
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
url,
json=payload,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status != 200:
body = await resp.text()
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=f"http_{resp.status}",
status_code=resp.status,
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
},
)
logger.warning(
f"Slack API error: status={resp.status} body={body[:200]}"
)
else:
data = await resp.json()
if not data.get("ok"):
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=str(data.get("error", "unknown")),
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
},
)
logger.warning(
f"Slack API error: {data.get('error', 'unknown')}"
)
elif installation_id:
self._installation_manager.mark_installation_health(
installation_id,
health_code="ok",
reason="chat_post_message_ok",
details={"workspace_id": workspace_id},
)
except Exception as e:
logger.warning(f"Slack reply failed: {e}")
# ------------------------------------------------------------------
# Platform contract: send_message / send_image
# ------------------------------------------------------------------
async def send_message(
self,
channel_id: str,
text: str,
delivery_context: Optional[Dict[str, Any]] = None,
):
"""Platform contract: send text message."""
await self._send_reply(
channel_id=channel_id,
text=text,
delivery_context=delivery_context,
)
async def send_image(
self,
channel_id: str,
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[Dict[str, Any]] = None,
):
"""Platform contract: send image (Slack files.upload)."""
try:
import aiohttp as _aiohttp
except ImportError:
logger.warning("aiohttp not available; cannot upload Slack image")
return
ctx = dict(delivery_context or {})
thread_ts = str(ctx.get("thread_id", "") or "").strip()
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
str(ctx.get("workspace_id", "") or "").strip()
)
if not bot_token:
logger.warning(
"Slack image dropped: no workspace token available (workspace=%s)",
workspace_id or "legacy",
)
return
url = "https://slack.com/api/files.upload"
headers = {
"Authorization": f"Bearer {bot_token}",
}
data = _aiohttp.FormData()
data.add_field("file", image_data, filename=filename, content_type="image/png")
data.add_field("channels", channel_id)
if caption:
data.add_field("initial_comment", caption)
if thread_ts:
data.add_field("thread_ts", thread_ts)
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
url,
data=data,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status != 200:
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=f"http_{resp.status}",
status_code=resp.status,
details={
"workspace_id": workspace_id,
"path": "files.upload",
},
)
logger.warning(f"Slack file upload error: status={resp.status}")
else:
resp_data = await resp.json()
if not resp_data.get("ok"):
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=str(resp_data.get("error", "unknown")),
details={
"workspace_id": workspace_id,
"path": "files.upload",
},
)
logger.warning(
f"Slack file upload error: {resp_data.get('error')}"
)
elif installation_id:
self._installation_manager.mark_installation_health(
installation_id,
health_code="ok",
reason="files_upload_ok",
details={"workspace_id": workspace_id},
)
except Exception as e:
logger.warning(f"Slack image upload failed: {e}")
+168 -12
View File
@@ -5,15 +5,20 @@ Long-polling implementation for Telegram Bot API.
import asyncio
import logging
import re
import time
from typing import Optional
from services.connector_replay_lifecycle import ConnectorReplayLifecycle
from ..config import ConnectorConfig
from ..contract import CommandRequest, CommandResponse
from ..reply_visibility import decide_reply_visibility
from ..router import CommandRouter
from ..state import ConnectorState
logger = logging.getLogger(__name__)
_THREAD_ID_RE = re.compile(r"^\d{1,10}$")
def _import_aiohttp():
@@ -24,6 +29,30 @@ def _import_aiohttp():
return aiohttp
def _normalize_message_thread_id(value) -> Optional[int]:
if value is None or isinstance(value, bool):
return None
text = str(value).strip()
if not _THREAD_ID_RE.fullmatch(text):
return None
try:
thread_id = int(text)
except ValueError:
return None
if thread_id <= 0:
return None
return thread_id
def _telegram_channel_kind(chat_id) -> str:
text = str(chat_id or "").strip()
if text.startswith("-100"):
return "supergroup"
if text.startswith("-"):
return "group"
return "dm"
class TelegramPolling:
def __init__(self, config: ConnectorConfig, router: CommandRouter):
self.config = config
@@ -35,6 +64,10 @@ class TelegramPolling:
# Remediation: Load offset from persistent state
self.offset = self.state_store.get_offset("telegram")
self.session = None
self._update_lifecycle = ConnectorReplayLifecycle(
ttl_sec=300,
max_entries=5000,
)
async def start(self):
aiohttp = _import_aiohttp()
@@ -91,15 +124,42 @@ class TelegramPolling:
if self.config.debug and not updates:
logger.debug("Telegram poll OK (no updates). offset=%s", self.offset)
for update in updates:
next_offset = update["update_id"] + 1
if next_offset > self.offset:
self.offset = next_offset
# Remediation: Persist offset
self.state_store.set_offset("telegram", self.offset)
update_id = update["update_id"]
lifecycle_key = f"telegram:update:{update_id}"
claim = self._update_lifecycle.claim(
lifecycle_key,
metadata={"platform": "telegram"},
)
if not claim.accepted:
logger.debug(
"Telegram duplicate update_id=%s code=%s state=%s",
update_id,
claim.code,
claim.record.state,
)
if claim.code == "duplicate_after_success":
self._commit_offset(update_id + 1)
continue
await self._process_update(update)
processed = await self._process_update(update)
if processed:
self._update_lifecycle.commit_success(
lifecycle_key, reason="processed"
)
self._commit_offset(update_id + 1)
else:
# IMPORTANT: keep failed-before-delivery updates retryable.
# Advancing the Telegram offset here would drop the update.
self._update_lifecycle.release_retryable(
lifecycle_key, reason="telegram_update_failed_before_commit"
)
async def _process_update(self, update: dict):
def _commit_offset(self, next_offset: int) -> None:
if next_offset > self.offset:
self.offset = next_offset
self.state_store.set_offset("telegram", self.offset)
async def _process_update(self, update: dict) -> bool:
# Telegram update shapes vary by chat type and sender mode.
# - Normal groups/DMs: `message`
# - Edited messages: `edited_message`
@@ -116,7 +176,7 @@ class TelegramPolling:
or update.get("edited_channel_post")
)
if not message or "text" not in message:
return
return True
chat_id = message["chat"]["id"]
# `from` may be missing for channel posts; `sender_chat` is used for anonymous admins.
@@ -125,6 +185,9 @@ class TelegramPolling:
user_id = from_obj.get("id")
username = from_obj.get("username") or sender_chat.get("username") or "unknown"
text = message["text"]
message_thread_id = _normalize_message_thread_id(
message.get("message_thread_id")
)
# Security Check
is_allowed = False
@@ -149,32 +212,98 @@ class TelegramPolling:
message_id=str(message["message_id"]),
text=text,
timestamp=time.time(),
thread_id=str(message_thread_id or ""),
)
try:
resp = await self.router.handle(req)
await self._send_response(chat_id, resp)
return await self._send_response(
chat_id,
resp,
delivery_context=(
{"thread_id": req.thread_id} if req.thread_id else None
),
)
except Exception as e:
logger.exception(f"Error handling command: {e}")
await self._send_response(
chat_id, CommandResponse(text="[Error] Internal processing error.")
return await self._send_response(
chat_id,
CommandResponse(text="[Error] Internal processing error."),
delivery_context=(
{"thread_id": req.thread_id} if req.thread_id else None
),
)
async def _send_response(self, chat_id: int, resp: CommandResponse):
def _thread_id_from_context(
self, delivery_context: Optional[dict]
) -> Optional[int]:
context = delivery_context or {}
return _normalize_message_thread_id(context.get("thread_id"))
async def _send_thread_diagnostic(self, chat_id, raw_thread_id) -> None:
preview = str(raw_thread_id or "")[:32]
logger.warning("Invalid Telegram message_thread_id ignored: %r", preview)
if not self.session:
return
url = f"{self.base_url}/sendMessage"
payload = {
"chat_id": chat_id,
"text": "[OpenClaw] Invalid Telegram thread/topic id; delivery used the parent chat.",
}
try:
async with self.session.post(url, json=payload) as r:
if r.status != 200:
logger.error(
f"Failed to send Telegram thread diagnostic: {r.status} {await r.text()}"
)
except Exception as e:
logger.error(f"Telegram thread diagnostic exception: {e}")
async def _send_response(
self,
chat_id: int,
resp: CommandResponse,
delivery_context: Optional[dict] = None,
) -> bool:
decision = decide_reply_visibility(
delivery_context=dict(delivery_context or {}),
platform="telegram",
channel_kind=_telegram_channel_kind(chat_id),
text=getattr(resp, "text", ""),
has_buttons=bool(getattr(resp, "buttons", None)),
has_files=bool(getattr(resp, "files", None)),
)
if decision.suppressed:
logger.info(
"Suppressed Telegram reply chat=%s reason=%s",
chat_id,
decision.reason,
)
return True
url = f"{self.base_url}/sendMessage"
payload = {
"chat_id": chat_id,
# Remediation: Plain text only, no parse_mode
"text": resp.text,
}
thread_id = self._thread_id_from_context(delivery_context)
if thread_id is not None:
payload["message_thread_id"] = thread_id
elif delivery_context and delivery_context.get("thread_id"):
await self._send_thread_diagnostic(
chat_id, delivery_context.get("thread_id")
)
try:
async with self.session.post(url, json=payload) as r:
if r.status != 200:
logger.error(
f"Failed to send Telegram response: {r.status} {await r.text()}"
)
return False
return True
except Exception as e:
logger.error(f"Telegram send exception: {e}")
return False
async def send_image(
self,
@@ -193,6 +322,13 @@ class TelegramPolling:
url = f"{self.base_url}/sendPhoto"
data = aiohttp.FormData()
data.add_field("chat_id", channel_id)
thread_id = self._thread_id_from_context(delivery_context)
if thread_id is not None:
data.add_field("message_thread_id", str(thread_id))
elif delivery_context and delivery_context.get("thread_id"):
await self._send_thread_diagnostic(
channel_id, delivery_context.get("thread_id")
)
if caption:
data.add_field("caption", caption)
@@ -215,11 +351,31 @@ class TelegramPolling:
"""Send text message."""
if not self.session:
return
decision = decide_reply_visibility(
delivery_context=dict(delivery_context or {}),
platform="telegram",
channel_kind=_telegram_channel_kind(channel_id),
text=text,
)
if decision.suppressed:
logger.info(
"Suppressed Telegram send_message chat=%s reason=%s",
channel_id,
decision.reason,
)
return
# Reuse internal logic logic but public
# Using simplified direct call
url = f"{self.base_url}/sendMessage"
payload = {"chat_id": channel_id, "text": text}
thread_id = self._thread_id_from_context(delivery_context)
if thread_id is not None:
payload["message_thread_id"] = thread_id
elif delivery_context and delivery_context.get("thread_id"):
await self._send_thread_diagnostic(
channel_id, delivery_context.get("thread_id")
)
try:
async with self.session.post(url, json=payload) as r:
if r.status != 200:
+2 -1
View File
@@ -18,6 +18,7 @@ from typing import Optional
from ..config import ConnectorConfig
from ..contract import CommandRequest, CommandResponse
from ..media_response import build_connector_media_response
from ..router import CommandRouter
from ..security_profile import AllowlistPolicy, ReplayGuard, verify_hmac_signature
from ..transport_contract import RelayResponseClassifier
@@ -219,7 +220,7 @@ class WhatsAppWebhookServer:
if not path:
return web.Response(status=404, text="Media Not Found or Expired")
return web.FileResponse(path)
return build_connector_media_response(web, path)
# ------------------------------------------------------------------
# Message Processing
+1 -1
View File
@@ -15,7 +15,7 @@ CHAT_SYSTEM_PROMPT = """You are OpenClaw Assistant, an AI helper for controlling
**Available Commands (for reference):**
- `/run <template_id> [--input key=value ...]` - Execute a workflow template
- `/status` - Check system status
- `/jobs` - View queue
- `/jobs` - View the authoritative jobs summary (admin)
- `/approvals` - List pending approvals (admin)
- `/approve <id>` - Approve a request (admin)
+129
View File
@@ -0,0 +1,129 @@
"""Shared connector reply visibility decisions."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
VISIBLE = "visible"
SUPPRESS_TEXT = "suppress_text"
TOOL_ONLY = "tool_only"
INTERNAL = "internal"
AUTO = "auto"
_VISIBLE_VALUES = {"", AUTO, VISIBLE, "public", "reply", "send"}
_SUPPRESS_VALUES = {SUPPRESS_TEXT, "suppress", "silent", "no_text", "none"}
_TOOL_ONLY_VALUES = {TOOL_ONLY, "tool-only", "tool", "action_only", "action-only"}
_INTERNAL_VALUES = {INTERNAL, "internal_only", "internal-only", "private"}
_TRUTHY_VALUES = {"1", "true", "yes", "y", "on"}
@dataclass(frozen=True)
class ReplyVisibilityDecision:
visible: bool
mode: str
reason: str
diagnostics: Dict[str, Any] = field(default_factory=dict)
@property
def suppressed(self) -> bool:
return not self.visible
def normalize_reply_visibility_mode(value: Any) -> str:
text = str(value or "").strip().lower()
if text in _VISIBLE_VALUES:
return VISIBLE
if text in _SUPPRESS_VALUES:
return SUPPRESS_TEXT
if text in _TOOL_ONLY_VALUES:
return TOOL_ONLY
if text in _INTERNAL_VALUES:
return INTERNAL
return VISIBLE
def decide_reply_visibility(
*,
delivery_context: Optional[Dict[str, Any]] = None,
platform: str = "",
channel_kind: str = "",
mentioned: Optional[bool] = None,
in_thread: bool = False,
text: str = "",
has_buttons: bool = False,
has_files: bool = False,
) -> ReplyVisibilityDecision:
ctx = dict(delivery_context or {})
explicit_mode = _extract_mode(ctx)
mode = normalize_reply_visibility_mode(explicit_mode)
normalized_channel_kind = (
str(ctx.get("channel_kind") or ctx.get("chat_type") or channel_kind or "")
.strip()
.lower()
)
threaded = bool(in_thread or str(ctx.get("thread_id", "") or "").strip())
diagnostics = {
"platform": str(platform or ctx.get("platform", "") or "").strip(),
"mode": mode,
"channel_kind": normalized_channel_kind,
"in_thread": threaded,
"has_text": bool(str(text or "").strip()),
"has_buttons": bool(has_buttons),
"has_files": bool(has_files),
}
# Approval/action replies must stay visible; hiding them can strand operators.
if has_buttons:
return ReplyVisibilityDecision(
True, VISIBLE, "interactive_action_required", diagnostics
)
if mode == INTERNAL or _truthy(ctx.get("internal_delivery")):
return ReplyVisibilityDecision(
False, INTERNAL, "internal_delivery", diagnostics
)
if (
mode in {SUPPRESS_TEXT, TOOL_ONLY}
or _truthy(ctx.get("tool_only"))
or _truthy(ctx.get("silent"))
):
if has_files:
return ReplyVisibilityDecision(
True, VISIBLE, "file_delivery_preserved", diagnostics
)
return ReplyVisibilityDecision(
False, mode, "text_reply_suppressed", diagnostics
)
if normalized_channel_kind in {"group", "supergroup", "channel"}:
if mentioned is None and "mentioned" in ctx:
mentioned = _truthy(ctx.get("mentioned"))
if mentioned is None and "mentioned_bot" in ctx:
mentioned = _truthy(ctx.get("mentioned_bot"))
if mentioned is False and not threaded:
return ReplyVisibilityDecision(
False, SUPPRESS_TEXT, "group_no_mention", diagnostics
)
return ReplyVisibilityDecision(True, VISIBLE, "visible", diagnostics)
def _extract_mode(ctx: Dict[str, Any]) -> Any:
for key in ("reply_visibility", "visibility", "reply_visibility_mode"):
if key in ctx:
return ctx.get(key)
policy = ctx.get("delivery_policy")
if isinstance(policy, dict):
for key in ("reply_visibility", "visibility", "reply_visibility_mode"):
if key in policy:
return policy.get(key)
return AUTO
def _truthy(value: Any) -> bool:
if isinstance(value, bool):
return value
return str(value or "").strip().lower() in _TRUTHY_VALUES
+17 -1
View File
@@ -6,6 +6,7 @@ from typing import Any, Dict, Optional
from .config import ConnectorConfig
from .contract import Platform
from .openclaw_client import OpenClawClient
from .reply_visibility import decide_reply_visibility
logger = logging.getLogger(__name__)
@@ -363,13 +364,28 @@ class ResultsPoller:
*,
delivery_context: Optional[Dict[str, Any]] = None,
):
ctx = dict(delivery_context or {})
decision = decide_reply_visibility(
delivery_context=ctx,
platform=platform_name,
text=text,
)
if decision.suppressed:
# IMPORTANT: a suppressed visible reply is a successful delivery no-op.
logger.info(
"Suppressed connector text reply platform=%s channel=%s reason=%s",
platform_name,
channel_id,
decision.reason,
)
return
platform = self.platforms.get(platform_name)
if platform:
try:
await platform.send_message(
channel_id,
text,
delivery_context=dict(delivery_context or {}),
delivery_context=ctx,
)
except Exception as e:
logger.error(f"Failed to send text to {platform_name}: {e}")
+20 -948
View File
@@ -3,33 +3,35 @@ Connector Router (F29 Remediation).
Dispatches parsed commands to handlers with AST argument parsing.
"""
import logging
import shlex
from typing import Any, Dict, List, Optional
from .config import CommandClass, ConnectorConfig
from .contract import CommandRequest, CommandResponse
from .config import ConnectorConfig
from .contract import CommandRequest as CommandRequest
from .contract import CommandResponse as CommandResponse
from .llm_client import LLMClient
from .openclaw_client import OpenClawClient
from .router_admin_handlers import RouterAdminMixin
from .router_chat_handlers import RouterChatMixin
from .router_dispatch import RouterDispatchMixin
from .router_execution_handlers import RouterExecutionMixin
from .state import ConnectorState
if False: # Type hinting only
from .results_poller import ResultsPoller
from .command_firewall import CommandFirewall
from .llm_client import LLMClient
from .prompts import CHAT_STATUS_PROMPT, CHAT_SYSTEM_PROMPT
from .rate_limiter import RateLimiter
from .semantic_guard import GuardAction, SemanticGuard
try:
from services.reasoning_redaction import sanitize_operator_payload
except Exception: # pragma: no cover - connector tests may stub import graph
sanitize_operator_payload = lambda value, **_: value # type: ignore
logger = logging.getLogger(__name__)
from .semantic_guard import SemanticGuard
class CommandRouter:
class CommandRouter(
RouterDispatchMixin,
RouterExecutionMixin,
RouterAdminMixin,
RouterChatMixin,
):
def _build_llm_client(self) -> LLMClient:
"""Resolve the facade dependency at call time to preserve patch seams."""
return LLMClient(self.client)
def __init__(
self,
config: ConnectorConfig,
@@ -40,7 +42,7 @@ class CommandRouter:
self.client = client
self.poller = poller
self.state = ConnectorState(path=self.config.state_path)
self._template_meta_cache: Dict[str, Dict[str, Any]] = {}
self._template_meta_cache: dict[str, dict[str, object]] = {}
# F32 WP2: Rate limiter
self._rate_limiter = RateLimiter(
user_rpm=self.config.rate_limit_user_rpm,
@@ -49,933 +51,3 @@ class CommandRouter:
# S44/R97: Semantic Guards
self.semantic_guard = SemanticGuard()
self.command_firewall = CommandFirewall()
async def handle(self, req: CommandRequest) -> CommandResponse:
"""Main dispatch loop."""
text = req.text.strip()
# NOTE: Debug-only raw message logging for troubleshooting parsing issues.
# Enable with OPENCLAW_CONNECTOR_DEBUG=1. May include sensitive user content.
if self.config.debug:
logger.info(
"DEBUG raw message: platform=%s user=%s chat=%s text=%r",
req.platform,
req.sender_id,
req.channel_id,
text,
)
# F32 WP2: Rate limiting
if not self._rate_limiter.is_allowed(str(req.sender_id), str(req.channel_id)):
return CommandResponse(
text="[Rate Limited] Too many requests. Please wait a moment."
)
# F32 WP5: Command length limit
if len(text) > self.config.max_command_length:
return CommandResponse(
text=f"[Error] Command too long ({len(text)} chars). Max: {self.config.max_command_length}."
)
try:
# IMPORTANT (recurring usability bug):
# Do not use `shlex.split()` directly for ChatOps commands that may include natural
# language. In POSIX mode, `shlex` treats apostrophes (`'`) as quote delimiters, so
# common contractions like "She's" trigger "unbalanced quotes" failures.
#
# We therefore only treat *double quotes* (`"`) as quoting characters, so users can
# still do: positive_prompt="a prompt with spaces" while apostrophes remain safe.
lexer = shlex.shlex(text, posix=True)
lexer.whitespace_split = True
lexer.commenters = ""
lexer.quotes = '"'
parts = list(lexer)
except ValueError:
return CommandResponse(
text="[Error] Parsing command arguments failed (unbalanced quotes?)."
)
if not parts:
return CommandResponse(text="Empty command.")
cmd = parts[0].lower()
args = parts[1:]
# Telegram group commands often include the bot username suffix, e.g. `/help@mybot`.
# If we don't strip it, the command won't match our dispatch table and appears "dead"
# even though polling is working.
if (
(req.platform or "").lower() == "telegram"
and cmd.startswith("/")
and "@" in cmd
):
cmd = cmd.split("@", 1)[0]
# Some users type `@bot /help` in group chats. Treat that as a command too.
if cmd.startswith("@") and args and args[0].startswith("/"):
cmd = args[0].lower()
args = args[1:]
# Dispatch Table
handlers = {
("/status", "status"): (self._handle_status, CommandClass.PUBLIC),
("/help", "help", "/start"): (self._handle_help, CommandClass.PUBLIC),
("/run", "run"): (self._handle_run, CommandClass.RUN),
("/interrupt", "interrupt", "/cancel", "cancel", "/stop"): (
self._handle_interrupt,
CommandClass.ADMIN,
), # Global interrupt => admin-only.
("/approvals", "approvals"): (
self._handle_approvals_list,
CommandClass.ADMIN,
),
("/approve", "approve"): (self._handle_approve, CommandClass.ADMIN),
("/reject", "reject"): (self._handle_reject, CommandClass.ADMIN),
("/schedules", "schedules"): (
self._handle_schedules_list,
CommandClass.ADMIN,
),
("/schedule", "schedule"): (
self._handle_schedule_subcommand,
CommandClass.ADMIN,
),
# Phase 3 Introspection
("/history", "history"): (self._handle_history, CommandClass.PUBLIC),
("/trace", "trace"): (self._handle_trace, CommandClass.ADMIN), # Admin only
("/jobs", "jobs", "queue"): (self._handle_jobs, CommandClass.PUBLIC),
# F30: Chat Assistant
("/chat", "chat"): (self._handle_chat, CommandClass.PUBLIC),
}
# Find Handler
handler = None
requires_admin = False
canonical_cmd = cmd # Fallback
for aliases, (func, cmd_class) in handlers.items():
if cmd in aliases:
handler = func
default_class = cmd_class
# R80 Remediation: Use canonical command (first alias) for policy checks
# This prevents "run" vs "/run" bypass issues.
if isinstance(aliases, tuple):
# Convention: first alias is canonical (e.g. "/run")
canonical_cmd = aliases[0]
else:
canonical_cmd = aliases
break
if not handler:
return CommandResponse(
text=f"Unknown command: {cmd}. Type /help for options."
)
# R80: Centralized Authorization Gate
# Pass canonical_cmd to ensure policy matches aliases correctly
if auth_err := self._check_command_authz(canonical_cmd, req, default_class):
return auth_err
# Execute
try:
return await handler(req, args)
except Exception as e:
logger.exception(f"Command execution error {cmd}: {e}")
return CommandResponse(text=f"[Internal Error] {str(e)}")
def _is_admin(self, user_id: str) -> bool:
return str(user_id) in self.config.admin_users
def _delivery_context(self, req: CommandRequest) -> Dict[str, Any]:
context: Dict[str, Any] = {}
if getattr(req, "workspace_id", ""):
context["workspace_id"] = str(req.workspace_id)
if getattr(req, "thread_id", ""):
context["thread_id"] = str(req.thread_id)
return context
def _check_command_authz(
self, cmd: str, req: CommandRequest, default_class: CommandClass
) -> Optional[CommandResponse]:
"""
R80: Verify command authorization policy.
Returns None if allowed, or CommandResponse(text=error) if denied.
"""
policy = self.config.command_policy
# 1. Resolve Effective Class (Handle per-command overrides)
# Note: 'cmd' here is the canonical parsed command string (lowercase), e.g., "/run" or "run"
# The overrides dict might use "/run" or "run", we should check both or normalize.
# Currently, the router logic normalized `cmd` from input (lines 90-101).
# We'll check exact match against the override key.
eff_class = policy.command_overrides.get(cmd, default_class)
# 2. Check AllowFrom List (Explicit User Allow)
# If an explicit AllowFrom list exists for this class, the user MUST be in it.
# This takes precedence over role logic.
allowed_users = policy.allow_from.get(eff_class)
if allowed_users is not None and len(allowed_users) > 0:
if str(req.sender_id) not in allowed_users:
# If explicit allow-list is active, even admins must be in it?
# Decision: YES, for strict compliance. If you want admins, add them to the list.
# However, for usability, usually admins are implied.
# Let's stick to "Explicit List Wins" for R80 strict mode.
return CommandResponse(
text="[Access Denied] You are not in the allow-list for this command."
)
# If in list, proceed (bypass default role checks? No, usually allows)
return None
# 3. Default Role Logic
if eff_class == CommandClass.ADMIN:
if not self._is_admin(req.sender_id):
return CommandResponse(
text="[Access Denied] This command requires Admin privileges."
)
# PUBLIC and RUN are allowed by default (RUN checks trust internally)
return None
def _is_trusted(self, req: CommandRequest) -> bool:
"""
Trusted users can execute /run immediately.
Untrusted users are routed to approval flow.
"""
if self._is_admin(req.sender_id):
return True
platform = (req.platform or "").lower()
sender_id = str(req.sender_id)
channel_id = str(req.channel_id)
if platform == "telegram":
try:
uid = int(sender_id)
except Exception:
uid = None
try:
cid = int(channel_id)
except Exception:
cid = None
if uid is not None and uid in self.config.telegram_allowed_users:
return True
if cid is not None and cid in self.config.telegram_allowed_chats:
return True
return False
if platform == "discord":
if sender_id in self.config.discord_allowed_users:
return True
if channel_id in self.config.discord_allowed_channels:
return True
return False
if platform == "line":
if sender_id in self.config.line_allowed_users:
return True
if channel_id in self.config.line_allowed_groups:
return True
return False
if platform == "whatsapp":
if sender_id in self.config.whatsapp_allowed_users:
return True
return False
if platform == "wechat":
if sender_id in self.config.wechat_allowed_users:
return True
return False
if platform == "kakao":
if sender_id in self.config.kakao_allowed_users:
return True
return False
if platform == "slack":
if sender_id in self.config.slack_allowed_users:
return True
if channel_id in self.config.slack_allowed_channels:
return True
return False
if platform == "feishu":
if sender_id in self.config.feishu_allowed_users:
return True
if channel_id in self.config.feishu_allowed_chats:
return True
return False
# Unknown platform: trust only admins
return False
# --- Handlers ---
async def _handle_status(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
health = await self.client.get_health()
queue = await self.client.get_prompt_queue()
# New standardized response handling
health_ok = health.get("ok")
status_icon = "Online" if health_ok else "Offline"
details = []
if health_ok:
data = health.get("data", {})
stats = data.get("stats", {})
details.append(f"Logs: {stats.get('logs_processed', 0)}")
details.append(f"Errors: {stats.get('errors_captured', 0)}")
q_res = queue.get("data", {})
q_rem = q_res.get("exec_info", {}).get("queue_remaining", 0)
details.append(f"Queue: {q_rem}")
else:
details.append(f"Error: {health.get('error')}")
return CommandResponse(
text=f"[{status_icon}] System Status\n"
+ "\n".join(f"- {d}" for d in details)
)
def _require_admin_token_configured(self) -> Optional[CommandResponse]:
"""
F32 WP3: Check if admin token is configured before running admin commands.
Fail-fast with clear error message instead of 403/500 later.
IMPORTANT (recurring CI failure mode):
- Admin-only commands are gated by BOTH:
(1) sender is an admin user, AND
(2) the connector admin token is configured (OPENCLAW_CONNECTOR_ADMIN_TOKEN).
- Unit tests that exercise admin command handlers MUST set `config.admin_token`,
otherwise they will correctly receive the config error response.
"""
if not self.config.admin_token:
return CommandResponse(
text="[Error] Admin token not configured. Set OPENCLAW_CONNECTOR_ADMIN_TOKEN and restart connector."
)
return None
async def _handle_run(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(
text="Usage: /run <template_id> [prompt text] [key=value ...] [--approval]"
)
# Parse flags
explicit_approval = False
clean_args = []
for arg in args:
if arg in ("--require-approval", "--approval", "-a"):
explicit_approval = True
else:
clean_args.append(arg)
if not clean_args:
return CommandResponse(text="Usage: /run <template_id> ...")
template_id = clean_args[0]
inputs: Dict[str, str] = {}
free_text_parts: List[str] = []
for arg in clean_args[1:]:
if "=" in arg:
k, v = arg.split("=", 1)
inputs[k.strip()] = v.strip()
else:
free_text_parts.append(arg)
# If user provided free text without key=value, treat it as the prompt.
# We map it to a best-effort prompt key (prefers template metadata if available).
if free_text_parts:
prompt_key = await self._resolve_prompt_key(template_id)
if prompt_key not in inputs:
inputs[prompt_key] = " ".join(free_text_parts).strip()
elif self.config.debug:
logger.info(
"DEBUG /run free-text ignored (prompt key already set): %s",
prompt_key,
)
# NOTE: Debug-only payload logging for troubleshooting prompt mismatches.
# Enable with OPENCLAW_CONNECTOR_DEBUG=1 to log template_id + inputs.
if self.config.debug:
logger.info(
"DEBUG /run payload: template=%s inputs=%s approval_flag=%s trusted=%s",
template_id,
inputs,
explicit_approval,
self._is_trusted(req),
)
trusted = self._is_trusted(req)
require_approval = explicit_approval or (not trusted)
res = await self.client.submit_job(
template_id, inputs, require_approval=require_approval
)
if res.get("ok"):
data = res.get("data", {})
trace_id = data.get("trace_id", "unknown")
if data.get("pending"):
approval_id = data.get("approval_id", "unknown")
msg = f"[Approval Requested]\nID: {approval_id}\nTrace: {trace_id}"
if "expires_at" in data:
msg += f"\nExpires: {data['expires_at']}"
if self.poller:
# IMPORTANT:
# For untrusted users, approvals are done in the OpenClaw UI.
# We must start tracking the approval_id so we can map
# approval_id -> executed_prompt_id later and auto-deliver images.
self.poller.track_approval(
approval_id,
req.platform,
req.channel_id,
req.sender_id,
delivery_context=self._delivery_context(req),
)
return CommandResponse(text=msg)
else:
prompt_id = data.get("prompt_id", "unknown")
if self.poller:
self.poller.track_job(
prompt_id,
req.platform,
req.channel_id,
req.sender_id,
delivery_context=self._delivery_context(req),
)
return CommandResponse(
text=f"[Job Submitted]\nID: {prompt_id}\nTemplate: {template_id}\nTrace: {trace_id}"
)
else:
err = res.get("error", "Unknown error")
return CommandResponse(text=f"[Submission Failed] Reason: {err}")
async def _resolve_prompt_key(self, template_id: str) -> str:
"""
Best-effort prompt key resolution.
Prefer template metadata (allowed_inputs), then fall back to common names.
"""
meta = await self._get_template_meta(template_id)
allowed = meta.get("allowed_inputs") or []
# If template explicitly declares a single input, use it.
if isinstance(allowed, list) and len(allowed) == 1:
return str(allowed[0])
preferred = ("positive_prompt", "prompt", "text", "positive", "caption")
if isinstance(allowed, list):
for key in preferred:
if key in allowed:
return key
# Default fallback
return "positive_prompt"
async def _get_template_meta(self, template_id: str) -> Dict[str, Any]:
if template_id in self._template_meta_cache:
return self._template_meta_cache[template_id]
try:
res = await self.client.get_templates()
if res.get("ok"):
for item in res.get("templates", []) or []:
if item.get("id") == template_id:
self._template_meta_cache[template_id] = item
return item
except Exception as e:
if self.config.debug:
logger.info(f"DEBUG template meta fetch failed: {e}")
return {}
async def _handle_interrupt(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
# Remediation: Global Interrupt
res = await self.client.interrupt_output()
if res.get("ok"):
return CommandResponse(text="[Stop] Global Interrupt sent to ComfyUI.")
else:
return CommandResponse(text=f"[Stop Failed] {res.get('error')}")
async def _handle_approvals_list(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_approvals()
if not res.get("ok"):
return CommandResponse(
text=f"[Error] Failed to list approvals: {res.get('error')}"
)
items = res.get("items", [])
if not items:
return CommandResponse(text="No pending approvals.")
pending_count = res.get("pending_count")
lines = []
buttons = []
for i in items:
# IMPORTANT (stability): the backend approval schema uses:
# `approval_id`, `template_id`, `status`, `requested_by`, `source`.
# Do not “simplify” these keys to `id/description/requester` unless you also
# update the backend API + all tests. This mismatch previously caused silent
# bad output and brittle regressions.
approval_id = i.get("approval_id") or i.get("id") or "unknown"
template_id = i.get("template_id") or "unknown"
status = i.get("status") or "unknown"
requested_by = i.get("requested_by") or "unknown"
source = i.get("source") or "unknown"
lines.append(
f"- {approval_id} [{status}] template={template_id} by={requested_by} source={source}"
)
for i in items[:3]:
approval_id = i.get("approval_id") or i.get("id") or "unknown"
short_id = str(approval_id)[:8]
buttons.append(
{
"label": f"Approve {short_id}",
"value": f"/approve {approval_id}",
"action_type": "approval.approve",
"approval_id": approval_id,
"style": "primary",
}
)
buttons.append(
{
"label": f"Reject {short_id}",
"value": f"/reject {approval_id}",
"action_type": "approval.reject",
"approval_id": approval_id,
"style": "danger",
}
)
header = "Pending Approvals"
if isinstance(pending_count, int):
header += f" ({pending_count})"
return CommandResponse(
text=header + ":\n" + "\n".join(lines),
buttons=buttons,
)
async def _handle_approve(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /approve <id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
# Assuming auto_execute=True by default for chat logic
res = await self.client.approve_request(args[0], auto_execute=True)
if not res.get("ok"):
return CommandResponse(text=f"[Failed] {res.get('error')}")
data = res.get("data", {})
msg = f"[Approved] {args[0]}"
# Phase 4: Show execution result
if "prompt_id" in data:
pid = data["prompt_id"]
msg += f"\nExecuted: {pid}"
if self.poller:
# Approval request might have come from different flow, but usually user invoking /approve
# wants the result. Using current req context is safest assumption for "ChatOps".
self.poller.track_job(
pid,
req.platform,
req.channel_id,
req.sender_id,
delivery_context=self._delivery_context(req),
)
elif data.get("executed") is False:
msg += "\n(Not Executed)"
if err := data.get("execution_error"):
msg += f"\nError: {err}"
return CommandResponse(text=msg)
async def _handle_reject(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /reject <id> [reason]")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
reason = " ".join(args[1:]) if len(args) > 1 else "Rejected via chat"
res = await self.client.reject_request(args[0], reason)
if not res.get("ok"):
return CommandResponse(text=f"[Failed] {res.get('error')}")
return CommandResponse(text=f"[Rejected] {args[0]}")
async def _handle_schedules_list(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_schedules()
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
scheds = res.get("schedules", [])
if not scheds:
return CommandResponse(text="No schedules found.")
lines = []
for s in scheds:
status = "+" if s.get("enabled") else "-"
lines.append(
f"[{status}] {s.get('id')}: {s.get('cron')} - {s.get('template_id')}"
)
return CommandResponse(text="Schedules:\n" + "\n".join(lines))
async def _handle_schedule_subcommand(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if len(args) < 2:
return CommandResponse(text="Usage: /schedule <run|toggle> <id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
sub = args[0].lower()
sid = args[1]
if sub == "run":
res = await self.client.run_schedule(sid)
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
return CommandResponse(text=f"[Success] Schedule {sid} triggered manually.")
else:
return CommandResponse(text="Not implemented yet.")
async def _handle_help(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
return CommandResponse(
text=(
"OpenClaw Connector\n"
"/status - Check system health and queue\n"
"/run <template> [prompt] [k=v] - Run a generation (trusted users auto-exec; others require approval)\n"
"/stop - Global Interrupt (Admin)\n"
"/history <id> - Job details\n"
"/jobs - Queue summary\n"
"Admin Only:\n"
"/approvals - List pending approvals\n"
"/approve <id>, /reject <id>\n"
"/schedules, /schedule run <id>\n"
"/trace <id> - Execution trace"
)
)
async def _handle_history(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /history <prompt_id>")
res = await self.client.get_history(args[0])
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
# Simple format
data = res.get("data", {})
status = data.get("status", {}).get("status_str", "unknown")
# Assuming backend returns a structure we can summarise
return CommandResponse(
text=f"Job {args[0]}: {status}\nFull details: not implemented in connector view yet."
)
async def _handle_trace(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /trace <prompt_id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_trace(args[0])
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
# Dump trace
sanitized = sanitize_operator_payload(res.get("data"))
return CommandResponse(text=f"Trace {args[0]}: {str(sanitized)[:1000]}...")
async def _handle_jobs(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# Try native /openclaw/jobs first
res = await self.client.get_jobs()
if res.get("ok"):
# Format nice summary
return CommandResponse(
text=f"Default Jobs View: {sanitize_operator_payload(res.get('data'))}"
)
# Fallback: Queue
q = await self.client.get_prompt_queue()
if q.get("ok"):
rem = q.get("data", {}).get("exec_info", {}).get("queue_remaining", "?")
return CommandResponse(text=f"[Fallback] Queue Remaining: {rem}")
return CommandResponse(text="[Error] Could not fetch jobs or queue.")
# -------------------------------------------------------------------------
# F30: Chat LLM Assistant
# -------------------------------------------------------------------------
async def _handle_chat(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
"""
/chat [subcommand] <message>
Subcommands: run, template, status
Default: general chat
Security: Never auto-executes commands. Only suggests command text.
"""
llm = LLMClient(self.client)
if not await llm.is_configured():
return CommandResponse(
text="[Chat Error] LLM not configured. Configure in OpenClaw Settings."
)
# Parse subcommand
if not args:
return CommandResponse(
text="Usage: /chat <message> or /chat run|template|status <request>"
)
subcommand = args[0].lower()
message = " ".join(args[1:]) if len(args) > 1 else ""
trust_level = "TRUSTED" if self._is_trusted(req) else "UNTRUSTED"
if subcommand == "run":
return await self._chat_run(llm, message, trust_level)
elif subcommand == "template":
return await self._chat_template(llm, message)
elif subcommand == "status":
return await self._chat_status(llm)
else:
# General chat: first word is part of message
full_message = " ".join(args)
return await self._chat_general(llm, full_message, trust_level)
async def _chat_general(
self, llm: LLMClient, message: str, trust_level: str
) -> CommandResponse:
"""General chat with assistant."""
# S44: Semantic Guard Evaluation
decision = self.semantic_guard.evaluate_request(message, {"trust": trust_level})
if decision.action == GuardAction.DENY:
return CommandResponse(
text=(
"[Blocked] Request denied by semantic policy "
f"({decision.reason}). {self._policy_kv(decision.to_contract())}"
)
)
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level=trust_level)
response = await llm.chat(system_prompt, message)
# S44: Output Validation + SAFE_REPLY sanitization.
try:
response = self.semantic_guard.validate_output(
response, "general", decision.action
)
except ValueError as e:
return CommandResponse(
text=(
"[Validation Error] Assistant output invalid: "
f"{e}. {self._policy_kv({'code': 'semantic_output_invalid', 'severity': 'medium', 'action': 'deny', 'reason': str(e)})}"
)
)
if decision.action == GuardAction.SAFE_REPLY:
safe_response = (
response
or "I can help with general guidance, but commands are restricted for this request."
)
return CommandResponse(
text=(
f"[Safe Mode] {safe_response}\n\n"
f"(Policy: {self._policy_kv(decision.to_contract())})"
)
)
return CommandResponse(text=response)
async def _chat_run(
self, llm: LLMClient, request: str, trust_level: str
) -> CommandResponse:
"""Suggest a /run command based on user request."""
if not request:
return CommandResponse(
text="Usage: /chat run <description of what you want>"
)
# S44: Semantic Guard Evaluation
decision = self.semantic_guard.evaluate_request(request, {"trust": trust_level})
if decision.action == GuardAction.DENY:
return CommandResponse(
text=(
"[Blocked] Request denied by semantic policy "
f"({decision.reason}). {self._policy_kv(decision.to_contract())}"
)
)
# Force Approval Override based on Risk
force_approval_policy = decision.action == GuardAction.FORCE_APPROVAL
# Get available templates (simplified - could fetch from API)
templates = "txt2img, img2img, upscale (examples)"
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level=trust_level)
user_prompt = f"""User wants to run a generation. Suggest a `/run` command.
Request: {request}
Available templates: {templates}
Trust level: {trust_level}
Remember: {"add --approval flag" if trust_level == "UNTRUSTED" else "no --approval needed"}.
Output only the command in a code block."""
response = await llm.chat(system_prompt, user_prompt)
# S44: Output Structure Validation
try:
response = self.semantic_guard.validate_output(
response, "run", decision.action
)
except ValueError as e:
return CommandResponse(
text=(
"[Validation Error] Assistant output invalid: "
f"{e}. {self._policy_kv({'code': 'semantic_output_invalid', 'severity': 'high', 'action': 'deny', 'reason': str(e)})}"
)
)
# R97: Command Firewall - Extract and Validate
import re
cmd_match = re.search(r"```(?:bash)?\s*(.*?)\s*```", response, re.DOTALL)
raw_cmd = cmd_match.group(1).strip() if cmd_match else response.strip()
# Validate through Firewall
normalized = self.command_firewall.validate_suggestion(raw_cmd)
if not normalized.is_safe:
return CommandResponse(
text=(
"[Safety Block] Assistant suggested unsafe command: "
f"{normalized.safety_reason}. {self._policy_kv(normalized.to_contract())}"
)
)
# R97: Strict /run enforcement (Remediation for Medium Severity)
# CRITICAL: keep this check. /chat run must never emit non-/run commands.
if normalized.command != "/run":
return CommandResponse(
text=(
"[Policy Block] Only /run commands are allowed in this mode. "
f"Got: {normalized.command}. "
f"{self._policy_kv({'code': 'firewall_non_run_command', 'severity': 'high', 'action': 'deny', 'reason': 'non_run_command_in_run_mode'})}"
)
)
# R97/S44: Apply Policy Overrides
# If risk was elevated, ensure --approval is present
if (
force_approval_policy
and "--approval" not in normalized.args
and "approval" not in normalized.flags
):
normalized.args.append("--approval")
final_cmd = normalized.to_string()
# Return as code block for easy copy-paste (or auto-execution UI cues)
if force_approval_policy:
return CommandResponse(
text=(
f"```\n{final_cmd}\n```\n"
f"(Policy: {self._policy_kv(decision.to_contract())})"
)
)
return CommandResponse(text=f"```\n{final_cmd}\n```")
@staticmethod
def _policy_kv(contract: Dict[str, Any]) -> str:
ordered = ("code", "severity", "action", "reason")
parts = []
for key in ordered:
value = contract.get(key)
if value is not None:
parts.append(f"{key}={value}")
return "[" + ", ".join(parts) + "]"
async def _chat_template(self, llm: LLMClient, request: str) -> CommandResponse:
"""Generate a template JSON suggestion."""
if not request:
return CommandResponse(text="Usage: /chat template <description>")
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level="N/A")
user_prompt = f"""Generate a workflow template JSON for this request:
Request: {request}
Output:
1. Suggested filename
2. Template JSON in a code block
Keep it minimal."""
response = await llm.chat(system_prompt, user_prompt)
return CommandResponse(text=response)
async def _chat_status(self, llm: LLMClient) -> CommandResponse:
"""Summarize system status using LLM."""
# Fetch status data
health = await self.client.get_health()
jobs = await self.client.get_jobs()
queue = await self.client.get_prompt_queue()
status_data = {
"health": health.get("data", {}) if health.get("ok") else "unavailable",
"jobs": jobs.get("data", {}) if jobs.get("ok") else "unavailable",
"queue": queue.get("data", {}) if queue.get("ok") else "unavailable",
}
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level="N/A")
user_prompt = CHAT_STATUS_PROMPT.format(status_data=status_data)
response = await llm.chat(system_prompt, user_prompt)
return CommandResponse(text=response)
+332
View File
@@ -0,0 +1,332 @@
"""Owned status, approval, schedule, and introspection command mixin."""
# ruff: noqa: UP006, UP035, UP045 -- preserve the frozen public annotations.
# mypy: disable-error-code="attr-defined"
from collections.abc import Mapping
from typing import List, Optional
from .contract import CommandRequest, CommandResponse
from .jobs_summary import JobsContractError, format_jobs_summary, format_queue_fallback
try:
from services.reasoning_redaction import sanitize_operator_payload
except Exception: # pragma: no cover - connector tests may stub import graph
def sanitize_operator_payload(value, **_): # type: ignore
return value
class RouterAdminMixin:
async def _handle_status(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
health = await self.client.get_health()
queue = await self.client.get_prompt_queue()
# New standardized response handling
health_ok = health.get("ok")
status_icon = "Online" if health_ok else "Offline"
details = []
if health_ok:
data = health.get("data", {})
stats = data.get("stats", {})
details.append(f"Logs: {stats.get('logs_processed', 0)}")
details.append(f"Errors: {stats.get('errors_captured', 0)}")
q_res = queue.get("data", {})
q_rem = q_res.get("exec_info", {}).get("queue_remaining", 0)
details.append(f"Queue: {q_rem}")
else:
details.append(f"Error: {health.get('error')}")
return CommandResponse(
text=f"[{status_icon}] System Status\n"
+ "\n".join(f"- {d}" for d in details)
)
def _require_admin_token_configured(self) -> Optional[CommandResponse]:
"""
F32 WP3: Check if admin token is configured before running admin commands.
Fail-fast with clear error message instead of 403/500 later.
IMPORTANT (recurring CI failure mode):
- Admin-only commands are gated by BOTH:
(1) sender is an admin user, AND
(2) the connector admin token is configured (OPENCLAW_CONNECTOR_ADMIN_TOKEN).
- Unit tests that exercise admin command handlers MUST set `config.admin_token`,
otherwise they will correctly receive the config error response.
"""
if not self.config.admin_token:
return CommandResponse(
text="[Error] Admin token not configured. Set OPENCLAW_CONNECTOR_ADMIN_TOKEN and restart connector."
)
return None
async def _handle_approvals_list(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_approvals()
if not res.get("ok"):
return CommandResponse(
text=f"[Error] Failed to list approvals: {res.get('error')}"
)
items = res.get("items", [])
if not items:
return CommandResponse(text="No pending approvals.")
pending_count = res.get("pending_count")
lines = []
buttons = []
for i in items:
# IMPORTANT (stability): the backend approval schema uses:
# `approval_id`, `template_id`, `status`, `requested_by`, `source`.
# Do not “simplify” these keys to `id/description/requester` unless you also
# update the backend API + all tests. This mismatch previously caused silent
# bad output and brittle regressions.
approval_id = i.get("approval_id") or i.get("id") or "unknown"
template_id = i.get("template_id") or "unknown"
status = i.get("status") or "unknown"
requested_by = i.get("requested_by") or "unknown"
source = i.get("source") or "unknown"
lines.append(
f"- {approval_id} [{status}] template={template_id} by={requested_by} source={source}"
)
for i in items[:3]:
approval_id = i.get("approval_id") or i.get("id") or "unknown"
short_id = str(approval_id)[:8]
buttons.append(
{
"label": f"Approve {short_id}",
"value": f"/approve {approval_id}",
"action_type": "approval.approve",
"approval_id": approval_id,
"style": "primary",
}
)
buttons.append(
{
"label": f"Reject {short_id}",
"value": f"/reject {approval_id}",
"action_type": "approval.reject",
"approval_id": approval_id,
"style": "danger",
}
)
header = "Pending Approvals"
if isinstance(pending_count, int):
header += f" ({pending_count})"
return CommandResponse(
text=header + ":\n" + "\n".join(lines),
buttons=buttons,
)
async def _handle_approve(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /approve <id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
# Assuming auto_execute=True by default for chat logic
res = await self.client.approve_request(args[0], auto_execute=True)
if not res.get("ok"):
return CommandResponse(text=f"[Failed] {res.get('error')}")
data = res.get("data", {})
msg = f"[Approved] {args[0]}"
# Phase 4: Show execution result
if "prompt_id" in data:
pid = data["prompt_id"]
msg += f"\nExecuted: {pid}"
if self.poller:
# Approval request might have come from different flow, but usually user invoking /approve
# wants the result. Using current req context is safest assumption for "ChatOps".
self.poller.track_job(
pid,
req.platform,
req.channel_id,
req.sender_id,
delivery_context=self._delivery_context(req),
)
elif data.get("executed") is False:
msg += "\n(Not Executed)"
if err := data.get("execution_error"):
msg += f"\nError: {err}"
return CommandResponse(text=msg)
async def _handle_reject(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /reject <id> [reason]")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
reason = " ".join(args[1:]) if len(args) > 1 else "Rejected via chat"
res = await self.client.reject_request(args[0], reason)
if not res.get("ok"):
return CommandResponse(text=f"[Failed] {res.get('error')}")
return CommandResponse(text=f"[Rejected] {args[0]}")
async def _handle_schedules_list(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_schedules()
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
scheds = res.get("schedules", [])
if not scheds:
return CommandResponse(text="No schedules found.")
lines = []
for s in scheds:
status = "+" if s.get("enabled") else "-"
lines.append(
f"[{status}] {s.get('id')}: {s.get('cron')} - {s.get('template_id')}"
)
return CommandResponse(text="Schedules:\n" + "\n".join(lines))
async def _handle_schedule_subcommand(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if len(args) < 2:
return CommandResponse(text="Usage: /schedule <run|toggle> <id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
sub = args[0].lower()
sid = args[1]
if sub == "run":
res = await self.client.run_schedule(sid)
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
return CommandResponse(text=f"[Success] Schedule {sid} triggered manually.")
else:
return CommandResponse(text="Not implemented yet.")
async def _handle_help(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
return CommandResponse(
text=(
"OpenClaw Connector\n"
"/status - Check system health and queue\n"
"/run <template> [prompt] [k=v] - Run a generation (trusted users auto-exec; others require approval)\n"
"/stop [job_id ...] - Cancel jobs by id; no args sends Global Interrupt (Admin)\n"
"/history <id> - Job details\n"
"Admin Only:\n"
"/jobs - Authoritative jobs summary\n"
"/approvals - List pending approvals\n"
"/approve <id>, /reject <id>\n"
"/schedules, /schedule run <id>\n"
"/trace <id> - Execution trace"
)
)
async def _handle_history(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /history <prompt_id>")
res = await self.client.get_history(args[0])
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
# Simple format
data = res.get("data", {})
status = data.get("status", {}).get("status_str", "unknown")
# Assuming backend returns a structure we can summarise
return CommandResponse(
text=f"Job {args[0]}: {status}\nFull details: not implemented in connector view yet."
)
async def _handle_trace(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /trace <prompt_id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_trace(args[0])
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
# Dump trace
sanitized = sanitize_operator_payload(res.get("data"))
return CommandResponse(text=f"Trace {args[0]}: {str(sanitized)[:1000]}...")
async def _handle_jobs(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if err := self._require_admin_token_configured():
return err
res = await self.client.get_jobs()
if not isinstance(res, Mapping):
return CommandResponse(
text="[Jobs] Could not fetch the authoritative jobs snapshot."
)
if res.get("ok") is True:
try:
return CommandResponse(text=format_jobs_summary(res.get("data")))
except JobsContractError:
return CommandResponse(
text="[Jobs] Malformed or unsupported jobs response."
)
status = res.get("status")
error = res.get("error")
access_denied = (
isinstance(status, int)
and not isinstance(status, bool)
and status in {401, 403}
)
if access_denied:
return CommandResponse(
text="[Jobs] Access denied. Check connector Admin authorization and token posture."
)
fallback_allowed = isinstance(error, str) and (
(status == 501 and error == "jobs_host_contract_unsupported")
or (status == 503 and error == "jobs_backend_unavailable")
)
if fallback_allowed:
return CommandResponse(
text=format_queue_fallback(await self.client.get_prompt_queue())
)
return CommandResponse(
text="[Jobs] Could not fetch the authoritative jobs snapshot."
)
# -------------------------------------------------------------------------
# F30: Chat LLM Assistant
# -------------------------------------------------------------------------
+244
View File
@@ -0,0 +1,244 @@
"""Owned chat and semantic-guard command-family mixin."""
# ruff: noqa: UP006, UP035 -- preserve the frozen public annotations.
# mypy: disable-error-code="attr-defined"
from typing import Any, Dict, List
from .contract import CommandRequest, CommandResponse
from .llm_client import LLMClient
from .prompts import CHAT_STATUS_PROMPT, CHAT_SYSTEM_PROMPT
from .semantic_guard import GuardAction
class RouterChatMixin:
async def _handle_chat(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
"""
/chat [subcommand] <message>
Subcommands: run, template, status
Default: general chat
Security: Never auto-executes commands. Only suggests command text.
"""
llm = self._build_llm_client()
if not await llm.is_configured():
return CommandResponse(
text="[Chat Error] LLM not configured. Configure in OpenClaw Settings."
)
# Parse subcommand
if not args:
return CommandResponse(
text="Usage: /chat <message> or /chat run|template|status <request>"
)
subcommand = args[0].lower()
message = " ".join(args[1:]) if len(args) > 1 else ""
trust_level = "TRUSTED" if self._is_trusted(req) else "UNTRUSTED"
if subcommand == "run":
return await self._chat_run(llm, message, trust_level)
elif subcommand == "template":
return await self._chat_template(llm, message)
elif subcommand == "status":
return await self._chat_status(llm)
else:
# General chat: first word is part of message
full_message = " ".join(args)
return await self._chat_general(llm, full_message, trust_level)
async def _chat_general(
self, llm: LLMClient, message: str, trust_level: str
) -> CommandResponse:
"""General chat with assistant."""
# S44: Semantic Guard Evaluation
decision = self.semantic_guard.evaluate_request(message, {"trust": trust_level})
if decision.action == GuardAction.DENY:
return CommandResponse(
text=(
"[Blocked] Request denied by semantic policy "
f"({decision.reason}). {self._policy_kv(decision.to_contract())}"
)
)
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level=trust_level)
response = await llm.chat(system_prompt, message)
# S44: Output Validation + SAFE_REPLY sanitization.
try:
response = self.semantic_guard.validate_output(
response, "general", decision.action
)
except ValueError as e:
return CommandResponse(
text=(
"[Validation Error] Assistant output invalid: "
f"{e}. {self._policy_kv({'code': 'semantic_output_invalid', 'severity': 'medium', 'action': 'deny', 'reason': str(e)})}"
)
)
if decision.action == GuardAction.SAFE_REPLY:
safe_response = (
response
or "I can help with general guidance, but commands are restricted for this request."
)
return CommandResponse(
text=(
f"[Safe Mode] {safe_response}\n\n"
f"(Policy: {self._policy_kv(decision.to_contract())})"
)
)
return CommandResponse(text=response)
async def _chat_run(
self, llm: LLMClient, request: str, trust_level: str
) -> CommandResponse:
"""Suggest a /run command based on user request."""
if not request:
return CommandResponse(
text="Usage: /chat run <description of what you want>"
)
# S44: Semantic Guard Evaluation
decision = self.semantic_guard.evaluate_request(request, {"trust": trust_level})
if decision.action == GuardAction.DENY:
return CommandResponse(
text=(
"[Blocked] Request denied by semantic policy "
f"({decision.reason}). {self._policy_kv(decision.to_contract())}"
)
)
# Force Approval Override based on Risk
force_approval_policy = decision.action == GuardAction.FORCE_APPROVAL
# Get available templates (simplified - could fetch from API)
templates = "txt2img, img2img, upscale (examples)"
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level=trust_level)
user_prompt = f"""User wants to run a generation. Suggest a `/run` command.
Request: {request}
Available templates: {templates}
Trust level: {trust_level}
Remember: {"add --approval flag" if trust_level == "UNTRUSTED" else "no --approval needed"}.
Output only the command in a code block."""
response = await llm.chat(system_prompt, user_prompt)
# S44: Output Structure Validation
try:
response = self.semantic_guard.validate_output(
response, "run", decision.action
)
except ValueError as e:
return CommandResponse(
text=(
"[Validation Error] Assistant output invalid: "
f"{e}. {self._policy_kv({'code': 'semantic_output_invalid', 'severity': 'high', 'action': 'deny', 'reason': str(e)})}"
)
)
# R97: Command Firewall - Extract and Validate
import re
cmd_match = re.search(r"```(?:bash)?\s*(.*?)\s*```", response, re.DOTALL)
raw_cmd = cmd_match.group(1).strip() if cmd_match else response.strip()
# Validate through Firewall
normalized = self.command_firewall.validate_suggestion(raw_cmd)
if not normalized.is_safe:
return CommandResponse(
text=(
"[Safety Block] Assistant suggested unsafe command: "
f"{normalized.safety_reason}. {self._policy_kv(normalized.to_contract())}"
)
)
# R97: Strict /run enforcement (Remediation for Medium Severity)
# CRITICAL: keep this check. /chat run must never emit non-/run commands.
if normalized.command != "/run":
return CommandResponse(
text=(
"[Policy Block] Only /run commands are allowed in this mode. "
f"Got: {normalized.command}. "
f"{self._policy_kv({'code': 'firewall_non_run_command', 'severity': 'high', 'action': 'deny', 'reason': 'non_run_command_in_run_mode'})}"
)
)
# R97/S44: Apply Policy Overrides
# If risk was elevated, ensure --approval is present
if (
force_approval_policy
and "--approval" not in normalized.args
and "approval" not in normalized.flags
):
normalized.args.append("--approval")
final_cmd = normalized.to_string()
# Return as code block for easy copy-paste (or auto-execution UI cues)
if force_approval_policy:
return CommandResponse(
text=(
f"```\n{final_cmd}\n```\n"
f"(Policy: {self._policy_kv(decision.to_contract())})"
)
)
return CommandResponse(text=f"```\n{final_cmd}\n```")
@staticmethod
def _policy_kv(contract: Dict[str, Any]) -> str:
ordered = ("code", "severity", "action", "reason")
parts = []
for key in ordered:
value = contract.get(key)
if value is not None:
parts.append(f"{key}={value}")
return "[" + ", ".join(parts) + "]"
async def _chat_template(self, llm: LLMClient, request: str) -> CommandResponse:
"""Generate a template JSON suggestion."""
if not request:
return CommandResponse(text="Usage: /chat template <description>")
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level="N/A")
user_prompt = f"""Generate a workflow template JSON for this request:
Request: {request}
Output:
1. Suggested filename
2. Template JSON in a code block
Keep it minimal."""
response = await llm.chat(system_prompt, user_prompt)
return CommandResponse(text=response)
async def _chat_status(self, llm: LLMClient) -> CommandResponse:
"""Summarize system status using LLM."""
# Fetch status data
health = await self.client.get_health()
queue = await self.client.get_prompt_queue()
status_data = {
"health": health.get("data", {}) if health.get("ok") else "unavailable",
"jobs": "admin-only; use /jobs as an authorized operator",
"queue": queue.get("data", {}) if queue.get("ok") else "unavailable",
}
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level="N/A")
user_prompt = CHAT_STATUS_PROMPT.format(status_data=status_data)
response = await llm.chat(system_prompt, user_prompt)
return CommandResponse(text=response)
+275
View File
@@ -0,0 +1,275 @@
"""Owned command parsing, dispatch, and authorization mixin."""
# ruff: noqa: UP006, UP035, UP045 -- preserve the frozen public annotations.
# mypy: disable-error-code="attr-defined,no-any-return"
import logging
import shlex
from dataclasses import dataclass
from typing import Any, Dict, Optional
from .config import CommandClass
from .contract import CommandRequest, CommandResponse
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class RouterRequestContext:
"""Immutable dispatch values for one authorized command attempt."""
request: CommandRequest
parsed_command: str
canonical_command: str
args: tuple[str, ...]
command_class: CommandClass
class RouterDispatchMixin:
async def handle(self, req: CommandRequest) -> CommandResponse:
"""Main dispatch loop."""
text = req.text.strip()
# NOTE: Debug-only raw message logging for troubleshooting parsing issues.
# Enable with OPENCLAW_CONNECTOR_DEBUG=1. May include sensitive user content.
if self.config.debug:
logger.info(
"DEBUG raw message: platform=%s user=%s chat=%s text=%r",
req.platform,
req.sender_id,
req.channel_id,
text,
)
# F32 WP2: Rate limiting
if not self._rate_limiter.is_allowed(str(req.sender_id), str(req.channel_id)):
return CommandResponse(
text="[Rate Limited] Too many requests. Please wait a moment."
)
# F32 WP5: Command length limit
if len(text) > self.config.max_command_length:
return CommandResponse(
text=f"[Error] Command too long ({len(text)} chars). Max: {self.config.max_command_length}."
)
try:
# IMPORTANT (recurring usability bug):
# Do not use `shlex.split()` directly for ChatOps commands that may include natural
# language. In POSIX mode, `shlex` treats apostrophes (`'`) as quote delimiters, so
# common contractions like "She's" trigger "unbalanced quotes" failures.
#
# We therefore only treat *double quotes* (`"`) as quoting characters, so users can
# still do: positive_prompt="a prompt with spaces" while apostrophes remain safe.
lexer = shlex.shlex(text, posix=True)
lexer.whitespace_split = True
lexer.commenters = ""
lexer.quotes = '"'
parts = list(lexer)
except ValueError:
return CommandResponse(
text="[Error] Parsing command arguments failed (unbalanced quotes?)."
)
if not parts:
return CommandResponse(text="Empty command.")
cmd = parts[0].lower()
args = parts[1:]
# Telegram group commands often include the bot username suffix, e.g. `/help@mybot`.
# If we don't strip it, the command won't match our dispatch table and appears "dead"
# even though polling is working.
if (
(req.platform or "").lower() == "telegram"
and cmd.startswith("/")
and "@" in cmd
):
cmd = cmd.split("@", 1)[0]
# Some users type `@bot /help` in group chats. Treat that as a command too.
if cmd.startswith("@") and args and args[0].startswith("/"):
cmd = args[0].lower()
args = args[1:]
# Dispatch Table
handlers = {
("/status", "status"): (self._handle_status, CommandClass.PUBLIC),
("/help", "help", "/start"): (self._handle_help, CommandClass.PUBLIC),
("/run", "run"): (self._handle_run, CommandClass.RUN),
("/interrupt", "interrupt", "/cancel", "cancel", "/stop"): (
self._handle_interrupt,
CommandClass.ADMIN,
), # Global interrupt => admin-only.
("/approvals", "approvals"): (
self._handle_approvals_list,
CommandClass.ADMIN,
),
("/approve", "approve"): (self._handle_approve, CommandClass.ADMIN),
("/reject", "reject"): (self._handle_reject, CommandClass.ADMIN),
("/schedules", "schedules"): (
self._handle_schedules_list,
CommandClass.ADMIN,
),
("/schedule", "schedule"): (
self._handle_schedule_subcommand,
CommandClass.ADMIN,
),
# Phase 3 Introspection
("/history", "history"): (self._handle_history, CommandClass.PUBLIC),
("/trace", "trace"): (self._handle_trace, CommandClass.ADMIN), # Admin only
("/jobs", "jobs", "queue"): (self._handle_jobs, CommandClass.ADMIN),
# F30: Chat Assistant
("/chat", "chat"): (self._handle_chat, CommandClass.PUBLIC),
}
# Find Handler
handler = None
canonical_cmd = cmd # Fallback
for aliases, (func, cmd_class) in handlers.items():
if cmd in aliases:
handler = func
default_class = cmd_class
# R80 Remediation: Use canonical command (first alias) for policy checks
# This prevents "run" vs "/run" bypass issues.
# Convention: first alias is canonical (e.g. "/run").
canonical_cmd = aliases[0] if isinstance(aliases, tuple) else aliases
break
if not handler:
return CommandResponse(
text=f"Unknown command: {cmd}. Type /help for options."
)
context = RouterRequestContext(
request=req,
parsed_command=cmd,
canonical_command=canonical_cmd,
args=tuple(args),
command_class=default_class,
)
# R80: Centralized Authorization Gate
# Pass canonical_cmd to ensure policy matches aliases correctly
if auth_err := self._check_command_authz(
context.canonical_command, context.request, context.command_class
):
return auth_err
# Execute
try:
return await handler(context.request, list(context.args))
except Exception as e:
logger.exception(f"Command execution error {cmd}: {e}")
return CommandResponse(text=f"[Internal Error] {e!s}")
def _is_admin(self, user_id: str) -> bool:
return str(user_id) in self.config.admin_users
def _delivery_context(self, req: CommandRequest) -> Dict[str, Any]:
context: Dict[str, Any] = {}
if getattr(req, "workspace_id", ""):
context["workspace_id"] = str(req.workspace_id)
if getattr(req, "thread_id", ""):
context["thread_id"] = str(req.thread_id)
return context
def _check_command_authz(
self, cmd: str, req: CommandRequest, default_class: CommandClass
) -> Optional[CommandResponse]:
"""
R80: Verify command authorization policy.
Returns None if allowed, or CommandResponse(text=error) if denied.
"""
policy = self.config.command_policy
# 1. Resolve Effective Class (Handle per-command overrides)
# Note: 'cmd' here is the canonical parsed command string (lowercase), e.g., "/run" or "run"
# The overrides dict might use "/run" or "run", we should check both or normalize.
# Currently, the router logic normalized `cmd` from input (lines 90-101).
# We'll check exact match against the override key.
eff_class = policy.command_overrides.get(cmd, default_class)
# 2. Check AllowFrom List (Explicit User Allow)
# If an explicit AllowFrom list exists for this class, the user MUST be in it.
# This takes precedence over role logic.
allowed_users = policy.allow_from.get(eff_class)
if allowed_users is not None and len(allowed_users) > 0:
if str(req.sender_id) not in allowed_users:
# If explicit allow-list is active, even admins must be in it?
# Decision: YES, for strict compliance. If you want admins, add them to the list.
# However, for usability, usually admins are implied.
# Let's stick to "Explicit List Wins" for R80 strict mode.
return CommandResponse(
text="[Access Denied] You are not in the allow-list for this command."
)
# If in list, proceed (bypass default role checks? No, usually allows)
return None
# 3. Default Role Logic
if eff_class == CommandClass.ADMIN and not self._is_admin(req.sender_id):
return CommandResponse(
text="[Access Denied] This command requires Admin privileges."
)
# PUBLIC and RUN are allowed by default (RUN checks trust internally)
return None
def _is_trusted(self, req: CommandRequest) -> bool:
"""
Trusted users can execute /run immediately.
Untrusted users are routed to approval flow.
"""
if self._is_admin(req.sender_id):
return True
platform = (req.platform or "").lower()
sender_id = str(req.sender_id)
channel_id = str(req.channel_id)
if platform == "telegram":
try:
uid = int(sender_id)
except ValueError:
uid = None
try:
cid = int(channel_id)
except ValueError:
cid = None
if uid is not None and uid in self.config.telegram_allowed_users:
return True
return cid is not None and cid in self.config.telegram_allowed_chats
if platform == "discord":
if sender_id in self.config.discord_allowed_users:
return True
return channel_id in self.config.discord_allowed_channels
if platform == "line":
if sender_id in self.config.line_allowed_users:
return True
return channel_id in self.config.line_allowed_groups
if platform == "whatsapp":
return sender_id in self.config.whatsapp_allowed_users
if platform == "wechat":
return sender_id in self.config.wechat_allowed_users
if platform == "kakao":
return sender_id in self.config.kakao_allowed_users
if platform == "slack":
if sender_id in self.config.slack_allowed_users:
return True
return channel_id in self.config.slack_allowed_channels
if platform == "feishu":
if sender_id in self.config.feishu_allowed_users:
return True
return channel_id in self.config.feishu_allowed_chats
# Unknown platform: trust only admins
return False
# --- Handlers ---
+217
View File
@@ -0,0 +1,217 @@
"""Owned run and interrupt command-family mixin."""
# ruff: noqa: UP006, UP035 -- preserve the frozen public annotations.
# mypy: disable-error-code="attr-defined,no-any-return"
import logging
from typing import Any, Dict, List
from .contract import CommandRequest, CommandResponse
logger = logging.getLogger(__name__)
class RouterExecutionMixin:
async def _handle_run(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(
text="Usage: /run <template_id> [prompt text] [key=value ...] [--approval]"
)
# Parse flags
explicit_approval = False
clean_args = []
for arg in args:
if arg in ("--require-approval", "--approval", "-a"):
explicit_approval = True
else:
clean_args.append(arg)
if not clean_args:
return CommandResponse(text="Usage: /run <template_id> ...")
template_id = clean_args[0]
inputs: Dict[str, str] = {}
free_text_parts: List[str] = []
for arg in clean_args[1:]:
if "=" in arg:
k, v = arg.split("=", 1)
inputs[k.strip()] = v.strip()
else:
free_text_parts.append(arg)
# If user provided free text without key=value, treat it as the prompt.
# We map it to a best-effort prompt key (prefers template metadata if available).
if free_text_parts:
prompt_key = await self._resolve_prompt_key(template_id)
if prompt_key not in inputs:
inputs[prompt_key] = " ".join(free_text_parts).strip()
elif self.config.debug:
logger.info(
"DEBUG /run free-text ignored (prompt key already set): %s",
prompt_key,
)
# NOTE: Debug-only payload logging for troubleshooting prompt mismatches.
# Enable with OPENCLAW_CONNECTOR_DEBUG=1 to log template_id + inputs.
if self.config.debug:
logger.info(
"DEBUG /run payload: template=%s inputs=%s approval_flag=%s trusted=%s",
template_id,
inputs,
explicit_approval,
self._is_trusted(req),
)
trusted = self._is_trusted(req)
require_approval = explicit_approval or (not trusted)
res = await self.client.submit_job(
template_id, inputs, require_approval=require_approval
)
if res.get("ok"):
data = res.get("data", {})
trace_id = data.get("trace_id", "unknown")
if data.get("pending"):
approval_id = data.get("approval_id", "unknown")
msg = f"[Approval Requested]\nID: {approval_id}\nTrace: {trace_id}"
if "expires_at" in data:
msg += f"\nExpires: {data['expires_at']}"
if self.poller:
# IMPORTANT:
# For untrusted users, approvals are done in the OpenClaw UI.
# We must start tracking the approval_id so we can map
# approval_id -> executed_prompt_id later and auto-deliver images.
self.poller.track_approval(
approval_id,
req.platform,
req.channel_id,
req.sender_id,
delivery_context=self._delivery_context(req),
)
return CommandResponse(text=msg)
else:
prompt_id = data.get("prompt_id", "unknown")
if self.poller:
self.poller.track_job(
prompt_id,
req.platform,
req.channel_id,
req.sender_id,
delivery_context=self._delivery_context(req),
)
return CommandResponse(
text=f"[Job Submitted]\nID: {prompt_id}\nTemplate: {template_id}\nTrace: {trace_id}"
)
else:
err = res.get("error", "Unknown error")
return CommandResponse(text=f"[Submission Failed] Reason: {err}")
async def _resolve_prompt_key(self, template_id: str) -> str:
"""
Best-effort prompt key resolution.
Prefer template metadata (allowed_inputs), then fall back to common names.
"""
meta = await self._get_template_meta(template_id)
allowed = meta.get("allowed_inputs") or []
# If template explicitly declares a single input, use it.
if isinstance(allowed, list) and len(allowed) == 1:
return str(allowed[0])
preferred = ("positive_prompt", "prompt", "text", "positive", "caption")
if isinstance(allowed, list):
for key in preferred:
if key in allowed:
return key
# Default fallback
return "positive_prompt"
async def _get_template_meta(self, template_id: str) -> Dict[str, Any]:
if template_id in self._template_meta_cache:
return self._template_meta_cache[template_id]
try:
res = await self.client.get_templates()
if res.get("ok"):
for item in res.get("templates", []) or []:
if item.get("id") == template_id:
self._template_meta_cache[template_id] = item
return item
except Exception as e:
if self.config.debug:
logger.info(f"DEBUG template meta fetch failed: {e}")
return {}
async def _handle_interrupt(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
targets = self._parse_stop_targets(args)
if not targets:
res = await self.client.interrupt_output()
if res.get("ok"):
return CommandResponse(text="[Stop] Global Interrupt sent to ComfyUI.")
return CommandResponse(text=f"[Stop Failed] {res.get('error')}")
if len(targets) == 1:
job_id = targets[0]
res = await self.client.cancel_job(job_id)
if res.get("ok"):
return CommandResponse(
text=f"[Stop] Cancellation requested for job {job_id}."
)
# IMPORTANT: Targeted stops must never degrade to no-payload global
# interrupt. Older-host fallback is allowed only with prompt_id set.
if self._jobs_cancel_unsupported(res):
fallback = await self.client.interrupt_output(prompt_id=job_id)
if fallback.get("ok"):
return CommandResponse(
text=(
f"[Stop] Targeted interrupt sent for job {job_id} "
"(jobs cancel unsupported)."
)
)
return CommandResponse(text=f"[Stop Failed] {fallback.get('error')}")
return CommandResponse(text=f"[Stop Failed] {res.get('error')}")
res = await self.client.cancel_jobs(targets)
if res.get("ok"):
return CommandResponse(
text=f"[Stop] Cancellation requested for {len(targets)} jobs."
)
return CommandResponse(text=f"[Stop Failed] {res.get('error')}")
@staticmethod
def _parse_stop_targets(args: List[str]) -> List[str]:
targets: List[str] = []
for arg in args:
for part in str(arg).split(","):
target = part.strip()
if target:
targets.append(target)
return targets
@staticmethod
def _jobs_cancel_unsupported(res: Dict[str, Any]) -> bool:
status = res.get("status")
if status in (404, 405, 501):
return True
error = str(res.get("error", "")).lower()
unsupported_markers = (
"404",
"not found",
"method not allowed",
"unsupported",
"not implemented",
)
return any(marker in error for marker in unsupported_markers)
+22 -16
View File
@@ -18,12 +18,14 @@ If the deployment enables remote control or bridge features, it must also pass *
- [ ] **Admin Boundaries**:
- [ ] Server-side admin write boundary uses `OPENCLAW_ADMIN_TOKEN` (legacy `MOLTBOT_ADMIN_TOKEN`).
- [ ] Connector admin command paths use `OPENCLAW_CONNECTOR_ADMIN_TOKEN`, and must match server admin token when server admin auth is enabled.
- [ ] **Connector Ingress Defaults**: Platform adapters remain disabled unless required token/enable vars are configured (Telegram/Discord/LINE/WhatsApp/WeChat/Kakao/Slack).
- [ ] **Connector Ingress Defaults**: Platform adapters remain disabled unless required token/enable vars are configured (Telegram/Discord/LINE/WhatsApp/WeChat/Kakao/Slack/Feishu).
- [ ] **Connector Allowlists (Strict Posture)**: In `public` deployment or `hardened` runtime posture, active connector platforms must have allowlist coverage before startup (fail-closed; public check code `DP-PUBLIC-009`).
- [ ] **Connector Replay & Visibility**: Duplicate committed connector events are no-ops, retryable pre-commit failures can be retried, and text-only reply suppression does not hide approval/action controls.
- [ ] **Observability**: `/openclaw/logs/tail` and `/openclaw/config` require `OPENCLAW_OBSERVABILITY_TOKEN` (legacy: `MOLTBOT_OBSERVABILITY_TOKEN`) if accessed remotely, or are loopback-only.
- [ ] **SSRF**: LLM `base_url` defaults to known providers. Custom public URLs require `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1` or explicit allowlist; private/reserved IP targets still require `OPENCLAW_ALLOW_INSECURE_BASE_URL=1`.
- [ ] **SSRF**: LLM `base_url` defaults to known providers. Custom public URLs require `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1` or explicit allowlist; private/reserved IP targets require the scoped LLM private-network setting or the broader `OPENCLAW_ALLOW_INSECURE_BASE_URL=1` override.
- [ ] **Public Boundary Contract (S69)**: for `OPENCLAW_DEPLOYMENT_PROFILE=public`, set `OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1` only after reverse-proxy path allowlist + network ACL deny ComfyUI-native high-risk routes.
- [ ] **Budgets**: `OPENCLAW_MAX_INFLIGHT_SUBMITS_TOTAL` (concurrency) and `OPENCLAW_MAX_RENDERED_WORKFLOW_BYTES` (payloads) are enforced.
- [ ] **External Tools**: external tool execution is disabled unless explicitly required; if enabled, the package-owned/default or custom allowlist is reviewed, sandbox policy is explicit for hardened posture, and sandbox/interpreter/timeout/workspace diagnostics are deterministic.
- [ ] **Contracts**: API endpoints match `docs/release/api_contract.md`; Configuration follows `docs/release/config_secrets_contract.md`.
### 2. Documentation & Recipes
@@ -37,24 +39,28 @@ If the deployment enables remote control or bridge features, it must also pass *
### 3. Validation (Must Pass)
Run the full regression suite:
Run the complete OS-specific regression gate:
```powershell
# Windows
powershell -File scripts/run_full_tests_windows.ps1
```
```bash
# 1. Secret Scanning
./.venv/Scripts/python.exe -m pre_commit run detect-secrets --all-files
# 2. Lint & Formatting
./.venv/Scripts/python.exe -m pre_commit run --all-files --show-diff-on-failure
# 3. Backend Unit Coverage Gate
MOLTBOT_STATE_DIR="$(pwd)/moltbot_state/_local_unit" ./.venv/Scripts/python.exe scripts/run_backend_coverage.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json --coverage-json .tmp/coverage/backend_unit_coverage.json
# 4. Frontend E2E (Unit/Integration)
# Ensure Node 18+
node -v
npm test
# Linux / WSL
bash scripts/run_full_tests_linux.sh
```
These scripts execute the authoritative `tests/TEST_SOP.md` sequence, including fresh
lockfile reconciliation with `npm ci`, the blocking
`npm audit --audit-level=high` check across production and development dependencies,
secret scanning, pre-commit hooks, governance and backend lanes, adaptive adversarial
validation, and frontend Playwright E2E. A standalone `npm test` result is not a
substitute for the complete release gate.
If staged/manual execution is required, follow the explicit command order in
`tests/TEST_SOP.md`; do not maintain a shortened release-only sequence here.
---
## Gate B: Bridge / Remote Control Safety (Conditional)
+25 -15
View File
@@ -2,9 +2,9 @@
## Quick Links
- Deployment profiles and checklists: [Security Deployment Guide](docs/security_deployment_guide.md)
- Runtime startup hardening behavior: [Runtime Hardening and Startup](docs/runtime_hardening_and_startup.md)
- Pre-exposure checklist: [Security Checklist](docs/security_checklist.md)
- Deployment profiles and checklists: [Security Deployment Guide](security_deployment_guide.md)
- Runtime startup hardening behavior: [Runtime Hardening and Startup](runtime_hardening_and_startup.md)
- Pre-exposure checklist: [Security Checklist](security_checklist.md)
- Deployment self-check command:
- `python scripts/check_deployment_profile.py --profile local|lan|public`
@@ -12,16 +12,16 @@
Only the latest version of ComfyUI-OpenClaw is supported for security updates.
| Version | Supported |
| ------- | ------------------ |
| Latest | :white_check_mark: |
| < 0.2.0 | :x: |
| Version | Supported |
| ------------------------ | ------------------ |
| Latest published release | :white_check_mark: |
| All earlier releases | :x: |
## Reporting a Vulnerability
Please report security vulnerabilities by creating a **private** issue on GitHub if possible, or contact the maintainers directly. Do not open public issues for sensitive security flaws.
### Disclosure Workflow and SLA (S48)
### Disclosure Workflow and SLA
Private reporting workflow:
1. Submit a private report with repro steps, affected version, and impact.
@@ -103,15 +103,20 @@ export OPENCLAW_ADMIN_TOKEN="your-secure-random-admin-token-here"
Then configure your proxy or client to send the header `X-OpenClaw-Obs-Token: your-secure-random-token-here` (legacy: `X-Moltbot-Obs-Token`).
### 1.1 Reasoning Debug Reveal Boundary (Local-only)
### 1.1 Reasoning and Internal Content Redaction Boundary
Operator-facing payloads strip provider reasoning / thinking traces by default across:
Operator-facing payloads strip provider reasoning / thinking traces and explicitly marked internal maintenance/helper prompt content by default across:
- assist responses
- event / SSE payloads
- trace responses
- callback payloads
- connector trace/debug replies
- audit event payload/meta fields
Internal maintenance/helper prompt content has no public or debug reveal path. Privileged reasoning reveal is limited to provider reasoning / thinking traces only.
### 1.2 Reasoning Debug Reveal Boundary (Local-only)
There is a privileged local-debug reveal path for troubleshooting, but it is fail-closed unless **all** of the following are true:
@@ -142,7 +147,7 @@ export OPENCLAW_TRUSTED_PROXIES="127.0.0.1,10.0.0.0/8"
# export MOLTBOT_TRUSTED_PROXIES="127.0.0.1,10.0.0.0/8"
```
### 3. Public Profile Boundary Acknowledgement (S69)
### 3. Public Profile Boundary Acknowledgement
For public profile deployments, you must explicitly acknowledge that reverse-proxy path controls and network ACL boundaries are already enforced:
@@ -159,7 +164,7 @@ If this acknowledgement is missing in public profile, deployment profile checks
Connector ingress posture is fail-closed in strict profiles:
- if connector platform ingress is active (Telegram/Discord/LINE/WhatsApp/WeChat/Kakao/Slack)
- if connector platform ingress is active (Telegram/Discord/LINE/WhatsApp/WeChat/Kakao/Slack/Feishu)
- and matching allowlist variables are missing
- startup/deployment checks fail closed (`DP-PUBLIC-009` for public profile)
@@ -175,6 +180,8 @@ For interactive connector callbacks (actions/modals/workflow style payloads), th
- stale timestamp, replay/duplicate request ID, payload-hash mismatch, or unknown action type are rejected
- workspace-to-installation resolution is fail-closed on missing/ambiguous/inactive/stale-token-ref binding
- policy mapping is explicit (`public`/`run`/`admin`) and untrusted `run` callbacks degrade to approval instead of direct privileged execution
- duplicate committed connector events are acknowledged without re-running completed actions, while retryable failures before action/delivery commit can be retried
- reply visibility is policy-driven; text-only silent/internal/tool-only/no-mention replies can be suppressed without suppressing approval cards, action buttons, allowlist checks, or callback replay checks
Operational note:
@@ -220,7 +227,7 @@ Operational note:
- this path remains backend-only; frontend surfaces stay secret-blind.
### 5. Startup Gate Behavior (R136 + S56)
### 5. Startup Gate Behavior
Startup security gates are fail-closed. Fatal startup gate/bootstrap failures abort route/worker registration and do not continue in a partial state.
@@ -236,6 +243,7 @@ OpenClaw validates custom LLM `base_url` settings to prevent Server-Side Request
* **Default**: known providers and localhost-safe paths are allowed.
* **Pinned connect contract**: on supported CPython versions (current baseline: 3.10+), the consolidated `safe_io` outbound executor dials resolved IPs directly for HTTP/HTTPS and keeps TLS `server_hostname` on the original host; the no-skip `tests.test_s70_ssrf_pinning_regression` lane is intended to fail loudly if stdlib connect behavior drifts.
* **Redirect handling**: redirect targets are revalidated against host allowlists, private/reserved-IP blocking, and pinned-connect rules before any follow-up connection is opened.
* **Custom base URL**:
- requires explicit opt-in:
@@ -250,8 +258,9 @@ OpenClaw validates custom LLM `base_url` settings to prevent Server-Side Request
```
- `OPENCLAW_LLM_ALLOWED_HOSTS` only permits additional exact public hosts; it does not bypass the private/reserved-IP block.
- `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1` widens to any public host only.
- `allow_private_network=true` on the LLM setting allows only the configured provider `base_url` host to resolve to a private/reserved IP while keeping exact-host allowlists, scheme/port checks, and DNS pinning.
- `OPENCLAW_ALLOW_INSECURE_BASE_URL=1` is the explicit risk-acceptance override for HTTP or private/reserved IP targets.
- the same override is enforced consistently for config validation, `/openclaw/llm/models`, and outbound provider requests.
- the same scoped/private or insecure decision is enforced consistently for config validation, `/openclaw/llm/models`, and outbound provider requests.
- wildcard values such as `OPENCLAW_LLM_ALLOWED_HOSTS="*"` are not supported.
- avoid broad bypass flags in production (`OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST`, `OPENCLAW_ALLOW_INSECURE_BASE_URL`).
@@ -281,7 +290,7 @@ OpenClaw enforces internal rate limits:
### 8. Sidecar Bridge
OpenClaw supports a "Sidecar Bridge" (F10) for safe interaction with external bots (Discord/Slack).
OpenClaw supports a "Sidecar Bridge" for safe interaction with external bots (Discord/Slack).
* **Default**: **DISABLED**.
* **Enable**: Set `OPENCLAW_BRIDGE_ENABLED=1` (legacy `MOLTBOT_BRIDGE_ENABLED=1`).
@@ -298,6 +307,7 @@ OpenClaw supports a "Sidecar Bridge" (F10) for safe interaction with external bo
* [ ] **Public shared-surface ack**: for `OPENCLAW_DEPLOYMENT_PROFILE=public`, set `OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1` only after proxy path allowlist + ACL are verified.
* [ ] **Public path deny rules**: block ComfyUI-native high-risk routes and `/api/*` equivalents unless explicitly required.
* [ ] **Connector strict-posture allowlists**: if connector ingress is active in `public` or `hardened`, ensure platform allowlists are set before startup (`DP-PUBLIC-009` for public profile).
* [ ] **External tools disabled by default**: keep `OPENCLAW_ENABLE_EXTERNAL_TOOLS=0` unless there is a reviewed need; if enabled, verify the tool allowlist, sandbox policy, and deterministic sandbox/interpreter/timeout/workspace diagnostics.
* [ ] **Multi-tenant boundary (if enabled)**: enforce one canonical tenant header path through proxy/app, keep fallback toggles disabled unless a migration window is actively in progress.
* [ ] **Audit integrity check**: run `python scripts/verify_audit_chain.py --json` after restart/rotation-sensitive maintenance and confirm retained audit logs still verify cleanly.
* [ ] **1Password guardrails (if enabled)**: require command allowlist + vault/template validation; in multi-tenant mode, include `{tenant}` in item template.
@@ -0,0 +1,27 @@
# Service Domain Packages
Bootstrap lifecycle, route registration, and effective security posture have explicit
implementation owners:
- `services/bootstrap/lifecycle.py` owns startup phase, outcome, and optional-warmup state.
- `services/bootstrap/registration.py` owns host route registration and retry coordination.
- `services/posture/effective.py` owns the immutable process security-posture snapshot.
The historical modules remain compatibility aliases:
- `services/startup_lifecycle.py`
- `services/route_bootstrap.py`
- `services/effective_security_posture.py`
Each alias maps its module name to the implementation module object. This preserves one
process singleton and keeps existing imports and patch points compatible. Do not replace
these aliases with copied re-exports: copied module globals can diverge from the state used
by implementation functions. Type-checker-only exports may describe the legacy interface,
but they must stay behind `TYPE_CHECKING` and must not become a second runtime owner.
New implementation code should import the domain-owned modules. Existing consumers may
continue to use the compatibility paths. An implementation module must never import its
compatibility alias; the repository dependency policy enforces that direction.
Package initializers are navigation-only. They must not register routes, resolve posture,
start threads, or re-export mutable process state during import.
+82
View File
@@ -0,0 +1,82 @@
# ComfyUI Asset API Adoption Decision (2026-04-16)
## 2026-07-31 reference anchor update
- Current reference anchor is ComfyUI `9cf91339` (`v0.29.0-12-g9cf91339`, pyproject `0.29.0`).
- SaveImage output sockets, 3D preview refs, typed asset dimensions, grouped asset downloads, and optional `hash` / `asset_hash` aliases do not change the no-go decision.
- ComfyUI asset hashing is host-side opt-in through `--enable-asset-hashing`, so normal filename-backed output refs must not require hash metadata.
- Current host asset metadata may expose `loader_path`; model uploads require `model_type:<folder_name>` tags, and `/features.supports_model_type_tags` advertises that contract. OpenClaw does not upload through or directly consume `/api/assets`, so these facts do not change the no-go decision.
- OpenClaw continues to use `/history` + `/view`; asset-service-only refs stay explicit `asset_api_required` states.
## 2026-06-12 reconfirmation
- Current output parsing is media-aware for ComfyUI result groups `images`, `video`, `audio`, `3d`, and bounded `text`.
- File-like media refs still use `/view` when they provide `filename`, or optional hash-backed preview metadata when the host provides it.
- HDR `.exr` / `.hdr` image refs stay on the `/view` source-preview contract but render as explicit fallback links because OpenClaw does not embed the host HDR viewer.
- Text output previews are bounded and rendered as text, not HTML.
- Asset-service-only identifiers remain explicit fallback states and still do not trigger automatic direct `/api/assets` fetches.
## 2026-05-31 reconfirmation
- Current host reference evidence shows upstream asset responses may expose optional `hash` alongside `asset_hash`.
- OpenClaw accepts `hash` as an alias for hash-backed previews when present, but still resolves those refs through `/view?filename=blake3:...`.
- This does not change the no-go decision for automatic direct `/api/assets` runtime fetches.
## Scope
- Goal: decide whether OpenClaw should adopt upstream `/api/assets` semantics as a normal runtime dependency beyond the bounded `/view` interoperability layer.
## Current baseline
- Current history/output-facing interop already accepts:
- classic ComfyUI output refs (`filename`, `subfolder`, `type`)
- optional asset-hash-backed refs that still resolve through `/view?filename=blake3:...` when host metadata is present
- media-aware output groups (`images`, `video`, `audio`, `3d`, and bounded `text`)
- HDR `.exr` / `.hdr` image refs as explicit `/view` source-preview fallback links, not normal thumbnails
- Current ComfyUI `9cf91339` / `v0.29.0-12-g9cf91339` / pyproject `0.29.0` reference facts:
- `/api/assets*` routes exist, but operational use is feature-gated behind `--enable-assets`
- content hashing is opt-in through `--enable-asset-hashing`, so normal filename-backed refs may omit `asset_hash` / `hash`
- `/features` exposes the `assets` capability flag so hosts can report whether the asset system is enabled
- frontend preview still resolves `blake3:...` asset hashes through `/view`, so hash-backed outputs do not require a direct `/api/assets` fetch
- asset responses may expose optional `hash` alongside `asset_hash`; OpenClaw treats both as hash-backed preview aliases when present
- asset metadata may expose `loader_path`; model uploads require `model_type:<folder_name>` tags, advertised by `/features.supports_model_type_tags`
- Current operator/runtime surfaces in scope:
- sidebar `Jobs`
- callback delivery payloads
- history/result consumption paths derived from `services.comfyui_history`
- Current non-goal:
- no gallery/explorer/runtime flow currently requires direct `/api/assets` fetches to stay functional.
## Decision
- **No-go for first-class `/api/assets` runtime adoption in phase 2.**
- OpenClaw keeps `/history` + `/view` as the supported runtime contract for normal output handling.
- Asset-api-only identifiers are treated as explicit unsupported contracts rather than implicit fetch targets.
## Rationale
1. Current OpenClaw output surfaces still succeed on the existing bounded `/view` contract, including optional asset-hash-backed refs when metadata exists.
2. Adding `/api/assets` as a normal dependency would widen runtime coupling to upstream host behavior without a demonstrated operator need in current features.
3. A silent fallback from `asset id only` to `/api/assets` would weaken boundary clarity and make host drift harder to reason about.
## Approved phase-2 seam
- Preserve current supported refs exactly:
- classic refs -> `/view?filename=...&type=...`
- optional asset-hash-backed refs -> `/view?filename=blake3:...` when metadata exists
- file-like media refs -> `/view` fallback/link surfaces when preview metadata is present
- HDR `.exr` / `.hdr` image refs -> explicit source-preview fallback links
- bounded text refs -> escaped text surfaces, not HTML
- For refs that expose only asset-service identifiers and are not representable through `/view`:
- keep them in normalized output payloads
- mark them as `asset_api_required`
- do not auto-fetch `/api/assets`
- surface a bounded operator-facing message where relevant
## Re-open triggers
Revisit this decision only if one of the following becomes true:
1. A current operator-facing surface cannot complete its supported workflow without direct `/api/assets` semantics.
2. Upstream ComfyUI stops providing `/view`-compatible output metadata for supported runtime flows.
3. OpenClaw intentionally adds a new asset-management feature whose documented contract depends on asset-service metadata beyond hash-backed preview resolution.
+58 -6
View File
@@ -22,6 +22,7 @@ The connector runs alongside ComfyUI on your machine.
- **Strict Profile Gate**: In `public` deployment or `hardened` runtime posture, enabling connector ingress without platform allowlist coverage is fail-closed at startup/deployment checks.
- **Local Secrets**: Bot tokens are stored in your local environment, never sent to ComfyUI.
- **Admin Boundary**: Control-plane actions call admin endpoints on the local OpenClaw server and require connector-side admin token configuration for admin command paths.
- **Reply Visibility**: Shared visibility policy can suppress text-only silent/internal/tool-only/no-mention replies without suppressing approval cards, action buttons, or the underlying trust checks.
### Installation and callback contract baseline
@@ -33,6 +34,8 @@ OpenClaw now includes a platform-agnostic baseline for multi-workspace connector
- workspace resolution is fail-closed on missing/ambiguous/inactive/stale bindings
- installation diagnostics can also surface stable health states such as `ok`, `invalid_token`, `revoked`, `workspace_unbound`, and `degraded`
- interactive callback contract enforces signed envelope checks, timestamp window, payload-hash validation, replay/idempotency guardrails, and command-policy mapping (`public`/`run`/`admin`) with explicit force-approval outcomes for untrusted `run` callbacks
- connector replay handling acknowledges duplicate committed events as no-ops while allowing retryable failures before delivery commit to be retried
- text reply visibility is resolved through one connector policy for direct-message, shared-chat, thread, internal-delivery, and tool-only contexts; suppressed text is logged/diagnostic and treated as successful no-op delivery
Admin diagnostics APIs:
@@ -45,6 +48,7 @@ Admin diagnostics APIs:
Extraction diagnostics note:
- `/openclaw/connector/extraction-contract` is an admin-only structural metadata route for maintainers and operators. It returns the current packaging recommendation, candidate extraction options, seam families, and blockers, but it does **not** expose live token or installation-state details beyond the existing diagnostics routes above.
- The extraction contract also includes the static service-env SecretRef propagation policy. It is not a live environment dump and does not expose token values.
Slack multi-workspace notes:
@@ -54,6 +58,8 @@ Slack multi-workspace notes:
- `GET /openclaw/connector/installations` diagnostics may include per-install health metadata plus aggregate `health_counts`.
- Slack lifecycle events such as `tokens_revoked`, `app_uninstalled`, and rate-limit degradation update installation health so outbound replies fail closed or degrade predictably for the affected workspace.
- In multi-workspace mode, outbound replies and delayed result deliveries resolve the bot token by workspace binding and keep Slack thread context when replying back to the originating conversation.
- Slack interactive callbacks use the configured interactions path, signature verification, replay/idempotency checks, and connector policy mapping before accepting action payloads.
- Slack text replies honor the shared reply-visibility policy when context metadata is available; channel no-mention or tool-only text can be suppressed while Block Kit/action responses remain deliverable.
Feishu / Lark notes:
@@ -61,6 +67,7 @@ Feishu / Lark notes:
- The connector supports both `feishu` and `lark` API domains through one shared binding contract, so region-specific app hosts do not require a different adapter.
- Websocket-mode Feishu deployments still host a callback route so interactive approval cards and command buttons remain available when message ingress itself is long-connection based.
- Feishu callback actions are signed, replay-guarded, tenant-aware, and deduplicated. Untrusted actors pressing run-affecting buttons are downgraded to approval flow instead of executing directly.
- Feishu text replies honor the shared reply-visibility policy when context metadata is available; group no-mention or tool-only text can be suppressed while interactive cards remain deliverable.
### Multi-tenant boundary behavior
@@ -73,7 +80,7 @@ When backend multi-tenant mode is enabled (`OPENCLAW_MULTI_TENANT_ENABLED=1`):
## Supported Platforms
- **Telegram**: Long-polling (instant response).
- **Telegram**: Long-polling (instant response), including forum topic reply context for immediate replies and delayed result delivery.
- **Discord**: Gateway WebSocket (instant response).
- **LINE**: Webhook (requires inbound HTTPS).
- **WhatsApp**: Webhook (requires inbound HTTPS).
@@ -97,7 +104,7 @@ Set the following environment variables (or put them in a `.env` file if you use
- `OPENCLAW_CONNECTOR_URL`: URL of your ComfyUI (default: `http://127.0.0.1:8188`)
- `OPENCLAW_CONNECTOR_DEBUG`: Set to `1` for verbose logs.
- `OPENCLAW_CONNECTOR_ADMIN_USERS`: Comma-separated list of user IDs allowed to run admin commands (for example `/stop`, approvals, schedules). Admin users are also treated as trusted senders for `/run`.
- `OPENCLAW_CONNECTOR_ADMIN_USERS`: Comma-separated list of user IDs allowed to run admin commands (for example `/stop`, `/cancel`, approvals, schedules). Admin users are also treated as trusted senders for `/run`.
- `OPENCLAW_CONNECTOR_ADMIN_TOKEN`: Admin token sent to OpenClaw (`X-OpenClaw-Admin-Token`).
- `OPENCLAW_LOG_TRUNCATE_ON_START`: Optional backend runtime flag. Set `1` to clear `openclaw.log` once at backend startup to avoid stale-history noise in UI log panels.
- `OPENCLAW_MULTI_TENANT_ENABLED`: Optional backend mode toggle. If `1`, connector diagnostics and installation resolution become tenant-scoped.
@@ -109,11 +116,19 @@ Set the following environment variables (or put them in a `.env` file if you use
- If the OpenClaw server has `OPENCLAW_ADMIN_TOKEN` configured, `OPENCLAW_CONNECTOR_ADMIN_TOKEN` must match it or admin calls return HTTP 403.
- Without `OPENCLAW_CONNECTOR_ADMIN_TOKEN`, admin command flows (`/approve`, `/reject`, `/trace`, schedules) are blocked by connector policy before upstream calls.
**SecretRef service environment behavior:**
- Service/sidecar launch helpers may preserve structured env-backed SecretRef metadata for connector credential variables such as platform bot tokens and signing secrets.
- Diagnostics show only the config path, env var name, source, status, and reason. They do not show raw token values from the installing shell.
- Raw secret strings, legacy `secretref-env:<NAME>` markers, unsupported env names, and gateway/admin auth env vars are rejected instead of being written into service metadata.
- Runtime-only auth secrets such as `OPENCLAW_CONNECTOR_ADMIN_TOKEN`, `OPENCLAW_WORKER_TOKEN`, and bridge device tokens must be provided by the runtime environment or a local secret manager rather than persisted through the connector service-env SecretRef boundary.
**Telegram:**
- `OPENCLAW_CONNECTOR_TELEGRAM_TOKEN`: Your Bot Token (from @BotFather).
- `OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_USERS`: Comma-separated list of User IDs (e.g. `123456, 789012`).
- `OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_CHATS`: Comma-separated list of Chat/Group IDs.
- Telegram forum topics are preserved when Telegram provides `message_thread_id`; command replies and delayed result delivery are sent back to the same topic. Manually configured delivery contexts must use numeric topic/thread IDs.
**Discord:**
@@ -274,6 +289,28 @@ OPENCLAW_COMMAND_ALLOW_FROM_RUN=alice_id,ops_bot_id
If a class-level `OPENCLAW_COMMAND_ALLOW_FROM_*` list is set and non-empty, only listed IDs can run that class.
### Authoritative jobs summary
`/jobs` and its `jobs` / `queue` aliases are Admin-class commands. They require both an
authorized connector admin user and a configured `OPENCLAW_CONNECTOR_ADMIN_TOKEN` before
the connector calls `GET /openclaw/jobs`.
The connector validates jobs contract version 1 before rendering any reply:
- output contains aggregate snapshot/page counts plus at most five job IDs and statuses;
- displayed job IDs are capped at 24 characters and the complete reply is capped at 1,000
characters;
- raw job records, prompts, workflows, outputs, errors, tracebacks, tenant identifiers,
and the upstream payload are never sent to the chat LLM or copied into error messages;
- HTTP 401/403 returns a fixed authorization message without fallback;
- only explicit HTTP 501 `jobs_host_contract_unsupported` or HTTP 503
`jobs_backend_unavailable` responses may fall back to a bounded coarse queue count;
- malformed, unknown-version, oversized, or inconsistent success payloads fail to a fixed
content-free message.
Public `/status` remains separate: it can summarize health and the coarse ComfyUI queue,
but it does not fetch or forward the Admin-only jobs snapshot.
### 3. Usage
#### Running the Connector
@@ -447,7 +484,7 @@ Slack uses the Events API webhook mode in OpenClaw. You must expose the endpoint
- `channels:history` (public channel messages)
- `groups:history` (private channel messages)
- For legacy single-workspace mode, click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`).
- For F58 multi-workspace mode, configure a redirect URL and let OpenClaw handle installs through its OAuth routes.
- For multi-workspace mode, configure a redirect URL and let OpenClaw handle installs through its OAuth routes.
3. **Configure connector environment variables**
@@ -458,6 +495,7 @@ Slack uses the Events API webhook mode in OpenClaw. You must expose the endpoint
OPENCLAW_CONNECTOR_PUBLIC_BASE_URL=https://your-public-host
OPENCLAW_CONNECTOR_SLACK_OAUTH_INSTALL_PATH=/slack/install
OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH=/slack/oauth/callback
OPENCLAW_CONNECTOR_SLACK_INTERACTIONS_PATH=/slack/interactions
OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS=U12345,U67890
OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS=C12345
OPENCLAW_CONNECTOR_SLACK_BIND=127.0.0.1
@@ -469,9 +507,10 @@ Slack uses the Events API webhook mode in OpenClaw. You must expose the endpoint
```
Notes:
- Legacy single-workspace fallback can still set `OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN=xoxb-...`; F58 multi-workspace mode no longer requires that token at startup if OAuth install flow is configured.
- Legacy single-workspace fallback can still set `OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN=xoxb-...`; multi-workspace mode no longer requires that token at startup if OAuth install flow is configured.
- `OPENCLAW_CONNECTOR_ADMIN_TOKEN` must match server `OPENCLAW_ADMIN_TOKEN` if server-side admin token is enabled.
- Slack ingress is fail-closed: invalid/missing signature, stale timestamp, and replayed events are rejected.
- Slack interactive callbacks use the same signing-secret verification and route actions through the connector policy layer before executing run-affecting behavior.
- OAuth callbacks also fail closed on invalid or replayed `state` values.
- External OAuth/install failures intentionally use bounded generic text; inspect connector logs and installation diagnostics for redacted detail instead of expecting raw exception text in the callback response.
@@ -480,10 +519,11 @@ Slack uses the Events API webhook mode in OpenClaw. You must expose the endpoint
- Expose local endpoint to public HTTPS (Cloudflare Tunnel/ngrok/reverse proxy):
- local upstream: `http://127.0.0.1:8095`
- public URL: `https://<public-host>/slack/events`
- interactions URL: `https://<public-host>/slack/interactions`
- install URL: `https://<public-host>/slack/install`
- callback URL: `https://<public-host>/slack/oauth/callback`
5. **Enable Event Subscriptions**
5. **Enable Event Subscriptions and Interactivity**
- Go to **Event Subscriptions** and enable events.
- Set **Request URL** to `https://<public-host>/slack/events`.
- Slack sends `url_verification`; connector responds automatically.
@@ -492,12 +532,15 @@ Slack uses the Events API webhook mode in OpenClaw. You must expose the endpoint
- `message.channels`
- `message.groups`
- `message.im`
- Go to **Interactivity & Shortcuts** and enable interactivity.
- Set **Request URL** to `https://<public-host>/slack/interactions`.
6. **Invite and validate**
- Open `https://<public-host>/slack/install` and complete the workspace install.
- Invite the app to target channels: `/invite @YourBot`.
- In channel: `@YourBot /status` (when `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true`).
- In DM: `/help`.
- For approval or action-capable replies, press a rendered Slack button and confirm the connector logs show a signed interaction accepted or a bounded policy rejection.
- Verify connector logs show signed ingress accepted and replies delivered.
- Verify `GET /openclaw/connector/installations` shows the Slack workspace binding and health state `ok`.
- If you test uninstall/token-revoke scenarios, verify the installation health flips to `revoked` or `invalid_token` and that subsequent replies for that workspace fail closed until reinstalled.
@@ -617,7 +660,8 @@ Notes:
| `/history <id>` | View details of a finished job. |
| `/help` | Show available commands. |
| `/run <template> [k=v] [--approval]` | Submit a job. Use `--approval` to request approval gate instead of creating job immediately. |
| `/stop` | **Global Interrupt**: Stop all running generations. |
| `/stop [job_id ...]` | Stop jobs. With no job IDs, sends an explicit global interrupt. With one or more IDs, requests targeted job cancellation through ComfyUI's jobs API; older single-job hosts may fall back to targeted interrupt. |
| `/cancel [job_id ...]`, `/interrupt [job_id ...]` | Aliases for `/stop` with the same targeted or global behavior. |
**Admin Only:**
*(Requires User ID in `OPENCLAW_CONNECTOR_ADMIN_USERS`)*
@@ -664,6 +708,14 @@ Notes:
- Sender is not in `OPENCLAW_CONNECTOR_ADMIN_USERS`.
- Fix: Add ID to `.env` and restart connector.
- **No visible chat reply after a command**:
- The command may have completed in a context where text-only replies are intentionally suppressed, such as internal delivery, tool-only handling, or a shared chat/channel without an active mention.
- Fix: check connector logs and job/approval state. Approval cards and action buttons should still be delivered when the action requires visible operator input.
- **Duplicate platform event is acknowledged but not executed again**:
- The connector has already committed the action and treats the retry/replay as a successful no-op.
- Fix: check the original event, job, or approval record instead of resending the same action payload. Retry only failures that happened before delivery/action commit.
- **HTTP 403 (Admin Token)**:
- Connector has the right user allowlist, but the upstream OpenClaw server rejected the Admin Token.
- Fix: Ensure `OPENCLAW_CONNECTOR_ADMIN_TOKEN` matches the server's `OPENCLAW_ADMIN_TOKEN`.
+1 -1
View File
@@ -71,7 +71,7 @@ sudo ufw allow from 192.168.1.0/24 to any port 8188
- ❌ Do not forward port 8188 on your router.
- ❌ Do not use `--listen 0.0.0.0` on a laptop connected to public WiFi.
- ❌ Do not set `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=true` on LAN/shared deployments.
- ❌ Do not assume LAN Remote Admin access also permits LAN-hosted custom LLM targets; `OPENCLAW_LLM_ALLOWED_HOSTS` alone does not allow private/reserved IP `base_url` values.
- ❌ Do not assume LAN Remote Admin access also permits LAN-hosted custom LLM targets; `OPENCLAW_LLM_ALLOWED_HOSTS` alone does not allow private/reserved IP `base_url` values. Use the scoped LLM private-network setting only for reviewed targets.
## Testing
-20
View File
@@ -1,20 +0,0 @@
# /etc/default/openclaw.env
# Secure environment configuration for OpenClaw
# Admin Token (Required for remote ops)
OPENCLAW_ADMIN_TOKEN=change-me-to-a-strong-secret
# Observability Token (Required for remote logs)
# (Legacy: MOLTBOT_OBSERVABILITY_TOKEN)
OPENCLAW_OBSERVABILITY_TOKEN=change-me-too
# Bridge (Default: 0/Disabled)
OPENCLAW_BRIDGE_ENABLED=0
# OPENCLAW_BRIDGE_DEVICE_TOKEN=
# Optional startup log hygiene (truncate openclaw.log once per process start)
# OPENCLAW_LOG_TRUNCATE_ON_START=1
# Network
# Bind to localhost by default
COMFYUI_LISTEN=127.0.0.1
+19
View File
@@ -0,0 +1,19 @@
# Copy this public template to /etc/default/openclaw.env before starting the service.
# Replace every placeholder locally; never commit the deployed environment file.
# Admin Token (required for remote operations)
OPENCLAW_ADMIN_TOKEN=replace-with-a-strong-secret
# Observability Token (required for remote logs)
# Legacy name: MOLTBOT_OBSERVABILITY_TOKEN
OPENCLAW_OBSERVABILITY_TOKEN=replace-with-an-observability-secret
# Bridge (default: disabled)
OPENCLAW_BRIDGE_ENABLED=0
# OPENCLAW_BRIDGE_DEVICE_TOKEN=
# Optional startup log hygiene (truncate openclaw.log once per process start)
# OPENCLAW_LOG_TRUNCATE_ON_START=1
# Bind to localhost by default
COMFYUI_LISTEN=127.0.0.1
+3 -3
View File
@@ -65,9 +65,9 @@ If Remote Admin is running on this Windows host, but your custom/OpenAI-compatib
- `OPENCLAW_LLM_ALLOWED_HOSTS` only allows additional exact public hosts.
- `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1` still applies only to public hosts.
- Private/reserved LAN IPs still require `OPENCLAW_ALLOW_INSECURE_BASE_URL=1`.
- Private/reserved LAN IPs still require the scoped `allow_private_network` LLM setting for that configured target, or `OPENCLAW_ALLOW_INSECURE_BASE_URL=1`.
- `OPENCLAW_LLM_ALLOWED_HOSTS=*` is not supported.
- On current builds, that same override is honored by both Remote Admin validation and `/openclaw/llm/models` refresh requests after a full restart.
- On current builds, that same scoped/private or insecure decision is honored by both Remote Admin validation and `/openclaw/llm/models` refresh requests after a full restart.
Recommended verification in the same embedded runtime:
@@ -75,7 +75,7 @@ Recommended verification in the same embedded runtime:
.\python_embeded\python.exe -c "import os; print(repr(os.environ.get('OPENCLAW_LLM_ALLOWED_HOSTS')))"
```
If you intentionally accept the risk and enable `OPENCLAW_ALLOW_INSECURE_BASE_URL=1`, restart ComfyUI fully after changing the env vars.
If you intentionally accept the broader risk and enable `OPENCLAW_ALLOW_INSECURE_BASE_URL=1`, restart ComfyUI fully after changing the env vars.
## Service Mode (NSSM)
+38
View File
@@ -0,0 +1,38 @@
# Frontend Tab Wiring
OpenClaw's frontend uses modular vanilla ES modules loaded by the ComfyUI extension host. Keep tab work inside this architecture unless a future migration decision explicitly changes the runtime model.
## Tab Registration
- Register the OpenClaw host sidebar entry through `registerOpenClawSidebar(app, tabDefinition)` from `web/openclaw_sidebar_registration.js`; it prefers ComfyUI's current sidebar store API and falls back to the deprecated frontend facade for older host bundles.
- Register tabs through `tabManager.registerTab({ id, title, icon, render, dispose? })`.
- Keep `id` stable; it is used for pane ids and active-tab storage.
- Treat `render(pane)` as the only place that mutates a tab pane.
- Return a promise from `render` only when the tab genuinely performs async work; async failures are routed through the tab error boundary.
- Use optional `dispose(pane)` for pending-render cleanup. Returning `true` requests a fresh render
when the user revisits the tab; completed panes should remain reusable.
## DOM Helpers
- Prefer shared helpers from `web/openclaw_utils.js` for new shell/tab wiring:
- `createDomElement(...)` for text-safe element construction.
- `appendChildren(...)` for optional child nodes.
- `queryRequired(...)` when a selector is mandatory for the tab to function.
- Use `textContent` semantics for user-visible text. Do not add raw HTML helper paths for convenience.
- Keep legacy class aliasing centralized through existing normalization and alias helpers.
- Keep Settings-specific status, LLM, secrets, logs, DOM, and lifecycle behavior in the focused
`web/tabs/settings_tab_*.js` owners rather than rebuilding a monolithic renderer.
## API Contracts
- Use `OpenClawAPI.fetch(...)` normalized results instead of direct `fetch` from tabs.
- Check `result.ok` before reading `result.data`.
- Preserve admin-token handling inside `OpenClawAPI` and shared session helpers.
- Add endpoint methods to the matching config, generation, resource, model, or event owner module;
preserve `web/openclaw_api.js` as the transport/session facade and keep one shared singleton.
## Verification
- Add Vitest coverage for new shared helpers or tab wiring behavior.
- Use Playwright harness specs for user-visible tab behavior such as active panes, rendered content, and action outcomes.
- For async tabs, cover switching/disposal and prove stale completion cannot mutate the new pane.
+44 -14
View File
@@ -4,27 +4,46 @@ This document summarizes the current OpenClaw sidebar UI structure and how to ve
## UI Structure
- Entry: `web/openclaw.js` registers the extension and sidebar tab.
- Entry: `web/openclaw.js` registers the extension; host sidebar registration is routed through `web/openclaw_sidebar_registration.js` so current ComfyUI sidebar-store hosts and older frontend facade hosts share one compatibility path.
- Shell: `web/openclaw_ui.js` now acts as the composition root for the sidebar shell and public singleton exports.
- Actions: `web/openclaw_actions.js` owns submit/cancel/retry wiring and guarded action routing for the shell.
- Queue monitor: `web/openclaw_queue_monitor.js` owns queue polling lifecycle and transient banner/status updates used by the shell.
- Event/task polling: admin-console and model/task views consume deterministic delta metadata (`effective_since_seq`, `next_since_seq`, reset/truncation hints) instead of assuming every refresh is a full snapshot.
- Notification center: `web/openclaw_notification_center.js` owns persistent in-app notification storage, dedupe, acknowledge, dismiss, and deep-link behavior.
- Banner runtime: `web/openclaw_banner_manager.js` owns transient banner state and shell-facing banner transitions.
- Tabs: `web/openclaw_tabs.js` manages tab registration, rendering, and remount safety.
- API: `web/openclaw_api.js` provides a normalized fetch wrapper and OpenClaw endpoints (legacy Moltbot endpoints still work).
- Host surface: `web/openclaw_host_surface.js` resolves the active frontend host surface and stamps explicit metadata so standalone frontend vs desktop-embedded behavior stays testable.
- Output refs: `web/openclaw_asset_refs.js` normalizes classic history refs and newer asset-backed output refs onto the same bounded `/view` preview contract, while keeping asset-service-only refs explicit as a fallback state instead of silently auto-fetching `/api/assets`.
- Tabs: `web/openclaw_tabs.js` manages tab registration, rendering, remount safety, and optional
pending-render disposal before switching panes.
- API: `web/openclaw_api.js` owns normalized transport, session, timeout, retry, and singleton
behavior. Config, generation, resource, model, and event endpoint families live in focused
`web/openclaw_api_*.js` owner modules behind the same public API (legacy Moltbot endpoints still
work).
- Settings: `web/tabs/settings_tab.js` composes status, LLM, secrets, logs, and DOM owner modules.
Its lifecycle owner invalidates stale async generations and clears scheduled work when the tab
is disposed, preventing late responses from mutating a remounted pane.
- Host surface: `web/openclaw_host_surface.js` resolves standalone frontend, legacy fixed-bundle
Desktop, and current managed-install Comfy-Desktop separately, then stamps explicit metadata so
generation-specific behavior stays testable.
- Output refs: `web/openclaw_asset_refs.js` normalizes classic history refs, optional `asset_hash`/`hash` refs when host metadata is present, and current previewable media groups (`images`, `video`, `audio`, `3d`, bounded inline or file-backed `text`) onto one media-aware contract. Allowlisted text files under the host `files` key stay on same-origin `/view` and use a 5-second, 64-KiB streaming, strict textual-MIME/UTF-8 reader with a 4,096-character display cap. HDR `.exr` / `.hdr` image refs show source-preview fallback links instead of normal thumbnails, text reaches the DOM only as literal text, and asset-service-only refs remain explicit fallback states instead of silently auto-fetching `/api/assets`.
- Styles: `web/openclaw.css` provides shared design tokens and component classes.
- Errors and compatibility helpers: `web/openclaw_utils.js` provides `showError()` / `clearError()` plus runtime legacy-class alias helpers used to keep canonical `openclaw-*` markup compatible with existing `moltbot-*` selectors.
Refactor note:
- `web/openclaw_ui.js` should stay focused on shell composition, shared singleton ownership, and exports.
- New shell behaviors should prefer the extracted action/queue modules unless they truly belong to top-level shell assembly.
- New API methods should be added to the matching route-family owner rather than growing the
transport facade; keep `openclawApi` as the only shared singleton.
- New Settings behavior should stay in the matching status/LLM/secrets/logs/DOM owner and use the
shared generation lifecycle for delayed or asynchronous UI changes.
- New tab markup should use canonical `openclaw-*` classes; legacy `moltbot-*` aliases are generated centrally at runtime instead of being duplicated in each template.
- New host sidebar registration changes should stay in `web/openclaw_sidebar_registration.js` rather than duplicating ComfyUI frontend API detection inside the extension entrypoint.
- Host-sensitive behaviors should consume the shared host-surface helper rather than inferring desktop vs standalone frontend from ad-hoc globals.
- Output preview flows should consume the shared asset-ref normalizer rather than assembling `/view` URLs independently in each tab or silently widening runtime behavior to direct `/api/assets` fetches.
- Graph/widget flows should preserve host-shaped promoted-widget source metadata and non-numeric node IDs, including Parameter Lab replay/apply paths.
- Parameter Lab flows should keep scalar/count/byte validation aligned with the backend policy and
use exact request-ID queue receipts; they must not infer prompt ownership from a globally recent
prompt when the host request boundary is unsupported or ambiguous.
- Output preview flows should consume the shared asset-ref normalizer rather than assembling `/view` URLs independently in each tab, treating non-image or HDR media as broken images, or silently widening runtime behavior to direct `/api/assets` fetches.
- Explorer/preflight consumers should treat inventory diagnostics as snapshot-first and surface `snapshot_ts`, `scan_state`, `stale`, and `last_error` instead of blocking the UI on full rescans.
- Explorer/preflight rendering should keep actionable missing-node/model failures separate from suppressed inactive-branch findings returned by the backend.
## Feature Gating (Capabilities)
@@ -42,10 +61,16 @@ If `assist_streaming` is unavailable or the stream transport degrades, Planner/R
## Host-Surface Contract
- OpenClaw treats standalone `ComfyUI_frontend` and `desktop` as distinct frontend host surfaces.
- The sidebar stamps its resolved host surface at mount time so desktop bundle drift is explicit in diagnostics and regression tests.
- The standalone Remote Admin Console now stamps the same host-surface metadata on its document root, so host-sensitive sidebar and admin behavior can be validated against one shared contract.
- Graph/widget compatibility code should route through shared host helpers to keep nested-subgraph and promoted-widget behavior aligned with current upstream host semantics.
- OpenClaw treats standalone `ComfyUI_frontend`, legacy fixed-bundle `desktop`, and current
managed-install `comfy_desktop` as distinct frontend host surfaces.
- The sidebar stamps its resolved host surface and refreshed host-reference metadata at mount time so desktop bundle drift is explicit in diagnostics and regression tests.
- The standalone Remote Admin Console stamps the same host-surface metadata on its document root,
including legacy Desktop `0.9.4`, fixed core `0.22.3`, embedded frontend `1.43.18`, and lagging
parity relative to standalone frontend `1.49.1`. It also exposes current Comfy-Desktop
`1.0.32-rc.1` with `installation_specific` hosted versions. Presence of
`window.__comfyDesktop2` identifies that host generation only; it does not authorize privileged
capability calls or inspect bridge members.
- Graph/widget compatibility code should route through shared host helpers to keep nested-subgraph and promoted-widget behavior aligned with current upstream host semantics, including preserving source metadata and string-shaped node IDs.
## Standalone Remote Admin Console
@@ -81,10 +106,13 @@ If `assist_streaming` is unavailable or the stream transport degrades, Planner/R
3. Confirm the sidebar host-surface metadata resolves correctly for the current environment instead of defaulting silently.
4. Planner: click **Plan Generation** with minimal input and confirm either live preview/stage updates appear (when streaming is supported) or a readable fallback result/error appears.
5. Refiner: click **Refine Prompts** (with or without image) and confirm either live preview/stage updates appear (when streaming is supported) or a readable fallback result/error appears.
6. Jobs: verify output previews still resolve for both classic history refs and any asset-backed refs surfaced by callback/history payloads, that asset-service-only refs stay explicit as a bounded fallback state, and that repeated polls do not duplicate rows after reconnect/resume.
7. Explorer: verify preflight inventory can show `refreshing` / `stale` / `error` state without freezing the tab while deep scan work continues.
8. Library/Approvals: if backend endpoints are not enabled, confirm the UI shows a clear error state (no crashes).
9. If you simulate/fake a stream failure in dev tools, confirm Planner/Refiner retry through the classic non-stream path without duplicate submits or broken loading state.
6. Jobs: verify output previews still resolve for classic history refs, optional hash-backed refs when host metadata is present, and supported media-aware refs (`images`, `video`, `audio`, `3d`, bounded inline/file-backed `text`); allowlisted text files should show literal bounded content or a deterministic source-link fallback, HDR `.exr` / `.hdr` image refs should render as explicit source-preview fallback links, asset-service-only refs should stay explicit as a bounded fallback state, and repeated polls should not duplicate rows after reconnect/resume.
7. Parameter Lab: verify bounded scalar sweep/compare values queue with an exact request receipt,
and verify unsupported structured values or unknown host queue-event shapes fail visibly without
assigning another prompt's lifecycle.
8. Explorer: verify preflight inventory can show `refreshing` / `stale` / `error` state without freezing the tab while deep scan work continues, and verify inactive-branch suppressed findings render separately from actionable failures.
9. Library/Approvals: if backend endpoints are not enabled, confirm the UI shows a clear error state (no crashes).
10. If you simulate/fake a stream failure in dev tools, confirm Planner/Refiner retry through the classic non-stream path without duplicate submits or broken loading state.
## E2E (Playwright) Checks
@@ -93,5 +121,7 @@ If `assist_streaming` is unavailable or the stream transport degrades, Planner/R
- Harness: `tests/e2e/test-harness.html` (mocks ComfyUI core + basic OpenClaw API calls)
- Harness bootstrap now retries one transient `openclaw.js` module-fetch failure before surfacing a hard load error, so CI-only first-request flakiness does not get misreported as a permanent sidebar failure.
- Web helper/self-test harness: `web/tests/e2e-harness.html` (includes frontend helper and wrapper idempotence checks)
- Frontend unit contracts also freeze API exports/signatures, singleton identity, Settings DOM
identities, owner direction, and stale-generation disposal across the decomposed modules.
- Desktop host parity lane: `tests/e2e/specs/desktop_host_parity.spec.js` verifies standalone vs desktop host evidence separately and covers both sidebar and Remote Admin host-sensitive behavior under the shared harness shims.
- When investigating suspected harness flakes locally, prefer `npm run test:stress -- <spec>` so the same shared bootstrap path is exercised repeatedly without changing the default `npm test` contract.
+49
View File
@@ -0,0 +1,49 @@
# Legacy Compatibility Governance
OpenClaw keeps selected legacy compatibility aliases so older workflows, browser extensions, and deployment scripts have a predictable migration path. New integrations should use the canonical OpenClaw names.
Compatibility aliases are governed by explicit status, review cadence, telemetry, and removal criteria. An alias is not removed just because a canonical replacement exists; removal requires usage evidence and regression coverage.
## Status Labels
- `deprecated-observed`: the alias is still accepted, emits telemetry or warnings where practical, and should move to the canonical surface.
- `retained-compatibility`: the alias remains available for older workflows or deployments, with review based on diagnostics, tests, and operator reports.
## Review Policy
Every legacy alias has:
- a review cadence in days
- a telemetry or evidence signal
- a review trigger
- concrete removal criteria
Removal requires all of these conditions:
- no observed compatibility usage for two consecutive review windows
- a documented canonical migration path
- targeted regression coverage and release notes for the removal
## Governed Aliases
| Key | Surface | Legacy alias | Canonical surface | Status | Telemetry or evidence |
| --- | --- | --- | --- | --- | --- |
| `api-path-moltbot-prefix` | API path | `/moltbot/*` and `/api/moltbot/*` | `/openclaw/*` and `/api/openclaw/*` | `deprecated-observed` | `legacy_api_hits` |
| `header-x-moltbot-aliases` | Header | `X-Moltbot-*` request headers | `X-OpenClaw-*` request headers | `deprecated-observed` | `legacy_api_hits` and warning logs |
| `environment-moltbot-prefix` | Environment | `MOLTBOT_*` environment variables | `OPENCLAW_*` environment variables | `retained-compatibility` | configuration diagnostics and warning logs |
| `ui-class-moltbot-prefix` | UI class | `moltbot-*` CSS classes and local UI keys | `openclaw-*` CSS classes and local UI keys | `retained-compatibility` | frontend compatibility helper tests and operator reports |
| `workflow-node-moltbot-classes` | Workflow node | `Moltbot*` node class aliases | `OpenClaw*` node classes and `openclaw` node category | `retained-compatibility` | workflow portability diagnostics and node-registration regression tests |
The historical `moltbot` node category is no longer the current display category. Current shipped nodes use `openclaw`; legacy workflow compatibility is preserved through the `Moltbot*` class aliases rather than through legacy category metadata.
## Operator Visibility
Legacy API path requests expose deprecation response headers when the response type supports headers:
- `Deprecation: true`
- `X-OpenClaw-Compatibility-Key`
- `X-OpenClaw-Compatibility-Status`
- `X-OpenClaw-Compatibility-Telemetry`
- `X-OpenClaw-Canonical-Path`
Use these headers with server logs and `legacy_api_hits` to decide whether a deployment still depends on legacy route aliases.
+77 -15
View File
@@ -1,8 +1,8 @@
openapi: "3.0.3"
info:
title: "ComfyUI-OpenClaw API"
version: "1.0.6"
description: "Generated from docs/release/api_contract.md (R66 baseline)."
version: "1.0.15"
description: "Generated from docs/release/api_contract.md."
servers:
- url: "/openclaw"
description: "Direct OpenClaw prefix"
@@ -22,7 +22,7 @@ paths:
/health:
get:
operationId: "get_health"
summary: "System status, uptime, and dependencies."
summary: "System status, uptime, dependencies, and startup lifecycle diagnostics."
responses:
200:
description: "OK"
@@ -64,7 +64,7 @@ paths:
description: "OK"
x-openclaw-auth: "Observability"
x-openclaw-section: "1.1 Core Observability & System"
description: "Trace payloads redact provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Trace payloads redact provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/trace/{id}"
x-openclaw-auth-tier: "observability"
security:
@@ -87,7 +87,7 @@ paths:
description: "OK"
x-openclaw-auth: "Observability"
x-openclaw-section: "1.1 Core Observability & System"
description: "Event payloads redact provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Event payloads redact provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/events"
x-openclaw-auth-tier: "observability"
security:
@@ -105,7 +105,7 @@ paths:
description: "OK"
x-openclaw-auth: "Observability"
x-openclaw-section: "1.1 Core Observability & System"
description: "SSE event payloads redact provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "SSE event payloads redact provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/events/stream"
x-openclaw-auth-tier: "observability"
security:
@@ -145,16 +145,30 @@ paths:
/jobs:
get:
operationId: "get_jobs"
summary: "List recent jobs (Stub/Not Implemented)."
summary: "List recent jobs through the versioned bounded in-process jobs read model."
responses:
200:
description: "OK"
x-openclaw-auth: "Observability"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.1 Core Observability & System"
x-openclaw-legacy-path: "/moltbot/jobs"
x-openclaw-auth-tier: "observability"
x-openclaw-auth-tier: "admin"
security:
- OpenClawObservabilityToken:
- OpenClawAdminToken:
[]
/preflight:
post:
operationId: "post_preflight"
summary: "Analyze a workflow or API prompt payload for missing nodes/models and portability diagnostics."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.1 Core Observability & System"
x-openclaw-legacy-path: "/moltbot/preflight"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/preflight/inventory:
get:
@@ -249,7 +263,7 @@ paths:
description: "OK"
x-openclaw-auth: "Admin/Local"
x-openclaw-section: "1.3 Assist, LLM & Chat"
description: "Structured assist payloads preserve final answer fields while redacting provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Structured assist payloads preserve final answer fields while redacting provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/assist/planner"
x-openclaw-auth-tier: "admin"
security:
@@ -267,7 +281,7 @@ paths:
description: "OK"
x-openclaw-auth: "Admin/Local"
x-openclaw-section: "1.3 Assist, LLM & Chat"
description: "Structured assist payloads preserve final answer fields while redacting provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Structured assist payloads preserve final answer fields while redacting provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/assist/refiner"
x-openclaw-auth-tier: "admin"
security:
@@ -285,7 +299,7 @@ paths:
description: "OK"
x-openclaw-auth: "Admin/Local"
x-openclaw-section: "1.3 Assist, LLM & Chat"
description: "Streaming assist final payloads redact provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Streaming assist final payloads redact provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/assist/planner/stream"
x-openclaw-auth-tier: "admin"
security:
@@ -304,7 +318,7 @@ paths:
description: "OK"
x-openclaw-auth: "Admin/Local"
x-openclaw-section: "1.3 Assist, LLM & Chat"
description: "Streaming assist final payloads redact provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Streaming assist final payloads redact provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/assist/refiner/stream"
x-openclaw-auth-tier: "admin"
security:
@@ -376,6 +390,20 @@ paths:
security:
- OpenClawAdminToken:
[]
/connector/extraction-contract:
get:
operationId: "get_connector_extraction_contract"
summary: "Get the machine-readable connector extraction recommendation, seam families, static service-env SecretRef propagation policy, and current blockers."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3B Connector Installation Diagnostics"
x-openclaw-legacy-path: "/moltbot/connector/extraction-contract"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/models/search:
get:
operationId: "get_models_search"
@@ -516,7 +544,7 @@ paths:
/models:
get:
operationId: "get_models"
summary: "List available models from configured provider. Request-time fetch uses the same SSRF contract as saved `base_url` validation, including the explicit insecure override for private-IP/HTTP targets."
summary: "List available models from configured provider. Request-time fetch uses the same SSRF contract as saved `base_url` validation, including scoped private-network allowance and the explicit insecure override for private-IP/HTTP targets."
responses:
200:
description: "OK"
@@ -791,6 +819,40 @@ paths:
required: true
schema:
type: "string"
/tools:
get:
operationId: "get_tools"
summary: "List allowed external tools and their declared sandbox metadata."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin Token Required"
x-openclaw-section: "1.5A External Tools"
x-openclaw-legacy-path: "/moltbot/tools"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/tools/{name}/run:
post:
operationId: "post_tools_name_run"
summary: "Execute a named allowlisted external tool with validated arguments."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin Token Required"
x-openclaw-section: "1.5A External Tools"
x-openclaw-legacy-path: "/moltbot/tools/{name}/run"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
parameters:
- name: "name"
in: "path"
required: true
schema:
type: "string"
/bridge/health:
get:
operationId: "get_bridge_health"
-49
View File
@@ -1,49 +0,0 @@
# R167 ComfyUI Asset API Adoption Decision (2026-04-16)
## Scope
- Item: `R167` from the active roadmap (`ComfyUI asset API adoption decision and bounded phase-2 interop seam`).
- Goal: decide whether OpenClaw should adopt upstream `/api/assets` semantics as a normal runtime dependency beyond the `R165` bounded `/view` interoperability layer.
## Current baseline
- Current history/output-facing interop already accepts:
- classic ComfyUI output refs (`filename`, `subfolder`, `type`)
- asset-hash-backed refs that still resolve through `/view?filename=blake3:...`
- Current operator/runtime surfaces in scope:
- sidebar `Jobs`
- callback delivery payloads
- history/result consumption paths derived from `services.comfyui_history`
- Current non-goal:
- no gallery/explorer/runtime flow currently requires direct `/api/assets` fetches to stay functional.
## Decision
- **No-go for first-class `/api/assets` runtime adoption in phase 2.**
- OpenClaw keeps `/history` + `/view` as the supported runtime contract for normal output handling.
- Asset-api-only identifiers are now treated as explicit unsupported contracts rather than implicit fetch targets.
## Rationale
1. Current OpenClaw output surfaces still succeed on the existing bounded `/view` contract, including asset-hash-backed refs.
2. Adding `/api/assets` as a normal dependency would widen runtime coupling to upstream host behavior without a demonstrated operator need in current features.
3. A silent fallback from `asset id only` to `/api/assets` would weaken boundary clarity and make host drift harder to reason about.
## Approved phase-2 seam
- Preserve current supported refs exactly:
- classic refs -> `/view?filename=...&type=...`
- asset-hash-backed refs -> `/view?filename=blake3:...`
- For refs that expose only asset-service identifiers and are not representable through `/view`:
- keep them in normalized output payloads
- mark them as `asset_api_required`
- do not auto-fetch `/api/assets`
- surface a bounded operator-facing message where relevant
## Re-open triggers
Revisit this decision only if one of the following becomes true:
1. A current operator-facing surface cannot complete its supported workflow without direct `/api/assets` semantics.
2. Upstream ComfyUI stops providing `/view`-compatible output metadata for supported runtime flows.
3. OpenClaw intentionally adds a new asset-management feature whose documented contract depends on asset-service metadata beyond hash-backed preview resolution.
+96 -12
View File
@@ -1,12 +1,12 @@
# OpenClaw API Contract (v1)
> **Status**: normative
> **Version**: 1.0.7
> **Date**: 2026-04-24
> **Version**: 1.0.15
> **Date**: 2026-07-10
This document defines the public API contract for OpenClaw. It serves as the authoritative baseline for client compatibility and breaking change policies.
## 0. Tenant Boundary Context (S49)
## 0. Tenant Boundary Context
Default behavior remains single-tenant compatible (`tenant_id=default`).
@@ -37,7 +37,7 @@ All new integrations should use the `/openclaw/` prefix. Use of `/moltbot/` is d
| Method | Path | Legacy Path | Auth | Description |
| :--- | :--- | :--- | :--- | :--- |
| `GET` | `/health` | `/moltbot/health` | None | System status, uptime, and dependencies. |
| `GET` | `/health` | `/moltbot/health` | None | System status, uptime, dependencies, and startup lifecycle diagnostics. |
| `GET` | `/capabilities` | `/moltbot/capabilities` | None | Feature flags and supported extensions (includes optional UX/runtime features such as assist streaming support). |
| `GET` | `/logs/tail` | `/moltbot/logs/tail` | Observability | Tail recent log lines (rate-limited). |
| `GET` | `/trace/{prompt_id}` | `/moltbot/trace/{id}` | Observability | Get execution trace by prompt ID. |
@@ -45,12 +45,32 @@ All new integrations should use the `/openclaw/` prefix. Use of `/moltbot/` is d
| `GET` | `/events/stream` | `/moltbot/events/stream` | Observability | SSE stream of job lifecycle events with resume support. |
| `GET` | `/config` | `/moltbot/config` | Observability | Read-only view of sanitized provider config. |
| `PUT` | `/config` | `/moltbot/config` | Admin | Update system configuration. |
| `GET` | `/jobs` | `/moltbot/jobs` | Observability | List recent jobs (Stub/Not Implemented). |
| `GET` | `/jobs` | `/moltbot/jobs` | Admin | List recent jobs through the versioned bounded in-process jobs read model. |
| `POST` | `/preflight` | `/moltbot/preflight` | Admin | Analyze a workflow or API prompt payload for missing nodes/models and portability diagnostics. |
| `GET` | `/preflight/inventory` | `/moltbot/preflight/inventory` | Admin | Snapshot-first inventory of nodes/models for operator diagnostics, including refresh-state metadata. |
Jobs list contract:
- `GET /openclaw/jobs` and its browser/legacy aliases are Admin-only and return
`contract_version: 1` from the bounded in-process ComfyUI jobs adapter.
- Supported query fields are `status`, `workflow_id`, `sort_by`, `sort_order`, `limit`,
and `offset`. Status values are `pending`, `in_progress`, `completed`, `failed`, and
`cancelled`; sorting supports `created_at` or `execution_duration` with `asc`/`desc`.
- The default/maximum page sizes are 50/200 and the source/offset window is capped at
10,000. Successful responses contain only `ok`, `contract_version`, `jobs`,
`pagination`, `source`, and `scan` at the top level.
- Job summaries allow only `id`, `status`, bounded priority/timestamps, `outputs_count`,
and bounded `workflow_id`. `preview_output` is never included, and list responses never
include raw prompts, workflows, execution errors, tracebacks, current inputs/outputs,
tenant/client/trace identifiers, reasoning, or internal content.
- An authoritative empty snapshot is HTTP 200 with `jobs: []`. Missing host helpers use
HTTP 501 `jobs_host_contract_unsupported`; unavailable or malformed snapshots use HTTP
503 `jobs_backend_unavailable`. These failures are never converted into empty success.
Reasoning-content redaction contract:
- operator-visible trace and events payloads strip provider reasoning / thinking-like fields by default
- operator-visible trace and events payloads strip provider reasoning / thinking-like fields and explicitly marked internal maintenance/helper content by default
- audit event payload/meta fields follow the same internal-content and reasoning-like redaction boundary before retention
- privileged reveal is opt-in only and requires:
- request header `X-OpenClaw-Debug-Reveal-Reasoning: 1` or query `debug_reasoning=1`
- server-side enablement via `OPENCLAW_DEBUG_REASONING_REVEAL=1`
@@ -59,12 +79,34 @@ Reasoning-content redaction contract:
- non-hardened runtime profile
- deployment profile `local` or `lan`
- clients MUST treat reveal behavior as debug-only and MUST NOT depend on reasoning payload presence in normal operation
- explicitly marked internal maintenance/helper content has no public or debug reveal path
Inventory diagnostics contract:
- `/preflight/inventory` is snapshot-first and may return before deep scan work finishes
- clients SHOULD treat `snapshot_ts`, `scan_state`, `stale`, and `last_error` as first-class diagnostics fields rather than assuming a blocking full-rescan model
Preflight workflow diagnostics contract:
- `POST /openclaw/preflight` accepts both API prompt dictionaries and frontend workflow JSON when supplied by operator tooling
- response summaries distinguish actionable `missing_nodes` / `missing_models` from `suppressed_missing_nodes` / `suppressed_missing_models`
- suppressed findings represent muted or bypassed root nodes or subgraph branches when the submitted workflow shape provides enough frontend ancestry metadata
- clients SHOULD display suppressed findings as informational context rather than blocking workflow readiness
History and output-ref contract:
- history/output consumers SHOULD treat the normalized output-ref contract as media-aware
- current previewable output groups are `images`, `video`, `audio`, `3d`, bounded inline `text`, and allowlisted file-backed text refs from the host `files` key
- file-like refs that can be represented through `/view` remain on the bounded `/history` + `/view` preview path
- file-backed text admission is limited to `.txt`, `.md`, `.markdown`, `.json`, `.csv`, `.yaml`, `.yml`, `.xml`, and `.log`; clients MUST build the URL from normalized filename/subfolder/type fields rather than trust a history-provided URL
- browser text previews MUST remain same-origin `/view` GET requests, reject redirects and active/ambiguous MIME types, use strict UTF-8, stream at most 64 KiB within 5 seconds, display at most 4,096 characters, and degrade to a source link when safe streaming is unavailable
- fetched text MUST be inserted as literal text; HTML, Markdown, SVG/XML, ANSI, or script interpretation is not part of this contract
- `asset_hash` / `hash` metadata is optional because current ComfyUI host asset hashing is opt-in through `--enable-asset-hashing`; clients MUST NOT require hashes for normal filename-backed previews
- refs with `asset_hash` or `hash` values, when host metadata provides them, preview through `/view?filename=blake3:...`
- refs that only expose upstream asset-service identifiers remain explicit `asset_api_required` states; clients MUST NOT silently infer direct `/api/assets` fetching from that marker
- HDR `.exr` / `.hdr` image refs remain image refs but should be rendered as explicit source-preview fallback links unless the client implements a safe HDR-specific viewer
- legacy callback/image-only consumers may continue using image-only extraction paths; non-image media refs should be rendered as explicit fallback/link/text surfaces unless the client implements a safe media-specific renderer
### 1.2 Webhooks & Triggers
**Auth**: Requires configured webhook secret or Admin Token.
@@ -76,6 +118,13 @@ Inventory diagnostics contract:
| `POST` | `/webhook/validate` | `/moltbot/webhook/validate` | Webhook Secret | Dry-run validation of webhook payload. |
| `POST` | `/triggers/fire` | `/moltbot/triggers/fire` | Admin | Fire an ad-hoc workflow trigger from external system. |
ComfyUI prompt submission interoperability:
- OpenClaw-generated ComfyUI `/prompt` payloads include `extra_data.comfy_usage_source = "comfyui-openclaw"` when the caller has not supplied a value
- caller-provided `extra_data.comfy_usage_source` is preserved
- attribution is a stable product identifier and MUST NOT include prompt text, tenant ids, trace ids, URLs, tokens, or secrets
- existing `extra_data.openclaw` and legacy `extra_data.moltbot` metadata remain caller-owned except for OpenClaw tenant metadata insertion under `extra_data.openclaw.tenant_id`
### 1.3 Assist, LLM & Chat
**Assist Base Path**: `/openclaw/assist/`
@@ -90,8 +139,9 @@ Inventory diagnostics contract:
Assist payload redaction contract:
- structured assist responses preserve final operator-visible answer fields but strip provider reasoning / chain-of-thought style fields by default
- structured assist responses preserve final operator-visible answer fields but strip provider reasoning / chain-of-thought style fields and explicitly marked internal maintenance/helper content by default
- when the privileged reveal gate is allowed, debug reasoning is exposed only in a separate debug payload and not merged back into the normal structured answer fields
- explicitly marked internal maintenance/helper content is not exposed by the privileged reasoning reveal gate
### 1.3B Connector Installation Diagnostics
@@ -104,13 +154,13 @@ Assist payload redaction contract:
| `GET` | `/connector/installations/{installation_id}` | `/moltbot/connector/installations/{installation_id}` | Admin | Get one redacted connector installation record. |
| `GET` | `/connector/installations/resolve` | `/moltbot/connector/installations/resolve` | Admin | Run fail-closed workspace resolution diagnostics (`platform`, `workspace_id`). |
| `GET` | `/connector/installations/audit` | `/moltbot/connector/installations/audit` | Admin | List installation lifecycle audit evidence (redacted). |
| `GET` | `/connector/extraction-contract` | `/moltbot/connector/extraction-contract` | Admin | Get the machine-readable connector extraction recommendation, seam families, and current blockers. |
| `GET` | `/connector/extraction-contract` | `/moltbot/connector/extraction-contract` | Admin | Get the machine-readable connector extraction recommendation, seam families, static service-env SecretRef propagation policy, and current blockers. |
Connector diagnostics contract notes:
- installation records may expose operator-safe health metadata under `installation.metadata.health` (for example `ok`, `invalid_token`, `revoked`, `degraded`) without exposing token material
- `/connector/installations` diagnostics may include aggregate `health_counts` in addition to lifecycle `status_counts`
- `/connector/installations/resolve` may expose a stable `health_code` alongside the legacy `reject_reason` so clients can distinguish `workspace_unbound` vs token-health failures without parsing status text
- `/connector/extraction-contract` is structural packaging metadata only; clients MUST NOT treat it as a live installation-health or token-status feed
- `/connector/extraction-contract` is structural packaging metadata and static service-env SecretRef policy only; clients MUST NOT treat it as a live installation-health, live environment dump, or token-status feed
### 1.3C Model Management & Installations
@@ -129,6 +179,9 @@ Connector diagnostics contract notes:
Model-manager contract notes:
- `/models/downloads` supports `since_seq` cursor polling and may return deterministic delta metadata (`requested_since_seq`, `effective_since_seq`, `next_since_seq`, truncation/reset hints) alongside the task list
- `model_type` values SHOULD use current ComfyUI folder keys where applicable, including `text_encoders`, `diffusion_models`, `clip_vision`, `style_models`, `upscale_models`, `vae_approx`, `gligen`, `latent_upscale_models`, `hypernetworks`, `photomaker`, `model_patches`, `audio_encoders`, `background_removal`, `frame_interpolation`, `geometry_estimation`, `optical_flow`, and `detection`
- legacy aliases such as `ckpt`, `checkpoints`, `loras`, `controlnets`, `clip`, `text_encoder`, `unet`, `diffusion_model`, `upscale_model`, `latent_upscale_model`, `hypernetwork`, `model_patch`, and `audio_encoder` are normalized before filtering or import destination resolution
- current ComfyUI folder keys that are not managed model-file destinations fail closed for download creation: `configs` (configuration YAML), `diffusers` (folder-valued trees), `classifiers` (extensionless classifier artifacts), `custom_nodes` (executable plugin code), and `datasets` (user-managed training data)
- download creation requires structured provenance metadata (`publisher`, `license`, `source_url`) and a 64-char `expected_sha256`
- import keeps fail-closed destination/filename validation and re-checks the staged file hash before activation
@@ -140,7 +193,7 @@ Model-manager contract notes:
| :--- | :--- | :--- | :--- | :--- |
| `POST` | `/chat` | `/moltbot/llm/chat` | Admin/Local | Unified chat interface for assistant interactions. |
| `POST` | `/test` | `/moltbot/llm/test` | Admin | Test LLM connectivity and configuration. |
| `GET` | `/models` | `/moltbot/llm/models` | Admin | List available models from configured provider. Request-time fetch uses the same SSRF contract as saved `base_url` validation, including the explicit insecure override for private-IP/HTTP targets. |
| `GET` | `/models` | `/moltbot/llm/models` | Admin | List available models from configured provider. Request-time fetch uses the same SSRF contract as saved `base_url` validation, including scoped private-network allowance and the explicit insecure override for private-IP/HTTP targets. |
### 1.4 Templates & Assets
@@ -177,6 +230,36 @@ Model-manager contract notes:
| `POST` | `/approvals/{id}/approve` | Approve a pending request. |
| `POST` | `/approvals/{id}/reject` | Reject a pending request. |
Schedule `delivery` is normalized before persistence. Supported fields are
`platform`, `target_id` (legacy aliases such as `channel_id` are accepted),
`thread_id` (aliases such as `thread_ts`, `topic_id`, and `message_thread_id`
are accepted), `workspace_id`, `account_id`, `mode`, and `failure_alert`.
Omitting `delivery` on update preserves the existing target, `delivery: null`
clears it, and `{"enabled": false}` or `{"mode": "none"}` records explicit
no-delivery. Invalid delivery targets are rejected before persistence with
bounded codes: `delivery_malformed`, `delivery_ambiguous`, or
`delivery_unsupported`.
### 1.5A External Tools
**OpenClaw path prefix**: `/openclaw/`
**Legacy Base Path**: `/moltbot/`
**Auth**: Admin Token Required
**Feature flag**: `OPENCLAW_ENABLE_EXTERNAL_TOOLS=true`
| Method | Path | Legacy Path | Auth | Description |
| :--- | :--- | :--- | :--- | :--- |
| `GET` | `/tools` | `/moltbot/tools` | Admin Token Required | List allowed external tools and their declared sandbox metadata. |
| `POST` | `/tools/{name}/run` | `/moltbot/tools/{name}/run` | Admin Token Required | Execute a named allowlisted external tool with validated arguments. |
Tool execution contract notes:
- tools are disabled unless `OPENCLAW_ENABLE_EXTERNAL_TOOLS` is truthy
- tool definitions load from package-owned `data/tools_allowlist.json` unless `OPENCLAW_TOOLS_CONFIG_PATH` is set
- in public/split high-risk surfaces, tool execution can be blocked by the surface guard even when the feature flag is enabled
- execution responses preserve the current HTTP payload shape: failed runs return `ok=false`, `tool`, `error`, redacted `output`, `exit_code`, and `duration_ms`
- the service-level tool runner classifies common local failures with stable diagnostics such as `sandbox_runtime_unavailable`, `interpreter_missing`, `timeout`, and `workspace_violation`; clients should still follow this API document for the current HTTP response shape
### 1.6 Bridge (Sidecar)
**Base Path**: `/bridge/`
@@ -223,6 +306,7 @@ All JSON responses (success or error) share a common structure:
| `413` | Payload Too Large | Input size exceeds `OPENCLAW_MAX_RENDERED_WORKFLOW_BYTES` or similar limits. |
| `429` | Too Many Requests | Rate limit or Execution Budget exceeded. |
| `500` | Internal Error | Unhandled server exception. |
| `501` | Not Implemented | Required current-host contract is unavailable (for example `jobs_host_contract_unsupported`). |
| `503` | Unavailable | Feature disabled or service not wired. |
Tenant-boundary error notes:
@@ -242,7 +326,7 @@ Tenant-boundary error notes:
- `error`
- `keepalive`
- Clients MUST treat `final` as the source of truth for structured assist results. `delta` preview text is best-effort and may be truncated or differ from the final parsed payload.
- Event-stream and polling payloads redact provider reasoning / thinking traces by default; reveal is debug-only and gated by the same privileged local-debug contract used by trace/assist surfaces.
- Event-stream and polling payloads redact provider reasoning / thinking traces and explicitly marked internal maintenance/helper content by default; reasoning reveal is debug-only and gated by the same privileged local-debug contract used by trace/assist surfaces.
- Clients SHOULD gracefully fall back to non-streaming assist endpoints when streaming capability is absent or streaming transport fails.
### 2.4 Pagination & Scan Diagnostics (Management Query Contract)
@@ -273,7 +357,7 @@ These limits are contractual and strictly enforced. Clients MUST handle `413` an
| **Payload Size** | Rendered workflow | 512KB | `OPENCLAW_MAX_RENDERED_WORKFLOW_BYTES` |
| **Webhook Body** | Raw JSON body | 10MB | `MAX_BODY_SIZE` (internal constant) |
| **Trigger Inputs** | Input variables | 32KB | Hardcoded in `api/triggers.py` |
| **Log Tail** | Max lines | 500 | Hardcoded in `api/routes.py` |
| **Log Tail** | Max lines | 500 | Hardcoded in `api/route_handlers.py` |
---
+7 -3
View File
@@ -8,7 +8,8 @@ All pull requests must pass the repository SOP gate before merge.
| --- | --- | --- |
| Secret detection | `pre-commit run detect-secrets --all-files` | Prevent secret leakage |
| Pre-commit hooks | `pre-commit run --all-files --show-diff-on-failure` | Enforce formatting and static checks |
| Frontend dependency audit | `npm audit --production` | Fail on production dependency vulnerabilities in the shipped Node dependency surface |
| Production dependency boundary | `python scripts/verify_production_dependencies.py` | Parse tracked production imports without importing modules; block ownership, direction, cycle, and dynamic-import drift |
| Frontend dependency audit | `npm ci` then `npm audit --audit-level=high` | Reconcile the lockfile and fail on high/critical vulnerabilities across production and development dependencies |
| Backend dependency audit | `pip-audit -r requirements.txt` | Audit declared Python project dependencies without scanning unrelated CI runner/toolchain packages |
| GitHub CodeQL analysis | `.github/workflows/codeql.yml` | Run repository-native static security analysis for Python, JavaScript/TypeScript, and GitHub Actions on push, pull request, and weekly schedule |
| Coverage governance | `python scripts/verify_quality_governance.py` | Fail closed on coverage-policy, mutation-threshold, SOP-guidance, and survivor-allowlist drift |
@@ -39,7 +40,8 @@ If a change intentionally modifies contract behavior:
- Coverage governance is part of the standard gate, not an optional reporting step.
- Dependency-audit governance is part of CI parity:
- Node audit should continue to target production dependencies only.
- Node audit must cover production and development dependencies because build and test tooling is part of the acceptance trust boundary.
- A separate production-only audit may be retained as a runtime-boundary readback, but it is not a substitute for the full blocking audit.
- Python audit must stay scoped to `requirements.txt`; env-wide bare `pip-audit` is out of contract because it can fail on tool-only transient packages that are not part of the repo dependency surface.
- GitHub Actions workflow files are part of the security boundary:
- workflows using `GITHUB_TOKEN` must declare explicit least-privilege `permissions:` instead of relying on repository defaults
@@ -47,7 +49,7 @@ If a change intentionally modifies contract behavior:
- CodeQL analysis must stay versioned in `.github/workflows/codeql.yml`; do not rely on UI-only default-setup drift for the repository baseline
- CodeQL rollout remains visibility-first until the active backlog is burned down; treat new workflow findings as triage input, not an automatic merge blocker, unless the gating policy is explicitly tightened in roadmap/docs
- `pyproject.toml` must keep:
- `fail_under >= 45.0`
- `fail_under >= 55.0`
- `show_missing = true`
- `skip_covered = true`
- staged coverage ratchet policy (`tests/coverage_governance_policy.json`) is the source of truth for:
@@ -59,6 +61,8 @@ If a change intentionally modifies contract behavior:
- `python scripts/report_coverage_governance.py --coverage-json <path-to-coverage.json>`
- release-cycle promotion evidence must be retained in:
- `tests/coverage_promotion_reviews.json`
- ratchet-55 reviews must contain consecutive release boundaries, immutable commit and
full-suite artifact identity, every required hotspot percentage, and owned regression suites
- backend coverage gate should use:
- `python scripts/run_backend_coverage.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json --coverage-json .tmp/coverage/backend_unit_coverage.json`
- Test debt governance remains fail-closed:
+46 -15
View File
@@ -3,22 +3,39 @@
```openclaw-compat-matrix-meta
{
"anchors": {
"comfyui": "0.19.3",
"comfyui_frontend": "1.44.4",
"desktop": "0.8.32 (core 0.19.3 / frontend 1.42.11)"
"comfy_desktop": "1.0.32-rc.1 (85e28b7a / v1.0.32-rc.1-3-g85e28b7)",
"comfyui": "9cf91339 (v0.29.0-12-g9cf91339 / pyproject 0.29.0)",
"comfyui_frontend": "1.49.1 (4b3866b838 / v1.49.1-19-g4b3866b838)",
"desktop": "0.9.4 (core 0.22.3 / frontend 1.43.18)"
},
"evidence": {
"evidence_id": "compat-matrix-refresh-20260418",
"updated_at": "2026-04-18T10:23:56.704178+00:00",
"updated_by": "manual"
"evidence_id": "compat-matrix-refresh-20260731",
"updated_at": "2026-07-31T04:03:00+08:00",
"updated_by": "host-reference-alignment"
},
"last_validated_date": "2026-04-18",
"matrix_version": "v0.2.2",
"host_surfaces": {
"comfy_desktop": {
"anchor_key": "comfy_desktop",
"core_version": null,
"frontend_version": null,
"generation": "managed_install",
"hosted_version_mode": "installation_specific"
},
"desktop": {
"anchor_key": "desktop",
"core_version": "0.22.3",
"frontend_version": "1.43.18",
"generation": "legacy_fixed_bundle",
"hosted_version_mode": "fixed"
}
},
"last_validated_date": "2026-07-30",
"matrix_version": "v0.2.9",
"policy": {
"max_age_days": 45,
"warn_age_days": 30
},
"schema_version": 1
"schema_version": 2
}
```
@@ -28,17 +45,31 @@ This document tracks the current reference anchors and validated environments fo
| Component | Validated Range | Best Effort / Experimental | Notes |
| :--- | :--- | :--- | :--- |
| **ComfyUI** | `0.19.3` reference anchor | Older snapshots | Current upstream reference repo version used for compatibility review |
| **ComfyUI Frontend** | `1.44.4` reference anchor | Minor drift around the anchor | Sidebar extension contract (`registerSidebarTab`) still matches this repo |
| **ComfyUI Desktop** | `0.8.32 (core 0.19.3 / frontend 1.42.11)` reference anchor | Desktop bundle may lag standalone frontend | Treat desktop parity as a distinct host surface, not an alias of standalone frontend HEAD |
| **ComfyUI** | `9cf91339` reference anchor (`v0.29.0-12-g9cf91339`; `pyproject.toml` version `0.29.0`) | Older tagged snapshots | Current local upstream reference repo snapshot used for compatibility review |
| **ComfyUI Frontend** | `1.49.1` reference anchor (`4b3866b838`; `v1.49.1-19-g4b3866b838`) | Minor drift around the anchor | Sidebar extension contract remains compatible; prefer the current sidebar store API with deprecated facade fallback |
| **Legacy Desktop** | `0.9.4 (core 0.22.3 / frontend 1.43.18)` reference anchor | Legacy fixed bundle may lag standalone frontend | Preserve the recorded fixed-bundle contract for existing parity coverage |
| **Current Comfy-Desktop** | `1.0.32-rc.1` reference anchor (`85e28b7a`; `v1.0.32-rc.1-3-g85e28b7`) | Hosted component versions vary by installation | Treat the managed-install generation separately; do not infer fixed core/frontend versions from the application release |
| **Python** | 3.10, 3.11, 3.12 | 3.9 | 3.13 not yet validated |
| **Torch** | 2.1.2+ | 1.13+ | CUDA 11.8/12.1 verified |
## Host-Surface Notes
- **ComfyUI host runtime**: current bootstrap assumptions remain aligned with upstream `PromptServer` startup and route registration flow.
- **Frontend host surface**: current sidebar integration contract remains compatible with the standalone frontend reference anchor, but nested-subgraph and promoted-widget behavior should be treated as a regression-sensitive seam.
- **Desktop host surface**: desktop currently embeds frontend `1.42.11`, which still lags the standalone frontend `1.44.4` reference. Validate desktop-specific behavior against the desktop anchor instead of assuming standalone-frontend parity.
- **ComfyUI host runtime**: current bootstrap assumptions remain aligned with upstream `PromptServer` startup and route registration flow, including `/api`-prefixed canonical API routing.
- **Frontend host surface**: current sidebar integration contract remains compatible with the standalone frontend reference anchor, while inactive subgraph diagnostics and promoted-widget behavior remain regression-sensitive seams.
- **Legacy Desktop host surface**: Desktop `0.9.4` embeds frontend `1.43.18`, which lags the standalone frontend `1.49.1` reference. Validate this fixed bundle against its own anchor.
- **Current Comfy-Desktop host surface**: application `1.0.32-rc.1` is a managed-install generation. Its hosted ComfyUI and frontend versions are `installation_specific`; the application anchor must not be cross-wired into fixed hosted-version claims.
## Residual Host-Contract Decisions
- **SaveImage output refs**: OpenClaw consumes runtime `/history` output refs and does not infer graph-rewrite behavior from output-node socket shape. `SaveImage` output sockets are allowed to exist without changing the normalized output-ref contract.
- **3D output refs**: `Load3DAdvanced` and related 3D preview refs remain media-aware output refs. File-like refs and optional hash-backed 3D refs stay on the bounded `/view` preview contract; clients without a 3D renderer should show an explicit fallback/link surface.
- **HDR image output refs**: `.exr` and `.hdr` image refs stay on the bounded `/view` source-preview contract but render as explicit fallback/link surfaces unless a client implements a safe HDR-specific viewer.
- **File-backed text output refs**: allowlisted text files under the host `files` output key normalize to text refs on the existing `/view` route. Job Monitor uses same-origin, redirect-free, strict MIME/UTF-8 streaming with fixed 5-second, 64-KiB transfer, and 4,096-character display limits; failures remain source-link fallbacks and content is never interpreted as HTML or Markdown.
- **Promoted widget source scope and structured widgets**: OpenClaw graph helpers preserve host-shaped promoted-widget source metadata and keep non-numeric node IDs stable. Backend preflight remains a conservative model-key whitelist; structured `COLORS` / `BOUNDING_BOXES` inputs and frontend source metadata are not treated as model references, and OpenClaw does not claim full host frontend active-scope parity without a richer graph-instance contract.
- **Asset dimensions and grouped assets**: typed width/height metadata and grouped multi-download behavior are host-frontend display/download concerns. They do not change OpenClaw fetch routing, and asset-service-only identifiers remain explicit `asset_api_required` states rather than implicit `/api/assets` fetches.
- **Asset loader paths and model tags**: current host asset metadata may expose `loader_path`; model uploads require `model_type:<folder_name>` tags, advertised by `/features.supports_model_type_tags`. OpenClaw does not upload through or directly consume `/api/assets`, so these schema facts do not change the existing `/history` + `/view` contract.
- **Sidebar registration**: prefer the current `sidebarTab.registerSidebarTab` host API and retain the deprecated `extensionManager.registerSidebarTab` fallback for older or desktop-embedded frontend hosts.
- **Node runtime policy**: the standalone ComfyUI frontend development workspace currently declares `node >=25 <26` and `pnpm >=11.3`, but OpenClaw keeps its package engine at `>=18.0.0` because this custom-node package runs its own Playwright/Vitest harness and does not build the host frontend workspace. OpenClaw acceptance remains governed by `tests/TEST_SOP.md` and `tests/E2E_TESTING_SOP.md`, which require Node.js 18+ and CI-parity validation on the project test harness.
## Operating Systems
+32 -8
View File
@@ -1,8 +1,8 @@
# OpenClaw Config & Secrets Contract (v1)
> **Status**: normative
> **Version**: 1.0.5
> **Date**: 2026-03-07
> **Version**: 1.0.6
> **Date**: 2026-06-04
This document defines the authoritative configuration contract for OpenClaw. It enumerates all supported environment variables, their precedence rules, and security classifications.
@@ -31,7 +31,7 @@ Controls the core LLM client used by nodes (Planner, Refiner, etc.).
| `OPENCLAW_LLM_BASE_URL` | No | Provider default | Override base URL (crucial for local/compatible providers). |
| `OPENCLAW_LLM_TIMEOUT`| No | `120` | Request timeout in seconds. |
Optional local secret-manager path (S11, disabled by default):
Optional local secret-manager path (disabled by default):
| Variable | Required | Default | Description |
| :--- | :--- | :--- | :--- |
@@ -58,6 +58,7 @@ Multi-tenant note:
| :--- | :--- | :--- |
| `OPENCLAW_LLM_ALLOWED_HOSTS` | - | Comma-separated list of additional exact public hosts for custom base URLs. |
| `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST` | `0` | Set `1` to bypass host allowlist and allow any public IP. |
| `OPENCLAW_LLM_ALLOW_PRIVATE_NETWORK` | `0` | Set `1` to allow the configured LLM `base_url` host to resolve to private/reserved IPs while keeping exact-host scope and DNS pinning. |
| `OPENCLAW_ALLOW_INSECURE_BASE_URL` | `0` | Set `1` to allow HTTP or private IP targets (Dangerous!). |
Notes:
@@ -66,8 +67,8 @@ Notes:
- `ollama` -> `http://127.0.0.1:11434/v1`
- `lmstudio` -> `http://localhost:1234/v1`
- Local loopback provider targets do not require enabling insecure SSRF flags.
- `OPENCLAW_LLM_ALLOWED_HOSTS` does not allow private/reserved IPs; those still require `OPENCLAW_ALLOW_INSECURE_BASE_URL=1`.
- The same insecure override applies to config-save validation, `/openclaw/llm/models`, and outbound provider requests.
- `OPENCLAW_LLM_ALLOWED_HOSTS` does not allow private/reserved IPs; those require scoped `allow_private_network` for the configured LLM target or `OPENCLAW_ALLOW_INSECURE_BASE_URL=1`.
- The same scoped private-network setting and insecure override apply to config-save validation, `/openclaw/llm/models`, and outbound provider requests.
- Wildcard entries such as `*` are not supported in `OPENCLAW_LLM_ALLOWED_HOSTS`.
### 2.2 Security & Authentication
@@ -164,8 +165,11 @@ Controls the `connector` sidecar process and outbound delivery.
Connector posture rules:
- In strict posture (`OPENCLAW_DEPLOYMENT_PROFILE=public` or `OPENCLAW_RUNTIME_PROFILE=hardened`), active connector platforms without allowlist coverage are fail-closed.
- Public deployment profile check surfaces this as `DP-PUBLIC-009`.
- Connector reply visibility is policy-driven and does not introduce new secret/config knobs: text-only silent/internal/tool-only/no-mention replies can be suppressed by context, while approval cards and action buttons remain deliverable.
- Connector replay handling treats duplicate committed events as successful no-ops and allows retry only for failures before action/delivery commit.
- Slack multi-workspace installs persist only encrypted token refs in `connector_installations.json`; raw bot/app tokens remain in encrypted secret storage and must not appear in diagnostics or exported config surfaces.
- Feishu/Lark bindings persist normalized installation identity plus secret references only; app secrets and callback signing material must stay in encrypted/local secret storage and must not appear in diagnostics or exported config surfaces.
- Connector service-env propagation preserves only structured env-backed SecretRef metadata for supported connector credential variables. It reports secret-blind status/reason fields and rejects raw secrets, legacy marker strings, unsupported env names, missing envs, and runtime-only auth tokens such as admin, worker, and bridge tokens. Raw token values must not be written into diagnostics or service metadata.
- Connector bind-port envs (`OPENCLAW_CONNECTOR_LINE_PORT`, `...WHATSAPP_PORT`, `...WECHAT_PORT`, `...KAKAO_PORT`, `...SLACK_PORT`, `...FEISHU_PORT`) must stay within `1..65535`; invalid or out-of-range values fall back to the documented platform defaults instead of crashing startup.
**Delivery & Media:**
@@ -180,7 +184,26 @@ Connector posture rules:
| `OPENCLAW_CONNECTOR_MEDIA_TTL_SEC` | Media expiry in seconds (default `300`, clamped to `60..86400`). |
| `OPENCLAW_CONNECTOR_MEDIA_MAX_MB` | Max staged media size in MB (default `8`, clamped to `1..64`). |
### 2.5 Execution Budgets & Limits
### 2.5 External Tools and Runtime Hygiene
External tool execution is opt-in and admin-gated. Package-owned defaults, runtime state, and local validation artifacts are separate ownership classes.
| Variable | Default | Description |
| :--- | :--- | :--- |
| `OPENCLAW_ENABLE_EXTERNAL_TOOLS` | `false` | Enables `/openclaw/tools` and `/openclaw/tools/{name}/run`. Keep disabled unless a reviewed deployment needs allowlisted external CLI execution. |
| `OPENCLAW_TOOLS_CONFIG_PATH` | package `data/tools_allowlist.json` | Explicit path to a custom tools allowlist. If unset, OpenClaw uses the package-owned shipped allowlist rather than a state-dir shadow file. |
| `OPENCLAW_TOOL_SANDBOX_RUNTIME_AVAILABLE` | `1` | Runtime availability marker used by hardened tool execution diagnostics. In hardened mode, `0` fails closed before tool execution. |
| `OPENCLAW_TOOL_SANDBOX_DIR` | `{state_dir}/tool_sandbox` | Optional override for external-tool scratch/temp workspace. Legacy alias: `MOLTBOT_TOOL_SANDBOX_DIR`. |
Ownership rules:
- package resources such as the default tool allowlist are read from the installed custom-node pack
- state-owned runtime cache/sandbox paths live under `OPENCLAW_STATE_DIR` unless explicitly overridden
- repo-local generated folders such as `.tmp/`, `.venv/`, and `node_modules/` are local tooling artifacts, not runtime state
- OpenClaw does not automatically repair, migrate, or delete runtime dependency caches
- tool execution results classify common local failures with deterministic diagnostics such as `sandbox_runtime_unavailable`, `interpreter_missing`, `timeout`, and `workspace_violation`
### 2.6 Execution Budgets & Limits
Contractual limits to prevent resource exhaustion.
@@ -194,12 +217,13 @@ Contractual limits to prevent resource exhaustion.
| `OPENCLAW_MAX_INFLIGHT_SUBMITS_PER_TENANT` | `1` | Per-tenant concurrent submit cap (applies when multi-tenant mode is enabled). |
| `OPENCLAW_MAX_RENDERED_WORKFLOW_BYTES` | `524288` | Max size (bytes) of a rendered workflow JSON (512KB). |
### 2.6 Runtime & Diagnostics
### 2.7 Runtime & Diagnostics
| Variable | Description |
| :--- | :--- |
| `OPENCLAW_STATE_DIR` | Directory for persistent state (DBs, history, logs). Default: `ComfyUI/user/default/openclaw` |
| `OPENCLAW_STATE_DIR` | Directory for persistent state (DBs, history, logs, runtime cache). Defaults to the platform user-data directory, such as `%LOCALAPPDATA%\comfyui-openclaw\`, `~/Library/Application Support/comfyui-openclaw/`, or `~/.local/share/comfyui-openclaw/`. |
| `OPENCLAW_LOG_TRUNCATE_ON_START` | Set `1` to truncate active log file (`openclaw.log`) once at process startup before new handlers write records. |
| `OPENCLAW_STARTUP_WARMUP_TIMEOUT_SEC` | Optional timeout for non-blocking startup warmups. Warmup timeout degrades health diagnostics but does not block required route startup. |
| `OPENCLAW_DIAGNOSTICS` | Comma-separated list of subsystems to enable debug logging for (e.g. `webhook.*,templates`). Safe-redacted. |
| `OPENCLAW_CONNECTOR_DEBUG` | Set `1` to enable verbose debug logging in Connector. |
+2
View File
@@ -20,8 +20,10 @@ Users should audit these flags before deploying to a public or untrusted network
| :--- | :--- | :--- | :--- |
| `OPENCLAW_CONNECTOR_ADMIN_TOKEN` | *None* | **Medium** | Required for admin commands (stop/approve/trace) if server auth is enabled. If missing, admin commands fail safe. |
| `OPENCLAW_ALLOW_REMOTE_ADMIN` | `0` | **High** | Be careful! Allows admin actions from non-loopback IPs if token is present (including writes from `/openclaw/admin` remote console). Default is loopback-only for admin. |
| `OPENCLAW_ENABLE_EXTERNAL_TOOLS` | `0` | **High** | Enables admin-gated external tool listing/execution routes. Requires reviewed tool allowlist and sandbox policy; keep disabled on public surfaces unless explicitly justified. |
| `OPENCLAW_BRIDGE_ENABLED` | `0` | **High** | Enables the sidecar bridge for remote orchestration. Requires `OPENCLAW_BRIDGE_DEVICE_TOKEN` (and in public posture also mTLS + device allowlist controls). |
| `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST` | `0` | **High** | Bypasses the known-host allowlist for LLM `base_url`. Allows SSRF to public IPs. |
| `OPENCLAW_LLM_ALLOW_PRIVATE_NETWORK` | `0` | **High** | Allows the configured LLM `base_url` host to resolve to private/reserved IPs while preserving exact-host scope and DNS pinning. |
| `OPENCLAW_ALLOW_INSECURE_BASE_URL` | `0` | **Critical** | Allows HTTP (non-HTTPS) or private IP `base_url` for LLM. Risk of internal network scanning (SSRF). |
| `OPENCLAW_MULTI_TENANT_ENABLED` | `0` | **High** | Enables fail-closed tenant boundary mode. Requests without valid tenant context can be rejected by design. |
| `OPENCLAW_TENANT_HEADER` | `X-OpenClaw-Tenant-Id` | **Low** | Customizes tenant header extraction key for multi-tenant mode. Keep stable across all clients/proxies. |
@@ -14,7 +14,7 @@ Closure note:
- this intake reference is now historical context only
- the residual wave was closed during `S91`, with GitHub `Code scanning` and `Secret scanning` reduced to `0` open findings on 2026-04-08
## 2. Current Residual Findings Baseline
## 2. Intake Residual Findings Baseline
Authenticated GitHub Security review showed this residual baseline at intake:
@@ -25,25 +25,25 @@ Authenticated GitHub Security review showed this residual baseline at intake:
Residual CodeQL families at intake:
1. `py/path-injection`
- current concentration: `services/model_manager_transfer.py`
- intake concentration: `services/model_manager_transfer.py`
- count at intake: `9`
2. `py/weak-sensitive-data-hashing`
- current concentration: `services/redaction.py`, `services/audit.py`, `services/bridge_token_lifecycle.py`
- intake concentration: `services/redaction.py`, `services/audit.py`, `services/bridge_token_lifecycle.py`
- count at intake: `3`
3. `py/stack-trace-exposure`
- current concentration: `connector/platforms/slack_webhook.py`, `connector/platforms/feishu_webhook.py`
- intake concentration: `connector/platforms/slack_webhook.py`, `connector/platforms/feishu_webhook.py`
- count at intake: `2`
4. `py/clear-text-logging-sensitive-data`
- current concentration: `api/bridge.py`, `services/audit.py`
- intake concentration: `api/bridge.py`, `services/audit.py`
- count at intake: `2`
5. `py/clear-text-storage-sensitive-data`
- current concentration: `services/audit.py`
- intake concentration: `services/audit.py`
- count at intake: `1`
6. `py/xml-bomb`
- current concentration: `connector/platforms/wechat_webhook.py`
- intake concentration: `connector/platforms/wechat_webhook.py`
- count at intake: `1`
7. `js/incomplete-sanitization`
- current concentration: `tests/e2e/specs/notifications.spec.js`
- intake concentration: `tests/e2e/specs/notifications.spec.js`
- count at intake: `1`
Residual secret-scanning family:
+27 -8
View File
@@ -122,7 +122,7 @@ interface ContextAction {
}
```
## 3. Parameter Lab (F52)
## 3. Parameter Lab
Contracts for bounded parameter sweeps and experiment orchestration.
@@ -135,20 +135,39 @@ Contracts for bounded parameter sweeps and experiment orchestration.
{
"node_id": "10",
"widget_name": "cfg",
"values": [6.0, 7.0, 8.0]
"values": [6.0, 7.0, 8.0],
"strategy": "grid"
},
{
"node_id": "3",
"node_id": "loader-alpha",
"widget_name": "seed",
"strategy": "random",
"count": 3
"values": [41, 42]
}
],
"max_runs": 20,
"batch_size": 1
]
}
```
Contract notes:
- `node_id` is a string-preserving host graph identifier. It may be numeric text such as `"10"` or a non-numeric host ID, and clients must not coerce it to a number when storing, comparing, or replaying experiment parameters.
- Experiment parameter keys such as `"10.cfg"` are display/storage keys derived from the original `node_id` plus `widget_name`; they are not a separate numeric node contract.
- Sweep values are limited to bounded strings, booleans, integers, and finite numbers. Null,
arrays, objects, non-finite numbers, overlong strings, presentation-ambiguous duplicates, and
unsupported strategies fail validation instead of being coerced.
- Sweep creation supports `grid` strategy only. The backend policy is authoritative and limits a
request to 5 MiB, workflow text to 4 MiB, eight dimensions, 50 values per dimension, and 50
generated combinations. Compare creation accepts at most eight scalar items.
### Queue ownership receipt
- The coordinator observes the host's reviewed `promptQueueing` and `promptQueued` request
boundaries, correlating their integer `requestId` and `batchCount` fields.
- It writes a transient UUID receipt only into the matching serialized workflow and returns the
exact `promptId` / `requestId` pair used to route bounded lifecycle event metadata.
- Unsupported event APIs, malformed or missing boundaries, pre-existing unobserved host queue
activity, receipt collisions, timeouts, and ambiguous batch ownership fail explicitly. There is
no fallback to a globally recent prompt ID.
### Experiment Result Schema (JSON)
```json
+192 -5
View File
@@ -7,6 +7,193 @@ Newest entries appear first.
<details>
<summary><strong>Startup, security posture, and architecture boundaries hardened</strong></summary>
- Added a dependency-light production source verifier to pre-commit. It parses tracked Python
imports without importing application modules and fails on unknown ownership, forbidden
dependency direction, cycles, and unreviewed dynamic imports.
- Patched transitive frontend development dependencies `ws`, `postcss`, and resolver-owned
`nanoid` without changing the root manifest or runtime dependency boundary. Windows/Linux
full-test, pre-push, and CI security paths now run a fresh `npm ci` and block high/critical
findings across the complete production and development dependency tree.
- Replaced coarse startup reporting with typed, redacted phase and state outcomes, bounded retry
and timing metadata, and explicit optional warmup results.
- Consolidated process-static deployment and security decisions into one immutable, secret-free
effective posture snapshot reused by startup, control-plane, and surface guards.
- Moved startup lifecycle, route registration, and effective posture implementations into focused
service-domain owner packages while preserving legacy module identity aliases.
- Replaced the public systemd environment file with an `.env.example`-style template and retained
a hard version-control boundary around secret-bearing environment files.
</details>
<details>
<summary><strong>Host alignment, Parameter Lab, and native workflow ownership refreshed</strong></summary>
- Split host metadata between legacy fixed-bundle Desktop and current managed-install
Comfy-Desktop. Presence of `window.__comfyDesktop2` identifies the current host generation but
never authorizes privileged bridge capability calls.
- Excluded ComfyUI's `datasets` user-data root from model inventory and Model Manager destination
handling so training data is not treated as managed model weights.
- Bounded Parameter Lab creation and persistence to string, boolean, integer, and finite-number
values, with byte/count limits, grid-only sweep validation, and explicit rejection of nested or
ambiguous values.
- Correlated Parameter Lab queue ownership through reviewed `promptQueueing` / `promptQueued`
request IDs and transient workflow receipts, failing closed on unsupported, malformed, busy, or
ambiguous host queue boundaries.
- Recognized advanced 3D `result` references as bounded source links without consuming later
metadata or rendering binary content.
- Documented native ComfyUI ownership for video/webcam inputs, audio and text-to-speech flows, and
the Graph/Workflows workspace instead of introducing duplicate OpenClaw node or workspace stacks.
</details>
<details>
<summary><strong>Maintainability, scale safeguards, and verification governance strengthened</strong></summary>
- Added a pinned incremental Ruff/Mypy policy to local, pre-commit, and CI validation. Existing
debt remains explicitly governed while new production-path findings fail the gate.
- Established deterministic scale baselines for 10,000-record jobs history, bounded connector
summaries, and 1,024 frontend output refs. Exact call counts, payload bounds, and stable digests
are enforced; host-sensitive elapsed time remains advisory.
- Classified and hardened selected config, connector, and platform-adapter exception boundaries,
preserving cancellation, compatibility fallback, public status mapping, and redacted logging.
- Decomposed API route and configuration ownership, connector command dispatch, Slack and Feishu
ingress/installation/delivery seams, and frontend API/Settings ownership behind stable facades.
Public routes, patch seams, security controls, singleton identity, DOM structure, and host
lifecycle behavior remain contract-tested.
- Promoted the backend coverage floor from 45% to 55% only after reconstructing two consecutive
release snapshots, retaining full-suite artifact hashes and all required hotspot percentages,
and assigning targeted regression owners. Incomplete, nonconsecutive, malformed, or atomically
mismatched promotion evidence now fails closed.
</details>
<details>
<summary><strong>Secure jobs visibility and connector summaries completed</strong></summary>
- Replaced the placeholder jobs listing with an Admin-only, versioned in-process read
model over current ComfyUI queue/history state, including five lifecycle states and
bounded status/workflow filters, sorting, pagination, and source/scan diagnostics.
- Reduced every listed job to an allowlisted summary and excluded raw prompts, workflows,
execution errors, tracebacks, current inputs/outputs, tenant/client/trace identifiers,
reasoning, and internal content from successful responses and audit details.
- Preserved authoritative empty snapshots while distinguishing unsupported host contracts
(HTTP 501) from unavailable or malformed snapshots (HTTP 503), so failures cannot look
like an empty queue.
- Added an Admin-class connector `/jobs` summary that validates contract version 1,
renders bounded aggregate counts and short IDs, keeps the raw payload out of the chat
LLM, and permits only a coarse queue-count fallback for explicit 501/503 conditions.
</details>
<details>
<summary><strong>Host compatibility reference anchors refreshed</strong></summary>
- Refreshed the active compatibility baseline to ComfyUI `1377a2f7` (`v0.27.0-47-g1377a2f7`, pyproject `0.27.0`) and standalone frontend `1.48.1` (`ceb5ae1eba`, `v1.48.1-1-gceb5ae1eba`).
- Kept Desktop pinned separately at `0.9.4` with core `0.22.3` and embedded frontend `1.43.18`, explicitly lagging the standalone frontend reference.
- Documented current host asset `loader_path` and namespaced model-tag schema without adopting direct `/api/assets` runtime access or changing OpenClaw's Node.js 18+ test policy.
- Added bounded Job Monitor previews for allowlisted text refs emitted under the host `files` output key, using only same-origin `/view`, strict textual MIME/UTF-8 streaming limits, literal DOM text, and explicit source-link fallback states.
</details>
<details>
<summary><strong>Host compatibility, output previews, media safety, and graph guards refreshed</strong></summary>
- Published host compatibility notes now pin the current ComfyUI, standalone frontend, and Desktop reference anchors while keeping Desktop embedded-frontend lag explicit.
- Output previews keep filename-backed refs first-class, accept optional `asset_hash` / `hash` metadata when present, and leave asset-service-only identifiers as explicit fallback states.
- LINE and WhatsApp connector media URLs now force dangerous active content such as SVG/HTML/JS/CSS/XML to download with no-sniff response headers while preserving safe image delivery.
- Job Monitor now treats HDR `.exr` and `.hdr` image outputs as explicit source-preview fallback links instead of normal thumbnails, matching the current host expectation without bundling a HDR viewer.
- Parameter Lab and graph-helper coverage now preserve non-numeric node IDs and promoted-widget source metadata, while structured color/box widget inputs stay out of missing-model diagnostics.
</details>
<details>
<summary><strong>Targeted connector cancellation and host contract guard coverage refreshed</strong></summary>
- Connector `/stop`, `/cancel`, and `/interrupt` commands now keep no-argument global interrupt explicit while routing supplied job IDs through targeted ComfyUI job cancellation.
- Single-job cancellation on older hosts can fall back only to targeted interrupt; multi-job cancellation failures no longer degrade into a global interrupt.
- Compatibility guard coverage now documents SaveImage-style output refs, 3D preview refs, typed asset dimensions, grouped asset behavior, sidebar registration fallback, and the OpenClaw Node.js runtime policy.
- OpenClaw keeps its own package/test harness on Node.js `>=18.0.0` while documenting that standalone ComfyUI frontend development may require a newer Node engine.
</details>
<details>
<summary><strong>Package hygiene, runtime cache ownership, and tool diagnostics tightened</strong></summary>
- Moved developer-only verification helpers out of the repository root and into the dedicated developer tooling area, keeping the custom-node package root focused on shipped package entrypoints and metadata.
- Made the default external-tool allowlist package-owned at `data/tools_allowlist.json`; custom allowlists are now explicitly routed through `OPENCLAW_TOOLS_CONFIG_PATH` instead of being accidentally masked by state-dir or bind-mounted source layouts.
- Added a dependency-light runtime hygiene contract that separates package resources, state-directory runtime cache/sandbox paths, and repo-local generated validation artifacts.
- Preserved the no-automatic-repair posture for runtime dependency caches: OpenClaw does not delete, migrate, or repair generated runtime dependency caches without an explicit future implementation.
- Added deterministic tool execution diagnostics for missing sandbox runtime, missing executable/interpreter, timeout, workspace/path violation, and process failures while keeping hardened missing-runtime behavior fail-closed and avoiding Docker or broader fallback execution.
</details>
<details>
<summary><strong>ComfyUI host compatibility, media outputs, model folders, and prompt attribution refreshed</strong></summary>
- Refreshed the published compatibility baseline for ComfyUI `51bf508a` (`v0.27.0-25-g51bf508a`, pyproject `0.27.0`), standalone frontend `1.47.6`, and Desktop `0.9.4` with core `0.22.3` plus embedded frontend `1.43.18`.
- Reconciled active prompt state after backend or SSE reconnects so completed prompts are not left in the active queue lane after a host recovery.
- Updated sidebar registration to prefer the current ComfyUI sidebar store API and keep the deprecated frontend facade as a compatibility fallback for older hosts.
- Aligned Model Manager and preflight diagnostics with current ComfyUI model folder names, including newer managed keys such as `gligen`, `latent_upscale_models`, `hypernetworks`, `photomaker`, `model_patches`, `geometry_estimation`, and `detection`, while retaining legacy aliases such as `clip` and `unet`.
- Made output parsing media-aware for current previewable result groups (`images`, `video`, `audio`, `3d`, and bounded `text`) while keeping image callbacks compatible and keeping asset-only identifiers as explicit fallback states instead of silently upgrading to `/api/assets`.
- OpenClaw prompt submissions now include stable `comfy_usage_source` attribution when missing, without overwriting caller-provided attribution or copying prompt/tenant/trace content into that field.
</details>
<details>
<summary><strong>Connector replay, reply visibility, and scheduled delivery behavior aligned with current chat workflows</strong></summary>
- Connector event handling now distinguishes duplicate committed actions from retryable pre-delivery failures across supported chat adapters, reducing accidental re-execution while still allowing safe retries.
- Reply visibility is now governed by a shared connector policy for direct messages, shared chats, threads, internal delivery, and tool-only contexts; suppressed text is logged as a successful no-op instead of a delivery failure.
- Telegram topics, Slack threads/workspaces, and Feishu account/workspace context are preserved for immediate replies and delayed result or approval follow-up, while approval/action buttons remain visible.
</details>
<details>
<summary><strong>Startup lifecycle diagnostics, connector SecretRef service boundaries, and internal prompt isolation aligned with the current runtime</strong></summary>
- Health diagnostics now distinguish required startup readiness, optional warmup degradation, and fatal startup failures; optional warmups run after route registration and no longer block baseline API availability.
- Connector/service launch planning now has a secret-blind env-backed SecretRef boundary that preserves supported connector credential references without expanding raw token values, while rejecting raw secrets, legacy marker strings, unsupported envs, and runtime-only auth tokens.
- Operator-visible and audit payload sanitization now removes explicitly marked internal maintenance/helper prompt content before normal reasoning redaction, while leaving ordinary user text intact.
</details>
<details>
<summary><strong>Host compatibility anchors and inactive-branch preflight diagnostics aligned with current ComfyUI hosts</strong></summary>
- Refreshed the published compatibility matrix for current ComfyUI, standalone frontend, and desktop reference anchors, keeping desktop embedded-frontend lag explicit instead of assuming standalone-frontend parity.
- Updated workflow portability and preflight diagnostics so muted or bypassed workflow branches are separated from actionable missing-node/model failures when frontend workflow metadata is available.
- Explorer now surfaces inactive-branch findings as suppressed diagnostics, so operators can still inspect them without treating them as current workflow blockers.
- Tightened repository ignore rules so public release documentation is not accidentally hidden from version control.
</details>
<details>
<summary><strong>Slack interactive callbacks, canonical node categories, and hardening governance aligned with the current runtime</strong></summary>
- Added Slack interactive callback handling for Block Kit actions, modal submissions, and workflow-style payloads, with signed ingress verification, replay/idempotency checks, bounded external errors, and policy-aware routing for run-affecting actions.
- Aligned shipped node metadata on the canonical `openclaw` category while keeping legacy `Moltbot*` class aliases available for existing workflows.
- Tightened node and frontend maintainability by moving batch-variant randomized seed imports to module scope and keeping tab DOM wiring on shared text-safe helper paths.
- Added explicit verification ownership for the `safe_io` and security-boundary hotspot families so future coverage ratchets depend on targeted regressions instead of broad coverage alone.
- Hardened exception-boundary governance around selected startup and connector paths so unexpected route/bootstrap or trust-parsing failures are surfaced instead of silently masked.
</details>
<details>
<summary><strong>Packaging boundaries, node portability guidance, config ownership seams, and connector extraction diagnostics aligned with the current runtime</strong></summary>
- Made the supported packaging model explicit: the ComfyUI custom node pack remains the primary artifact, the embedded operator platform is the first-class runtime identity, and the connector stays an optional attached subsystem rather than a separate published package.
@@ -21,7 +208,7 @@ Newest entries appear first.
<summary><strong>Verification governance, config bootstrap hygiene, and connector env hardening aligned with the current runtime</strong></summary>
- Promoted the staged coverage-ratchet baseline to the enforced `45%` floor, added retained review-cycle evidence for hotspot families, and wired backend coverage collection through one shared local/CI helper instead of ad hoc `fail_under` edits.
- Promoted the staged coverage-ratchet baseline to the then-enforced `45%` floor, added retained review-cycle evidence for hotspot families, and wired backend coverage collection through one shared local/CI helper instead of ad hoc `fail_under` edits.
- Added focused connector and config/bootstrap hotspot regressions, reviewed the governed hotspot-family coverage summaries, and retired the temporary promotion-gap exceptions now that both promotion-blocking families are represented by explicit review evidence.
- Added fail-closed test-debt governance for no-skip modules and mutation-survivor allowlist entries, with explicit `reason` and `review_after` metadata now enforced by the standard full-test flow.
- Hardened pack metadata/version fallback parsing and made config/bootstrap imports side-effect-safe, so pack version fallback stays deterministic and importing config helpers no longer creates the state directory or log file before first real use.
@@ -140,7 +327,7 @@ Newest entries appear first.
<summary><strong>Exception-fidelity cleanup and verification-governance baseline completed</strong></summary>
- Preserved original traceback origins on the remaining planner/refiner/vision/config failure paths and aligned request-time default `LLMClient` refresh so runtime config hot-reload no longer mutates long-lived service state just to get a fresh client.
- Added explicit coverage governance in `pyproject.toml`, including the active `45%` `fail_under`, visible missing-line reporting, and skip-covered output, so baseline quality drift is no longer implicit.
- Added explicit coverage governance in `pyproject.toml`, including the then-active `45%` `fail_under`, visible missing-line reporting, and skip-covered output, so baseline quality drift is no longer implicit.
- Added a stdlib-only governance verifier that fails closed when coverage config, adversarial mutation thresholds, SOP guidance, or mutation-survivor allowlist shape drift away from the enforced baseline.
- Wired the governance verifier into Linux/Windows full-test flows and the repo pre-push gate, keeping local CI-parity checks aligned with the enforced verification contract.
- Re-validated the full implementation on WSL with the full SOP gate: detect-secrets, pre-commit, governance verification, backend full suites, adaptive adversarial gate, and Playwright E2E.
@@ -175,7 +362,7 @@ Newest entries appear first.
<summary><strong>Planning, startup/config hardening, compatibility governance, and frontend hotspot reduction batch</strong></summary>
- Normalized the active planning surface onto `.planning/roadmap.md` and clarified the docs-only test-flow exemption in the project SOP guidance.
- Normalized the active maintainer planning surface and clarified the docs-only test-flow exemption in the project SOP guidance.
- Hardened route/bootstrap registration around a declarative manifest and centralized validation seam so startup wiring is less fragile under delayed readiness and import-order edge cases.
- Completed the next config-unification pass around one effective-config read facade, reducing precedence drift across backend and frontend-facing config consumers.
- Centralized legacy compatibility handling for backend headers and frontend API/storage fallbacks so deprecation behavior is explicit, shared, and regression-covered.
@@ -188,9 +375,9 @@ Newest entries appear first.
<summary><strong>Private-host LLM SSRF contract clarified across Remote Admin, docs, and deployment guidance</strong></summary>
- Clarified that `OPENCLAW_LLM_ALLOWED_HOSTS` only extends the exact public-host allowlist for custom LLM `base_url` values and does not permit private/reserved LAN targets by itself.
- Updated Remote Admin and model-refresh SSRF error messages so operators can distinguish public-host allowlisting from the explicit insecure override required for private-IP targets.
- Updated Remote Admin and model-refresh SSRF error messages so operators can distinguish public-host allowlisting, scoped private-network allowance, and the explicit insecure override for private-IP targets.
- Documented Windows portable env inheritance expectations, including the need to set variables before launching `python_embeded\python.exe`, restart after changes, and avoid unsupported wildcard entries such as `*`.
- Fixed request-time parity so Remote Admin validation, `/openclaw/llm/models`, and outbound provider requests now honor the same explicit insecure override for intentional private-host/HTTP LLM targets.
- Fixed request-time parity so Remote Admin validation, `/openclaw/llm/models`, and outbound provider requests now honor the same scoped private-network allowance or explicit insecure override for intentional private-host/HTTP LLM targets.
- Added a pre-commit autofix guard that regenerates `docs/openapi.yaml` when OpenAPI contract/generator inputs change, preventing generated-spec drift from surfacing only at push time.
- Added regression coverage for the clarified SSRF error contract and re-validated with the full SOP gate.
@@ -1,27 +1,26 @@
# Residual Security Execution Chain
Date: 2026-04-08
Roadmap chain: `S88 -> S89 -> S90 -> S91`
## 1. Purpose
This document mirrors the active execution chain for the remaining GitHub Security findings after the first remediation wave and the initial residual follow-up fixes.
This document summarizes the public security closeout status for the remaining GitHub Security findings after the first remediation wave and the initial residual follow-up fixes.
It is intended as a repo-visible planning reference in `docs/` and should stay aligned with `.planning/roadmap.md` and `.planning/roadmap/open/SECURITY_OPEN.md`.
Maintainer-only execution records remain internal; this page is limited to public-facing status and remediation areas.
## 2. Active Item Order
## 2. Remediation Areas
1. `S88`: GitHub Security residual alert verification, dismissal, and closure execution wave
2. `S89`: Residual audit and bridge alert retirement sweep
3. `S90`: Residual model-manager path-boundary false-positive retirement wave
4. `S91`: GitHub code-scanning mode switch and final residual alert closure wave
1. GitHub Security residual alert verification, dismissal, and closure execution wave.
2. Residual audit and bridge alert retirement sweep.
3. Residual model-manager path-boundary false-positive retirement wave.
4. GitHub code-scanning mode switch and final residual alert closure wave.
## 3. Final State
- `S88` is completed. Authenticated GitHub verification confirmed the repaired findings retired or were closed with explicit rationale after the advanced CodeQL switch.
- `S89` is completed. The audit and bridge identifier cleanup removed the true residual sinks and reduced the remaining audit findings to GitHub-managed false positives.
- `S90` is completed. The model-manager path-boundary proof stayed fail-closed, and the remaining `py/path-injection` alerts were retired through authenticated false-positive dismissal after the advanced rescan.
- `S91` is completed. GitHub code scanning default setup was switched off, the committed `.github/workflows/codeql.yml` run on `main` succeeded, the final residual CodeQL alerts were dismissed with recorded rationale, and the historical secret-scanning docs example was resolved.
- Authenticated GitHub verification confirmed the repaired findings retired or were closed with explicit rationale after the advanced CodeQL switch.
- The audit and bridge identifier cleanup removed the true residual sinks and reduced the remaining audit findings to GitHub-managed false positives.
- The model-manager path-boundary proof stayed fail-closed, and the remaining `py/path-injection` alerts were retired through authenticated false-positive dismissal after the advanced rescan.
- GitHub code scanning default setup was switched off, the committed `.github/workflows/codeql.yml` run on `main` succeeded, the final residual CodeQL alerts were dismissed with recorded rationale, and the historical secret-scanning docs example was resolved.
## 4. Execution Rules
+5 -4
View File
@@ -18,7 +18,7 @@
- **Environment**: macOS, older Windows versions.
- **Python**: 3.12.
- **ComfyUI**: nightly builds and farther-from-anchor upstream drift.
- **Desktop host**: desktop bundle variants outside the current recorded desktop anchor, including cases where the embedded frontend lags standalone frontend.
- **Desktop host**: legacy fixed-bundle variants outside the recorded legacy anchor and current managed-install variants whose installed host components fall outside their own supported anchors.
### Tier 3: Unsupported
@@ -34,9 +34,10 @@
## Compatibility Anchor Policy
- The authoritative compatibility reference points are recorded in [`compatibility_matrix.md`](/mnt/c/Users/Ray/Documents/我的專案/ComfyUI-OpenClaw/docs/release/compatibility_matrix.md).
- `ComfyUI`, standalone `ComfyUI_frontend`, and `desktop` are tracked as separate host surfaces.
- Desktop should not be assumed to match standalone frontend HEAD; the embedded frontend version may intentionally lag and must be evaluated against its own recorded bundle anchor.
- The authoritative compatibility reference points are recorded in [`compatibility_matrix.md`](compatibility_matrix.md).
- `ComfyUI`, standalone `ComfyUI_frontend`, legacy `desktop`, and current `comfy_desktop` are tracked as separate anchors.
- Legacy Desktop is a fixed bundle and must be evaluated against its recorded core/frontend versions.
- Current Comfy-Desktop is a managed-install generation; hosted ComfyUI and frontend versions are installation-specific and must not be inferred from the application version.
- Upstream reference refreshes should update the matrix anchors before being treated as the new default support baseline.
## Reporting Issues
+8 -4
View File
@@ -18,16 +18,20 @@ Operators should use this to understand the risks of deployment.
* **Access**: Read-only logs (`/openclaw/logs/tail`), config (`/openclaw/config`), health.
* **Mechanism**: `OPENCLAW_OBSERVABILITY_TOKEN`.
* **Redaction**: Logs/Config are redacted by default to prevent secret leakage.
* **Reasoning-content posture**: provider reasoning / thinking traces are stripped by default from operator-visible assist responses, event streams, trace responses, callback payloads, and connector trace replies; privileged reveal is local-debug only, admin-gated, auditable, and fail-closed outside permissive local posture.
* **Reasoning/internal-content posture**: provider reasoning / thinking traces and explicitly marked internal maintenance/helper prompt content are stripped by default from operator-visible assist responses, event streams, trace responses, callback payloads, connector trace replies, and audit event payload/meta fields. Privileged reasoning reveal is local-debug only, admin-gated, auditable, and fail-closed outside permissive local posture; internal maintenance/helper prompt content has no public or debug reveal path.
### 3. The "Connector" Boundary (ChatOps)
* **Who**: Chat users (Telegram/Discord/LINE).
* **Access**:
* **User**: `submit_job` (via Allowlisted templates), `query_status`.
* **Admin (Chat)**: `approve_request`, `cancel_job`, `trace`.
* **Admin (Chat)**: `approve_request`, `cancel_job`, `trace`, and privacy-minimized
`list_jobs` summaries.
* **Mechanism**: Chat platform auth + OpenClaw User Allowlist (or `require_approval` policy).
* **Risk**: Spam/DoS (mitigated by Budgets + Rate Limits), or Prompt Injection (mitigated by Template Constraints).
* **Risk**: Spam/DoS (mitigated by Budgets + Rate Limits), Prompt Injection (mitigated by
Template Constraints), or job metadata disclosure (mitigated by Admin-only authorization,
allowlisted bounded fields, content-free errors, and keeping raw jobs payloads out of the
chat LLM).
---
@@ -50,7 +54,7 @@ Operators should use this to understand the risks of deployment.
* *Mitigation*: Known-host allowlist by default. Custom URLs need explicit opt-in + DNS validation.
* **Callback Delivery**: `POST` results to webhook targets.
* *Risk*: SSRF / Information Leakage.
* *Mitigation*: DNS-safe validation (no private IPs) + operator-payload redaction, including reasoning-content stripping by default.
* *Mitigation*: DNS-safe validation (no private IPs) + operator-payload redaction, including reasoning/internal-content stripping by default.
* **Image Fetching**: `image_url` inputs.
* *Mitigation*: SafeIO module (size limits, no file://).
+8 -1
View File
@@ -45,12 +45,19 @@ Retained release-cycle review evidence lives in:
- `tests/coverage_promotion_reviews.json`
The current enforced stage is `ratchet-45`, which means the repository floor is now `fail_under = 45.0` and future promotions must retain at least two reviewed cycles for the previous stage.
The current enforced stage is `ratchet-55`, which means the repository floor is now
`fail_under = 55.0`. The promotion is backed by two consecutive ratchet-45 release-cycle
reviews with immutable release commits, full-suite artifact hashes, all required hotspot
percentages, and named regression owners.
## Governance Baseline
- `tests/coverage_governance_policy.json` is the source of truth for the current enforced floor, next planned ratchet target, hotspot families, and temporary exceptions.
- `pyproject.toml` coverage settings must stay aligned with the active stage floor declared in `tests/coverage_governance_policy.json`.
- `tests/coverage_promotion_reviews.json` is the retained promotion-evidence ledger for reviewed hotspot summaries across release cycles.
- Ratchet-55 evidence must identify consecutive release boundaries, the reviewed commit,
full-suite command and artifact SHA-256, all required hotspot percentages, and owned suites.
- Rollback is atomic: a future approved rollback must move both the policy current stage and
`pyproject.toml` floor together; config drift fails the governance check.
- Test-debt governance remains fail-closed; review metadata such as `reason` and `review_after` must stay current for governed skip-policy and mutation-survivor entries.
- Detailed CI-gate composition and merge requirements remain documented in `docs/release/ci_regression_policy.md` and `tests/TEST_SOP.md`.
+80
View File
@@ -6,6 +6,9 @@ This guide explains the startup security model and bridge compatibility behavior
- Runtime profile selection
- Hardened startup enforcement behavior
- Typed startup lifecycle diagnostics
- Process-static effective security posture
- External tool sandbox diagnostics
- Module startup boundaries
- Bridge protocol handshake compatibility
@@ -48,6 +51,52 @@ In `minimal` mode, these checks are warning-first for local/LAN posture, but `pu
Startup bootstrap no longer swallows fatal security-gate errors.
If a critical startup gate fails, initialization aborts deterministically instead of continuing with partial route registration.
### Startup lifecycle diagnostics
The health response includes a `startup` diagnostic object with:
- `schema_version`: diagnostic schema version
- `phase`: `package_import`, `required_initialization`, `host_wait`,
`route_registration`, `complete`, or `optional_warmup`
- `state`: `starting`, `initializing`, `waiting_for_host`, `registering_routes`, `ready`,
`degraded`, or `fatal`
- `reason_code`: stable, content-free transition reason
- `ready`: whether required route/service startup completed
- `degraded` and `fatal`: explicit terminal posture flags
- `attempt` and `max_attempts`: bounded host-wait retry progress
- `elapsed_ms`, `phase_elapsed_ms`, and `ready_elapsed_ms`: bounded lifecycle timing
- `warmups`: bounded optional warmup entries with name, state, reason, timeout, and duration
Required startup work still fails closed. Optional warmups such as model inventory refresh run after
route registration and do not block baseline API availability. A failed or timed-out optional
warmup changes the startup state to `degraded`; individual warmup states are `pending`, `running`,
`succeeded`, `failed`, or `timed_out`.
Optional warmup timeout can be tuned with:
- `OPENCLAW_STARTUP_WARMUP_TIMEOUT_SEC`
- legacy alias: `MOLTBOT_STARTUP_WARMUP_TIMEOUT_SEC`
### Effective security posture snapshot
During the process-wide route bootstrap, OpenClaw resolves deployment, runtime, connector,
control-plane, and surface decisions once into an immutable `EffectiveSecurityPosture` snapshot.
The snapshot records configuration presence and stable decision codes, not secret values.
Startup gates, control-plane policy, and surface authorization reuse this same object identity so
process-static security decisions cannot drift between modules. Request-dynamic controls such as
authentication, replay checks, and rate limiting still evaluate each request using their normal
runtime inputs.
The owner modules are:
- `services/bootstrap/lifecycle.py`
- `services/bootstrap/registration.py`
- `services/posture/effective.py`
Legacy imports remain identity-preserving aliases. See
[Service Domain Packages](architecture/service_domain_packages.md) for the ownership contract.
## Public deployment shared-surface acknowledgement
When running deployment profile checks for public posture (`OPENCLAW_DEPLOYMENT_PROFILE=public`),
@@ -78,6 +127,37 @@ When a platform is active, at least one platform-specific allowlist variable mus
- `hardened` runtime profile: fail-closed at startup gate
- non-strict local/LAN posture: warning posture in Security Doctor (`s32_allowlist_coverage`)
## External tool sandbox diagnostics
External tool execution is opt-in and remains admin-gated. Set `OPENCLAW_ENABLE_EXTERNAL_TOOLS=true` only for reviewed local/LAN workflows that need allowlisted CLI execution.
Current runtime behavior:
- default tool definitions are loaded from the package-owned `data/tools_allowlist.json`
- custom tool definitions must be supplied with `OPENCLAW_TOOLS_CONFIG_PATH`
- tool scratch/temp execution paths default to the state directory's `tool_sandbox/`
- `OPENCLAW_TOOL_SANDBOX_DIR` can override the scratch path for an explicitly reviewed deployment
- legacy `MOLTBOT_TOOL_SANDBOX_DIR` remains a compatibility alias
Hardened posture behavior:
- if `OPENCLAW_RUNTIME_PROFILE=hardened` and the sandbox runtime is marked unavailable, tool execution fails closed before `subprocess.run`
- tools without an explicit `sandbox` block fail closed in hardened mode
- network-enabled tools require `allow_network_hosts` in hardened mode
- filesystem allowlists are checked before execution; out-of-scope paths are blocked
Common service-level diagnostic codes:
- `sandbox_runtime_unavailable`
- `sandbox_policy_missing`
- `network_hosts_missing`
- `interpreter_missing`
- `timeout`
- `workspace_violation`
- `process_failed`
These diagnostics are designed to help operators fix local runtime setup without silently falling back to broader host execution. OpenClaw does not currently install Docker images, repair sandbox runtimes, or auto-delete runtime dependency caches.
## Localhost no-origin override posture
`OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN` controls a localhost convenience escape hatch for clients
+5 -1
View File
@@ -18,6 +18,8 @@
- Feishu/Lark: `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS` / `_ALLOWED_CHATS`
- [ ] Verify startup banner shows "No trusted users" warning if allowlists are empty.
- [ ] For strict posture (`OPENCLAW_DEPLOYMENT_PROFILE=public` or `OPENCLAW_RUNTIME_PROFILE=hardened`), do not enable connector ingress without allowlists; startup/deployment checks fail closed.
- [ ] Verify duplicate or retried connector events are acknowledged without re-running completed actions.
- [ ] Verify reply-visibility suppression only applies to text-only silent/internal/tool-only/no-mention contexts and does not suppress approval cards or action buttons.
### 2. Webhook Security (LINE)
@@ -43,6 +45,7 @@
- [ ] For shared/LAN/public exposure, keep `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0` (or unset).
- [ ] For `OPENCLAW_DEPLOYMENT_PROFILE=public`, set `OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1` only after reverse-proxy path allowlist + network ACL explicitly block ComfyUI-native high-risk routes.
- [ ] For `OPENCLAW_DEPLOYMENT_PROFILE=public`, if any connector platform token/enable flag is set, confirm corresponding allowlist coverage before startup (`DP-PUBLIC-009`).
- [ ] Keep `OPENCLAW_ENABLE_EXTERNAL_TOOLS=0` unless external tool execution is explicitly required; if enabled, review `OPENCLAW_TOOLS_CONFIG_PATH`, sandbox policy, and deterministic runtime diagnostics before exposure.
- [ ] Run `GET /openclaw/security/doctor` and verify no `csrf_no_origin_override` warning before exposure.
- [ ] Run `python scripts/verify_audit_chain.py --json` after restart/rotation-sensitive maintenance and confirm retained audit logs still verify cleanly.
@@ -68,7 +71,8 @@
| Rate limiting | Enabled | 10 req/min/user, 30 req/min/channel |
| Debug mode | Disabled | No sensitive logging |
| Replay protection | Enabled | LINE webhooks reject replays >5min old |
| Feishu interactive callbacks | Signed + deduped | Callback actions reject stale/replayed envelopes and degrade untrusted run actions to approval flow |
| Connector replay and reply visibility | Enabled | Duplicate committed events are no-ops; text-only suppressed replies do not suppress approval/action controls |
| Slack / Feishu interactive callbacks | Signed + deduped | Callback actions reject stale/replayed envelopes and degrade untrusted run actions to approval flow |
## 📞 Support
+10 -8
View File
@@ -23,6 +23,7 @@ Before using OpenClaw in any internet-facing setup, you must explicitly accept:
3. The operator/deployer is responsible for network isolation, auth boundaries, key management, monitoring, and incident response.
4. If you cannot satisfy the `public` profile baseline and checklist, do not deploy publicly. Use `local` or private/VPN-only access instead.
5. High-risk capabilities (external tools, registry sync, transforms, remote admin) must remain disabled unless there is a reviewed and time-bounded operational requirement.
6. If external tools are enabled, review the allowlist path (`data/tools_allowlist.json` or `OPENCLAW_TOOLS_CONFIG_PATH`), sandbox policy, and runtime diagnostics before exposing the deployment beyond localhost.
## 0.1 Shared-Port Boundary Statement (Critical)
@@ -113,7 +114,7 @@ OPENCLAW_ADMIN_TOKEN=change-this-local-admin-token
2. Keep remote admin disabled.
3. Keep external tools/registry sync/transforms disabled unless explicitly needed.
4. For local LLM providers (Ollama/LM Studio), use loopback URLs only (`localhost`/`127.0.0.1`/`::1`); keep `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=0` and `OPENCLAW_ALLOW_INSECURE_BASE_URL=0`.
5. `OPENCLAW_LLM_ALLOWED_HOSTS` is only for additional exact public hosts; it does not permit RFC1918/private LAN targets.
5. `OPENCLAW_LLM_ALLOWED_HOSTS` is only for additional exact public hosts; it does not permit RFC1918/private LAN targets. Use the scoped `allow_private_network` LLM setting only when a reviewed local deployment needs a private target.
6. The same LLM SSRF contract applies consistently to config validation, `/openclaw/llm/models`, and outbound provider requests.
7. Keep `OPENCLAW_DEBUG_REASONING_REVEAL=0` unless you are doing short-lived local admin debugging and explicitly need privileged reasoning reveal.
8. Keep `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0` unless you explicitly need local CLI/no-origin compatibility.
@@ -156,7 +157,7 @@ OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0
2. Use distinct admin and observability tokens.
3. Keep bridge/tools/registry/transforms disabled unless there is a reviewed requirement.
4. Keep `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0` for LAN deployments.
5. If your LLM is on another LAN/private-IP host, that still counts as an insecure `base_url` target; `OPENCLAW_LLM_ALLOWED_HOSTS` alone is not sufficient.
5. If your LLM is on another LAN/private-IP host, `OPENCLAW_LLM_ALLOWED_HOSTS` alone is not sufficient; use the scoped `allow_private_network` LLM setting for that configured target, or the broader insecure override only after review.
6. The same LLM SSRF contract applies consistently to config validation, `/openclaw/llm/models`, and outbound provider requests.
7. Run:
- `python scripts/check_deployment_profile.py --profile lan`
@@ -228,16 +229,17 @@ OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0
5. Enforce split control plane in public posture (`OPENCLAW_CONTROL_PLANE_MODE=split` + external URL/TOKEN).
6. Keep `OPENCLAW_DEBUG_REASONING_REVEAL=0`; privileged reasoning reveal is for local debugging only and must not be enabled on public user planes.
7. If any connector platform token/enable flag is configured, set corresponding platform allowlist vars before startup (`DP-PUBLIC-009` fail-closed).
8. Keep risky features disabled on public user-facing plane.
9. Keep `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0` in public deployments.
9. Verify split posture from capabilities:
8. For connector approvals/actions, verify duplicate platform retries do not re-run completed actions and text-only reply suppression does not hide approval/action controls.
9. Keep risky features disabled on public user-facing plane.
10. Keep `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0` in public deployments.
11. Verify split posture from capabilities:
- `GET /openclaw/capabilities` and confirm `control_plane.mode=split`
10. Run:
12. Run:
- `python scripts/check_deployment_profile.py --profile public`
11. Validate with project test and release gates before rollout:
13. Validate with project test and release gates before rollout:
- `tests/TEST_SOP.md`
- [RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md)
12. Ensure `/openclaw/admin` is blocked at public edge unless a separately hardened private admin plane is in place.
14. Ensure `/openclaw/admin` is blocked at public edge unless a separately hardened private admin plane is in place.
## 6. Bridge in Public Profile (only when absolutely required)
+1 -1
View File
@@ -185,7 +185,7 @@ Example commands:
```bash
python scripts/run_crypto_lifecycle_drills.py --pretty
python scripts/run_crypto_lifecycle_drills.py --scenarios planned_rotation,emergency_revoke --output .planning/logs/crypto_drills.json --pretty
python scripts/run_crypto_lifecycle_drills.py --scenarios planned_rotation,emergency_revoke --output crypto_drills.json --pretty
```
Evidence bundle contract (JSON):
+2 -3
View File
@@ -10,11 +10,10 @@
- `docs/connector.md`
- `docs/security_deployment_guide.md`
- `docs/security_key_lifecycle_sop.md`
- `.planning/roadmap.md` (latest implementation status and remaining work)
## Status Note
- Bridge APIs and connector runtime are available.
- Connector/sidecar runtime remains an optional attached subsystem; the primary package artifact is the ComfyUI custom node pack.
- Connector extraction remains a no-go-for-split-now decision until the shared installation/callback/delivery/config seams are independently versioned; see `docs/adr/ADR-0003-connector-extraction-feasibility-and-seams.md`.
- Standalone sidecar/gateway evolution is tracked in `.planning/roadmap.md`.
- Connector extraction remains a no-go-for-split-now decision until the shared installation/callback/delivery/reply-visibility/config seams are independently versioned; see `docs/adr/ADR-0003-connector-extraction-feasibility-and-seams.md`.
- Standalone sidecar/gateway evolution is tracked in maintainer roadmap records.
+61 -7
View File
@@ -37,11 +37,14 @@ What to check:
1. Open the Explorer / inventory diagnostics view or inspect `/openclaw/preflight/inventory`.
2. Confirm whether the workflow references `openclaw:*` nodes that are not present on the current host.
3. Look for portability/replacement guidance rather than renaming nodes blindly.
4. If Explorer shows inactive-branch suppressed findings, inspect them as context but do not treat them as active blockers unless the corresponding branch is enabled.
Notes:
- Compatibility class names such as `Moltbot*` still exist for older workflows, but the canonical portability contract is anchored on `openclaw:*` node identities.
- Current shipped nodes use the `openclaw` category in ComfyUI; seeing older `moltbot` category text usually means the installed pack is stale or ComfyUI has not been restarted after update.
- Current diagnostics may include deterministic replacement hints when an unavailable OpenClaw node can degrade to a more portable workflow pattern.
- Muted or bypassed root nodes and subgraph branches are separated into suppressed diagnostics when the workflow payload includes enough frontend metadata. Plain API prompt JSON remains deterministic, but it may not contain frontend ancestry needed to identify inactive subgraph context.
- If no portability guidance is present and the pack itself is loaded correctly, treat that as a real contract gap rather than assuming the workflow can be repaired by arbitrary JSON edits.
## Operator Doctor
@@ -60,7 +63,57 @@ Explorer / inventory note:
- A response showing `scan_state=refreshing` or `stale=true` does not necessarily mean the inventory path is broken; it can mean the cached snapshot was returned quickly while a deeper model scan continues in the background.
- Treat `last_error` as the primary signal that the background scan actually failed.
## Jobs preview shows an explicit asset fallback state instead of an image preview
## Jobs list or connector `/jobs` reports authorization or backend errors
`GET /openclaw/jobs` is an Admin-only bounded read model. For direct API/browser calls,
send the configured Admin token and use only the documented `status`, `workflow_id`,
`sort_by`, `sort_order`, `limit`, and `offset` query fields.
Interpret results as follows:
- HTTP 200 with `jobs: []` is an authoritative empty snapshot.
- HTTP 401/403 means Admin authentication or tenant authorization failed; verify
`OPENCLAW_ADMIN_TOKEN`, and for connector commands also verify
`OPENCLAW_CONNECTOR_ADMIN_TOKEN` plus the sender's Admin allowlist/class policy.
- HTTP 501 `jobs_host_contract_unsupported` means the active ComfyUI host does not expose
the required queue/history helper contract.
- HTTP 503 `jobs_backend_unavailable` means the host snapshot was unavailable or malformed;
it must not be treated as an empty queue.
The connector `/jobs` command returns fixed, content-free failures. It can show a bounded
coarse queue-count fallback only for the explicit 501/503 conditions above; authorization,
unknown-version, malformed, or oversized responses do not fall back and never echo the raw
upstream payload.
## External tool execution is disabled or fails with sandbox diagnostics
External tools are disabled by default and require an admin boundary plus an explicit feature flag.
Checklist:
1. Confirm the feature flag is enabled only for the deployment that needs it:
- `OPENCLAW_ENABLE_EXTERNAL_TOOLS=true`
2. Confirm the request is authenticated as an admin when using:
- `GET /openclaw/tools`
- `POST /openclaw/tools/{name}/run`
3. Confirm the tool definition exists in the allowlist:
- default allowlist: package-owned `data/tools_allowlist.json`
- custom allowlist: set `OPENCLAW_TOOLS_CONFIG_PATH=/path/to/tools_allowlist.json`
4. If the result or logs report `sandbox_runtime_unavailable`, do not bypass hardened mode blindly:
- make the sandbox runtime available, then set `OPENCLAW_TOOL_SANDBOX_RUNTIME_AVAILABLE=1`
- or keep tooling disabled until the deployment can fail closed safely
5. If the result or logs report `interpreter_missing`, install the executable referenced by the tool allowlist or update the command path.
6. If the result or logs report `timeout`, review the command behavior before increasing the tool's `timeout_sec`.
7. If the result or logs report `workspace_violation`, move inputs under the configured filesystem allowlist or update the tool sandbox policy.
Notes:
- Tool scratch/temp paths default to the configured state directory's `tool_sandbox/`.
- `OPENCLAW_TOOL_SANDBOX_DIR` can override the scratch path for reviewed deployments.
- Runtime cache and sandbox scratch paths are generated state, not package resources.
- OpenClaw does not automatically repair, migrate, or delete runtime dependency caches.
## Jobs preview shows an explicit media or asset fallback state
Current OpenClaw builds keep `/history` + `/view` as the supported runtime preview contract for job results.
@@ -69,8 +122,9 @@ If a result ref only exposes an upstream asset-service identifier and cannot be
What this means:
- `asset_api_required` is a bounded compatibility state, not a generic parser failure.
- Classic history refs and hash-backed refs that still map onto `/view` should continue to preview normally.
- If an operator workflow starts depending on direct asset-service identifiers, treat that as a contract gap and review [`docs/r167_asset_api_adoption_decision.md`](r167_asset_api_adoption_decision.md) before widening the runtime dependency.
- Classic history refs should continue to preview normally even when hash metadata is absent. Optional hash-backed refs exposed as `asset_hash` or `hash` still map onto `/view` when ComfyUI host metadata provides them.
- Current media-aware outputs can include `images`, `video`, `audio`, `3d`, bounded inline text, and allowlisted file-backed text; normal images render as thumbnails, HDR `.exr` / `.hdr` image refs render as explicit source-preview fallback links, and text renders as literal bounded content. A file-backed text response that is oversized, slow, redirected, non-text, invalid UTF-8, or unavailable as a safe browser stream remains an explicit source-link fallback instead of exposing response details.
- If an operator workflow starts depending on direct asset-service identifiers, treat that as a contract gap and review [`docs/asset_api_adoption_decision.md`](asset_api_adoption_decision.md) before widening the runtime dependency.
## Verify audit-chain continuity after restart or rotation
@@ -123,7 +177,7 @@ This is expected under the current SSRF policy.
- `OPENCLAW_ALLOW_REMOTE_ADMIN=1` only allows remote admin access; it does not relax outbound LLM egress rules.
- `OPENCLAW_LLM_ALLOWED_HOSTS` only extends the exact-host allowlist for custom public hosts.
- Private/reserved IP targets such as `192.168.x.x`, `10.x.x.x`, and `172.16.x.x` remain blocked unless `OPENCLAW_ALLOW_INSECURE_BASE_URL=1` is also set.
- Private/reserved IP targets such as `192.168.x.x`, `10.x.x.x`, and `172.16.x.x` remain blocked unless the scoped `allow_private_network` LLM setting is enabled for the configured target, or `OPENCLAW_ALLOW_INSECURE_BASE_URL=1` is also set.
- `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1` does not allow private/reserved IPs.
- `OPENCLAW_LLM_ALLOWED_HOSTS=*` is not a wildcard and will not bypass the policy.
@@ -133,7 +187,7 @@ Correct setup flow:
2. If you need a custom public LLM host, set:
- `OPENCLAW_ALLOW_CUSTOM_BASE_URL=1`
- `OPENCLAW_LLM_ALLOWED_HOSTS=<exact-host>` or `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1`
3. If you intentionally need a LAN/private-IP target, set `OPENCLAW_ALLOW_INSECURE_BASE_URL=1`, accept the SSRF risk, and fully restart ComfyUI.
3. If you intentionally need a LAN/private-IP target, prefer enabling `allow_private_network` only for that configured LLM target. Use `OPENCLAW_ALLOW_INSECURE_BASE_URL=1` only when you intentionally accept the broader SSRF risk, then fully restart ComfyUI.
4. On Windows portable, set environment variables in the same launcher that starts `python_embeded\\python.exe`, or restart after `setx` / System Properties changes.
5. Verify the effective value in the same embedded Python runtime:
@@ -143,8 +197,8 @@ python_embeded\python.exe -c "import os; print(repr(os.environ.get('OPENCLAW_LLM
Safer alternative:
- keep the LLM behind a reviewed public HTTPS reverse proxy and allowlist that public host, instead of enabling `OPENCLAW_ALLOW_INSECURE_BASE_URL`
- on current builds, once that override is intentionally enabled and the process is restarted, both Remote Admin validation and `/openclaw/llm/models` should follow the same decision
- keep the LLM behind a reviewed public HTTPS reverse proxy and allowlist that public host, instead of enabling private-network or insecure overrides
- on current builds, the scoped private-network setting and insecure override are both applied consistently by Remote Admin validation and `/openclaw/llm/models`
## Admin Token: server-side vs UI
@@ -16,6 +16,8 @@
- `web/openclaw_notification_center.js`
- `web/openclaw_banner_manager.js`
- `web/openclaw_tabs.js`
- `web/openclaw_api.js` plus focused config/generation/resource/model/event API owners
- `web/tabs/settings_tab.js` plus focused status/LLM/secrets/logs/DOM/lifecycle owners
- `web/admin_console_app.js`
- `web/admin_console_api.js`
- Runtime model:
@@ -69,8 +71,9 @@ Scored 1-5 (higher is better), weighted by current risk profile:
1. OpenClaw frontend is host-coupled to ComfyUI extension lifecycle and remount behavior; framework migration introduces significant integration and lifecycle risk with limited near-term operator value.
2. Current architecture already has critical stability controls (`ErrorBoundary`, tab remount safety, capability-gated registration, compatibility aliases, Vitest + Playwright lanes).
Recent decomposition work further reduced shell/admin/runtime hotspot size without introducing a framework dependency.
3. Most remaining roadmap priorities are functionality/security features (`F53/F54/F58/F59`), not frontend rendering abstraction gaps; migration now would consume high-risk bandwidth with weak ROI.
Recent decomposition work further reduced shell, API, Settings, and admin/runtime hotspot size
without introducing a framework dependency, while adding explicit stale-render disposal.
3. Most remaining product priorities are functionality and security features, not frontend rendering abstraction gaps; migration now would consume high-risk bandwidth with weak ROI.
## Decision
+2 -3
View File
@@ -1,5 +1,6 @@
import json
import logging
import random
from typing import Any, Dict, List, Tuple
try:
@@ -55,7 +56,7 @@ class OpenClawBatchVariants:
OUTPUT_IS_LIST = (True, True, True)
FUNCTION = "generate_variants"
CATEGORY = "moltbot"
CATEGORY = "openclaw"
def generate_variants(
self,
@@ -95,8 +96,6 @@ class OpenClawBatchVariants:
# Let's stick to simple increment for now or random python if implied?
# "randomized" usually means unpredictable.
# Let's implement a simple hash for now to be deterministic but "jumpy"
import random
r = random.Random(seed_base + i)
current_seed = r.randint(0, 0xFFFFFFFFFFFFFFFF)
+1 -1
View File
@@ -63,7 +63,7 @@ class OpenClawImageToPrompt:
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("caption", "tags", "prompt_suggestion")
FUNCTION = "generate_prompt"
CATEGORY = "moltbot"
CATEGORY = "openclaw"
# R154: keep the compatibility method name, but bind the shared helper
# directly so node wrappers do not duplicate image conversion logic.
+1 -1
View File
@@ -59,7 +59,7 @@ class OpenClawPromptPlanner:
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("positive", "negative", "params_json")
FUNCTION = "plan_generation"
CATEGORY = "moltbot"
CATEGORY = "openclaw"
def plan_generation(
self, profile: str, requirements: str, style_directives: str, seed: int
+1 -1
View File
@@ -75,7 +75,7 @@ class OpenClawPromptRefiner:
"rationale",
)
FUNCTION = "refine_prompt"
CATEGORY = "moltbot"
CATEGORY = "openclaw"
# R154: keep the compatibility method name, but bind the shared helper
# directly so node wrappers do not duplicate image conversion logic.
+637 -944
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -7,7 +7,7 @@
"devDependencies": {
"@playwright/test": "^1.50.0",
"jsdom": "^26.0.0",
"vitest": "^3.2.4"
"vitest": "^4.1.0"
},
"scripts": {
"test": "node scripts/run-playwright.mjs",
+22 -6
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-openclaw"
description = "Your own personal AIGC Factory. Any picture. Any reel. The Comfy way.©️"
version = "0.9.0"
version = "1.0.7"
license = {text = "MIT"}
readme = "README.md"
requires-python = ">=3.10"
@@ -31,7 +31,7 @@ Icon = ""
[tool.ruff]
# Ruff replaces flake8, isort, and other linters
target-version = "py310"
line-length = 120
line-length = 88
indent-width = 4
[tool.ruff.lint]
@@ -64,12 +64,19 @@ indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "lf"
[tool.black]
line-length = 88
target-version = ["py310"]
[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_configs = true
warn_unused_configs = false
disallow_untyped_defs = false # Start lenient, can tighten later
disallow_any_unimported = false
explicit_package_bases = true
no_site_packages = true
ignore_missing_imports = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
@@ -77,8 +84,17 @@ warn_no_return = true
check_untyped_defs = true
strict_equality = true
# Paths to check
files = ["*.py", "tests/**/*.py"]
# Production paths governed by tests/static_analysis_policy.json.
files = [
"__init__.py",
"config.py",
"api",
"connector",
"models",
"nodes",
"services",
"scripts",
]
# Ignore missing imports for ComfyUI and external packages
[[tool.mypy.overrides]]
@@ -123,7 +139,7 @@ omit = [
]
[tool.coverage.report]
fail_under = 45.0
fail_under = 55.0
show_missing = true
skip_covered = true
precision = 2
+4
View File
@@ -0,0 +1,4 @@
# Development/test-only static-analysis toolchain.
# Keep exact pins aligned with tests/static_analysis_policy.json.
ruff==0.15.20
mypy==2.2.0
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""Repo-local supply-chain hardening checks.
This checker is intentionally stdlib-only and read-only so it can run before
package installation. It detects known Mini Shai-Hulud package-family and
persistence indicators without executing code from dependencies.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
AFFECTED_NPM_PREFIXES = (
"@tanstack/",
"@uipath/",
"@mistralai/",
"@opensearch-project/",
"@squawk/",
"@tallyui/",
"@draftauth/",
"@draftlab/",
"@taskflow-corp/",
"@tolka/",
"@beproduct/",
"@dirigible-ai/",
"@ml-toolkit-ts/",
"@supersurkhet/",
"@mesadev/",
)
AFFECTED_NPM_NAMES = {
"safe-action",
"agentwork-cli",
"cmux-agent-mcp",
"cross-stitch",
"git-branch-selector",
"git-git-git",
"ml-toolkit-ts",
"nextmove-mcp",
"ts-dna",
"wot-api",
"intercom-client",
}
AFFECTED_PYPI_NAMES = {
"mistralai",
"guardrails-ai",
"lightning",
"pytorch-lightning",
"intercom-client",
}
IOC_FILENAMES = {
"router_init.js",
"tanstack_runner.js",
"opensearch_init.js",
"setup.mjs",
"setup_bun.js",
"transformers.pyz",
"shai-hulud-workflow.yml",
"shai-hulud-workflow.yaml",
}
IOC_STRINGS = {
"@tanstack/setup",
"git-tanstack",
"83.142.209.194",
"IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner",
"Shai-Hulud",
"shai-hulud",
"Session Protocol",
"transformers.pyz",
}
# Current repo baseline. A new package lifecycle script must be reviewed.
ALLOWED_INSTALL_SCRIPT_PACKAGES = {
"esbuild",
"fsevents",
"vite/node_modules/fsevents",
}
TEXT_SCAN_PATHS = (
".github",
".vscode",
"package.json",
"package-lock.json",
"requirements.txt",
"pyproject.toml",
)
SKIP_DIR_NAMES = {
".git",
".planning",
".pytest_cache",
".tmp",
".venv",
".venv-wsl",
"reference",
"REFERENCE",
"__pycache__",
}
@dataclass(frozen=True)
class Finding:
code: str
path: str
detail: str
def _normalize_package_name(name: str) -> str:
return name.strip().lower().replace("_", "-")
def _load_json(path: Path) -> Any:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def _iter_lock_packages(lock_path: Path) -> Iterable[tuple[str, str, dict[str, Any]]]:
lock = _load_json(lock_path)
packages = lock.get("packages") or {}
for package_path, metadata in packages.items():
if not package_path.startswith("node_modules/"):
continue
name = package_path.removeprefix("node_modules/")
version = str((metadata or {}).get("version") or "")
yield name, version, metadata or {}
def check_npm_lock(lock_path: Path) -> list[Finding]:
findings: list[Finding] = []
if not lock_path.exists():
return findings
for name, version, metadata in _iter_lock_packages(lock_path):
normalized = _normalize_package_name(name)
if normalized in AFFECTED_NPM_NAMES or normalized.startswith(
AFFECTED_NPM_PREFIXES
):
findings.append(
Finding(
"mini-shai-hulud-npm-package",
str(lock_path),
f"{name}@{version} matches a known affected package family",
)
)
if metadata.get("hasInstallScript") is True:
if name not in ALLOWED_INSTALL_SCRIPT_PACKAGES:
findings.append(
Finding(
"unexpected-npm-install-script",
str(lock_path),
f"{name}@{version} declares hasInstallScript=true and is not allowlisted",
)
)
return findings
_REQ_NAME_RE = re.compile(
r"^\s*([A-Za-z0-9_.-]+)\s*(?:\[.*?\])?\s*(?:[<>=!~]=|==|~=|>|<|$)"
)
_TOML_DEP_RE = re.compile(
r"""["']([A-Za-z0-9_.-]+)(?:\[.*?\])?\s*(?:[<>=!~]=|==|~=|>|<|["'])"""
)
def check_python_manifest(path: Path) -> list[Finding]:
findings: list[Finding] = []
if not path.exists():
return findings
text = path.read_text(encoding="utf-8", errors="replace")
candidates: set[str] = set()
if path.name == "requirements.txt":
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or stripped.startswith("-"):
continue
match = _REQ_NAME_RE.match(stripped)
if match:
candidates.add(_normalize_package_name(match.group(1)))
else:
for match in _TOML_DEP_RE.finditer(text):
candidates.add(_normalize_package_name(match.group(1)))
for name in sorted(candidates & AFFECTED_PYPI_NAMES):
findings.append(
Finding(
"mini-shai-hulud-pypi-package",
str(path),
f"{name} matches a known affected PyPI package family",
)
)
return findings
def _is_skipped_dir(path: Path, root: Path) -> bool:
try:
rel_parts = path.relative_to(root).parts
except ValueError:
return True
return any(part in SKIP_DIR_NAMES for part in rel_parts)
def check_ioc_filenames(root: Path) -> list[Finding]:
findings: list[Finding] = []
# IMPORTANT: prune skipped dirs before stat; Windows cannot stat WSL venv links reliably.
for current_dir, dir_names, file_names in os.walk(root):
current_path = Path(current_dir)
dir_names[:] = [
name for name in dir_names if not _is_skipped_dir(current_path / name, root)
]
for file_name in file_names:
if file_name in IOC_FILENAMES:
path = current_path / file_name
findings.append(
Finding(
"mini-shai-hulud-ioc-file",
str(path.relative_to(root)),
f"matched suspicious filename {file_name}",
)
)
return findings
def _iter_text_scan_files(root: Path) -> Iterable[Path]:
for rel in TEXT_SCAN_PATHS:
path = root / rel
if path.is_file():
yield path
elif path.is_dir():
for child in path.rglob("*"):
if child.is_file() and not _is_skipped_dir(child, root):
yield child
def check_ioc_strings(root: Path) -> list[Finding]:
findings: list[Finding] = []
for path in _iter_text_scan_files(root):
try:
if path.stat().st_size > 2_000_000:
continue
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
for needle in sorted(IOC_STRINGS):
if needle in text:
findings.append(
Finding(
"mini-shai-hulud-ioc-string",
str(path.relative_to(root)),
f"matched suspicious string {needle!r}",
)
)
return findings
def run_checks(root: Path) -> list[Finding]:
root = root.resolve()
findings: list[Finding] = []
findings.extend(check_npm_lock(root / "package-lock.json"))
findings.extend(check_npm_lock(root / "node_modules" / ".package-lock.json"))
findings.extend(check_python_manifest(root / "requirements.txt"))
findings.extend(check_python_manifest(root / "pyproject.toml"))
findings.extend(check_ioc_filenames(root))
findings.extend(check_ioc_strings(root))
return findings
def _print_findings(findings: Iterable[Finding]) -> None:
for finding in findings:
print(f"{finding.code}: {finding.path}: {finding.detail}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", default=".", help="Repository root to scan")
parser.add_argument("--json", action="store_true", help="Emit JSON output")
args = parser.parse_args(argv)
findings = run_checks(Path(args.root))
if args.json:
print(json.dumps([finding.__dict__ for finding in findings], indent=2))
elif findings:
_print_findings(findings)
else:
print("supply-chain hardening check passed")
return 1 if findings else 0
if __name__ == "__main__":
raise SystemExit(main())
+7 -1
View File
@@ -55,7 +55,12 @@ def main() -> int:
parser.add_argument(
"--anchor-desktop",
default=None,
help="Observed ComfyUI Desktop anchor/version (optional)",
help="Observed legacy ComfyUI Desktop anchor/version (optional)",
)
parser.add_argument(
"--anchor-comfy-desktop",
default=None,
help="Observed current Comfy-Desktop anchor/version (optional)",
)
parser.add_argument(
"--updated-by",
@@ -88,6 +93,7 @@ def main() -> int:
comfyui=args.anchor_comfyui,
comfyui_frontend=args.anchor_frontend,
desktop=args.anchor_desktop,
comfy_desktop=args.anchor_comfy_desktop,
)
result = run_refresh_workflow(
matrix_path=args.matrix_path,
+10
View File
@@ -0,0 +1,10 @@
/** Portable digest helpers for governed UTF-8 text contracts. */
import crypto from "node:crypto";
import fs from "node:fs";
export function stableTextDigest(filePath) {
// IMPORTANT: normalize text newlines; raw hashing breaks frozen contracts after Windows checkout.
const normalized = fs.readFileSync(filePath, "utf8").replace(/\r\n?/g, "\n");
return crypto.createHash("sha256").update(normalized, "utf8").digest("hex");
}
+23
View File
@@ -0,0 +1,23 @@
"""Portable digest and write helpers for governed text contracts."""
from __future__ import annotations
import hashlib
from pathlib import Path
def normalize_text_newlines(payload: bytes) -> bytes:
"""Return text bytes with CRLF and lone CR represented as LF."""
# IMPORTANT: normalize text newlines; raw hashing breaks frozen contracts after Windows checkout.
return payload.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
def stable_text_digest(path: Path) -> str:
"""Hash governed text independently of checkout newline representation."""
return hashlib.sha256(normalize_text_newlines(path.read_bytes())).hexdigest()
def write_text_lf(path: Path, text: str) -> None:
"""Write UTF-8 contract text with explicit LF newlines on every platform."""
with path.open("w", encoding="utf-8", newline="\n") as handle:
handle.write(text)
@@ -3,11 +3,13 @@ Debug script for S35 Transform Isolation.
Verifies that the correct executor (TransformProcessRunner) is allowed/loaded.
"""
import os
import sys
from pathlib import Path
# Ensure project root is in path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from services.constrained_transforms import get_transform_executor
from services.transform_runner import TransformProcessRunner
@@ -4,9 +4,12 @@ Verify S30 Security Doctor output.
import os
import sys
from pathlib import Path
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from services.security_doctor import run_security_doctor
+1 -1
View File
@@ -1,5 +1,5 @@
"""
R66 OpenAPI spec generator.
OpenAPI spec generator.
Generates docs/openapi.yaml from docs/release/api_contract.md.
"""
+258
View File
@@ -0,0 +1,258 @@
"""Lightweight closeout evidence for changes classified as high risk.
This pilot deliberately reuses the adversarial gate's path classifier. Standard-
risk and empty diffs remain outside this workflow and produce no receipt.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from collections.abc import Sequence
from datetime import datetime, timezone
from pathlib import Path
from run_adversarial_gate import (
DEFAULT_HIGH_RISK_PATTERNS,
_filter_high_risk_files,
_run_git_diff,
)
SCHEMA = "openclaw-high-risk-receipt/1"
EXACT_COMMIT_RE = re.compile(r"[0-9a-fA-F]{40}\Z")
ITEM_RE = re.compile(r"[A-Z][A-Z0-9-]{0,31}\Z")
class AcceptanceError(RuntimeError):
"""A safe, user-actionable closeout validation failure."""
def _run_git(repo_root: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=repo_root,
capture_output=True,
text=True,
check=False,
)
def _git_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0 or not result.stdout.strip():
raise AcceptanceError("current directory is not inside a Git worktree")
return Path(result.stdout.strip()).resolve()
def _resolve_commit(repo_root: Path, reference: str, label: str) -> str:
if not reference or reference.startswith("-"):
raise AcceptanceError(f"{label} must be a valid Git revision")
result = _run_git(repo_root, "rev-parse", "--verify", f"{reference}^{{commit}}")
commit = result.stdout.strip().lower()
if result.returncode != 0 or not EXACT_COMMIT_RE.fullmatch(commit):
raise AcceptanceError(f"{label} does not resolve to a commit")
return commit
def _require_ancestor(repo_root: Path, base_commit: str, candidate_commit: str) -> None:
result = _run_git(
repo_root,
"merge-base",
"--is-ancestor",
base_commit,
candidate_commit,
)
if result.returncode != 0:
raise AcceptanceError("base commit is not an ancestor of candidate commit")
def _changed_files(
repo_root: Path, base_commit: str, candidate_commit: str
) -> list[str]:
previous_cwd = Path.cwd()
try:
os.chdir(repo_root)
return [str(path) for path in _run_git_diff(base_commit, candidate_commit)]
finally:
os.chdir(previous_cwd)
def _require_closeout_state(
repo_root: Path,
candidate_argument: str,
candidate_commit: str,
) -> str:
if not EXACT_COMMIT_RE.fullmatch(candidate_argument):
raise AcceptanceError(
"high-risk candidate must be an exact 40-character commit SHA"
)
head_commit = _resolve_commit(repo_root, "HEAD", "HEAD")
if candidate_commit != head_commit:
raise AcceptanceError("candidate commit must equal current HEAD")
branch_result = _run_git(repo_root, "branch", "--show-current")
branch = branch_result.stdout.strip()
if branch_result.returncode != 0 or branch != "dev":
raise AcceptanceError("high-risk closeout must run on branch dev")
status_result = _run_git(
repo_root,
"status",
"--porcelain",
"--untracked-files=no",
)
if status_result.returncode != 0 or status_result.stdout.strip():
raise AcceptanceError("tracked worktree and index must be clean")
return branch
def _require_identity(value: str | None, label: str) -> str:
if value is None or not value.strip():
raise AcceptanceError(f"{label} is required for high-risk closeout")
normalized = value.strip()
if len(normalized) > 128 or any(ord(character) < 32 for character in normalized):
raise AcceptanceError(f"{label} contains invalid characters")
return normalized
def _validate_closeout_arguments(args: argparse.Namespace) -> tuple[str, str, str]:
if args.item is None or not ITEM_RE.fullmatch(args.item):
raise AcceptanceError("item must be an uppercase roadmap identifier")
implementer = _require_identity(args.implementer, "implementer")
reviewer = _require_identity(args.reviewer, "reviewer")
if implementer.casefold() == reviewer.casefold():
raise AcceptanceError("reviewer must be distinct from implementer")
if args.review_verdict != "APPROVED":
raise AcceptanceError("review verdict must be APPROVED")
if args.full_gate_status != "PASS":
raise AcceptanceError("full TEST_SOP gate must be PASS")
return args.item, implementer, reviewer
def _resolve_output(repo_root: Path, output: str | None) -> tuple[Path, str]:
if output is None or not output.strip():
raise AcceptanceError("output is required for high-risk closeout")
candidate = Path(output)
output_path = (
(repo_root / candidate).resolve()
if not candidate.is_absolute()
else candidate.resolve()
)
planning_root = (repo_root / ".planning").resolve()
try:
relative_to_planning = output_path.relative_to(planning_root)
relative_to_repo = output_path.relative_to(repo_root)
except ValueError as exc:
raise AcceptanceError(
"output must be under the repository .planning directory"
) from exc
if relative_to_planning == Path("."):
raise AcceptanceError("output must name a file under .planning")
if output_path.exists():
raise AcceptanceError("output already exists")
relative_posix = relative_to_repo.as_posix()
ignored = _run_git(
repo_root,
"check-ignore",
"-v",
"--no-index",
"--",
relative_posix,
)
if ignored.returncode != 0:
raise AcceptanceError("output must be ignored by repository Git rules")
return output_path, relative_posix
def _write_receipt(output_path: Path, receipt: dict[str, object]) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
try:
with output_path.open("x", encoding="utf-8", newline="\n") as handle:
json.dump(receipt, handle, indent=2, sort_keys=True)
handle.write("\n")
except FileExistsError as exc:
raise AcceptanceError("output already exists") from exc
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Validate and record the lightweight high-risk closeout pilot."
)
parser.add_argument("--base", required=True, help="Base Git revision.")
parser.add_argument("--candidate", default="HEAD", help="Candidate Git revision.")
parser.add_argument("--item")
parser.add_argument("--implementer")
parser.add_argument("--reviewer")
parser.add_argument("--review-verdict")
parser.add_argument("--full-gate-status")
parser.add_argument("--output")
return parser
def run(argv: Sequence[str] | None = None) -> int:
args = _parser().parse_args(argv)
try:
repo_root = _git_root()
base_commit = _resolve_commit(repo_root, args.base, "base")
candidate_commit = _resolve_commit(repo_root, args.candidate, "candidate")
# IMPORTANT: ancestry is validated before classification so unrelated
# histories cannot be mistaken for a standard-risk, non-applicable diff.
_require_ancestor(repo_root, base_commit, candidate_commit)
changed_files = _changed_files(repo_root, base_commit, candidate_commit)
high_risk_changed = _filter_high_risk_files(
changed_files, DEFAULT_HIGH_RISK_PATTERNS
)
if not high_risk_changed:
print("HIGH_RISK_ACCEPTANCE: NOT_APPLICABLE")
return 0
branch = _require_closeout_state(repo_root, args.candidate, candidate_commit)
item, implementer, reviewer = _validate_closeout_arguments(args)
output_path, output_label = _resolve_output(repo_root, args.output)
receipt: dict[str, object] = {
"schema": SCHEMA,
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"item": item,
"branch": branch,
"base_commit": base_commit,
"candidate_commit": candidate_commit,
"changed_files": changed_files,
"high_risk_changed_files": high_risk_changed,
"review": {
"implementer": implementer,
"reviewer": reviewer,
"verdict": args.review_verdict,
},
"gates": {"full_test_sop": args.full_gate_status},
"limitations": (
"Pilot receipt records declared review and gate results; it is not "
"identity authentication or a cryptographic attestation."
),
}
_write_receipt(output_path, receipt)
print(f"HIGH_RISK_ACCEPTANCE: PASS ({output_label})")
return 0
except AcceptanceError as exc:
print(f"HIGH_RISK_ACCEPTANCE: FAIL: {exc}")
return 1
except (OSError, subprocess.SubprocessError):
print("HIGH_RISK_ACCEPTANCE: FAIL: repository validation could not complete")
return 1
if __name__ == "__main__":
sys.exit(run())
+25
View File
@@ -80,6 +80,24 @@ report_precommit_repo_drift_and_exit() {
exit 1
}
assert_clean_public_worktree() {
# CRITICAL: tracked diff snapshots do not include untracked deliverables. A push
# gate must validate one clean committed candidate, not local-only files.
local status
if ! status="$(git status --porcelain --untracked-files=all)"; then
echo "[pre-push] ERROR: unable to inspect Git worktree state" >&2
exit 1
fi
if [ -n "$status" ]; then
echo "[pre-push] ERROR: validation requires a clean committed candidate." >&2
echo "[pre-push] Commit or remove public changes, then retry." >&2
printf '%s\n' "$status" >&2
exit 1
fi
}
assert_clean_public_worktree
is_wsl() {
grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null
}
@@ -357,6 +375,12 @@ else
fi
echo "[pre-push] Node version: $(node -v)"
echo "[pre-push] 0/10 supply-chain hardening check"
"$VENV_PY" scripts/check_supply_chain_hardening.py
echo "[pre-push] 0.25/10 frontend dependency install and audit"
# IMPORTANT: never accept a warmed or manually changed node_modules tree.
npm ci
npm audit --audit-level=high
echo "[pre-push] 0/7 R120 dependency preflight"
"$VENV_PY" scripts/preflight_check.py --strict
echo "[pre-push] 1/7 detect-secrets"
@@ -424,4 +448,5 @@ MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_pre_push_adversarial" \
echo "[pre-push] 9/9 npm test (Playwright)"
npm test
assert_clean_public_worktree
echo "[pre-push] PASS"
+171 -1
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from datetime import date
from fnmatch import fnmatch
from itertools import pairwise
from pathlib import Path
from typing import Any, Iterable
@@ -14,6 +16,9 @@ REQUIRED_HOTSPOT_FAMILIES = (
"config_bootstrap",
)
MIN_PROMOTION_REVIEW_CYCLES = 2
RATCHET55_CRITICAL_FAMILIES = REQUIRED_HOTSPOT_FAMILIES
_FULL_GIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
@dataclass(frozen=True)
@@ -52,6 +57,28 @@ def _validate_hotspot_family(
)
def _validate_ratchet55_readiness(family: dict[str, Any], failures: list[str]) -> None:
family_id = family["id"]
readiness = family.get("ratchet55_readiness")
if not isinstance(readiness, dict):
failures.append(
f"coverage policy: hotspot family {family_id} must include ratchet55_readiness metadata"
)
return
for field_name in (
"targeted_regression_suite",
"ownership_status",
"readiness_notes",
):
value = readiness.get(field_name)
if not isinstance(value, str) or not value.strip():
failures.append(
"coverage policy: hotspot family "
f"{family_id} ratchet55_readiness missing {field_name}"
)
def load_and_validate_policy(path: Path) -> tuple[dict[str, Any] | None, list[str]]:
failures: list[str] = []
if not path.is_file():
@@ -99,7 +126,7 @@ def load_and_validate_policy(path: Path) -> tuple[dict[str, Any] | None, list[st
CoverageStage(stage_id=stage_id, min_fail_under=float(min_fail_under))
)
for previous, current in zip(stages, stages[1:]):
for previous, current in pairwise(stages):
if current.min_fail_under <= previous.min_fail_under:
failures.append(
"coverage policy: coverage stages must increase strictly by min_fail_under"
@@ -142,6 +169,24 @@ def load_and_validate_policy(path: Path) -> tuple[dict[str, Any] | None, list[st
continue
_validate_hotspot_family(family, seen_family_ids, failures)
policy_next_stage = None
if current_stage in stage_ids:
for index, raw_stage in enumerate(stages_raw):
if raw_stage.get("id") == current_stage:
if index + 1 < len(stages_raw):
policy_next_stage = stages_raw[index + 1].get("id")
break
if current_stage == "ratchet-55" or policy_next_stage == "ratchet-55":
families_by_id = {
family.get("id"): family
for family in family_payload
if isinstance(family, dict) and isinstance(family.get("id"), str)
}
for family_id in RATCHET55_CRITICAL_FAMILIES:
family = families_by_id.get(family_id)
if family is not None:
_validate_ratchet55_readiness(family, failures)
missing_declared_required = sorted(set(required_families) - seen_family_ids)
if missing_declared_required:
failures.append(
@@ -291,6 +336,131 @@ def load_and_validate_review_evidence(
f"coverage review evidence: review {cycle_id!r} must include artifact_reference"
)
if policy.get("current_stage") == "ratchet-55":
ratchet45_reviews = [
entry
for entry in reviews
if isinstance(entry, dict) and entry.get("stage_id") == "ratchet-45"
]
required_families = set(policy.get("required_hotspot_families", []))
complete_reviews: list[dict[str, Any]] = []
for entry in ratchet45_reviews:
cycle_id = entry.get("cycle_id")
release_cycle = entry.get("release_cycle")
reviewed_commit = entry.get("reviewed_commit")
coverage_command = entry.get("coverage_command")
artifact_sha256 = entry.get("artifact_sha256")
raw_reviewed_families = entry.get("reviewed_hotspot_families")
reviewed_families = (
set(raw_reviewed_families)
if isinstance(raw_reviewed_families, list)
else set()
)
hotspot_percent = entry.get("hotspot_percent_covered")
owned_suites = entry.get("owned_regression_suites")
start_tag = (
release_cycle.get("start_tag")
if isinstance(release_cycle, dict)
else None
)
end_tag = (
release_cycle.get("end_tag")
if isinstance(release_cycle, dict)
else None
)
start_commit = (
release_cycle.get("start_commit")
if isinstance(release_cycle, dict)
else None
)
end_commit = (
release_cycle.get("end_commit")
if isinstance(release_cycle, dict)
else None
)
release_fields_valid = (
isinstance(start_tag, str)
and bool(start_tag.strip())
and isinstance(end_tag, str)
and bool(end_tag.strip())
and isinstance(start_commit, str)
and _FULL_GIT_SHA_RE.fullmatch(start_commit.lower()) is not None
and isinstance(end_commit, str)
and _FULL_GIT_SHA_RE.fullmatch(end_commit.lower()) is not None
)
artifact_valid = (
isinstance(artifact_sha256, str)
and _SHA256_RE.fullmatch(artifact_sha256.lower()) is not None
and isinstance(coverage_command, str)
and "run_backend_coverage.py" in coverage_command
and "--start-dir tests" in coverage_command
)
reviewed_commit_valid = (
isinstance(reviewed_commit, str)
and _FULL_GIT_SHA_RE.fullmatch(reviewed_commit.lower()) is not None
and isinstance(end_commit, str)
and reviewed_commit.lower() == end_commit.lower()
)
hotspot_valid = (
reviewed_families == required_families
and isinstance(hotspot_percent, dict)
and all(
isinstance(hotspot_percent.get(family), (int, float))
and 0.0 <= float(hotspot_percent[family]) <= 100.0
for family in required_families
)
)
ownership_valid = isinstance(owned_suites, dict) and all(
isinstance(owned_suites.get(family), list)
and bool(owned_suites[family])
and all(
isinstance(path, str)
and path.startswith("tests/")
and path.endswith(".py")
for path in owned_suites[family]
)
for family in required_families
)
overall = entry.get("overall_percent_covered")
overall_valid = isinstance(overall, (int, float)) and float(overall) >= 45.0
if all(
(
release_fields_valid,
artifact_valid,
reviewed_commit_valid,
hotspot_valid,
ownership_valid,
overall_valid,
)
):
complete_reviews.append(entry)
else:
failures.append(
"coverage review evidence: ratchet-55 promotion review "
f"{cycle_id!r} requires complete release-cycle evidence, full-suite "
"artifact identity, all required hotspots, and owned regression suites"
)
if len(complete_reviews) >= MIN_PROMOTION_REVIEW_CYCLES:
for previous, current in pairwise(complete_reviews):
previous_cycle = previous.get("release_cycle")
current_cycle = current.get("release_cycle")
if not isinstance(previous_cycle, dict) or not isinstance(
current_cycle, dict
):
continue
if (
previous_cycle["end_tag"] != current_cycle["start_tag"]
or previous_cycle["end_commit"].lower()
!= current_cycle["start_commit"].lower()
):
failures.append(
"coverage review evidence: ratchet-55 requires consecutive release cycles"
)
break
return payload, failures
+1 -1
View File
@@ -38,7 +38,7 @@ function runPlaywright(args, { label }) {
const cli = resolvePlaywrightCli();
if (!cli) {
console.error(
`[OpenClaw] Failed to run ${label}: Playwright CLI not found. Did you run 'npm install'?`,
`[OpenClaw] Failed to run ${label}: Playwright CLI not found. Did you run 'npm ci'?`,
);
process.exit(1);
}
+11 -4
View File
@@ -37,6 +37,9 @@ DEFAULT_HIGH_RISK_PATTERNS = [
"services/access_control.py",
"services/tenant_context.py",
"api/routes.py",
# CRITICAL: keep route-bootstrap owners exact; broad service globs over-escalate CI.
"services/bootstrap/registration.py",
"services/route_bootstrap_contract.py",
"services/security_*.py",
"services/startup_profile_gate.py",
"services/control_plane.py",
@@ -52,7 +55,10 @@ EXTENDED_MUTATION_THRESHOLD = 80.0
def _normalize_rel_path(path: str) -> str:
return pathlib.PurePosixPath(path.replace("\\", "/")).as_posix().lstrip("./")
normalized = path.replace("\\", "/")
while normalized.startswith("./"):
normalized = normalized[2:]
return pathlib.PurePosixPath(normalized).as_posix()
def _run_git_diff(base: Optional[str], head: Optional[str]) -> List[str]:
@@ -116,10 +122,11 @@ def _collect_changed_files(
def _filter_high_risk_files(changed_files: List[str], patterns: List[str]) -> List[str]:
matched: Set[str] = set()
normalized_patterns = [_normalize_rel_path(p) for p in patterns if p.strip()]
for f in changed_files:
for file_path in changed_files:
normalized_file = _normalize_rel_path(file_path)
for pattern in normalized_patterns:
if fnmatch.fnmatch(f, pattern):
matched.add(f)
if fnmatch.fnmatch(normalized_file, pattern):
matched.add(normalized_file)
break
return sorted(matched)
+38 -5
View File
@@ -83,15 +83,32 @@ report_precommit_repo_drift_and_exit() {
exit 1
}
assert_clean_public_worktree() {
# CRITICAL: tracked diff snapshots do not include untracked deliverables. A full
# acceptance gate must bind to one clean committed candidate, not local-only files.
local status
if ! status="$(git status --porcelain --untracked-files=all)"; then
echo "[tests] ERROR: unable to inspect Git worktree state" >&2
exit 1
fi
if [ -n "$status" ]; then
echo "[tests] ERROR: acceptance requires a clean committed candidate." >&2
echo "[tests] Commit or remove public changes, then rerun." >&2
printf '%s\n' "$status" >&2
exit 1
fi
}
assert_clean_public_worktree
require_cmd node
require_cmd npm
ensure_npm_deps() {
if [ -f "$ROOT_DIR/node_modules/@playwright/test/package.json" ]; then
return 0
fi
echo "[tests] Installing frontend dependencies via npm install ..."
npm install
# IMPORTANT: acceptance must reconcile the complete lockfile; file-presence
# shortcuts can silently reuse an invalid or stale development dependency tree.
echo "[tests] Reconciling frontend dependencies via npm ci ..."
npm ci
}
# Always use project-local venv to avoid global interpreter / tool drift.
@@ -123,6 +140,12 @@ if ! "$VENV_PY" -c "import cryptography" >/dev/null 2>&1; then
echo "[tests] Installing cryptography into project venv ($VENV_DIR) ..."
pip_install_or_fail "required for S57 secrets-at-rest encryption tests" cryptography
fi
if ! "$VENV_PY" -c "import json, sys; from importlib.metadata import version; p=json.load(open('tests/static_analysis_policy.json', encoding='utf-8')); sys.exit(0 if all(version(name)==cfg['version'] for name,cfg in p['tools'].items()) else 1)" >/dev/null 2>&1; then
echo "[tests] Installing pinned Ruff/Mypy into project venv ($VENV_DIR) ..."
pip_install_or_fail "required for static-analysis policy" -r requirements-quality.txt
fi
if ! "$VENV_PY" -c "import defusedxml" >/dev/null 2>&1; then
# IMPORTANT: keep local full-test bootstrap aligned with requirements.txt.
echo "[tests] Installing defusedxml into project venv ($VENV_DIR) ..."
@@ -189,8 +212,17 @@ fi
echo "[tests] Node version: $(node -v)"
echo "[tests] 0/11 supply-chain hardening check"
"$VENV_PY" scripts/check_supply_chain_hardening.py
ensure_npm_deps
echo "[tests] 0.25/11 frontend dependency audit"
npm audit --audit-level=high
echo "[tests] 0.5/11 static analysis policy"
"$VENV_PY" scripts/verify_static_analysis_policy.py
echo "[tests] 0/9 R120 dependency preflight"
"$VENV_PY" scripts/preflight_check.py --strict
@@ -251,4 +283,5 @@ echo "[tests] 10/10 frontend E2E"
# not assume a warmed local browser cache when running on fresh WSL/Linux hosts.
OPENCLAW_PLAYWRIGHT_INSTALL=1 OPENCLAW_PLAYWRIGHT_BROWSERS=chromium npm test
assert_clean_public_worktree
echo "[tests] PASS"
+41 -7
View File
@@ -54,17 +54,28 @@ function Assert-PreCommitDidNotMutateRepo {
}
}
function Assert-CleanPublicWorktree {
# CRITICAL: tracked diff snapshots do not include untracked deliverables. A full
# acceptance gate must bind to one clean committed candidate, not local-only files.
$statusLines = @(& git status --porcelain --untracked-files=all)
if ($LASTEXITCODE -ne 0) {
throw "[tests] ERROR: unable to inspect Git worktree state"
}
if ($statusLines.Count -gt 0) {
throw "[tests] ERROR: acceptance requires a clean committed candidate. Commit or remove public changes, then rerun.`n$($statusLines -join [Environment]::NewLine)"
}
}
Assert-CleanPublicWorktree
Require-Cmd node
Require-Cmd npm
function Ensure-NpmDeps {
$playwrightPkg = Join-Path $root "node_modules\@playwright\test\package.json"
if (Test-Path $playwrightPkg) {
return
}
Write-Host "[tests] Installing frontend dependencies via npm install ..."
Invoke-Checked "npm install" { npm install }
# IMPORTANT: acceptance must reconcile the complete lockfile; file-presence
# shortcuts can silently reuse an invalid or stale development dependency tree.
Write-Host "[tests] Reconciling frontend dependencies via npm ci ..."
Invoke-Checked "npm ci" { npm ci }
}
# Prefer project-local virtualenv to avoid global PATH / cache conflicts on Windows.
@@ -195,6 +206,16 @@ if (-not $hasCoverageTomlSupport) {
Invoke-Checked "pip install coverage[toml]" { & $venvPython -m pip install "coverage[toml]" }
}
$qualityToolsReady = $true
& $venvPython -c "import json, sys; from importlib.metadata import version; p=json.load(open('tests/static_analysis_policy.json', encoding='utf-8')); sys.exit(0 if all(version(name)==cfg['version'] for name,cfg in p['tools'].items()) else 1)" | Out-Null
if ($LASTEXITCODE -ne 0) {
$qualityToolsReady = $false
}
if (-not $qualityToolsReady) {
Write-Host "[tests] Installing pinned Ruff/Mypy into project venv ..."
Invoke-Checked "pip install quality tools" { & $venvPython -m pip install -r requirements-quality.txt }
}
# Ensure Node >= 18
$nodeMajor = [int]((& node -p "process.versions.node.split('.')[0]").Trim())
if ($nodeMajor -lt 18) {
@@ -260,7 +281,19 @@ else {
}
Write-Host "[tests] Node version: $(node -v)"
Write-Host "[tests] 0/11 supply-chain hardening check"
Invoke-Checked "supply-chain hardening check" {
& $venvPython scripts\check_supply_chain_hardening.py
}
Ensure-NpmDeps
Write-Host "[tests] 0.25/11 frontend dependency audit"
Invoke-Checked "npm audit" { npm audit --audit-level=high }
Write-Host "[tests] 0.5/11 static analysis policy"
Invoke-Checked "static analysis policy" {
& $venvPython scripts/verify_static_analysis_policy.py
}
Write-Host "[tests] 0/8 R120 dependency preflight"
Invoke-Checked "preflight_check" { & $venvPython scripts\preflight_check.py --strict }
@@ -344,4 +377,5 @@ $env:OPENCLAW_PLAYWRIGHT_BROWSERS = "chromium"
# not assume a warmed local browser cache when running on fresh Windows hosts.
Invoke-Checked "frontend E2E" { npm test }
Assert-CleanPublicWorktree
Write-Host "[tests] PASS"
+154
View File
@@ -0,0 +1,154 @@
"""Verify the frozen R221 API config facade and governance contract."""
from __future__ import annotations
import argparse
import hashlib
import inspect
import json
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.contract_digest import stable_text_digest, write_text_lf # noqa: E402
CONTRACT_PATH = ROOT / "tests" / "api_config_contract_r221.json"
def _canonical_json(value: Any) -> str:
return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
def _digest(value: Any) -> str:
return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()
def _metadata(handler: Any) -> dict[str, Any]:
from services.endpoint_manifest import get_metadata
meta = get_metadata(handler)
if meta is None:
raise RuntimeError(f"missing endpoint metadata for {handler.__name__}")
return {
"auth": meta.auth_tier.value,
"risk": meta.risk_tier.value,
"plane": meta.route_plane.value if meta.route_plane else None,
"summary": meta.summary,
"description": meta.description,
"audit": meta.audit_action,
"scopes": list(meta.required_scopes),
}
def build_contract() -> dict[str, Any]:
from api import config
handlers = (
"config_get_handler",
"llm_models_handler",
"config_put_handler",
"llm_test_handler",
"llm_chat_handler",
)
patch_seams = (
"web",
"logger",
"require_observability_access",
"require_admin_token",
"require_same_origin_if_no_token",
"check_rate_limit",
"build_rate_limit_response",
"resolve_token_info",
"emit_audit_event",
"request_tenant_scope",
"get_effective_config",
"get_runtime_guardrails",
"get_settings_schema",
"update_config",
"get_apply_semantics",
"get_admin_token",
"payload_contains_runtime_guardrails",
"get_llm_egress_controls",
"is_loopback_client",
"get_client_ip",
"resolve_model_list_target",
"validate_model_list_target",
"fetch_remote_model_list",
"get_stale_cached_models",
"_cache_get",
"_format_llm_ssrf_error",
"_llm_insecure_override_enabled",
"LLMClient",
)
schema = config.get_settings_schema()
route_contract = ROOT / "tests" / "api_route_contract_r220.json"
openapi = ROOT / "docs" / "openapi.yaml"
return {
"schema_version": 1,
"facade_signatures": {
name: str(inspect.signature(getattr(config, name))) for name in handlers
},
"facade_metadata": {
name: _metadata(getattr(config, name)) for name in handlers
},
"patch_seams": list(patch_seams),
"provider_catalog": config.PROVIDER_CATALOG,
"allowed_llm_keys": sorted(config.ALLOWED_LLM_KEYS),
"model_cache": {
"max_entries": config._MODEL_LIST_MAX_ENTRIES,
"ttl_sec": config._MODEL_LIST_TTL_SEC,
"exported_cache_type": type(config._MODEL_LIST_CACHE).__name__,
},
"settings_schema_sha256": _digest(schema),
"apply_semantics": {
"provider": config.get_apply_semantics(["provider"]),
"model": config.get_apply_semantics(["model"]),
"base_url": config.get_apply_semantics(["base_url"]),
},
"owned_response_matrices": {
"config": [
"tests.test_s66_api_config_guardrails",
"tests.test_r53_apply_semantics",
"tests.security.test_r99_sensitive_contract",
"tests.test_r219_exception_boundary_phase2",
],
"models": [
"tests.test_api_model_list",
"tests.test_r60_model_cache",
"tests.test_r123_real_backend_model_list_lane",
"tests.test_r155_exception_fidelity",
"tests.test_llm_default_allowlist",
],
"llm": [
"tests.test_s28s29_chat_csrf_redaction",
"tests.test_r219_exception_boundary_phase2",
],
},
"r220_route_contract_sha256": stable_text_digest(route_contract),
"openapi_sha256": stable_text_digest(openapi),
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--write-baseline", action="store_true")
args = parser.parse_args()
actual = build_contract()
if args.write_baseline:
write_text_lf(CONTRACT_PATH, _canonical_json(actual))
print(f"API-CONFIG-CONTRACT-WRITTEN: {CONTRACT_PATH}")
return 0
expected = json.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
if actual != expected:
print("API-CONFIG-CONTRACT-FAIL: frozen config/facade contract drifted")
return 1
print("API-CONFIG-CONTRACT-PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+178
View File
@@ -0,0 +1,178 @@
"""Verify the frozen R220 API route/facade contract."""
from __future__ import annotations
import argparse
import inspect
import json
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.contract_digest import stable_text_digest, write_text_lf # noqa: E402
CONTRACT_PATH = ROOT / "tests" / "api_route_contract_r220.json"
class _NamedHandler:
def __init__(self, name: str) -> None:
self.__name__ = name
class _AttributeHandlers:
def __getattr__(self, name: str) -> _NamedHandler:
return _NamedHandler(name)
def _handler_map() -> defaultdict[str, _NamedHandler]:
return defaultdict(lambda: _NamedHandler("unknown"))
def _normalize_specs(specs: Any) -> list[dict[str, str]]:
return [
{
"method": spec.method,
"path": spec.path,
"handler": getattr(spec.handler, "__name__", type(spec.handler).__name__),
}
for spec in specs
]
def _metadata(handler: Any) -> dict[str, Any]:
from services.endpoint_manifest import get_metadata
meta = get_metadata(handler)
if meta is None:
raise RuntimeError(f"missing endpoint metadata for {handler.__name__}")
return {
"auth": meta.auth_tier.value,
"risk": meta.risk_tier.value,
"plane": meta.route_plane.value if meta.route_plane else None,
"summary": meta.summary,
"description": meta.description,
"audit": meta.audit_action,
"scopes": list(meta.required_scopes),
}
def build_contract() -> dict[str, Any]:
from api import routes
from api.route_registrars import (
build_assist_route_specs,
build_connector_installation_route_specs,
build_core_route_specs,
build_pack_route_specs,
)
core_handlers = _handler_map()
core_keys = inspect.getsource(build_core_route_specs)
for key in {part.split('"', 1)[0] for part in core_keys.split('handlers["')[1:]}:
core_handlers[key] = _NamedHandler(key)
connector_handlers = _handler_map()
connector_keys = inspect.getsource(build_connector_installation_route_specs)
for key in {
part.split('"', 1)[0] for part in connector_keys.split('handlers["')[1:]
}:
connector_handlers[key] = _NamedHandler(key)
packs = _AttributeHandlers()
assist = _AttributeHandlers()
families: dict[str, list[dict[str, str]]] = {}
for prefix in ("/openclaw", "/moltbot"):
families[f"core:{prefix}"] = _normalize_specs(
build_core_route_specs(prefix, core_handlers)
)
families[f"assist:{prefix}"] = _normalize_specs(
build_assist_route_specs(prefix, assist)
)
families[f"connector_installations:{prefix}"] = _normalize_specs(
build_connector_installation_route_specs(prefix, connector_handlers)
)
families[f"packs:{prefix}"] = _normalize_specs(
build_pack_route_specs(prefix, packs)
)
facade_names = (
"health_handler",
"_ensure_observability_deps_ready",
"logs_tail_handler",
"jobs_handler",
"_emit_jobs_list_audit",
"trace_handler",
"register_dual_route",
"_resolve_mae_profile",
"_run_mae_startup_gate",
"register_routes",
)
facade = {
name: str(inspect.signature(getattr(routes, name))) for name in facade_names
}
metadata = {
name: _metadata(getattr(routes, name))
for name in (
"health_handler",
"logs_tail_handler",
"jobs_handler",
"trace_handler",
)
}
return {
"schema_version": 1,
"registration_order": [
"startup_profile_gate",
"core:/openclaw",
"core:/moltbot",
"assist:/openclaw",
"assist:/moltbot",
"connector_installations:/openclaw",
"connector_installations:/moltbot",
"bridge",
"mae_posture_gate",
"packs:/openclaw",
"packs:/moltbot",
],
"feature_conditions": {
"assist": "assist is truthy",
"connector_installations": "list handler is truthy",
"bridge": "server.app exists and BRIDGE module is enabled",
"packs": "optional pack imports succeed",
},
"direct_alias_rule": "each registered path also attempts path and /api+path",
"legacy_rule": "moltbot handlers retain telemetry and deprecation headers",
"families": families,
"facade_signatures": facade,
"facade_metadata": metadata,
"openapi_sha256": stable_text_digest(ROOT / "docs" / "openapi.yaml"),
}
def _canonical_json(value: Any) -> str:
return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--write-baseline", action="store_true")
args = parser.parse_args()
actual = build_contract()
if args.write_baseline:
write_text_lf(CONTRACT_PATH, _canonical_json(actual))
print(f"API-ROUTE-CONTRACT-WRITTEN: {CONTRACT_PATH}")
return 0
expected = json.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
if actual != expected:
print("API-ROUTE-CONTRACT-FAIL: frozen route/facade contract drifted")
return 1
print("API-ROUTE-CONTRACT-PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+133
View File
@@ -0,0 +1,133 @@
"""Verify the frozen R222 CommandRouter facade and command contract."""
from __future__ import annotations
import argparse
import ast
import inspect
import json
import sys
import textwrap
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.contract_digest import stable_text_digest, write_text_lf # noqa: E402
CONTRACT_PATH = ROOT / "tests" / "connector_router_contract_r222.json"
def _canonical_json(value: Any) -> str:
return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
def _command_table() -> list[dict[str, Any]]:
from connector.router import CommandRouter
tree = ast.parse(textwrap.dedent(inspect.getsource(CommandRouter.handle)))
for node in ast.walk(tree):
if isinstance(node, ast.Assign) and any(
isinstance(target, ast.Name) and target.id == "handlers"
for target in node.targets
):
if not isinstance(node.value, ast.Dict):
break
entries = []
for key, value in zip(node.value.keys, node.value.values, strict=True):
if not isinstance(key, ast.Tuple) or not isinstance(value, ast.Tuple):
raise RuntimeError("invalid command table entry")
aliases = [ast.literal_eval(item) for item in key.elts]
handler_attr = value.elts[0]
command_class = value.elts[1]
if not isinstance(handler_attr, ast.Attribute) or not isinstance(
command_class, ast.Attribute
):
raise RuntimeError("invalid command table target")
entries.append(
{
"aliases": aliases,
"handler": handler_attr.attr,
"class": command_class.attr,
}
)
return entries
raise RuntimeError("CommandRouter.handle command table not found")
def build_contract() -> dict[str, Any]:
from connector.router import CommandRouter
method_names = sorted(
{
name
for owner in CommandRouter.__mro__
if owner is not object
for name, value in owner.__dict__.items()
if callable(value)
and (name == "handle" or name.startswith("_"))
and name != "_build_llm_client"
}
)
digests = {}
for filename in (
"api_config_contract_r221.json",
"api_route_contract_r220.json",
):
path = ROOT / "tests" / filename
digests[filename] = stable_text_digest(path)
return {
"schema_version": 1,
"constructor_signature": str(inspect.signature(CommandRouter)),
"facade_signatures": {
name: str(inspect.signature(getattr(CommandRouter, name)))
for name in method_names
},
"command_table": _command_table(),
"instance_ownership": [
"config",
"client",
"poller",
"state",
"_template_meta_cache",
"_rate_limiter",
"semantic_guard",
"command_firewall",
],
"response_matrix_owners": [
"tests.connector.test_router_hotspot_r181",
"tests.connector.test_r214_jobs_command",
"tests.connector.test_router_admin",
"tests.connector.test_router_command_authz_r80",
"tests.connector.test_security",
"tests.connector.test_chat",
"tests.connector.test_chat_integration",
"tests.connector.test_media_delivery",
"tests.chat_connector.test_router_phase2",
"tests.chat_connector.test_router_phase3",
],
"upstream_contract_digests": digests,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--write-baseline", action="store_true")
args = parser.parse_args()
actual = build_contract()
if args.write_baseline:
write_text_lf(CONTRACT_PATH, _canonical_json(actual))
print(f"CONNECTOR-ROUTER-CONTRACT-WRITTEN: {CONTRACT_PATH}")
return 0
expected = json.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
if actual != expected:
print("CONNECTOR-ROUTER-CONTRACT-FAIL")
return 1
print("CONNECTOR-ROUTER-CONTRACT-PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+277
View File
@@ -0,0 +1,277 @@
"""Verify selected broad-exception boundary policy.
This intentionally checks only modules listed in tests/exception_boundary_policy.json.
The repo still has too many historical broad catches for a global BLE001-style
rule to be useful.
"""
from __future__ import annotations
import argparse
import ast
import json
from collections import Counter
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import Any, Iterable
VALID_CLASSIFICATIONS = {
"allowed_boundary_guard",
"needs_narrowing",
"needs_follow_up_test_coverage",
}
VALID_COVERAGE_MODES = {"all_broad_catches", "selected_scopes"}
@dataclass(frozen=True)
class BroadCatch:
path: str
line: int
scope: str
catch_type: str
class _BroadCatchVisitor(ast.NodeVisitor):
def __init__(self, path: Path):
self.path = path.as_posix()
self.scope_stack: list[str] = []
self.catches: list[BroadCatch] = []
def visit_ClassDef(self, node: ast.ClassDef) -> None:
self.scope_stack.append(node.name)
self.generic_visit(node)
self.scope_stack.pop()
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
self.scope_stack.append(node.name)
self.generic_visit(node)
self.scope_stack.pop()
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
self.visit_FunctionDef(node)
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
catch_type = _catch_type_name(node.type)
if catch_type in {"bare", "Exception", "BaseException"}:
self.catches.append(
BroadCatch(
path=self.path,
line=node.lineno,
scope=".".join(self.scope_stack) or "<module>",
catch_type=catch_type,
)
)
self.generic_visit(node)
def _catch_type_name(node: ast.expr | None) -> str:
if node is None:
return "bare"
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Tuple):
names = {_catch_type_name(item) for item in node.elts}
if "BaseException" in names:
return "BaseException"
if "Exception" in names:
return "Exception"
return ""
def iter_broad_catches(path: Path) -> Iterable[BroadCatch]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
visitor = _BroadCatchVisitor(path)
visitor.visit(tree)
return tuple(visitor.catches)
def load_policy(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def validate_exception_boundary_policy(
repo_root: Path,
policy: dict[str, Any],
) -> list[str]:
failures: list[str] = []
if set(policy) != {"version", "selected_modules"}:
failures.append("policy root keys must match the version 2 schema")
if policy.get("version") != 2:
failures.append("policy version must equal 2")
modules = policy.get("selected_modules")
if not isinstance(modules, dict) or not modules:
return ["policy selected_modules must be a non-empty object"]
for rel_path, module_policy in sorted(modules.items()):
rel_path_obj = Path(rel_path)
if (
rel_path_obj.is_absolute()
or ".." in rel_path_obj.parts
or rel_path_obj.suffix != ".py"
):
failures.append(f"{rel_path}: unsafe selected module path")
continue
if not isinstance(module_policy, dict):
failures.append(f"{rel_path}: module policy must be an object")
continue
path = repo_root / rel_path
if not path.is_file():
failures.append(f"{rel_path}: selected module does not exist")
continue
allowed = module_policy.get("broad_catches")
if not isinstance(allowed, list):
failures.append(f"{rel_path}: broad_catches must be a list")
continue
coverage = module_policy.get("coverage")
if coverage not in VALID_COVERAGE_MODES:
failures.append(f"{rel_path}: invalid coverage mode {coverage!r}")
continue
expected_module_keys = {"coverage", "broad_catches"}
if coverage == "selected_scopes":
expected_module_keys.add("selected_scopes")
if set(module_policy) != expected_module_keys:
failures.append(f"{rel_path}: module keys must match coverage schema")
selected_scopes_raw = module_policy.get("selected_scopes", [])
if coverage == "selected_scopes":
if (
not isinstance(selected_scopes_raw, list)
or not selected_scopes_raw
or any(
not isinstance(scope, str) or not scope
for scope in selected_scopes_raw
)
or len(selected_scopes_raw) != len(set(selected_scopes_raw))
):
failures.append(
f"{rel_path}: selected_scopes must be a unique non-empty string list"
)
selected_scopes: set[str] = set()
else:
selected_scopes = set(selected_scopes_raw)
else:
if selected_scopes_raw:
failures.append(
f"{rel_path}: all_broad_catches must not declare selected_scopes"
)
selected_scopes = set()
entries_by_scope: dict[str, dict[str, Any]] = {}
for index, entry in enumerate(allowed):
if not isinstance(entry, dict):
failures.append(f"{rel_path}: broad_catches[{index}] must be an object")
continue
if set(entry) != {
"scope",
"expected_count",
"classification",
"reason",
"regression_owner",
"review_after",
}:
failures.append(
f"{rel_path}: broad_catches[{index}] entry keys must match schema"
)
scope = entry.get("scope")
classification = entry.get("classification")
reason = entry.get("reason")
regression_owner = entry.get("regression_owner")
review_after = entry.get("review_after")
if not isinstance(scope, str) or not scope:
failures.append(f"{rel_path}: broad_catches[{index}] missing scope")
continue
if scope in entries_by_scope:
failures.append(f"{rel_path}: duplicate broad-catch scope {scope}")
entries_by_scope[scope] = entry
if classification not in VALID_CLASSIFICATIONS:
failures.append(
f"{rel_path}:{scope}: invalid classification {classification!r}"
)
if not isinstance(reason, str) or not reason.strip():
failures.append(f"{rel_path}:{scope}: missing reason")
if not isinstance(regression_owner, str) or not regression_owner.strip():
failures.append(f"{rel_path}:{scope}: missing regression_owner")
else:
owner_path = Path(regression_owner)
if (
owner_path.is_absolute()
or ".." in owner_path.parts
or not owner_path.parts
or owner_path.parts[0] != "tests"
or owner_path.suffix != ".py"
):
failures.append(
f"{rel_path}:{scope}: regression_owner must be a safe tests/*.py path"
)
elif not (repo_root / owner_path).is_file():
failures.append(
f"{rel_path}:{scope}: regression_owner does not exist"
)
try:
if not isinstance(review_after, str):
raise TypeError
review_date = date.fromisoformat(review_after)
except (TypeError, ValueError):
failures.append(f"{rel_path}:{scope}: invalid review_after")
else:
if review_date < date.today():
failures.append(f"{rel_path}:{scope}: review_after is expired")
if coverage == "selected_scopes" and scope not in selected_scopes:
failures.append(f"{rel_path}:{scope}: entry is outside selected_scopes")
catches = tuple(iter_broad_catches(path))
governed_catches = (
catches
if coverage == "all_broad_catches"
else tuple(catch for catch in catches if catch.scope in selected_scopes)
)
counts = Counter(catch.scope for catch in governed_catches)
if coverage == "selected_scopes":
for stale_scope in sorted(selected_scopes - set(counts)):
failures.append(
f"{rel_path}:{stale_scope}: selected scope has no broad catch"
)
for catch in governed_catches:
if catch.scope not in entries_by_scope:
failures.append(
f"{rel_path}:{catch.line}: undocumented broad catch in {catch.scope}"
)
for scope, entry in entries_by_scope.items():
expected_count = entry.get("expected_count", 1)
if not isinstance(expected_count, int) or expected_count < 1:
failures.append(f"{rel_path}:{scope}: expected_count must be >= 1")
continue
actual_count = counts.get(scope, 0)
if actual_count != expected_count:
failures.append(
f"{rel_path}:{scope}: expected {expected_count} broad catch(es), found {actual_count}"
)
return failures
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--repo-root", default=".")
parser.add_argument(
"--policy",
default="tests/exception_boundary_policy.json",
)
args = parser.parse_args()
repo_root = Path(args.repo_root).resolve()
policy_path = repo_root / args.policy
failures = validate_exception_boundary_policy(repo_root, load_policy(policy_path))
if failures:
for failure in failures:
print(f"EXCEPTION-BOUNDARY-FAIL: {failure}")
return 1
print("EXCEPTION-BOUNDARY-PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,111 @@
/** Verify the frozen R224 Settings/API frontend contract. */
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { stableTextDigest } from "./contract_digest.mjs";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const CONTRACT_PATH = path.join(ROOT, "web", "tests", "fixtures", "frontend_decomposition_contract_r224.json");
function canonicalJson(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
function read(relativePath) {
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
}
function familySources(directory, prefix) {
return fs.readdirSync(path.join(ROOT, directory))
.filter((name) => name === `${prefix}.js` || name.startsWith(`${prefix}_`))
.sort()
.map((name) => read(path.join(directory, name)))
.join("\n");
}
function uniqueSorted(values) {
return [...new Set(values)].sort();
}
function matches(source, pattern, group = 1) {
return [...source.matchAll(pattern)].map((match) => match[group]);
}
function methodSignatures(source) {
const result = {};
const pattern = /^\s{4}(?:async\s+)?([A-Za-z_$][\w$]*)\(([^)]*)\)\s*\{/gm;
for (const match of source.matchAll(pattern)) {
result[match[1]] = match[2].replace(/\s+/g, " ").trim();
}
return Object.fromEntries(Object.entries(result).sort(([a], [b]) => a.localeCompare(b)));
}
function digest(relativePath) {
return stableTextDigest(path.join(ROOT, relativePath));
}
export function buildContract() {
const apiFacade = read("web/openclaw_api.js");
const settingsFacade = read("web/tabs/settings_tab.js");
const apiSources = familySources("web", "openclaw_api");
const settingsSources = familySources("web/tabs", "settings_tab");
return {
schema_version: 1,
api: {
exports: matches(apiFacade, /^export\s+(?:class|const)\s+([A-Za-z_$][\w$]*)/gm),
methods: methodSignatures(apiSources),
constructor_state: uniqueSorted(matches(
apiFacade,
/^\s{8}this\.([A-Za-z_$][\w$]*)\s*=/gm,
)),
path_suffixes: uniqueSorted(matches(apiSources, /this\._path\("([^"]+)"\)/g)),
compatibility_seams: [
"fetch",
"_fetchWithCandidates",
"_capabilitiesCache",
"_capabilitiesCacheTs",
"streamSSEPost",
"subscribeEvents",
],
},
settings: {
exports: matches(settingsFacade, /^export\s+const\s+([A-Za-z_$][\w$]*)/gm),
identity: {
id: settingsFacade.match(/\bid:\s*"([^"]+)"/)?.[1] || "",
title: settingsFacade.match(/\btitle:\s*"([^"]+)"/)?.[1] || "",
icon: settingsFacade.match(/\bicon:\s*"([^"]+)"/)?.[1] || "",
},
dom_ids: uniqueSorted(matches(settingsSources, /id="(openclaw-[^"]+)"/g)),
class_tokens: uniqueSorted(matches(settingsSources, /\b(openclaw-[a-z0-9-]+)\b/g)),
section_headings: uniqueSorted(matches(
settingsSources,
/create(?:Collapsible)?Section\("([^"]+)"/g,
)),
compatibility_seams: ["settingsTab", "settingsTab.render"],
},
upstream_contract_digests: {
"tests/api_route_contract_r220.json": digest("tests/api_route_contract_r220.json"),
"tests/api_config_contract_r221.json": digest("tests/api_config_contract_r221.json"),
"tests/platform_adapter_contract_r223.json": digest("tests/platform_adapter_contract_r223.json"),
},
};
}
export function verifyContract({ writeBaseline = false } = {}) {
const actual = buildContract();
if (writeBaseline) {
fs.mkdirSync(path.dirname(CONTRACT_PATH), { recursive: true });
fs.writeFileSync(CONTRACT_PATH, canonicalJson(actual), "utf8");
return { ok: true, message: `FRONTEND-CONTRACT-WRITTEN:${CONTRACT_PATH}` };
}
const expected = JSON.parse(fs.readFileSync(CONTRACT_PATH, "utf8"));
const ok = canonicalJson(actual) === canonicalJson(expected);
return { ok, message: ok ? "FRONTEND-CONTRACT-PASS" : "FRONTEND-CONTRACT-FAIL" };
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const result = verifyContract({ writeBaseline: process.argv.includes("--write-baseline") });
console.log(result.message);
process.exit(result.ok ? 0 : 1);
}

Some files were not shown because too many files have changed in this diff Show More