mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-17 02:12:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
822661ec81 | ||
|
|
c649331ef5 | ||
|
|
f086f7a0e8 | ||
|
|
c46cae810f | ||
|
|
397c9a1cbe | ||
|
|
71f399369c | ||
|
|
87c4c2df08 | ||
|
|
ec50c09b93 | ||
|
|
8970d6ff28 | ||
|
|
76f535aa1a | ||
|
|
9f6287f947 | ||
|
|
816231c49f | ||
|
|
975843ac1a | ||
|
|
b6760ee595 | ||
|
|
7d7a1c412f | ||
|
|
55d320db3d | ||
|
|
e89b1c85a8 | ||
|
|
a68dbfa433 | ||
|
|
e5c1f48448 | ||
|
|
22b4a341c2 | ||
|
|
37f2507d37 | ||
|
|
c05436944d | ||
|
|
3fafa42c93 | ||
|
|
8c175f47ab | ||
|
|
5babb01a56 | ||
|
|
85afda3277 | ||
|
|
fe5bf6c684 | ||
|
|
351b418b83 | ||
|
|
291d537214 | ||
|
|
570c4d0dc5 | ||
|
|
f1f221bb6d | ||
|
|
76cbaa404f | ||
|
|
c1aca449c5 | ||
|
|
a28316ed38 | ||
|
|
06a25f7393 | ||
|
|
bc06b56a5b | ||
|
|
11faa8a789 | ||
|
|
b8b8d9c180 | ||
|
|
746d9a1352 | ||
|
|
fdda687141 | ||
|
|
b68d951fd0 | ||
|
|
10c8f2e4aa | ||
|
|
ed98ed6568 | ||
|
|
0f23b04a32 | ||
|
|
486e94d2e0 | ||
|
|
fd82dd7fef | ||
|
|
02b0c4d2d6 | ||
|
|
79f4a722f0 | ||
|
|
c8df8b1886 | ||
|
|
5efc587c3f | ||
|
|
c04dbde73d | ||
|
|
f10c632bc5 | ||
|
|
ed9c2bcda4 | ||
|
|
6a35056631 | ||
|
|
a22be165d8 | ||
|
|
886e91c491 | ||
|
|
c612a67053 | ||
|
|
ea1dbbe315 | ||
|
|
db1cc91bdc | ||
|
|
4f64294b85 | ||
|
|
ba3c64bdd1 | ||
|
|
035290fb47 | ||
|
|
fbbb8642b8 | ||
|
|
c4b9d2b271 | ||
|
|
c6cfbdb606 | ||
|
|
a99ec20fa5 | ||
|
|
223514c9b4 | ||
|
|
b3bf098382 | ||
|
|
620549ea96 | ||
|
|
b32a6a9010 | ||
|
|
a46a7db84b | ||
|
|
bd2741aad0 | ||
|
|
9784328e40 | ||
|
|
db9067a380 | ||
|
|
4cfa9fba39 | ||
|
|
fba88fc5e4 | ||
|
|
18a5341697 | ||
|
|
853d272dd1 | ||
|
|
ab4449f6de | ||
|
|
e03c4d527e | ||
|
|
fd21de18db | ||
|
|
92ba574b99 | ||
|
|
99a425c6c5 | ||
|
|
4ddade280c | ||
|
|
552f1a079f | ||
|
|
8f08abd229 | ||
|
|
bdf1a0a9ad | ||
|
|
2c98df607e | ||
|
|
89f1e923f0 | ||
|
|
bba8055c3f | ||
|
|
06f395b4ec | ||
|
|
bc9283f27c | ||
|
|
0102aabbad | ||
|
|
12405e5a17 | ||
|
|
c6aef620bc | ||
|
|
d1e8b0e92b | ||
|
|
ce934d00a9 | ||
|
|
f6d92b2d60 | ||
|
|
fc7c296e65 | ||
|
|
55fbe67b2a | ||
|
|
4de75e70d4 | ||
|
|
1d95cd864f | ||
|
|
1777c03926 | ||
|
|
05bc4edab7 | ||
|
|
71fbfc9c52 | ||
|
|
6e5ce4fa07 | ||
|
|
02362d47f4 | ||
|
|
a59663bfb8 | ||
|
|
95d9a305f4 | ||
|
|
6f5d8c06e9 | ||
|
|
da5fb1fcbd | ||
|
|
c0bd987ec1 | ||
|
|
062e0f2e11 | ||
|
|
2ee8245ff1 | ||
|
|
d49e1d416f | ||
|
|
8660ece6c1 | ||
|
|
0a959f96aa | ||
|
|
1f8e11205f | ||
|
|
4e3a30272e | ||
|
|
157ef81505 | ||
|
|
a6e2669858 | ||
|
|
404f19175f | ||
|
|
9285f44766 | ||
|
|
4dc268b499 | ||
|
|
a3df558797 | ||
|
|
62eea5fb35 | ||
|
|
ff0ddfd65b | ||
|
|
e84aacf295 | ||
|
|
5f948878a8 | ||
|
|
fc00538b43 | ||
|
|
2195e2d2a5 | ||
|
|
fdf3a9ec27 | ||
|
|
0136543d71 | ||
|
|
3d267092b3 | ||
|
|
82c1d15a08 | ||
|
|
c727814e36 | ||
|
|
9c0de89fc5 | ||
|
|
cf74e0c0c0 | ||
|
|
68b7040ac7 | ||
|
|
75d560719e | ||
|
|
71199efa07 | ||
|
|
723215ac48 | ||
|
|
922299ae8a | ||
|
|
10c0a60a39 | ||
|
|
10d6c6cee3 | ||
|
|
450c470716 | ||
|
|
fd3a11c25f | ||
|
|
59fce18c4d | ||
|
|
fb7d0c1c2d | ||
|
|
8e1cbd089a | ||
|
|
d0acea3952 | ||
|
|
f4ddb22233 | ||
|
|
b39b7204dd | ||
|
|
5aff764837 | ||
|
|
872edafdd6 | ||
|
|
a0b92e018d | ||
|
|
cc64ee4608 | ||
|
|
fed9fc44c5 | ||
|
|
5c0d168e31 | ||
|
|
a1748856ce | ||
|
|
c58dda9032 | ||
|
|
a558825ffb | ||
|
|
100e2ac7bf | ||
|
|
8209d7d9b2 | ||
|
|
11fec30e8e | ||
|
|
a44001fcab | ||
|
|
dd20639802 | ||
|
|
62fe3430e7 | ||
|
|
b64c4c118d | ||
|
|
c21ce49a87 | ||
|
|
f57d46061f | ||
|
|
b99b9a9865 | ||
|
|
0abdafab73 | ||
|
|
3cf28d7a4c | ||
|
|
016657c37d | ||
|
|
bf06ef539c | ||
|
|
09e318b415 | ||
|
|
2cc3edf959 | ||
|
|
43a44495a1 | ||
|
|
f70b197ef4 | ||
|
|
4a84b9858d | ||
|
|
95c9c655de | ||
|
|
e7c0566efe | ||
|
|
0f133ec6e0 | ||
|
|
e5d37e80c7 | ||
|
|
2da760de02 | ||
|
|
728aeefb4a | ||
|
|
92ebb2b113 | ||
|
|
28975b1f22 | ||
|
|
d6f26d38ce | ||
|
|
b8470e73cc | ||
|
|
4f32f98f7d | ||
|
|
522771bee4 | ||
|
|
af9c9a4c2d | ||
|
|
01f9828f99 | ||
|
|
d429c11018 | ||
|
|
95279609d2 | ||
|
|
153f158a6a | ||
|
|
2fa9fe24d9 | ||
|
|
1ad2e0fcc8 | ||
|
|
75245d7748 | ||
|
|
0e9829ce39 | ||
|
|
a8030cf8dc | ||
|
|
2d98e2ead4 | ||
|
|
5876f12c19 | ||
|
|
73b920a925 | ||
|
|
86dfbb6566 | ||
|
|
cc0853d61c | ||
|
|
bc6484bf7d | ||
|
|
c607a86228 | ||
|
|
d0f7c35620 | ||
|
|
0de9cdec15 | ||
|
|
4366ee32cd | ||
|
|
1137112caf |
+69
-33
@@ -10,6 +10,9 @@ on:
|
||||
schedule:
|
||||
- cron: '0 3 * * *' # nightly 03:00 UTC for adversarial-extended
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
import-smoke:
|
||||
@@ -20,13 +23,16 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- uses: actions/setup-node@v4
|
||||
- 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
|
||||
@@ -50,23 +56,26 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- uses: actions/setup-node@v4
|
||||
- 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
|
||||
python -m pip install cryptography
|
||||
python -m pip install -r requirements.txt
|
||||
- name: R120 preflight
|
||||
run: |
|
||||
python scripts/preflight_check.py --strict
|
||||
- name: Install Node deps
|
||||
run: |
|
||||
npm install
|
||||
npm ci
|
||||
- name: Install Playwright browsers
|
||||
run: |
|
||||
npx playwright install chromium
|
||||
@@ -79,23 +88,32 @@ jobs:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- uses: actions/setup-node@v4
|
||||
- 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
|
||||
# Keep aligned with local pre-push/full-test scripts.
|
||||
# aiohttp is required by multiple unit-test import paths.
|
||||
# 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 numpy pillow aiohttp
|
||||
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
|
||||
@@ -110,19 +128,25 @@ jobs:
|
||||
env:
|
||||
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_unit
|
||||
run: |
|
||||
python scripts/run_unittests.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json
|
||||
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
|
||||
- name: Coverage hotspot report
|
||||
run: |
|
||||
python scripts/report_coverage_governance.py --coverage-json .tmp/coverage/backend_unit_coverage.json
|
||||
|
||||
backend-e2e-real:
|
||||
name: Backend E2E (real-backend lane, low-mock)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- uses: actions/setup-node@v4
|
||||
- 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
|
||||
@@ -148,13 +172,16 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- uses: actions/setup-node@v4
|
||||
- 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
|
||||
@@ -171,18 +198,21 @@ jobs:
|
||||
name: Security Audit (S23)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- 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@v5
|
||||
- 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
|
||||
@@ -200,12 +230,15 @@ jobs:
|
||||
name: Adversarial Gate (adaptive)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v5
|
||||
- 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
|
||||
@@ -223,7 +256,7 @@ jobs:
|
||||
python scripts/run_adversarial_gate.py --profile auto --seed 42 --artifact-dir .tmp/adversarial
|
||||
- name: Upload adversarial artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: adversarial-adaptive-manifest
|
||||
path: .tmp/adversarial/
|
||||
@@ -234,10 +267,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- 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
|
||||
@@ -253,7 +289,7 @@ jobs:
|
||||
python scripts/run_adversarial_gate.py --profile extended --artifact-dir .tmp/adversarial-extended
|
||||
- name: Upload adversarial artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: adversarial-extended-manifest
|
||||
path: .tmp/adversarial-extended/
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
schedule:
|
||||
- cron: "0 4 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
- language: python
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Initialize CodeQL
|
||||
# IMPORTANT: keep CodeQL workflow config versioned in-repo so security
|
||||
# scanning policy changes are reviewable and do not silently drift in UI.
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -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
|
||||
@@ -9,6 +9,9 @@ on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pre-commit:
|
||||
name: Run Pre-commit Hooks
|
||||
@@ -16,10 +19,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
@@ -28,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
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
- "pyproject.toml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
@@ -18,11 +19,20 @@ jobs:
|
||||
if: ${{ github.repository_owner == 'rookiestar28' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 2
|
||||
submodules: true
|
||||
- name: Evaluate registry publish guard
|
||||
id: publish_guard
|
||||
run: |
|
||||
python scripts/registry_publish_guard.py --pyproject pyproject.toml --previous-ref HEAD^ --github-output "$GITHUB_OUTPUT"
|
||||
- name: Skip registry publish when version is unchanged
|
||||
if: steps.publish_guard.outputs.should_publish != 'true'
|
||||
run: |
|
||||
echo "Skipping registry publish because pyproject version is unchanged."
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@v1
|
||||
if: steps.publish_guard.outputs.should_publish == 'true'
|
||||
uses: Comfy-Org/publish-node-action@d2366e7abb6ab16f3bb03e3520ae25c8cf749bc9
|
||||
with:
|
||||
## Add your own personal access token to your Github Repository secrets and reference it here.
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
|
||||
@@ -9,6 +9,9 @@ on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
secret-scan:
|
||||
name: Detect Secrets
|
||||
@@ -16,10 +19,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
|
||||
+56
-20
@@ -1,36 +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/
|
||||
.tmp/
|
||||
*.log
|
||||
moltbot_state/
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
+20
-3
@@ -9,6 +9,7 @@ if _OPENCLAW_ROOT not in sys.path:
|
||||
if __package__:
|
||||
from .nodes.batch_variants import OpenClawBatchVariants
|
||||
from .nodes.image_to_prompt import OpenClawImageToPrompt
|
||||
from .nodes.portability_contract import NODE_PORTABILITY_MAPPINGS
|
||||
from .nodes.prompt_planner import OpenClawPromptPlanner
|
||||
from .nodes.prompt_refiner import OpenClawPromptRefiner
|
||||
|
||||
@@ -27,12 +28,18 @@ if __package__:
|
||||
"MoltbotPromptRefiner": "openclaw: Prompt Refiner",
|
||||
}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
__all__ = [
|
||||
"NODE_CLASS_MAPPINGS",
|
||||
"NODE_DISPLAY_NAME_MAPPINGS",
|
||||
"NODE_PORTABILITY_MAPPINGS",
|
||||
"WEB_DIRECTORY",
|
||||
]
|
||||
else:
|
||||
# Allow test collection to proceed without crashing on relative imports
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
__all__ = ["WEB_DIRECTORY"]
|
||||
NODE_PORTABILITY_MAPPINGS = {}
|
||||
__all__ = ["NODE_PORTABILITY_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
@@ -46,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()
|
||||
|
||||
+16
-4
@@ -26,6 +26,7 @@ try:
|
||||
from ..services.execution_budgets import BudgetExceededError
|
||||
from ..services.idempotency_store import IdempotencyStore
|
||||
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from ..services.redaction import stable_redaction_tag
|
||||
from ..services.sidecar.auth import is_bridge_enabled, require_bridge_auth
|
||||
from ..services.sidecar.bridge_contract import (
|
||||
BRIDGE_ENDPOINTS,
|
||||
@@ -44,6 +45,7 @@ except ImportError:
|
||||
from services.execution_budgets import BudgetExceededError
|
||||
from services.idempotency_store import IdempotencyStore
|
||||
from services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from services.redaction import stable_redaction_tag
|
||||
from services.sidecar.auth import is_bridge_enabled, require_bridge_auth
|
||||
from services.sidecar.bridge_contract import (
|
||||
BRIDGE_ENDPOINTS,
|
||||
@@ -82,6 +84,10 @@ MAX_FILES_COUNT = 10
|
||||
_startup_time = time.time()
|
||||
|
||||
|
||||
def _bridge_sensitive_tag(value: Optional[str], *, label: str) -> str:
|
||||
return stable_redaction_tag(value, label=label)
|
||||
|
||||
|
||||
class BridgeHandlers:
|
||||
"""Handlers for bridge API endpoints."""
|
||||
|
||||
@@ -107,7 +113,7 @@ class BridgeHandlers:
|
||||
def _bridge_token(self, device_id: Optional[str], scope: Optional[str] = None):
|
||||
scopes = {scope} if scope else set()
|
||||
return SimpleNamespace(
|
||||
token_id=f"bridge:{device_id or 'unknown'}",
|
||||
token_id=f"bridge:{_bridge_sensitive_tag(device_id, label='device')}",
|
||||
role="bridge",
|
||||
scopes=scopes,
|
||||
)
|
||||
@@ -716,7 +722,10 @@ class BridgeHandlers:
|
||||
store_key = f"wr:{idempotency_key}"
|
||||
is_dup, _ = self._idempotency_store.check_and_record(store_key, ttl=86400)
|
||||
if is_dup:
|
||||
logger.info(f"Duplicate worker result suppressed: {idempotency_key}")
|
||||
logger.info(
|
||||
"Duplicate worker result suppressed for %s",
|
||||
_bridge_sensitive_tag(idempotency_key, label="idem"),
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
@@ -745,7 +754,8 @@ class BridgeHandlers:
|
||||
self._worker_results[job_id] = {
|
||||
"status": data.get("status", "completed"),
|
||||
"outputs": data.get("outputs", {}),
|
||||
"worker_id": device_id,
|
||||
# IMPORTANT: keep worker identity redacted in cached bridge state.
|
||||
"worker_id": _bridge_sensitive_tag(device_id, label="device"),
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
@@ -761,7 +771,9 @@ class BridgeHandlers:
|
||||
scope=BridgeScope.JOB_SUBMIT.value,
|
||||
details={"status": data.get("status", "completed")},
|
||||
)
|
||||
logger.info(f"F46: Worker result accepted for job={job_id} from={device_id}")
|
||||
# IMPORTANT: keep this success log constant. Request-auth CodeQL still treats
|
||||
# the surrounding handler scope as credential-tainted even when job_id is benign.
|
||||
logger.info("F46: Worker result accepted.")
|
||||
return web.json_response(response_data, status=201)
|
||||
|
||||
@endpoint_metadata(
|
||||
|
||||
+75
-804
@@ -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())
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -25,6 +25,9 @@ except ImportError: # pragma: no cover
|
||||
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.access_control import require_admin_token, resolve_token_info
|
||||
from ..services.connector_extraction_contract import (
|
||||
get_connector_extraction_contract,
|
||||
)
|
||||
from ..services.connector_installation_registry import (
|
||||
get_connector_installation_registry,
|
||||
)
|
||||
@@ -33,6 +36,9 @@ if __package__ and "." in __package__:
|
||||
else: # pragma: no cover
|
||||
from services.access_control import require_admin_token # type: ignore
|
||||
from services.access_control import resolve_token_info # type: ignore
|
||||
from services.connector_extraction_contract import ( # type: ignore
|
||||
get_connector_extraction_contract,
|
||||
)
|
||||
from services.connector_installation_registry import ( # type: ignore
|
||||
get_connector_installation_registry,
|
||||
)
|
||||
@@ -241,3 +247,22 @@ async def connector_installation_audit_handler(request):
|
||||
{"ok": False, "error": exc.code, "message": str(exc)},
|
||||
status=403,
|
||||
)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.LOW,
|
||||
summary="Connector extraction contract",
|
||||
description="Returns the machine-readable connector extraction feasibility contract.",
|
||||
audit="connector.extraction_contract.get",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def connector_extraction_contract_handler(request):
|
||||
if (guard := _require_admin(request)) is not None:
|
||||
return guard
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"contract": get_connector_extraction_contract(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
PNG Info API handler (R168).
|
||||
POST /openclaw/pnginfo (legacy: /moltbot/pnginfo)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
try:
|
||||
from ..services.access_control import require_admin_token
|
||||
from ..services.aiohttp_compat import import_aiohttp_web
|
||||
from ..services.async_utils import run_in_thread
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
from ..services.pnginfo import PngInfoError, parse_image_metadata
|
||||
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
except ImportError: # pragma: no cover
|
||||
from services.access_control import require_admin_token # type: ignore
|
||||
from services.aiohttp_compat import import_aiohttp_web # type: ignore
|
||||
from services.async_utils import run_in_thread # type: ignore
|
||||
from services.endpoint_manifest import ( # type: ignore
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
from services.pnginfo import PngInfoError, parse_image_metadata # type: ignore
|
||||
from services.rate_limit import ( # type: ignore
|
||||
build_rate_limit_response,
|
||||
check_rate_limit,
|
||||
)
|
||||
|
||||
web = import_aiohttp_web()
|
||||
|
||||
|
||||
def _json(payload: dict[str, Any], status: int = 200) -> web.Response:
|
||||
return web.json_response(payload, status=status)
|
||||
|
||||
|
||||
def _require_admin(request: web.Request) -> Optional[web.Response]:
|
||||
ok, error = require_admin_token(request)
|
||||
if ok:
|
||||
return None
|
||||
return _json({"ok": False, "error": error or "unauthorized"}, 403)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.LOW,
|
||||
summary="Parse image metadata",
|
||||
description="Extract A1111 or ComfyUI metadata from an uploaded image payload.",
|
||||
audit="pnginfo.parse",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def pnginfo_handler(request: web.Request) -> web.Response:
|
||||
deny = _require_admin(request)
|
||||
if deny:
|
||||
return deny
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=False,
|
||||
)
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
return _json({"ok": False, "error": "invalid_json"}, 400)
|
||||
if not isinstance(payload, dict):
|
||||
return _json({"ok": False, "error": "invalid_payload"}, 400)
|
||||
try:
|
||||
result = await run_in_thread(parse_image_metadata, payload.get("image_b64", ""))
|
||||
except PngInfoError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": exc.detail}, exc.status)
|
||||
except Exception:
|
||||
return _json({"ok": False, "error": "internal_error"}, 500)
|
||||
return _json(result)
|
||||
@@ -26,6 +26,7 @@ if __package__ and "." in __package__:
|
||||
)
|
||||
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from ..services.request_ip import get_client_ip
|
||||
from ..services.workflow_portability import get_workflow_portability_contract
|
||||
else: # pragma: no cover (test-only import mode)
|
||||
from models.schemas import MAX_BODY_SIZE # type: ignore
|
||||
from services.access_control import is_loopback, require_admin_token # type: ignore
|
||||
@@ -39,6 +40,9 @@ else: # pragma: no cover (test-only import mode)
|
||||
check_rate_limit,
|
||||
)
|
||||
from services.request_ip import get_client_ip # type: ignore
|
||||
from services.workflow_portability import ( # type: ignore
|
||||
get_workflow_portability_contract,
|
||||
)
|
||||
|
||||
# R98: Endpoint Metadata
|
||||
if __package__ and "." in __package__:
|
||||
@@ -213,6 +217,7 @@ async def inventory_handler(request: web.Request) -> web.Response:
|
||||
"ok": True,
|
||||
"nodes": node_classes,
|
||||
"models": inventory_snapshot["models"],
|
||||
"portability_contract": get_workflow_portability_contract(),
|
||||
"snapshot_ts": inventory_snapshot["snapshot_ts"],
|
||||
"scan_state": inventory_snapshot["scan_state"],
|
||||
"stale": inventory_snapshot["stale"],
|
||||
|
||||
+35
-23
@@ -8,29 +8,41 @@ import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
from ..services.access_control import require_admin_token, resolve_token_info
|
||||
from ..services.aiohttp_compat import import_aiohttp_web
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
from ..services.presets import Preset, preset_store
|
||||
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
|
||||
except ImportError:
|
||||
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
|
||||
from services.access_control import require_admin_token, resolve_token_info
|
||||
from services.aiohttp_compat import import_aiohttp_web
|
||||
from services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
from services.presets import Preset, preset_store
|
||||
from services.tenant_context import TenantBoundaryError, request_tenant_scope
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.import_fallback import import_attrs_dual
|
||||
else:
|
||||
from services.import_fallback import import_attrs_dual # type: ignore
|
||||
|
||||
(require_admin_token, resolve_token_info) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.access_control",
|
||||
"services.access_control",
|
||||
("require_admin_token", "resolve_token_info"),
|
||||
)
|
||||
(import_aiohttp_web,) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.aiohttp_compat",
|
||||
"services.aiohttp_compat",
|
||||
("import_aiohttp_web",),
|
||||
)
|
||||
(AuthTier, RiskTier, RoutePlane, endpoint_metadata) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.endpoint_manifest",
|
||||
"services.endpoint_manifest",
|
||||
("AuthTier", "RiskTier", "RoutePlane", "endpoint_metadata"),
|
||||
)
|
||||
(Preset, preset_store) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.presets",
|
||||
"services.presets",
|
||||
("Preset", "preset_store"),
|
||||
)
|
||||
(TenantBoundaryError, request_tenant_scope) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.tenant_context",
|
||||
"services.tenant_context",
|
||||
("TenantBoundaryError", "request_tenant_scope"),
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.presets")
|
||||
web = import_aiohttp_web()
|
||||
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
@@ -58,6 +58,7 @@ def build_core_route_specs(
|
||||
f"{prefix}/preflight/inventory",
|
||||
handlers["inventory_handler"],
|
||||
),
|
||||
RouteSpec("POST", f"{prefix}/pnginfo", handlers["pnginfo_handler"]),
|
||||
RouteSpec("GET", f"{prefix}/checkpoints", handlers["list_checkpoints_handler"]),
|
||||
RouteSpec(
|
||||
"POST",
|
||||
@@ -225,6 +226,11 @@ def build_connector_installation_route_specs(
|
||||
f"{prefix}/connector/installations",
|
||||
handlers["connector_installations_list_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"GET",
|
||||
f"{prefix}/connector/extraction-contract",
|
||||
handlers["connector_extraction_contract_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"GET",
|
||||
f"{prefix}/connector/installations/resolve",
|
||||
|
||||
+244
-481
@@ -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,13 +104,20 @@ 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
|
||||
config_get_handler = config_put_handler = llm_test_handler = llm_models_handler = llm_chat_handler = None # type: ignore
|
||||
remote_admin_page_handler = None # type: ignore # F61
|
||||
security_doctor_handler = None # type: ignore # S30
|
||||
connector_installations_list_handler = connector_installation_get_handler = None # type: ignore
|
||||
connector_installation_resolve_handler = connector_installation_audit_handler = None # type: ignore
|
||||
connector_extraction_contract_handler = None # type: ignore
|
||||
templates_list_handler = None # type: ignore
|
||||
rewrite_recipes_list_handler = rewrite_recipe_get_handler = None # type: ignore
|
||||
rewrite_recipe_create_handler = rewrite_recipe_update_handler = None # type: ignore
|
||||
@@ -95,6 +147,7 @@ if web is not None:
|
||||
connector_installation_get_handler,
|
||||
connector_installation_resolve_handler,
|
||||
connector_installation_audit_handler,
|
||||
connector_extraction_contract_handler,
|
||||
) = import_attrs_dual(
|
||||
__package__,
|
||||
"..api.connector_contracts",
|
||||
@@ -104,6 +157,7 @@ if web is not None:
|
||||
"connector_installation_get_handler",
|
||||
"connector_installation_resolve_handler",
|
||||
"connector_installation_audit_handler",
|
||||
"connector_extraction_contract_handler",
|
||||
),
|
||||
)
|
||||
(
|
||||
@@ -158,6 +212,12 @@ if web is not None:
|
||||
"api.preflight_handler",
|
||||
("inventory_handler", "preflight_handler"),
|
||||
)
|
||||
(pnginfo_handler,) = import_attrs_dual(
|
||||
__package__,
|
||||
"..api.pnginfo",
|
||||
"api.pnginfo",
|
||||
("pnginfo_handler",),
|
||||
)
|
||||
(secrets_delete_handler, secrets_put_handler, secrets_status_handler) = (
|
||||
import_attrs_dual(
|
||||
__package__,
|
||||
@@ -259,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__,
|
||||
@@ -335,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(
|
||||
@@ -371,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(
|
||||
@@ -512,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -633,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
|
||||
@@ -697,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
|
||||
@@ -775,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:
|
||||
@@ -825,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,
|
||||
@@ -855,6 +684,7 @@ def register_routes(server) -> None:
|
||||
"templates_list_handler": templates_list_handler,
|
||||
"preflight_handler": preflight_handler,
|
||||
"inventory_handler": inventory_handler,
|
||||
"pnginfo_handler": pnginfo_handler,
|
||||
"list_checkpoints_handler": list_checkpoints_handler,
|
||||
"create_checkpoint_handler": create_checkpoint_handler,
|
||||
"get_checkpoint_handler": get_checkpoint_handler,
|
||||
@@ -889,94 +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
@@ -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)
|
||||
|
||||
|
||||
+39
-30
@@ -8,33 +8,47 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
try:
|
||||
from ..services.access_control import require_admin_token, resolve_token_info
|
||||
from ..services.aiohttp_compat import import_aiohttp_web
|
||||
from ..services.audit import emit_audit_event
|
||||
from ..services.tool_runner import get_tool_runner, is_tools_enabled
|
||||
except ImportError:
|
||||
from services.access_control import require_admin_token # type: ignore
|
||||
from services.access_control import resolve_token_info # type: ignore
|
||||
from services.aiohttp_compat import import_aiohttp_web # type: ignore
|
||||
from services.audit import emit_audit_event # type: ignore
|
||||
from services.tool_runner import get_tool_runner, is_tools_enabled
|
||||
|
||||
# R98: Endpoint Metadata
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
from ..services.import_fallback import import_attrs_dual
|
||||
else:
|
||||
from services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
from services.import_fallback import import_attrs_dual # type: ignore
|
||||
|
||||
(require_admin_token, resolve_token_info) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.access_control",
|
||||
"services.access_control",
|
||||
("require_admin_token", "resolve_token_info"),
|
||||
)
|
||||
(import_aiohttp_web,) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.aiohttp_compat",
|
||||
"services.aiohttp_compat",
|
||||
("import_aiohttp_web",),
|
||||
)
|
||||
(emit_audit_event,) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.audit",
|
||||
"services.audit",
|
||||
("emit_audit_event",),
|
||||
)
|
||||
(get_tool_runner, is_tools_enabled) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.tool_runner",
|
||||
"services.tool_runner",
|
||||
("get_tool_runner", "is_tools_enabled"),
|
||||
)
|
||||
(AuthTier, RiskTier, RoutePlane, endpoint_metadata) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.endpoint_manifest",
|
||||
"services.endpoint_manifest",
|
||||
("AuthTier", "RiskTier", "RoutePlane", "endpoint_metadata"),
|
||||
)
|
||||
(check_surface,) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.surface_guard",
|
||||
"services.surface_guard",
|
||||
("check_surface",),
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.tools")
|
||||
web = import_aiohttp_web()
|
||||
@@ -87,11 +101,6 @@ async def tools_run_handler(request: web.Request) -> web.Response:
|
||||
Requires: Admin Token.
|
||||
"""
|
||||
# S62: Block tool execution in public+split mode
|
||||
try:
|
||||
# CRITICAL: package-relative import must stay first in ComfyUI runtime.
|
||||
from ..services.surface_guard import check_surface
|
||||
except ImportError:
|
||||
from services.surface_guard import check_surface # type: ignore
|
||||
blocked = check_surface("tool_execution", request)
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
+61
-36
@@ -14,43 +14,68 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
try:
|
||||
from .errors import APIError, ErrorCode, create_error_response
|
||||
except ImportError:
|
||||
# Build-time / Test fallback
|
||||
from api.errors import APIError, ErrorCode, create_error_response
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.import_fallback import import_attrs_dual
|
||||
else:
|
||||
from services.import_fallback import import_attrs_dual # type: ignore
|
||||
|
||||
try:
|
||||
from ..models.schemas import MAX_BODY_SIZE, WebhookJobRequest
|
||||
from ..services.aiohttp_compat import import_aiohttp_web
|
||||
from ..services.metrics import metrics
|
||||
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from ..services.trace import get_effective_trace_id
|
||||
from ..services.webhook_auth import get_auth_summary, require_auth
|
||||
except ImportError:
|
||||
from models.schemas import MAX_BODY_SIZE, WebhookJobRequest
|
||||
from services.aiohttp_compat import import_aiohttp_web # type: ignore
|
||||
from services.metrics import metrics
|
||||
from services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from services.trace import get_effective_trace_id
|
||||
from services.webhook_auth import get_auth_summary, require_auth
|
||||
|
||||
try:
|
||||
from ..services.diagnostics_flags import diagnostics
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
except ImportError:
|
||||
from services.diagnostics_flags import diagnostics
|
||||
from services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
(APIError, ErrorCode, create_error_response) = import_attrs_dual(
|
||||
__package__,
|
||||
".errors",
|
||||
"api.errors",
|
||||
("APIError", "ErrorCode", "create_error_response"),
|
||||
)
|
||||
(
|
||||
MAX_BODY_SIZE,
|
||||
WebhookJobRequest,
|
||||
) = import_attrs_dual(
|
||||
__package__,
|
||||
"..models.schemas",
|
||||
"models.schemas",
|
||||
("MAX_BODY_SIZE", "WebhookJobRequest"),
|
||||
)
|
||||
(import_aiohttp_web,) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.aiohttp_compat",
|
||||
"services.aiohttp_compat",
|
||||
("import_aiohttp_web",),
|
||||
)
|
||||
(metrics,) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.metrics",
|
||||
"services.metrics",
|
||||
("metrics",),
|
||||
)
|
||||
(build_rate_limit_response, check_rate_limit) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.rate_limit",
|
||||
"services.rate_limit",
|
||||
("build_rate_limit_response", "check_rate_limit"),
|
||||
)
|
||||
(get_effective_trace_id,) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.trace",
|
||||
"services.trace",
|
||||
("get_effective_trace_id",),
|
||||
)
|
||||
(get_auth_summary, require_auth) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.webhook_auth",
|
||||
"services.webhook_auth",
|
||||
("get_auth_summary", "require_auth"),
|
||||
)
|
||||
(diagnostics,) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.diagnostics_flags",
|
||||
"services.diagnostics_flags",
|
||||
("diagnostics",),
|
||||
)
|
||||
(AuthTier, RiskTier, RoutePlane, endpoint_metadata) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.endpoint_manifest",
|
||||
"services.endpoint_manifest",
|
||||
("AuthTier", "RiskTier", "RoutePlane", "endpoint_metadata"),
|
||||
)
|
||||
|
||||
# R46: Scoped logger for safe-by-default redaction
|
||||
logger = diagnostics.get_logger("ComfyUI-OpenClaw.api.webhook", "webhook")
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 330 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 30 KiB |
@@ -42,22 +42,31 @@ PACK_NAME = "ComfyUI-OpenClaw"
|
||||
PACK_START_TIME = time.time()
|
||||
|
||||
|
||||
def _read_pyproject_version() -> Optional[str]:
|
||||
"""
|
||||
Read version from pyproject.toml ([project].version) as the single source of truth.
|
||||
def _extract_toml_section(text: str, header: str) -> Optional[str]:
|
||||
pattern = re.compile(
|
||||
rf"(?ms)^\ufeff?\[{re.escape(header)}\]\s*$\n(?P<body>.*?)(?=^\[|\Z)"
|
||||
)
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return None
|
||||
return match.group("body")
|
||||
|
||||
Uses a lightweight regex parse to avoid non-stdlib TOML dependencies.
|
||||
"""
|
||||
try:
|
||||
pack_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
pyproject_path = os.path.join(pack_dir, "pyproject.toml")
|
||||
if not os.path.exists(pyproject_path):
|
||||
return None
|
||||
text = ""
|
||||
with open(pyproject_path, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
|
||||
# Prefer stdlib TOML parser if available (Python 3.11+), then fallback to regex.
|
||||
def _extract_toml_string_assignment(section_text: str, key: str) -> Optional[str]:
|
||||
match = re.search(
|
||||
rf"(?m)^\s*{re.escape(key)}\s*=\s*['\"]([^'\"]+)['\"]\s*$",
|
||||
section_text,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
value = match.group(1).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _parse_pyproject_version_text(
|
||||
text: str, *, prefer_tomllib: bool = True
|
||||
) -> Optional[str]:
|
||||
if prefer_tomllib:
|
||||
try:
|
||||
from tomllib import loads as _toml_loads # type: ignore
|
||||
except Exception:
|
||||
@@ -72,21 +81,41 @@ def _read_pyproject_version() -> Optional[str]:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Find the [project] section and parse `version = "..."` within it.
|
||||
# IMPORTANT: tolerate BOM/CRLF so the UI version does not silently fall back to 0.1.0.
|
||||
# This is intentionally conservative to avoid false matches in other sections.
|
||||
m = re.search(
|
||||
r"(?ms)^\ufeff?\\[project\\]\\s*(?:[^\\[]*?)^version\\s*=\\s*[\"']([^\"']+)[\"']\\s*$",
|
||||
text,
|
||||
)
|
||||
if not m:
|
||||
# IMPORTANT: keep this fallback section-bounded.
|
||||
# Matching any `version = ...` outside `[project]` silently reports the wrong build.
|
||||
project_section = _extract_toml_section(text, "project")
|
||||
if project_section is None:
|
||||
return None
|
||||
return _extract_toml_string_assignment(project_section, "version")
|
||||
|
||||
|
||||
def _read_pyproject_version_from_path(
|
||||
pyproject_path: os.PathLike[str] | str, *, prefer_tomllib: bool = True
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Read version from pyproject.toml ([project].version) as the single source of truth.
|
||||
|
||||
Uses a lightweight regex parse to avoid non-stdlib TOML dependencies.
|
||||
"""
|
||||
try:
|
||||
pyproject_path = os.fspath(pyproject_path)
|
||||
if not os.path.exists(pyproject_path):
|
||||
return None
|
||||
ver = (m.group(1) or "").strip()
|
||||
return ver or None
|
||||
with open(pyproject_path, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
return _parse_pyproject_version_text(
|
||||
text,
|
||||
prefer_tomllib=prefer_tomllib,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _read_pyproject_version() -> Optional[str]:
|
||||
pack_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
return _read_pyproject_version_from_path(os.path.join(pack_dir, "pyproject.toml"))
|
||||
|
||||
|
||||
# Version: single source of truth is pyproject.toml (line 4 in this repo).
|
||||
PACK_VERSION = _read_pyproject_version() or "0.1.0"
|
||||
|
||||
@@ -98,24 +127,39 @@ LEGACY2_ENV_API_KEY = GENERIC_LLM_API_KEY_ENV_KEYS[2]
|
||||
# Data directory (R11: use portable state directory)
|
||||
try:
|
||||
# Prefer package-relative import (ComfyUI loads custom nodes by file loader)
|
||||
from .services.state_dir import get_log_path, get_state_dir # type: ignore
|
||||
from .services.state_dir import ( # type: ignore
|
||||
get_log_path,
|
||||
get_state_dir,
|
||||
peek_log_path,
|
||||
peek_state_dir,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
# Fallback for unit tests / direct sys.path imports
|
||||
from services.state_dir import get_log_path, get_state_dir
|
||||
from services.state_dir import (
|
||||
get_log_path,
|
||||
get_state_dir,
|
||||
peek_log_path,
|
||||
peek_state_dir,
|
||||
)
|
||||
except Exception:
|
||||
get_state_dir = None
|
||||
get_log_path = None
|
||||
peek_state_dir = None
|
||||
peek_log_path = None
|
||||
|
||||
if get_state_dir and get_log_path:
|
||||
DATA_DIR = get_state_dir()
|
||||
LOG_FILE = get_log_path()
|
||||
if peek_state_dir and peek_log_path:
|
||||
DATA_DIR = peek_state_dir()
|
||||
LOG_FILE = peek_log_path()
|
||||
else:
|
||||
# Last-resort fallback during early import or if state_dir is unavailable
|
||||
PACK_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DATA_DIR = os.path.join(PACK_DIR, "data")
|
||||
LOG_FILE = os.path.join(DATA_DIR, "openclaw.log")
|
||||
|
||||
_IMPORT_DATA_DIR = DATA_DIR
|
||||
_IMPORT_LOG_FILE = LOG_FILE
|
||||
|
||||
# IMPORTANT: startup log truncation must run once per process.
|
||||
# Multiple module-level loggers call setup_logger(); repeated truncation would
|
||||
# erase fresh logs emitted after the first logger initialization.
|
||||
@@ -151,6 +195,16 @@ def _maybe_truncate_log_on_start(logger: logging.Logger) -> None:
|
||||
logger.warning(f"Failed to truncate startup log file {LOG_FILE}: {e}")
|
||||
|
||||
|
||||
def _ensure_log_targets() -> tuple[str, str]:
|
||||
global DATA_DIR, LOG_FILE
|
||||
if DATA_DIR != _IMPORT_DATA_DIR or LOG_FILE != _IMPORT_LOG_FILE:
|
||||
return DATA_DIR, LOG_FILE
|
||||
if get_state_dir and get_log_path:
|
||||
DATA_DIR = get_state_dir()
|
||||
LOG_FILE = get_log_path()
|
||||
return DATA_DIR, LOG_FILE
|
||||
|
||||
|
||||
class RedactedFormatter(logging.Formatter):
|
||||
"""
|
||||
Custom formatter to redact sensitive information (like API keys) from logs.
|
||||
@@ -194,6 +248,7 @@ def setup_logger(name: str = "ComfyUI-OpenClaw") -> logging.Logger:
|
||||
|
||||
# Only add handler if not already added to avoid duplicates on reload
|
||||
if not logger.handlers:
|
||||
data_dir, log_file = _ensure_log_targets()
|
||||
_maybe_truncate_log_on_start(logger)
|
||||
api_key = get_api_key()
|
||||
sensitive = [api_key] if api_key else []
|
||||
@@ -208,9 +263,9 @@ def setup_logger(name: str = "ComfyUI-OpenClaw") -> logging.Logger:
|
||||
|
||||
# File handler with rotation (5MB, 3 backups)
|
||||
try:
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
file_handler = RotatingFileHandler(
|
||||
LOG_FILE,
|
||||
log_file,
|
||||
maxBytes=5 * 1024 * 1024, # 5MB
|
||||
backupCount=3,
|
||||
encoding="utf-8",
|
||||
@@ -226,5 +281,5 @@ def setup_logger(name: str = "ComfyUI-OpenClaw") -> logging.Logger:
|
||||
return logger
|
||||
|
||||
|
||||
# Global config accessor if needed
|
||||
logger = setup_logger()
|
||||
# Global logger handle for compatibility; actual handler bootstrap is lazy.
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw")
|
||||
|
||||
+74
-1
@@ -10,6 +10,9 @@ import sys
|
||||
from .config import load_config
|
||||
from .openclaw_client import OpenClawClient
|
||||
from .platforms.discord_gateway import DiscordGateway
|
||||
from .platforms.feishu_installation_manager import FeishuInstallationManager
|
||||
from .platforms.feishu_long_connection import FeishuLongConnectionClient
|
||||
from .platforms.feishu_webhook import FeishuWebhookServer
|
||||
from .platforms.kakao_webhook import KakaoWebhookServer
|
||||
from .platforms.line_webhook import LINEWebhookServer
|
||||
from .platforms.slack_webhook import SlackWebhookServer
|
||||
@@ -44,6 +47,7 @@ def _print_security_banner(config):
|
||||
or config.wechat_allowed_users
|
||||
or config.kakao_allowed_users
|
||||
or config.slack_allowed_users
|
||||
or config.feishu_allowed_users
|
||||
)
|
||||
has_admins = bool(config.admin_users)
|
||||
|
||||
@@ -107,6 +111,8 @@ async def main():
|
||||
wechat_server = None
|
||||
kakao_server = None
|
||||
slack_server = None
|
||||
feishu_server = None
|
||||
feishu_long_clients = []
|
||||
|
||||
# 3. Platforms
|
||||
if config.telegram_bot_token:
|
||||
@@ -206,6 +212,67 @@ async def main():
|
||||
else:
|
||||
logger.info("Slack not configured (OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN missing)")
|
||||
|
||||
if config.feishu_bindings_json or (
|
||||
config.feishu_app_id and config.feishu_app_secret
|
||||
):
|
||||
try:
|
||||
feishu_installation_manager = FeishuInstallationManager(config)
|
||||
except Exception as exc:
|
||||
logger.error("Feishu adapter startup aborted (fail-closed): %s", exc)
|
||||
feishu_installation_manager = None
|
||||
if config.feishu_mode == "webhook":
|
||||
if feishu_installation_manager is not None:
|
||||
feishu_server = FeishuWebhookServer(
|
||||
config,
|
||||
router,
|
||||
installation_manager=feishu_installation_manager,
|
||||
)
|
||||
elif config.feishu_mode == "websocket":
|
||||
# CRITICAL: Feishu long-connection remains explicit opt-in; do not
|
||||
# auto-fallback across transports or ingress verification can drift.
|
||||
if feishu_installation_manager is not None:
|
||||
feishu_server = FeishuWebhookServer(
|
||||
config,
|
||||
router,
|
||||
installation_manager=feishu_installation_manager,
|
||||
)
|
||||
binding_configs = feishu_installation_manager.binding_configs()
|
||||
if not binding_configs:
|
||||
binding_configs = [config]
|
||||
for binding_config in binding_configs:
|
||||
feishu_long_clients.append(
|
||||
FeishuLongConnectionClient(
|
||||
binding_config,
|
||||
router,
|
||||
installation_manager=feishu_installation_manager,
|
||||
bound_account_id=str(
|
||||
binding_config.feishu_account_id or ""
|
||||
).strip(),
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Invalid OPENCLAW_CONNECTOR_FEISHU_MODE=%r. Expected 'websocket' or 'webhook'.",
|
||||
config.feishu_mode,
|
||||
)
|
||||
feishu_server = None
|
||||
logger.error("Feishu adapter startup aborted (fail-closed).")
|
||||
|
||||
if feishu_server is None and not feishu_long_clients:
|
||||
logger.warning("Feishu adapter disabled due to invalid mode config.")
|
||||
else:
|
||||
platforms["feishu"] = feishu_server or feishu_long_clients[0]
|
||||
if feishu_server is not None:
|
||||
await feishu_server.start()
|
||||
for feishu_long_client in feishu_long_clients:
|
||||
await feishu_long_client.start()
|
||||
if not tasks:
|
||||
tasks.append(asyncio.create_task(asyncio.sleep(3600 * 24 * 365)))
|
||||
elif config.feishu_app_id:
|
||||
logger.warning("Feishu configured but App Secret missing. Skipping.")
|
||||
else:
|
||||
logger.info("Feishu not configured (OPENCLAW_CONNECTOR_FEISHU_APP_ID missing)")
|
||||
|
||||
if (
|
||||
not tasks
|
||||
and not line_server
|
||||
@@ -213,11 +280,13 @@ async def main():
|
||||
and not wechat_server
|
||||
and not kakao_server
|
||||
and not slack_server
|
||||
and not feishu_server
|
||||
and not feishu_long_clients
|
||||
):
|
||||
logger.error(
|
||||
"No platforms configured! Set TELEGRAM_TOKEN, DISCORD_TOKEN, "
|
||||
"LINE_SECRET, WHATSAPP_ACCESS_TOKEN, WECHAT_TOKEN, "
|
||||
"KAKAO_ENABLED or SLACK_BOT_TOKEN."
|
||||
"KAKAO_ENABLED, SLACK_BOT_TOKEN or FEISHU_APP_ID."
|
||||
)
|
||||
await client.close()
|
||||
return
|
||||
@@ -246,6 +315,10 @@ async def main():
|
||||
await kakao_server.stop()
|
||||
if slack_server:
|
||||
await slack_server.stop()
|
||||
for feishu_long_client in feishu_long_clients:
|
||||
await feishu_long_client.stop()
|
||||
if feishu_server:
|
||||
await feishu_server.stop()
|
||||
if poller:
|
||||
await poller.stop()
|
||||
await client.close()
|
||||
|
||||
+324
-54
@@ -3,12 +3,155 @@ Connector Configuration (F29).
|
||||
Loads environment variables and validates allowlists.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.connector.config")
|
||||
|
||||
DEFAULT_DELIVERY_MAX_IMAGES = 4
|
||||
MIN_DELIVERY_MAX_IMAGES = 1
|
||||
MAX_DELIVERY_MAX_IMAGES = 16
|
||||
|
||||
DEFAULT_DELIVERY_MAX_BYTES = 10 * 1024 * 1024
|
||||
MIN_DELIVERY_MAX_BYTES = 64 * 1024
|
||||
MAX_DELIVERY_MAX_BYTES = 50 * 1024 * 1024
|
||||
|
||||
DEFAULT_DELIVERY_TIMEOUT_SEC = 600
|
||||
MIN_DELIVERY_TIMEOUT_SEC = 30
|
||||
MAX_DELIVERY_TIMEOUT_SEC = 3600
|
||||
|
||||
DEFAULT_LINE_BIND_PORT = 8099
|
||||
DEFAULT_WHATSAPP_BIND_PORT = 8098
|
||||
DEFAULT_WECHAT_BIND_PORT = 8097
|
||||
DEFAULT_KAKAO_BIND_PORT = 8096
|
||||
DEFAULT_SLACK_BIND_PORT = 8095
|
||||
DEFAULT_FEISHU_BIND_PORT = 8094
|
||||
MIN_BIND_PORT = 1
|
||||
MAX_BIND_PORT = 65535
|
||||
|
||||
DEFAULT_SLACK_OAUTH_STATE_TTL_SEC = 600
|
||||
MIN_SLACK_OAUTH_STATE_TTL_SEC = 60
|
||||
MAX_SLACK_OAUTH_STATE_TTL_SEC = 3600
|
||||
|
||||
DEFAULT_RATE_LIMIT_USER_RPM = 10
|
||||
DEFAULT_RATE_LIMIT_CHANNEL_RPM = 30
|
||||
MIN_RATE_LIMIT_RPM = 1
|
||||
MAX_RATE_LIMIT_RPM = 600
|
||||
|
||||
DEFAULT_MAX_COMMAND_LENGTH = 4096
|
||||
MIN_MAX_COMMAND_LENGTH = 128
|
||||
MAX_MAX_COMMAND_LENGTH = 32768
|
||||
|
||||
DEFAULT_MEDIA_TTL_SEC = 300
|
||||
MIN_MEDIA_TTL_SEC = 60
|
||||
MAX_MEDIA_TTL_SEC = 86400
|
||||
|
||||
DEFAULT_MEDIA_MAX_MB = 8
|
||||
MIN_MEDIA_MAX_MB = 1
|
||||
MAX_MEDIA_MAX_MB = 64
|
||||
|
||||
|
||||
def _warn_default_env(
|
||||
env_key: str, raw_value: str, *, default: int, reason: str
|
||||
) -> None:
|
||||
logger.warning(
|
||||
"Connector env %s=%r %s; using default %s.",
|
||||
env_key,
|
||||
raw_value,
|
||||
reason,
|
||||
default,
|
||||
)
|
||||
|
||||
|
||||
def _warn_clamped_env(
|
||||
env_key: str,
|
||||
raw_value: str,
|
||||
*,
|
||||
bound_name: str,
|
||||
bound_value: int,
|
||||
resolved: int,
|
||||
) -> None:
|
||||
logger.warning(
|
||||
(
|
||||
"Connector env %s=%r is below %s %s; clamped to %s."
|
||||
if bound_name == "minimum"
|
||||
else "Connector env %s=%r is above %s %s; clamped to %s."
|
||||
),
|
||||
env_key,
|
||||
raw_value,
|
||||
bound_name,
|
||||
bound_value,
|
||||
resolved,
|
||||
)
|
||||
|
||||
|
||||
def _load_bounded_int_env(
|
||||
env_key: str,
|
||||
*,
|
||||
default: int,
|
||||
minimum: Optional[int] = None,
|
||||
maximum: Optional[int] = None,
|
||||
clamp: bool = True,
|
||||
) -> int:
|
||||
raw_value = os.environ.get(env_key)
|
||||
if raw_value is None:
|
||||
return default
|
||||
raw_value = raw_value.strip()
|
||||
if not raw_value:
|
||||
return default
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
_warn_default_env(
|
||||
env_key,
|
||||
raw_value,
|
||||
default=default,
|
||||
reason="is not a valid integer",
|
||||
)
|
||||
return default
|
||||
|
||||
if minimum is not None and value < minimum:
|
||||
if clamp:
|
||||
_warn_clamped_env(
|
||||
env_key,
|
||||
raw_value,
|
||||
bound_name="minimum",
|
||||
bound_value=minimum,
|
||||
resolved=minimum,
|
||||
)
|
||||
return minimum
|
||||
_warn_default_env(
|
||||
env_key,
|
||||
raw_value,
|
||||
default=default,
|
||||
reason=f"is outside supported range {minimum}..{maximum or 'inf'}",
|
||||
)
|
||||
return default
|
||||
|
||||
if maximum is not None and value > maximum:
|
||||
if clamp:
|
||||
_warn_clamped_env(
|
||||
env_key,
|
||||
raw_value,
|
||||
bound_name="maximum",
|
||||
bound_value=maximum,
|
||||
resolved=maximum,
|
||||
)
|
||||
return maximum
|
||||
_warn_default_env(
|
||||
env_key,
|
||||
raw_value,
|
||||
default=default,
|
||||
reason=f"is outside supported range {minimum or '-inf'}..{maximum}",
|
||||
)
|
||||
return default
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class CommandClass(str, Enum):
|
||||
PUBLIC = "public" # status, help, tools
|
||||
@@ -40,9 +183,9 @@ class ConnectorConfig:
|
||||
|
||||
# Results Delivery
|
||||
delivery_enabled: bool = True
|
||||
delivery_max_images: int = 4
|
||||
delivery_max_bytes: int = 10 * 1024 * 1024 # 10MB
|
||||
delivery_timeout_sec: int = 600
|
||||
delivery_max_images: int = DEFAULT_DELIVERY_MAX_IMAGES
|
||||
delivery_max_bytes: int = DEFAULT_DELIVERY_MAX_BYTES
|
||||
delivery_timeout_sec: int = DEFAULT_DELIVERY_TIMEOUT_SEC
|
||||
|
||||
# Telegram
|
||||
telegram_bot_token: Optional[str] = None
|
||||
@@ -60,7 +203,7 @@ class ConnectorConfig:
|
||||
line_allowed_users: List[str] = field(default_factory=list)
|
||||
line_allowed_groups: List[str] = field(default_factory=list)
|
||||
line_bind_host: str = "127.0.0.1"
|
||||
line_bind_port: int = 8099
|
||||
line_bind_port: int = DEFAULT_LINE_BIND_PORT
|
||||
line_webhook_path: str = "/line/webhook"
|
||||
|
||||
# WhatsApp
|
||||
@@ -70,7 +213,7 @@ class ConnectorConfig:
|
||||
whatsapp_phone_number_id: Optional[str] = None
|
||||
whatsapp_allowed_users: List[str] = field(default_factory=list)
|
||||
whatsapp_bind_host: str = "127.0.0.1"
|
||||
whatsapp_bind_port: int = 8098
|
||||
whatsapp_bind_port: int = DEFAULT_WHATSAPP_BIND_PORT
|
||||
whatsapp_webhook_path: str = "/whatsapp/webhook"
|
||||
|
||||
# WeChat Official Account (R74/S31/F43)
|
||||
@@ -80,13 +223,13 @@ class ConnectorConfig:
|
||||
wechat_encoding_aes_key: Optional[str] = None # R82: AES encrypted mode
|
||||
wechat_allowed_users: List[str] = field(default_factory=list)
|
||||
wechat_bind_host: str = "127.0.0.1"
|
||||
wechat_bind_port: int = 8097
|
||||
wechat_bind_port: int = DEFAULT_WECHAT_BIND_PORT
|
||||
wechat_webhook_path: str = "/wechat/webhook"
|
||||
|
||||
# KakaoTalk (F44 Phase A)
|
||||
kakao_enabled: bool = False
|
||||
kakao_bind_host: str = "127.0.0.1"
|
||||
kakao_bind_port: int = 8096
|
||||
kakao_bind_port: int = DEFAULT_KAKAO_BIND_PORT
|
||||
kakao_webhook_path: str = "/kakao/webhook"
|
||||
kakao_allowed_users: List[str] = field(default_factory=list)
|
||||
|
||||
@@ -96,8 +239,9 @@ class ConnectorConfig:
|
||||
slack_allowed_users: List[str] = field(default_factory=list)
|
||||
slack_allowed_channels: List[str] = field(default_factory=list)
|
||||
slack_bind_host: str = "127.0.0.1"
|
||||
slack_bind_port: int = 8095
|
||||
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
|
||||
@@ -118,7 +262,28 @@ class ConnectorConfig:
|
||||
"mpim:history",
|
||||
]
|
||||
)
|
||||
slack_oauth_state_ttl_sec: int = 600
|
||||
slack_oauth_state_ttl_sec: int = DEFAULT_SLACK_OAUTH_STATE_TTL_SEC
|
||||
|
||||
# Feishu / Lark (F67)
|
||||
feishu_app_id: Optional[str] = None
|
||||
feishu_app_secret: Optional[str] = None
|
||||
feishu_verification_token: Optional[str] = None
|
||||
feishu_encrypt_key: Optional[str] = None
|
||||
feishu_account_id: Optional[str] = None
|
||||
feishu_default_account_id: Optional[str] = None
|
||||
feishu_workspace_id: Optional[str] = None
|
||||
feishu_workspace_name: Optional[str] = None
|
||||
feishu_bindings_json: Optional[str] = None
|
||||
feishu_allowed_users: List[str] = field(default_factory=list)
|
||||
feishu_allowed_chats: List[str] = field(default_factory=list)
|
||||
feishu_bind_host: str = "127.0.0.1"
|
||||
feishu_bind_port: int = DEFAULT_FEISHU_BIND_PORT
|
||||
feishu_webhook_path: str = "/feishu/events"
|
||||
feishu_callback_path: str = "/feishu/callback"
|
||||
feishu_domain: str = "feishu" # feishu | lark
|
||||
feishu_mode: str = "websocket" # websocket | webhook
|
||||
feishu_require_mention: bool = True
|
||||
feishu_reply_in_thread: bool = True
|
||||
|
||||
# Privileged Access (ID match across platforms; Telegram Int vs Discord Str handled by router)
|
||||
admin_users: List[str] = field(default_factory=list)
|
||||
@@ -126,13 +291,19 @@ class ConnectorConfig:
|
||||
# Media Host (F33)
|
||||
public_base_url: Optional[str] = None
|
||||
media_path: str = "/media"
|
||||
media_ttl_sec: int = 300
|
||||
media_max_mb: int = 8
|
||||
media_ttl_sec: int = DEFAULT_MEDIA_TTL_SEC
|
||||
media_max_mb: int = DEFAULT_MEDIA_MAX_MB
|
||||
|
||||
# Security (F32)
|
||||
rate_limit_user_rpm: int = 10 # Requests per minute per user
|
||||
rate_limit_channel_rpm: int = 30 # Requests per minute per channel
|
||||
max_command_length: int = 4096 # Max characters in a single command
|
||||
rate_limit_user_rpm: int = (
|
||||
DEFAULT_RATE_LIMIT_USER_RPM # Requests per minute per user
|
||||
)
|
||||
rate_limit_channel_rpm: int = (
|
||||
DEFAULT_RATE_LIMIT_CHANNEL_RPM # Requests per minute per channel
|
||||
)
|
||||
max_command_length: int = (
|
||||
DEFAULT_MAX_COMMAND_LENGTH # Max characters in a single command
|
||||
)
|
||||
llm_max_tokens_per_request: int = 1024 # LLM token budget
|
||||
|
||||
# R80: Command Auth Policy
|
||||
@@ -165,14 +336,23 @@ def load_config() -> ConnectorConfig:
|
||||
cfg.state_path = os.environ.get("OPENCLAW_CONNECTOR_STATE_PATH")
|
||||
|
||||
# Delivery
|
||||
cfg.delivery_max_images = int(
|
||||
os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES", "4")
|
||||
cfg.delivery_max_images = _load_bounded_int_env(
|
||||
"OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES",
|
||||
default=DEFAULT_DELIVERY_MAX_IMAGES,
|
||||
minimum=MIN_DELIVERY_MAX_IMAGES,
|
||||
maximum=MAX_DELIVERY_MAX_IMAGES,
|
||||
)
|
||||
cfg.delivery_max_bytes = int(
|
||||
os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES", str(10 * 1024 * 1024))
|
||||
cfg.delivery_max_bytes = _load_bounded_int_env(
|
||||
"OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES",
|
||||
default=DEFAULT_DELIVERY_MAX_BYTES,
|
||||
minimum=MIN_DELIVERY_MAX_BYTES,
|
||||
maximum=MAX_DELIVERY_MAX_BYTES,
|
||||
)
|
||||
cfg.delivery_timeout_sec = int(
|
||||
os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC", "600")
|
||||
cfg.delivery_timeout_sec = _load_bounded_int_env(
|
||||
"OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC",
|
||||
default=DEFAULT_DELIVERY_TIMEOUT_SEC,
|
||||
minimum=MIN_DELIVERY_TIMEOUT_SEC,
|
||||
maximum=MAX_DELIVERY_TIMEOUT_SEC,
|
||||
)
|
||||
|
||||
# Telegram
|
||||
@@ -208,9 +388,13 @@ def load_config() -> ConnectorConfig:
|
||||
cfg.line_allowed_groups = [u.strip() for u in l_groups.split(",") if u.strip()]
|
||||
|
||||
cfg.line_bind_host = os.environ.get("OPENCLAW_CONNECTOR_LINE_BIND", "127.0.0.1")
|
||||
if l_port := os.environ.get("OPENCLAW_CONNECTOR_LINE_PORT"):
|
||||
if l_port.isdigit():
|
||||
cfg.line_bind_port = int(l_port)
|
||||
cfg.line_bind_port = _load_bounded_int_env(
|
||||
"OPENCLAW_CONNECTOR_LINE_PORT",
|
||||
default=DEFAULT_LINE_BIND_PORT,
|
||||
minimum=MIN_BIND_PORT,
|
||||
maximum=MAX_BIND_PORT,
|
||||
clamp=False,
|
||||
)
|
||||
cfg.line_webhook_path = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_LINE_PATH", "/line/webhook"
|
||||
)
|
||||
@@ -233,9 +417,13 @@ def load_config() -> ConnectorConfig:
|
||||
cfg.whatsapp_bind_host = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_WHATSAPP_BIND", "127.0.0.1"
|
||||
)
|
||||
if wa_port := os.environ.get("OPENCLAW_CONNECTOR_WHATSAPP_PORT"):
|
||||
if wa_port.isdigit():
|
||||
cfg.whatsapp_bind_port = int(wa_port)
|
||||
cfg.whatsapp_bind_port = _load_bounded_int_env(
|
||||
"OPENCLAW_CONNECTOR_WHATSAPP_PORT",
|
||||
default=DEFAULT_WHATSAPP_BIND_PORT,
|
||||
minimum=MIN_BIND_PORT,
|
||||
maximum=MAX_BIND_PORT,
|
||||
clamp=False,
|
||||
)
|
||||
cfg.whatsapp_webhook_path = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_WHATSAPP_PATH", "/whatsapp/webhook"
|
||||
)
|
||||
@@ -250,9 +438,13 @@ def load_config() -> ConnectorConfig:
|
||||
if wc_users := os.environ.get("OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS"):
|
||||
cfg.wechat_allowed_users = [u.strip() for u in wc_users.split(",") if u.strip()]
|
||||
cfg.wechat_bind_host = os.environ.get("OPENCLAW_CONNECTOR_WECHAT_BIND", "127.0.0.1")
|
||||
if wc_port := os.environ.get("OPENCLAW_CONNECTOR_WECHAT_PORT"):
|
||||
if wc_port.isdigit():
|
||||
cfg.wechat_bind_port = int(wc_port)
|
||||
cfg.wechat_bind_port = _load_bounded_int_env(
|
||||
"OPENCLAW_CONNECTOR_WECHAT_PORT",
|
||||
default=DEFAULT_WECHAT_BIND_PORT,
|
||||
minimum=MIN_BIND_PORT,
|
||||
maximum=MAX_BIND_PORT,
|
||||
clamp=False,
|
||||
)
|
||||
cfg.wechat_webhook_path = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_WECHAT_PATH", "/wechat/webhook"
|
||||
)
|
||||
@@ -262,9 +454,13 @@ def load_config() -> ConnectorConfig:
|
||||
cfg.kakao_enabled = True
|
||||
|
||||
cfg.kakao_bind_host = os.environ.get("OPENCLAW_CONNECTOR_KAKAO_BIND", "127.0.0.1")
|
||||
if kp := os.environ.get("OPENCLAW_CONNECTOR_KAKAO_PORT"):
|
||||
if kp.isdigit():
|
||||
cfg.kakao_bind_port = int(kp)
|
||||
cfg.kakao_bind_port = _load_bounded_int_env(
|
||||
"OPENCLAW_CONNECTOR_KAKAO_PORT",
|
||||
default=DEFAULT_KAKAO_BIND_PORT,
|
||||
minimum=MIN_BIND_PORT,
|
||||
maximum=MAX_BIND_PORT,
|
||||
clamp=False,
|
||||
)
|
||||
cfg.kakao_webhook_path = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_KAKAO_PATH", "/kakao/webhook"
|
||||
)
|
||||
@@ -279,12 +475,19 @@ def load_config() -> ConnectorConfig:
|
||||
if sc := os.environ.get("OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS"):
|
||||
cfg.slack_allowed_channels = [u.strip() for u in sc.split(",") if u.strip()]
|
||||
cfg.slack_bind_host = os.environ.get("OPENCLAW_CONNECTOR_SLACK_BIND", "127.0.0.1")
|
||||
if sp := os.environ.get("OPENCLAW_CONNECTOR_SLACK_PORT"):
|
||||
if sp.isdigit():
|
||||
cfg.slack_bind_port = int(sp)
|
||||
cfg.slack_bind_port = _load_bounded_int_env(
|
||||
"OPENCLAW_CONNECTOR_SLACK_PORT",
|
||||
default=DEFAULT_SLACK_BIND_PORT,
|
||||
minimum=MIN_BIND_PORT,
|
||||
maximum=MAX_BIND_PORT,
|
||||
clamp=False,
|
||||
)
|
||||
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"
|
||||
@@ -314,36 +517,103 @@ def load_config() -> ConnectorConfig:
|
||||
]
|
||||
if parsed_scopes:
|
||||
cfg.slack_oauth_scopes = parsed_scopes
|
||||
if slack_oauth_ttl := os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC"
|
||||
cfg.slack_oauth_state_ttl_sec = _load_bounded_int_env(
|
||||
"OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC",
|
||||
default=DEFAULT_SLACK_OAUTH_STATE_TTL_SEC,
|
||||
minimum=MIN_SLACK_OAUTH_STATE_TTL_SEC,
|
||||
maximum=MAX_SLACK_OAUTH_STATE_TTL_SEC,
|
||||
)
|
||||
|
||||
# Feishu / Lark (F67)
|
||||
cfg.feishu_app_id = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_APP_ID")
|
||||
cfg.feishu_app_secret = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_APP_SECRET")
|
||||
cfg.feishu_verification_token = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_FEISHU_VERIFICATION_TOKEN"
|
||||
)
|
||||
cfg.feishu_encrypt_key = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_ENCRYPT_KEY")
|
||||
cfg.feishu_account_id = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_ACCOUNT_ID")
|
||||
cfg.feishu_default_account_id = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_FEISHU_DEFAULT_ACCOUNT_ID"
|
||||
)
|
||||
cfg.feishu_workspace_id = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_ID")
|
||||
cfg.feishu_workspace_name = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_NAME"
|
||||
)
|
||||
cfg.feishu_bindings_json = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON")
|
||||
if fu := os.environ.get("OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS"):
|
||||
cfg.feishu_allowed_users = [u.strip() for u in fu.split(",") if u.strip()]
|
||||
if fc := os.environ.get("OPENCLAW_CONNECTOR_FEISHU_ALLOWED_CHATS"):
|
||||
cfg.feishu_allowed_chats = [u.strip() for u in fc.split(",") if u.strip()]
|
||||
cfg.feishu_bind_host = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_BIND", "127.0.0.1")
|
||||
cfg.feishu_bind_port = _load_bounded_int_env(
|
||||
"OPENCLAW_CONNECTOR_FEISHU_PORT",
|
||||
default=DEFAULT_FEISHU_BIND_PORT,
|
||||
minimum=MIN_BIND_PORT,
|
||||
maximum=MAX_BIND_PORT,
|
||||
clamp=False,
|
||||
)
|
||||
cfg.feishu_webhook_path = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_FEISHU_PATH", "/feishu/events"
|
||||
)
|
||||
cfg.feishu_callback_path = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH", "/feishu/callback"
|
||||
)
|
||||
cfg.feishu_domain = (
|
||||
os.environ.get("OPENCLAW_CONNECTOR_FEISHU_DOMAIN", "feishu").strip() or "feishu"
|
||||
)
|
||||
cfg.feishu_mode = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_FEISHU_MODE", "websocket"
|
||||
).lower()
|
||||
if (
|
||||
os.environ.get("OPENCLAW_CONNECTOR_FEISHU_REQUIRE_MENTION", "").lower()
|
||||
== "false"
|
||||
):
|
||||
if slack_oauth_ttl.isdigit():
|
||||
cfg.slack_oauth_state_ttl_sec = max(60, int(slack_oauth_ttl))
|
||||
cfg.feishu_require_mention = False
|
||||
if (
|
||||
os.environ.get("OPENCLAW_CONNECTOR_FEISHU_REPLY_IN_THREAD", "").lower()
|
||||
== "false"
|
||||
):
|
||||
cfg.feishu_reply_in_thread = False
|
||||
|
||||
# Admin
|
||||
if admins := os.environ.get("OPENCLAW_CONNECTOR_ADMIN_USERS"):
|
||||
cfg.admin_users = [u.strip() for u in admins.split(",") if u.strip()]
|
||||
|
||||
# Security (F32)
|
||||
if rpm := os.environ.get("OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM"):
|
||||
if rpm.isdigit():
|
||||
cfg.rate_limit_user_rpm = int(rpm)
|
||||
if rpm := os.environ.get("OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM"):
|
||||
if rpm.isdigit():
|
||||
cfg.rate_limit_channel_rpm = int(rpm)
|
||||
if max_len := os.environ.get("OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH"):
|
||||
if max_len.isdigit():
|
||||
cfg.max_command_length = int(max_len)
|
||||
cfg.rate_limit_user_rpm = _load_bounded_int_env(
|
||||
"OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM",
|
||||
default=DEFAULT_RATE_LIMIT_USER_RPM,
|
||||
minimum=MIN_RATE_LIMIT_RPM,
|
||||
maximum=MAX_RATE_LIMIT_RPM,
|
||||
)
|
||||
cfg.rate_limit_channel_rpm = _load_bounded_int_env(
|
||||
"OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM",
|
||||
default=DEFAULT_RATE_LIMIT_CHANNEL_RPM,
|
||||
minimum=MIN_RATE_LIMIT_RPM,
|
||||
maximum=MAX_RATE_LIMIT_RPM,
|
||||
)
|
||||
cfg.max_command_length = _load_bounded_int_env(
|
||||
"OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH",
|
||||
default=DEFAULT_MAX_COMMAND_LENGTH,
|
||||
minimum=MIN_MAX_COMMAND_LENGTH,
|
||||
maximum=MAX_MAX_COMMAND_LENGTH,
|
||||
)
|
||||
|
||||
# Media Host (F33)
|
||||
cfg.public_base_url = os.environ.get("OPENCLAW_CONNECTOR_PUBLIC_BASE_URL")
|
||||
cfg.media_path = os.environ.get("OPENCLAW_CONNECTOR_MEDIA_PATH", "/media")
|
||||
if ttl := os.environ.get("OPENCLAW_CONNECTOR_MEDIA_TTL_SEC"):
|
||||
if ttl.isdigit():
|
||||
cfg.media_ttl_sec = int(ttl)
|
||||
if mb := os.environ.get("OPENCLAW_CONNECTOR_MEDIA_MAX_MB"):
|
||||
if mb.isdigit():
|
||||
cfg.media_max_mb = int(mb)
|
||||
cfg.media_ttl_sec = _load_bounded_int_env(
|
||||
"OPENCLAW_CONNECTOR_MEDIA_TTL_SEC",
|
||||
default=DEFAULT_MEDIA_TTL_SEC,
|
||||
minimum=MIN_MEDIA_TTL_SEC,
|
||||
maximum=MAX_MEDIA_TTL_SEC,
|
||||
)
|
||||
cfg.media_max_mb = _load_bounded_int_env(
|
||||
"OPENCLAW_CONNECTOR_MEDIA_MAX_MB",
|
||||
default=DEFAULT_MEDIA_MAX_MB,
|
||||
minimum=MIN_MEDIA_MAX_MB,
|
||||
maximum=MAX_MEDIA_MAX_MB,
|
||||
)
|
||||
|
||||
# R80: Command Auth Policy
|
||||
import json
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,474 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from connector.config import ConnectorConfig
|
||||
|
||||
try:
|
||||
from services.connector_installation_registry import (
|
||||
ConnectorInstallation,
|
||||
ConnectorInstallationRegistry,
|
||||
InstallationResolution,
|
||||
get_connector_installation_registry,
|
||||
)
|
||||
from services.secret_store import SecretStore, get_secret_store
|
||||
from services.state_dir import get_state_dir
|
||||
from services.tenant_context import DEFAULT_TENANT_ID, get_current_tenant_id
|
||||
except ImportError: # pragma: no cover
|
||||
from services.connector_installation_registry import ( # type: ignore
|
||||
ConnectorInstallation,
|
||||
ConnectorInstallationRegistry,
|
||||
InstallationResolution,
|
||||
get_connector_installation_registry,
|
||||
)
|
||||
from services.secret_store import SecretStore, get_secret_store # type: ignore
|
||||
from services.state_dir import get_state_dir # type: ignore
|
||||
from services.tenant_context import ( # type: ignore
|
||||
DEFAULT_TENANT_ID,
|
||||
get_current_tenant_id,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeishuBinding:
|
||||
account_id: str
|
||||
app_id: str
|
||||
app_secret: str
|
||||
workspace_id: str = ""
|
||||
workspace_name: str = ""
|
||||
tenant_id: str = DEFAULT_TENANT_ID
|
||||
verification_token: str = ""
|
||||
encrypt_key: str = ""
|
||||
domain: str = "feishu"
|
||||
mode: str = "websocket"
|
||||
|
||||
@property
|
||||
def installation_id(self) -> str:
|
||||
return f"feishu:{self.account_id}"
|
||||
|
||||
def public_metadata(self) -> Dict[str, Any]:
|
||||
metadata = {
|
||||
"account_id": self.account_id,
|
||||
"app_id": self.app_id,
|
||||
"domain": self.domain,
|
||||
"transport_mode": self.mode,
|
||||
}
|
||||
if self.workspace_name:
|
||||
metadata["workspace_name"] = self.workspace_name
|
||||
return metadata
|
||||
|
||||
|
||||
class FeishuInstallationManager:
|
||||
def __init__(
|
||||
self,
|
||||
config: ConnectorConfig,
|
||||
*,
|
||||
registry: Optional[ConnectorInstallationRegistry] = None,
|
||||
secret_store: Optional[SecretStore] = None,
|
||||
state_dir: Optional[str] = None,
|
||||
):
|
||||
self.config = config
|
||||
self._state_dir = state_dir or get_state_dir()
|
||||
self._registry = registry or get_connector_installation_registry(
|
||||
state_dir=self._state_dir
|
||||
)
|
||||
self._secret_store = secret_store or get_secret_store(self._state_dir)
|
||||
self._bindings: Dict[str, FeishuBinding] = {}
|
||||
self._load_bindings()
|
||||
|
||||
def _normalize_nonempty(self, value: Any, field_name: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
raise ValueError(f"{field_name}_missing")
|
||||
return text
|
||||
|
||||
def _normalize_optional(self, value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
def _binding_from_payload(self, raw: Dict[str, Any]) -> FeishuBinding:
|
||||
app_id = self._normalize_nonempty(raw.get("app_id"), "app_id")
|
||||
app_secret = self._normalize_nonempty(raw.get("app_secret"), "app_secret")
|
||||
account_id = self._normalize_optional(raw.get("account_id")) or app_id
|
||||
workspace_id = self._normalize_optional(raw.get("workspace_id"))
|
||||
return FeishuBinding(
|
||||
account_id=account_id,
|
||||
app_id=app_id,
|
||||
app_secret=app_secret,
|
||||
workspace_id=workspace_id,
|
||||
workspace_name=self._normalize_optional(raw.get("workspace_name")),
|
||||
tenant_id=self._normalize_optional(raw.get("tenant_id"))
|
||||
or DEFAULT_TENANT_ID,
|
||||
verification_token=self._normalize_optional(raw.get("verification_token")),
|
||||
encrypt_key=self._normalize_optional(raw.get("encrypt_key")),
|
||||
domain=self._normalize_optional(raw.get("domain")) or "feishu",
|
||||
mode=self._normalize_optional(raw.get("mode")) or self.config.feishu_mode,
|
||||
)
|
||||
|
||||
def _load_bindings(self) -> None:
|
||||
bindings: List[FeishuBinding] = []
|
||||
if self.config.feishu_bindings_json:
|
||||
raw = json.loads(self.config.feishu_bindings_json)
|
||||
if not isinstance(raw, list) or not raw:
|
||||
raise ValueError("feishu_bindings_json must be a non-empty JSON list")
|
||||
bindings = [self._binding_from_payload(item or {}) for item in raw]
|
||||
elif self.config.feishu_app_id and self.config.feishu_app_secret:
|
||||
account_id = (
|
||||
self._normalize_optional(self.config.feishu_account_id)
|
||||
or self._normalize_optional(self.config.feishu_default_account_id)
|
||||
or self._normalize_optional(self.config.feishu_app_id)
|
||||
)
|
||||
if account_id:
|
||||
bindings = [
|
||||
FeishuBinding(
|
||||
account_id=account_id,
|
||||
app_id=self.config.feishu_app_id,
|
||||
app_secret=self.config.feishu_app_secret,
|
||||
workspace_id=self._normalize_optional(
|
||||
self.config.feishu_workspace_id
|
||||
),
|
||||
workspace_name=self._normalize_optional(
|
||||
self.config.feishu_workspace_name
|
||||
),
|
||||
tenant_id=DEFAULT_TENANT_ID,
|
||||
verification_token=self._normalize_optional(
|
||||
self.config.feishu_verification_token
|
||||
),
|
||||
encrypt_key=self._normalize_optional(
|
||||
self.config.feishu_encrypt_key
|
||||
),
|
||||
domain=self.config.feishu_domain,
|
||||
mode=self.config.feishu_mode,
|
||||
)
|
||||
]
|
||||
for binding in bindings:
|
||||
if binding.account_id in self._bindings:
|
||||
raise ValueError(f"duplicate_feishu_account:{binding.account_id}")
|
||||
self._bindings[binding.account_id] = binding
|
||||
if binding.workspace_id:
|
||||
self._sync_binding(binding, reason="config_load")
|
||||
|
||||
def has_bindings(self) -> bool:
|
||||
return bool(self._bindings)
|
||||
|
||||
def binding_count(self) -> int:
|
||||
return len(self._bindings)
|
||||
|
||||
@property
|
||||
def registry(self):
|
||||
return self._registry
|
||||
|
||||
def bindings(self) -> List[FeishuBinding]:
|
||||
return list(self._bindings.values())
|
||||
|
||||
def binding_configs(self) -> List[ConnectorConfig]:
|
||||
return [
|
||||
self.config_for_binding(binding.account_id) for binding in self.bindings()
|
||||
]
|
||||
|
||||
def config_for_binding(self, account_id: str) -> ConnectorConfig:
|
||||
binding = self.get_binding(account_id)
|
||||
if binding is None:
|
||||
raise ValueError(f"unknown_feishu_account:{account_id}")
|
||||
return replace(
|
||||
self.config,
|
||||
feishu_app_id=binding.app_id,
|
||||
feishu_app_secret=binding.app_secret,
|
||||
feishu_verification_token=binding.verification_token,
|
||||
feishu_encrypt_key=binding.encrypt_key,
|
||||
feishu_account_id=binding.account_id,
|
||||
feishu_workspace_id=binding.workspace_id or self.config.feishu_workspace_id,
|
||||
feishu_workspace_name=binding.workspace_name
|
||||
or self.config.feishu_workspace_name,
|
||||
feishu_domain=binding.domain,
|
||||
feishu_mode=binding.mode or self.config.feishu_mode,
|
||||
)
|
||||
|
||||
def get_binding(self, account_id: str) -> Optional[FeishuBinding]:
|
||||
binding = self._bindings.get(str(account_id or "").strip())
|
||||
if binding is None:
|
||||
return None
|
||||
return replace(binding)
|
||||
|
||||
def _sync_binding(
|
||||
self, binding: FeishuBinding, *, reason: str
|
||||
) -> ConnectorInstallation:
|
||||
token_values = {"app_secret": binding.app_secret}
|
||||
if binding.verification_token:
|
||||
token_values["verification_token"] = binding.verification_token
|
||||
if binding.encrypt_key:
|
||||
token_values["encrypt_key"] = binding.encrypt_key
|
||||
inst = self._registry.upsert_installation(
|
||||
platform="feishu",
|
||||
tenant_id=binding.tenant_id,
|
||||
workspace_id=binding.workspace_id,
|
||||
installation_id=binding.installation_id,
|
||||
token_values=token_values,
|
||||
status="active",
|
||||
metadata=binding.public_metadata(),
|
||||
status_reason=reason,
|
||||
)
|
||||
return self._registry.activate_installation(
|
||||
inst.installation_id, reason=reason or "feishu_binding_ready"
|
||||
)
|
||||
|
||||
def ensure_workspace_binding(
|
||||
self, account_id: str, workspace_id: str
|
||||
) -> ConnectorInstallation:
|
||||
binding = self._bindings.get(str(account_id or "").strip())
|
||||
if binding is None:
|
||||
raise ValueError(f"unknown_feishu_account:{account_id}")
|
||||
normalized_workspace = self._normalize_nonempty(workspace_id, "workspace_id")
|
||||
if binding.workspace_id and binding.workspace_id != normalized_workspace:
|
||||
raise ValueError("feishu_workspace_mismatch")
|
||||
if binding.workspace_id == normalized_workspace:
|
||||
inst = self._registry.get_installation(binding.installation_id)
|
||||
if inst is not None:
|
||||
return inst
|
||||
binding.workspace_id = normalized_workspace
|
||||
self._bindings[binding.account_id] = binding
|
||||
return self._sync_binding(binding, reason="workspace_bound")
|
||||
|
||||
def installation_id_for_account(self, account_id: str) -> str:
|
||||
return f"feishu:{str(account_id or '').strip()}"
|
||||
|
||||
def _secrets_for_installation(
|
||||
self, installation: ConnectorInstallation
|
||||
) -> Dict[str, str]:
|
||||
secrets: Dict[str, str] = {}
|
||||
for name, ref in dict(installation.token_refs or {}).items():
|
||||
value = self._secret_store.get_secret(ref, tenant_id=installation.tenant_id)
|
||||
if value:
|
||||
secrets[name] = value
|
||||
return secrets
|
||||
|
||||
def resolve_binding(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str = "",
|
||||
account_id: str = "",
|
||||
) -> Tuple[InstallationResolution, Optional[FeishuBinding], Dict[str, str]]:
|
||||
normalized_workspace = self._normalize_optional(workspace_id)
|
||||
normalized_account = self._normalize_optional(account_id)
|
||||
|
||||
if normalized_account:
|
||||
binding = self._bindings.get(normalized_account)
|
||||
if binding is None:
|
||||
return (
|
||||
InstallationResolution(
|
||||
ok=False,
|
||||
reject_reason="missing_binding",
|
||||
audit_code="conn_install.resolve_missing",
|
||||
health_code="workspace_unbound",
|
||||
),
|
||||
None,
|
||||
{},
|
||||
)
|
||||
if normalized_workspace:
|
||||
try:
|
||||
self.ensure_workspace_binding(
|
||||
binding.account_id, normalized_workspace
|
||||
)
|
||||
except ValueError:
|
||||
return (
|
||||
InstallationResolution(
|
||||
ok=False,
|
||||
reject_reason="tenant_mismatch",
|
||||
audit_code="conn_install.resolve_tenant_mismatch",
|
||||
health_code="degraded",
|
||||
),
|
||||
replace(binding),
|
||||
{},
|
||||
)
|
||||
inst = self._registry.get_installation(binding.installation_id)
|
||||
if inst is not None and inst.workspace_id:
|
||||
resolution = self._registry.resolve_installation(
|
||||
"feishu",
|
||||
inst.workspace_id,
|
||||
tenant_id=get_current_tenant_id(),
|
||||
)
|
||||
if not resolution.ok:
|
||||
return resolution, replace(binding), {}
|
||||
return (
|
||||
resolution,
|
||||
replace(binding),
|
||||
self._secrets_for_installation(resolution.installation),
|
||||
)
|
||||
return (
|
||||
InstallationResolution(
|
||||
ok=True,
|
||||
audit_code="conn_install.resolve_ok",
|
||||
health_code="ok",
|
||||
),
|
||||
replace(binding),
|
||||
{
|
||||
"app_secret": binding.app_secret,
|
||||
"verification_token": binding.verification_token,
|
||||
"encrypt_key": binding.encrypt_key,
|
||||
},
|
||||
)
|
||||
|
||||
if normalized_workspace:
|
||||
matching_unbound = [
|
||||
binding
|
||||
for binding in self._bindings.values()
|
||||
if not binding.workspace_id and self.binding_count() == 1
|
||||
]
|
||||
if matching_unbound:
|
||||
inst = self.ensure_workspace_binding(
|
||||
matching_unbound[0].account_id, normalized_workspace
|
||||
)
|
||||
return (
|
||||
InstallationResolution(
|
||||
ok=True,
|
||||
installation=inst,
|
||||
audit_code="conn_install.resolve_ok",
|
||||
health_code="ok",
|
||||
),
|
||||
replace(matching_unbound[0]),
|
||||
self._secrets_for_installation(inst),
|
||||
)
|
||||
resolution = self._registry.resolve_installation(
|
||||
"feishu",
|
||||
normalized_workspace,
|
||||
tenant_id=get_current_tenant_id(),
|
||||
)
|
||||
if not resolution.ok or resolution.installation is None:
|
||||
return resolution, None, {}
|
||||
account_id = str(
|
||||
(resolution.installation.metadata or {}).get("account_id", "") or ""
|
||||
).strip()
|
||||
binding = self._bindings.get(account_id)
|
||||
return (
|
||||
resolution,
|
||||
replace(binding) if binding else None,
|
||||
self._secrets_for_installation(resolution.installation),
|
||||
)
|
||||
|
||||
default_account = self._normalize_optional(
|
||||
self.config.feishu_default_account_id
|
||||
)
|
||||
if default_account:
|
||||
return self.resolve_binding(account_id=default_account)
|
||||
if self.binding_count() == 1:
|
||||
only_binding = next(iter(self._bindings.values()))
|
||||
return self.resolve_binding(account_id=only_binding.account_id)
|
||||
return (
|
||||
InstallationResolution(
|
||||
ok=False,
|
||||
reject_reason="ambiguous_binding",
|
||||
audit_code="conn_install.resolve_ambiguous",
|
||||
health_code="degraded",
|
||||
),
|
||||
None,
|
||||
{},
|
||||
)
|
||||
|
||||
def resolve_inbound_binding(
|
||||
self,
|
||||
*,
|
||||
verification_token: str = "",
|
||||
workspace_id: str = "",
|
||||
account_id: str = "",
|
||||
) -> FeishuBinding:
|
||||
normalized_token = self._normalize_optional(verification_token)
|
||||
normalized_workspace = self._normalize_optional(workspace_id)
|
||||
normalized_account = self._normalize_optional(account_id)
|
||||
|
||||
if normalized_account:
|
||||
resolution, binding, _ = self.resolve_binding(
|
||||
workspace_id=normalized_workspace,
|
||||
account_id=normalized_account,
|
||||
)
|
||||
if not resolution.ok or binding is None:
|
||||
raise ValueError(resolution.reject_reason or "missing_binding")
|
||||
return binding
|
||||
|
||||
candidates = list(self._bindings.values())
|
||||
if normalized_token:
|
||||
candidates = [
|
||||
binding
|
||||
for binding in candidates
|
||||
if binding.verification_token == normalized_token
|
||||
]
|
||||
if not candidates:
|
||||
raise ValueError("invalid_verification_token")
|
||||
if normalized_workspace:
|
||||
exact = [
|
||||
binding
|
||||
for binding in candidates
|
||||
if binding.workspace_id == normalized_workspace
|
||||
]
|
||||
if exact:
|
||||
candidates = exact
|
||||
else:
|
||||
unbound = [
|
||||
binding for binding in candidates if not binding.workspace_id
|
||||
]
|
||||
if len(unbound) == 1:
|
||||
self.ensure_workspace_binding(
|
||||
unbound[0].account_id, normalized_workspace
|
||||
)
|
||||
return replace(self._bindings[unbound[0].account_id])
|
||||
if len(candidates) == 1:
|
||||
binding = candidates[0]
|
||||
if normalized_workspace and binding.workspace_id:
|
||||
self.ensure_workspace_binding(binding.account_id, normalized_workspace)
|
||||
return replace(binding)
|
||||
raise ValueError("ambiguous_binding")
|
||||
|
||||
def mark_installation_health(
|
||||
self,
|
||||
installation_id: str,
|
||||
*,
|
||||
health_code: str,
|
||||
reason: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self._registry.update_installation_health(
|
||||
installation_id,
|
||||
health_code=health_code,
|
||||
reason=reason,
|
||||
details=details,
|
||||
)
|
||||
|
||||
def mark_resolution_success(self, installation_id: str, workspace_id: str) -> None:
|
||||
self._registry.update_installation_health(
|
||||
installation_id,
|
||||
health_code="ok",
|
||||
reason="workspace_resolved",
|
||||
details={"workspace_id": workspace_id},
|
||||
)
|
||||
|
||||
def classify_error_health(self, error_code: str, status_code: int = 0) -> str:
|
||||
normalized = str(error_code or "").strip().lower()
|
||||
if (
|
||||
status_code in (401, 403)
|
||||
or "invalid" in normalized
|
||||
or "unauth" in normalized
|
||||
):
|
||||
return "invalid_token"
|
||||
if "revoke" in normalized:
|
||||
return "revoked"
|
||||
return "degraded"
|
||||
|
||||
def mark_api_error(
|
||||
self,
|
||||
installation_id: str,
|
||||
*,
|
||||
error_code: str,
|
||||
status_code: int = 0,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
health_code = self.classify_error_health(error_code, status_code=status_code)
|
||||
self.mark_installation_health(
|
||||
installation_id,
|
||||
health_code=health_code,
|
||||
reason=error_code or f"http_{status_code}",
|
||||
details=details,
|
||||
)
|
||||
return health_code
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Feishu long-connection client (F67).
|
||||
|
||||
This keeps the event-normalization path shared with the webhook adapter so
|
||||
transport choice does not change router or delivery behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from ..config import ConnectorConfig
|
||||
from ..router import CommandRouter
|
||||
from .feishu_installation_manager import FeishuInstallationManager
|
||||
from .feishu_webhook import FeishuWebhookServer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _import_feishu_sdk():
|
||||
# CRITICAL: keep this optional import lazy; CI and unit tests must remain
|
||||
# runnable without the Feishu SDK installed.
|
||||
try:
|
||||
import lark_oapi as sdk # type: ignore
|
||||
except ModuleNotFoundError:
|
||||
try:
|
||||
import larksuiteoapi as sdk # type: ignore
|
||||
except ModuleNotFoundError:
|
||||
return None
|
||||
return sdk
|
||||
|
||||
|
||||
class FeishuLongConnectionClient(FeishuWebhookServer):
|
||||
def __init__(
|
||||
self,
|
||||
config: ConnectorConfig,
|
||||
router: CommandRouter,
|
||||
*,
|
||||
installation_manager: Optional[FeishuInstallationManager] = None,
|
||||
bound_account_id: str = "",
|
||||
):
|
||||
super().__init__(
|
||||
config,
|
||||
router,
|
||||
installation_manager=installation_manager,
|
||||
bound_account_id=bound_account_id,
|
||||
)
|
||||
self._ws_client: Any = None
|
||||
self._run_task: Optional[asyncio.Task] = None
|
||||
|
||||
async def start(self):
|
||||
if not self.config.feishu_app_id or not self.config.feishu_app_secret:
|
||||
logger.info(
|
||||
"Feishu long-connection disabled "
|
||||
"(OPENCLAW_CONNECTOR_FEISHU_APP_ID / APP_SECRET missing)"
|
||||
)
|
||||
return
|
||||
sdk = _import_feishu_sdk()
|
||||
if sdk is None:
|
||||
logger.warning(
|
||||
"Feishu SDK not installed. Skipping long-connection adapter."
|
||||
)
|
||||
return
|
||||
await self.prime_bot_identity()
|
||||
self._ws_client = self._build_ws_client(sdk)
|
||||
starter = getattr(self._ws_client, "start", None)
|
||||
if not callable(starter):
|
||||
logger.error("Feishu SDK client does not expose a start() method.")
|
||||
self._ws_client = None
|
||||
return
|
||||
logger.info(
|
||||
"Starting Feishu long-connection client (%s)", self.config.feishu_domain
|
||||
)
|
||||
maybe = self._start_client(starter)
|
||||
if inspect.isawaitable(maybe):
|
||||
self._run_task = asyncio.create_task(maybe)
|
||||
|
||||
async def stop(self):
|
||||
if self._run_task:
|
||||
self._run_task.cancel()
|
||||
try:
|
||||
await self._run_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
stopper = getattr(self._ws_client, "stop", None)
|
||||
if callable(stopper):
|
||||
maybe = stopper()
|
||||
if inspect.isawaitable(maybe):
|
||||
await maybe
|
||||
|
||||
def _build_ws_client(self, sdk):
|
||||
domain = getattr(getattr(sdk, "Domain", object()), "Lark", None)
|
||||
if str(self.config.feishu_domain or "").strip().lower() != "lark":
|
||||
domain = getattr(getattr(sdk, "Domain", object()), "Feishu", domain)
|
||||
kwargs = {
|
||||
"app_id": self.config.feishu_app_id,
|
||||
"app_secret": self.config.feishu_app_secret,
|
||||
}
|
||||
if domain is not None:
|
||||
kwargs["domain"] = domain
|
||||
ws_cls = getattr(sdk, "WSClient", None)
|
||||
if ws_cls is None:
|
||||
raise RuntimeError("Feishu SDK missing WSClient")
|
||||
return ws_cls(**kwargs)
|
||||
|
||||
def _start_client(self, starter):
|
||||
try:
|
||||
return starter(event_handler=self._handle_long_connection_event)
|
||||
except TypeError:
|
||||
return starter(self._handle_long_connection_event)
|
||||
|
||||
async def _handle_long_connection_event(self, payload: Any):
|
||||
if hasattr(payload, "to_dict"):
|
||||
payload = payload.to_dict()
|
||||
if not isinstance(payload, dict):
|
||||
return
|
||||
await self.process_event_payload(payload)
|
||||
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
Feishu / Lark connector baseline adapter (F67).
|
||||
|
||||
Implements:
|
||||
- webhook ingress with verification-token challenge response
|
||||
- shared event normalization for webhook + long-connection transports
|
||||
- DM / group mention gating into CommandRequest
|
||||
- text / image delivery through Feishu Open API
|
||||
|
||||
Notes:
|
||||
- Feishu "workspace" is represented by tenant_key for connector diagnostics.
|
||||
- group traffic is gated by explicit bot mention unless disabled in config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from ..config import ConnectorConfig
|
||||
from ..router import CommandRouter
|
||||
from ..security_profile import AllowlistPolicy, ReplayGuard
|
||||
from .feishu_delivery_handlers import FeishuDeliveryMixin, FeishuDeliveryTarget
|
||||
from .feishu_ingress_handlers import FeishuIngressMixin
|
||||
from .feishu_installation_handlers import FeishuInstallationMixin
|
||||
from .feishu_installation_manager import FeishuInstallationManager
|
||||
|
||||
try:
|
||||
from services.safe_io import safe_request_json
|
||||
except ImportError: # pragma: no cover
|
||||
from services.safe_io import safe_request_json # type: ignore
|
||||
|
||||
try:
|
||||
from services.connector_callback_contract import ConnectorCallbackContract
|
||||
except ImportError: # pragma: no cover
|
||||
from services.connector_callback_contract import ( # type: ignore
|
||||
ConnectorCallbackContract,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FEISHU_WEBHOOK_MAX_BODY_BYTES = 256 * 1024
|
||||
FEISHU_TOKEN_TTL_SEC = 3600
|
||||
FEISHU_DOMAIN_BASES = {
|
||||
"feishu": "https://open.feishu.cn",
|
||||
"lark": "https://open.larksuite.com",
|
||||
}
|
||||
_SUPPORTED_EVENT_TYPES = frozenset({"im.message.receive_v1"})
|
||||
_PLACEHOLDER_TYPES = {
|
||||
"image": "<image>",
|
||||
"audio": "<audio>",
|
||||
"file": "<file>",
|
||||
"media": "<media>",
|
||||
"sticker": "<sticker>",
|
||||
}
|
||||
_FEISHU_CALLBACK_POLICY_MAP = {
|
||||
"approval.approve": "admin",
|
||||
"approval.reject": "admin",
|
||||
"command.status": "public",
|
||||
"command.run": "run",
|
||||
}
|
||||
|
||||
|
||||
def _import_aiohttp_web():
|
||||
# CRITICAL: do not replace with direct import; connector tests and minimal
|
||||
# CI envs intentionally exercise adapter startup without aiohttp installed.
|
||||
try:
|
||||
import aiohttp # type: ignore
|
||||
from aiohttp import web # type: ignore
|
||||
except ModuleNotFoundError:
|
||||
return None, None
|
||||
return aiohttp, web
|
||||
|
||||
|
||||
class _CompatResponse:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status: int = 200,
|
||||
text: str = "",
|
||||
content_type: str = "text/plain",
|
||||
body: Optional[bytes] = None,
|
||||
):
|
||||
self.status = status
|
||||
self.text = text
|
||||
self.content_type = content_type
|
||||
self.body = body if body is not None else text.encode("utf-8")
|
||||
|
||||
|
||||
def _make_response(web_mod, *, status: int = 200, text: str = "OK"):
|
||||
if web_mod is not None:
|
||||
return web_mod.Response(status=status, text=text)
|
||||
return _CompatResponse(status=status, text=text)
|
||||
|
||||
|
||||
def _make_json_response(web_mod, data: Dict[str, Any], *, status: int = 200):
|
||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
if web_mod is not None:
|
||||
return web_mod.json_response(data, status=status)
|
||||
return _CompatResponse(
|
||||
status=status,
|
||||
text=body.decode("utf-8"),
|
||||
content_type="application/json",
|
||||
body=body,
|
||||
)
|
||||
|
||||
|
||||
def _safe_external_error_code(default: str, _exc: Exception) -> str:
|
||||
# IMPORTANT: keep Feishu external failures constant. Returning exception-
|
||||
# derived codes/text here reopens the residual CodeQL stack-trace finding.
|
||||
return default
|
||||
|
||||
|
||||
def _resolve_domain_base(domain: str) -> str:
|
||||
normalized = str(domain or "feishu").strip().lower()
|
||||
return FEISHU_DOMAIN_BASES.get(normalized, FEISHU_DOMAIN_BASES["feishu"])
|
||||
|
||||
|
||||
def _allowed_api_hosts(domain: str) -> set[str]:
|
||||
host = urlparse(_resolve_domain_base(domain)).hostname or ""
|
||||
return {host} if host else set()
|
||||
|
||||
|
||||
def _build_multipart_form(
|
||||
*,
|
||||
fields: Dict[str, str],
|
||||
file_field: str,
|
||||
filename: str,
|
||||
file_bytes: bytes,
|
||||
file_content_type: str,
|
||||
) -> Tuple[bytes, str]:
|
||||
boundary = f"----openclaw-feishu-{secrets.token_hex(8)}"
|
||||
parts: list[bytes] = []
|
||||
for key, value in fields.items():
|
||||
parts.extend(
|
||||
[
|
||||
f"--{boundary}\r\n".encode("utf-8"),
|
||||
(f'Content-Disposition: form-data; name="{key}"\r\n\r\n').encode(
|
||||
"utf-8"
|
||||
),
|
||||
str(value).encode("utf-8"),
|
||||
b"\r\n",
|
||||
]
|
||||
)
|
||||
parts.extend(
|
||||
[
|
||||
f"--{boundary}\r\n".encode("utf-8"),
|
||||
(
|
||||
f'Content-Disposition: form-data; name="{file_field}"; '
|
||||
f'filename="{filename}"\r\n'
|
||||
).encode("utf-8"),
|
||||
f"Content-Type: {file_content_type}\r\n\r\n".encode("utf-8"),
|
||||
file_bytes,
|
||||
b"\r\n",
|
||||
f"--{boundary}--\r\n".encode("utf-8"),
|
||||
]
|
||||
)
|
||||
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
|
||||
|
||||
|
||||
def _json_loads_safe(raw: str) -> Dict[str, Any]:
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _normalize_mentions(message: Dict[str, Any]) -> list[dict]:
|
||||
mentions = message.get("mentions") or []
|
||||
return mentions if isinstance(mentions, list) else []
|
||||
|
||||
|
||||
def _strip_bot_mention(text: str, mentions: list[dict], bot_open_id: str) -> str:
|
||||
cleaned = text or ""
|
||||
for mention in mentions:
|
||||
key = str(mention.get("key", "") or "").strip()
|
||||
open_id = str(((mention.get("id") or {}).get("open_id")) or "").strip()
|
||||
if key and bot_open_id and open_id == bot_open_id:
|
||||
cleaned = cleaned.replace(key, " ")
|
||||
return " ".join(cleaned.split())
|
||||
|
||||
|
||||
def _post_text_to_plain(parsed: Dict[str, Any]) -> str:
|
||||
pieces: list[str] = []
|
||||
title = str(parsed.get("title", "") or "").strip()
|
||||
if title:
|
||||
pieces.append(title)
|
||||
for row in parsed.get("content") or []:
|
||||
if not isinstance(row, list):
|
||||
continue
|
||||
row_pieces: list[str] = []
|
||||
for item in row:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
tag = str(item.get("tag", "") or "").strip().lower()
|
||||
if tag == "text":
|
||||
row_pieces.append(str(item.get("text", "") or ""))
|
||||
elif tag == "at":
|
||||
name = str(item.get("user_name", "") or "").strip()
|
||||
row_pieces.append(f"@{name}" if name else "@mentioned")
|
||||
line = "".join(row_pieces).strip()
|
||||
if line:
|
||||
pieces.append(line)
|
||||
return "\n".join(piece for piece in pieces if piece).strip()
|
||||
|
||||
|
||||
def parse_feishu_message_text(message: Dict[str, Any]) -> str:
|
||||
msg_type = str(message.get("message_type", "") or "").strip().lower()
|
||||
raw_content = str(message.get("content", "") or "")
|
||||
parsed = _json_loads_safe(raw_content)
|
||||
if msg_type == "text":
|
||||
return str(parsed.get("text", "") or "").strip()
|
||||
if msg_type == "post":
|
||||
return _post_text_to_plain(parsed)
|
||||
if msg_type in _PLACEHOLDER_TYPES:
|
||||
return _PLACEHOLDER_TYPES[msg_type]
|
||||
return str(parsed.get("text", "") or "").strip()
|
||||
|
||||
|
||||
def _infer_callback_action_type(command_text: str, button: Dict[str, Any]) -> str:
|
||||
explicit = str(button.get("action_type", "") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
normalized = str(command_text or "").strip().lower()
|
||||
if normalized.startswith("/approve"):
|
||||
return "approval.approve"
|
||||
if normalized.startswith("/reject"):
|
||||
return "approval.reject"
|
||||
if normalized.startswith("/run"):
|
||||
return "command.run"
|
||||
if normalized.startswith("/status"):
|
||||
return "command.status"
|
||||
return "command.unknown"
|
||||
|
||||
|
||||
def _force_approval_command(command_text: str) -> str:
|
||||
normalized = str(command_text or "").strip()
|
||||
if not normalized:
|
||||
return normalized
|
||||
if normalized.startswith("/run") and "--approval" not in normalized:
|
||||
return f"{normalized} --approval"
|
||||
return normalized
|
||||
|
||||
|
||||
class FeishuWebhookServer(
|
||||
FeishuInstallationMixin,
|
||||
FeishuIngressMixin,
|
||||
FeishuDeliveryMixin,
|
||||
):
|
||||
REPLAY_WINDOW_SEC = 300
|
||||
NONCE_CACHE_SIZE = 5000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: ConnectorConfig,
|
||||
router: CommandRouter,
|
||||
*,
|
||||
installation_manager: Optional[FeishuInstallationManager] = None,
|
||||
bound_account_id: str = "",
|
||||
):
|
||||
self.config = config
|
||||
self.router = router
|
||||
self._installation_manager = installation_manager or FeishuInstallationManager(
|
||||
config
|
||||
)
|
||||
self._bound_account_id = str(bound_account_id or "").strip()
|
||||
self.app = None
|
||||
self.runner = None
|
||||
self.site = None
|
||||
self._replay_guard = ReplayGuard(
|
||||
window_sec=self.REPLAY_WINDOW_SEC,
|
||||
max_entries=self.NONCE_CACHE_SIZE,
|
||||
)
|
||||
self._user_allowlist = AllowlistPolicy(
|
||||
config.feishu_allowed_users, strict=False
|
||||
)
|
||||
self._chat_allowlist = AllowlistPolicy(
|
||||
config.feishu_allowed_chats, strict=False
|
||||
)
|
||||
self._tenant_access_tokens: Dict[str, str] = {}
|
||||
self._tenant_access_token_expires_at: Dict[str, float] = {}
|
||||
self._bot_open_ids: Dict[str, str] = {}
|
||||
self._bot_open_id: str = ""
|
||||
self._callback_contracts: Dict[str, ConnectorCallbackContract] = {}
|
||||
self._callback_contract_secrets: Dict[str, str] = {}
|
||||
|
||||
# IMPORTANT: keep facade patch seams live across extracted protocol owners.
|
||||
@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_safe_external_error_code(*args, **kwargs):
|
||||
return _safe_external_error_code(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _adapter_resolve_domain_base(*args, **kwargs):
|
||||
return _resolve_domain_base(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _adapter_allowed_api_hosts(*args, **kwargs):
|
||||
return _allowed_api_hosts(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _adapter_build_multipart_form(*args, **kwargs):
|
||||
return _build_multipart_form(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _adapter_json_loads_safe(*args, **kwargs):
|
||||
return _json_loads_safe(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _adapter_normalize_mentions(*args, **kwargs):
|
||||
return _normalize_mentions(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _adapter_strip_bot_mention(*args, **kwargs):
|
||||
return _strip_bot_mention(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _adapter_parse_message_text(*args, **kwargs):
|
||||
return parse_feishu_message_text(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _adapter_infer_callback_action_type(*args, **kwargs):
|
||||
return _infer_callback_action_type(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _adapter_force_approval_command(*args, **kwargs):
|
||||
return _force_approval_command(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _adapter_safe_request_json(*args, **kwargs):
|
||||
return safe_request_json(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _adapter_max_body_bytes():
|
||||
return FEISHU_WEBHOOK_MAX_BODY_BYTES
|
||||
|
||||
@staticmethod
|
||||
def _adapter_token_ttl_sec():
|
||||
return FEISHU_TOKEN_TTL_SEC
|
||||
|
||||
@staticmethod
|
||||
def _adapter_callback_policy_map():
|
||||
return _FEISHU_CALLBACK_POLICY_MAP
|
||||
|
||||
@staticmethod
|
||||
def _adapter_supported_event_types():
|
||||
return _SUPPORTED_EVENT_TYPES
|
||||
|
||||
@staticmethod
|
||||
def _adapter_delivery_target(*args, **kwargs):
|
||||
return FeishuDeliveryTarget(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _adapter_logger():
|
||||
return logger
|
||||
|
||||
async def start(self):
|
||||
aiohttp, web = _import_aiohttp_web()
|
||||
if aiohttp is None or web is None:
|
||||
logger.warning("aiohttp not installed. Skipping Feishu webhook adapter.")
|
||||
return
|
||||
if not self._installation_manager.has_bindings():
|
||||
logger.info(
|
||||
"Feishu adapter disabled "
|
||||
"(OPENCLAW_CONNECTOR_FEISHU_APP_ID / APP_SECRET missing)"
|
||||
)
|
||||
return
|
||||
has_event_ingress = any(
|
||||
binding.verification_token
|
||||
for binding in self._installation_manager.bindings()
|
||||
)
|
||||
has_callback_ingress = bool(str(self.config.feishu_callback_path or "").strip())
|
||||
if not has_event_ingress and not has_callback_ingress:
|
||||
logger.info(
|
||||
"Feishu webhook adapter disabled "
|
||||
"(verification token and callback path missing)"
|
||||
)
|
||||
return
|
||||
logger.info(
|
||||
"Starting Feishu webhook on %s:%s%s (%s)",
|
||||
self.config.feishu_bind_host,
|
||||
self.config.feishu_bind_port,
|
||||
self.config.feishu_webhook_path,
|
||||
self.config.feishu_domain,
|
||||
)
|
||||
self.app = web.Application(client_max_size=FEISHU_WEBHOOK_MAX_BODY_BYTES)
|
||||
if has_event_ingress:
|
||||
self.app.router.add_post(self.config.feishu_webhook_path, self.handle_event)
|
||||
if has_callback_ingress:
|
||||
self.app.router.add_post(
|
||||
self.config.feishu_callback_path,
|
||||
self.handle_callback,
|
||||
)
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
self.site = web.TCPSite(
|
||||
self.runner,
|
||||
self.config.feishu_bind_host,
|
||||
self.config.feishu_bind_port,
|
||||
)
|
||||
await self.site.start()
|
||||
|
||||
async def stop(self):
|
||||
if self.site:
|
||||
await self.site.stop()
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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) -----------
|
||||
|
||||
@@ -97,6 +114,46 @@ def _make_redirect_response(web_mod, url: str):
|
||||
return _CompatResponse(status=302, text=url)
|
||||
|
||||
|
||||
def _safe_external_error_text(default: str, _exc: Exception) -> str:
|
||||
# IMPORTANT: keep Slack external failures constant. Even "short safe-looking"
|
||||
# exception text remains scanner-tainted and can re-expose internal detail.
|
||||
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).
|
||||
@@ -146,7 +203,11 @@ def verify_slack_signature(
|
||||
# -- Slack adapter ----------------------------------------------------------
|
||||
|
||||
|
||||
class SlackWebhookServer:
|
||||
class SlackWebhookServer(
|
||||
SlackInstallationMixin,
|
||||
SlackIngressMixin,
|
||||
SlackDeliveryMixin,
|
||||
):
|
||||
"""
|
||||
F56 -- Slack Events API adapter.
|
||||
|
||||
@@ -173,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)
|
||||
@@ -182,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
|
||||
# ------------------------------------------------------------------
|
||||
@@ -219,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
|
||||
@@ -243,496 +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:
|
||||
logger.warning("Slack OAuth callback failed: %s", exc)
|
||||
return _make_response(web, status=502, text=str(exc))
|
||||
|
||||
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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -32,6 +32,9 @@ import time
|
||||
from typing import Optional
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from defusedxml import ElementTree as DefusedET
|
||||
from defusedxml.common import DefusedXmlException
|
||||
|
||||
from ..config import ConnectorConfig
|
||||
from ..contract import CommandRequest, CommandResponse
|
||||
from ..router import CommandRouter
|
||||
@@ -265,9 +268,17 @@ def parse_wechat_xml(raw: bytes) -> dict:
|
||||
f"Payload size {len(raw)} exceeds limit {XML_MAX_PAYLOAD_BYTES}"
|
||||
)
|
||||
|
||||
lowered = raw.lower()
|
||||
if b"<!doctype" in lowered or b"<!entity" in lowered:
|
||||
# IMPORTANT: reject DTD / ENTITY declarations before parser entry to
|
||||
# keep entity-expansion bombs fail-closed.
|
||||
raise XMLBudgetExceeded("DTD/entity declarations are not allowed")
|
||||
|
||||
try:
|
||||
root = ET.fromstring(raw)
|
||||
except ET.ParseError as e:
|
||||
# IMPORTANT: keep defusedxml here. Reverting to the stdlib parser path
|
||||
# reopens the residual CodeQL xml-bomb finding on this ingress seam.
|
||||
root = DefusedET.fromstring(raw.decode("utf-8"))
|
||||
except (ET.ParseError, DefusedXmlException, UnicodeDecodeError) as e:
|
||||
raise XMLBudgetExceeded(f"XML parse error: {e}") from e
|
||||
|
||||
# Depth check — WeChat envelopes are <xml><Tag>val</Tag></xml>, depth=2
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
-916
@@ -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,901 +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
|
||||
|
||||
# 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 = []
|
||||
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}"
|
||||
)
|
||||
|
||||
header = "Pending Approvals"
|
||||
if isinstance(pending_count, int):
|
||||
header += f" ({pending_count})"
|
||||
return CommandResponse(text=header + ":\n" + "\n".join(lines))
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -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)
|
||||
@@ -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 ---
|
||||
@@ -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
@@ -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 Tests
|
||||
MOLTBOT_STATE_DIR="$(pwd)/moltbot_state/_local_unit" ./.venv/Scripts/python.exe scripts/run_unittests.py --start-dir tests --pattern "test_*.py"
|
||||
|
||||
# 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)
|
||||
|
||||
+43
-16
@@ -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.
|
||||
|
||||
@@ -235,7 +242,8 @@ python scripts/check_deployment_profile.py --profile public --strict-warnings
|
||||
OpenClaw validates custom LLM `base_url` settings to prevent Server-Side Request Forgery (SSRF).
|
||||
|
||||
* **Default**: known providers and localhost-safe paths are allowed.
|
||||
* **Pinned connect contract**: on supported CPython versions (current baseline: 3.10+), `safe_io` 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.
|
||||
* **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,11 +258,28 @@ 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`).
|
||||
|
||||
### 6.1 Audit Chain Verification
|
||||
|
||||
OpenClaw keeps append-only audit evidence verifiable across restart and retained-log rotation.
|
||||
|
||||
Recommended operator check:
|
||||
|
||||
```bash
|
||||
python scripts/verify_audit_chain.py --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- verification covers the active `audit.log` plus retained rotated segments in the state directory
|
||||
- when an audit chain key is not supplied externally, OpenClaw persists a local `audit.log.key` sidecar so the retained chain stays verifiable after restart
|
||||
- treat any verification failure as an integrity incident and investigate before trusting the retained audit trail
|
||||
|
||||
### 7. Rate Limiting
|
||||
|
||||
OpenClaw enforces internal rate limits:
|
||||
@@ -265,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`).
|
||||
@@ -282,7 +307,9 @@ 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.
|
||||
* [ ] **Startup gate preflight**: run `python scripts/check_deployment_profile.py --profile public --strict-warnings`.
|
||||
* [ ] **Runtime diagnostics**: review `GET /openclaw/security/doctor` before exposure.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# ADR-0002: Product Boundary And Packaging Contract
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-04-23
|
||||
- Owners: OpenClaw maintainers
|
||||
- Related roadmap items: `R160` with follow-up execution in `R161` and `R162`
|
||||
|
||||
## Context
|
||||
|
||||
OpenClaw started as a ComfyUI-focused node pack, but the repository now also contains:
|
||||
|
||||
- embedded HTTP APIs and operator UI surfaces,
|
||||
- a standalone Remote Admin Console route,
|
||||
- connector runtime code for multiple chat platforms,
|
||||
- split control-plane governance for higher-risk deployments.
|
||||
|
||||
That evolution made one question increasingly ambiguous: what is the supported identity of this repo/package today, and which parts are first-class versus optional attached subsystems?
|
||||
|
||||
Without a boundary decision, future work such as config decomposition, connector extraction, or packaging hygiene is forced to rely on repo intuition instead of an explicit contract.
|
||||
|
||||
## Decision
|
||||
|
||||
OpenClaw is defined as a **ComfyUI custom node pack** first, with two explicit first-class identities layered on top of that package:
|
||||
|
||||
1. **ComfyUI custom node pack**
|
||||
- This is the primary distribution artifact and runtime anchor.
|
||||
- `__init__.py` remains the package entrypoint loaded from `custom_nodes/`.
|
||||
|
||||
2. **embedded operator platform**
|
||||
- In-process OpenClaw APIs, runtime/security governance, sidebar UX, and remote admin surfaces are treated as part of the shipped package, not as separate products.
|
||||
|
||||
3. **connector-capable control surface**
|
||||
- Remote chat control is supported through the in-repo connector sidecar, but the connector remains an **optional attached subsystem**, not the primary package artifact.
|
||||
|
||||
## Core vs Attached Subsystems
|
||||
|
||||
Core to the package:
|
||||
|
||||
- custom node pack entrypoint and exported nodes
|
||||
- embedded API/runtime governance (`/openclaw/*`, route bootstrap, control-plane policy)
|
||||
- embedded operator UI surfaces (sidebar plus `/openclaw/admin`)
|
||||
|
||||
Optional attached subsystem:
|
||||
|
||||
- connector sidecar (`python -m connector`) and platform-specific adapters
|
||||
|
||||
This means:
|
||||
|
||||
- the connector is supported and intentionally in-repo,
|
||||
- but the repo is **not** currently defined as a connector-first distribution,
|
||||
- and the repo is **not** currently defined as a standalone generic backend independent of ComfyUI.
|
||||
|
||||
## Supported Topologies
|
||||
|
||||
Supported:
|
||||
|
||||
1. **embedded local/lan**
|
||||
- OpenClaw runs inside the ComfyUI process as the primary package artifact.
|
||||
|
||||
2. **embedded package with split high-risk control plane**
|
||||
- The same package stays primary, while higher-risk control surfaces are externalized according to the split-mode contract.
|
||||
|
||||
3. **embedded package plus optional connector sidecar**
|
||||
- The connector runs as a companion process that calls the local OpenClaw APIs.
|
||||
|
||||
Unsupported as first-class package identities today:
|
||||
|
||||
1. **connector-only distribution**
|
||||
2. **standalone non-ComfyUI backend package**
|
||||
|
||||
Those possibilities are future design questions, not current promises. Connector extraction feasibility remains explicitly deferred to `R162`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- future extraction/pruning decisions now have one explicit contract to evaluate against
|
||||
- docs and contributor discussions can use the same terms instead of mixing "node pack", "server", and "sidecar" loosely
|
||||
- `R161` and `R162` can narrow their scope around a known boundary instead of re-litigating product identity
|
||||
|
||||
Trade-offs:
|
||||
|
||||
- the repo still carries multiple execution surfaces inside one codebase
|
||||
- connector remains intentionally attached even though it is operationally separable
|
||||
- some public docs must stay careful not to imply standalone server packaging that does not exist yet
|
||||
|
||||
## Rejected Alternatives
|
||||
|
||||
1. Treat the connector as an equal primary package artifact today
|
||||
- Rejected because there is no separate connector package/distribution contract yet.
|
||||
|
||||
2. Define OpenClaw as a generic standalone backend package
|
||||
- Rejected because current runtime ownership still assumes a ComfyUI host process.
|
||||
|
||||
3. Keep the boundary implicit and rely on contributor convention
|
||||
- Rejected because packaging and extraction follow-ups now depend on an explicit contract.
|
||||
@@ -0,0 +1,89 @@
|
||||
# ADR-0003: Connector Extraction Feasibility And Split-Package Seams
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-04-24
|
||||
- Owners: OpenClaw maintainers
|
||||
- Related roadmap items: `R162` with prior boundary decision in `ADR-0002`
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0002 established that the connector is an **optional attached subsystem**, not the primary published artifact of this repository. The remaining question is whether that attached subsystem should now be extracted into a separately packaged connector or separate repo.
|
||||
|
||||
Current code structure still mixes:
|
||||
|
||||
- connector platform adapters and runtime
|
||||
- shared installation/token lifecycle services
|
||||
- shared callback signing and replay-protection contracts
|
||||
- backend delivery/result APIs that the connector calls locally
|
||||
- tenant/config/auth boundaries that remain owned by the core package
|
||||
|
||||
That means extraction is no longer a purely packaging question. It is a shared-contract question.
|
||||
|
||||
## Decision
|
||||
|
||||
OpenClaw adopts a **no-go-for-split-now** decision for connector extraction.
|
||||
|
||||
Current recommendation:
|
||||
|
||||
1. Keep the connector **in-repo** as an **optional attached subsystem**.
|
||||
2. Treat a future **optional extra package** as the only plausible next extraction target.
|
||||
3. Treat both **sidecar-only distribution** and **separate repo / primary connector package** as **no-go now** options.
|
||||
|
||||
## Minimum Stable Seams Required Before Any Split
|
||||
|
||||
Any future extraction must first stabilize these seam families:
|
||||
|
||||
1. **installation registry and token refs**
|
||||
- workspace/account binding records
|
||||
- tenant-scoped token-reference ownership
|
||||
- installation diagnostics and fail-closed resolution
|
||||
|
||||
2. **interactive callback security contract**
|
||||
- signed callback envelopes
|
||||
- timestamp / replay / idempotency checks
|
||||
- action-policy mapping and approval downgrade semantics
|
||||
|
||||
3. **delivery and result bridge**
|
||||
- submission/result polling contract
|
||||
- callback delivery expectations
|
||||
- backend result payload compatibility
|
||||
|
||||
4. **config/auth and tenant boundary**
|
||||
- connector runtime config contract
|
||||
- admin token / auth expectations
|
||||
- tenant header behavior
|
||||
- server-side secret/state ownership
|
||||
|
||||
## Why Separate Packaging Is A No-Go Now
|
||||
|
||||
Current blockers are concrete, not theoretical:
|
||||
|
||||
- shared services import connector types and connector adapters import shared services, so extraction would currently create unstable bidirectional package seams
|
||||
- installation/token/state ownership still lives in shared repo services rather than a versioned connector-boundary package
|
||||
- connector API/client flows still assume in-repo backend evolution instead of a versioned external backend contract
|
||||
- `services/sidecar` still imports connector config/client modules directly, so even a packaging-only split would not isolate ownership yet
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- maintainers now have one explicit go/no-go answer instead of repeatedly re-litigating extraction
|
||||
- future connector extraction work can target named seam families instead of rediscovering coupling ad hoc
|
||||
- admin diagnostics can expose the same contract to future packaging or release automation
|
||||
|
||||
Trade-offs:
|
||||
|
||||
- the repo intentionally keeps connector and core package code together for now
|
||||
- packaging hygiene remains a future concern rather than a solved distribution problem
|
||||
- extraction pressure is deferred until shared contracts are versionable on their own
|
||||
|
||||
## Rejected Alternatives
|
||||
|
||||
1. Extract connector into a separate repo now
|
||||
- Rejected because current coupling would move instability across package boundaries instead of reducing it.
|
||||
|
||||
2. Publish connector as a sidecar-only primary distribution now
|
||||
- Rejected because current operator workflows still assume the embedded OpenClaw package/runtime remains primary.
|
||||
|
||||
3. Leave extraction as an undocumented future possibility
|
||||
- Rejected because future packaging work needs an explicit seam map and a clear no-go baseline.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+212
-13
@@ -1,23 +1,28 @@
|
||||
# OpenClaw Connector
|
||||
|
||||
The **OpenClaw Connector** (`connector`) is a standalone process that allows you to control your local ComfyUI instance remotely via chat platforms like **Telegram**, **Discord**, **LINE**, **WhatsApp**, **WeChat Official Account**, and **KakaoTalk (Kakao i Open Builder)**.
|
||||
The **OpenClaw Connector** (`connector`) is a standalone process that allows you to control your local ComfyUI instance remotely via chat platforms like **Telegram**, **Discord**, **LINE**, **WhatsApp**, **WeChat Official Account**, **KakaoTalk (Kakao i Open Builder)**, **Slack**, and **Feishu/Lark**.
|
||||
|
||||
Per the product boundary contract, the connector is an **optional attached subsystem**. The primary published artifact of this repo remains the **ComfyUI custom node pack**, and the connector augments that package rather than replacing it.
|
||||
|
||||
Current extraction decision: keep the connector **in-repo** as an optional attached subsystem for now. OpenClaw does **not** currently treat a standalone connector package/repo as a supported distribution; see [ADR-0003](adr/ADR-0003-connector-extraction-feasibility-and-seams.md).
|
||||
|
||||
## How It Works
|
||||
|
||||
The connector runs alongside ComfyUI on your machine.
|
||||
|
||||
1. It connects outbound to Telegram/Discord (polling/gateway).
|
||||
2. LINE/WhatsApp/WeChat/KakaoTalk use inbound webhooks (HTTPS required).
|
||||
2. LINE/WhatsApp/WeChat/KakaoTalk/Slack use inbound webhooks (HTTPS required), while Feishu/Lark can run in webhook mode or long-connection mode with a separate callback ingress path for interactive actions.
|
||||
3. It talks to ComfyUI via `localhost`.
|
||||
4. It relays commands and status updates securely.
|
||||
|
||||
**Security**:
|
||||
|
||||
- **Transport Model**: Telegram/Discord are outbound. LINE/WhatsApp/WeChat/KakaoTalk/Slack require inbound HTTPS webhook endpoints.
|
||||
- **Transport Model**: Telegram/Discord are outbound. LINE/WhatsApp/WeChat/KakaoTalk/Slack require inbound HTTPS webhook endpoints. Feishu/Lark supports webhook ingress or long-connection transport, but interactive callbacks still use a bounded local HTTPS callback path.
|
||||
- **Allowlist/Trust Model**: Allowlists define trusted senders/channels. Non-allowlisted senders are treated as untrusted (for example, `/run` is approval-routed instead of auto-executed).
|
||||
- **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
|
||||
|
||||
@@ -29,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:
|
||||
|
||||
@@ -36,6 +43,12 @@ Admin diagnostics APIs:
|
||||
- `GET /openclaw/connector/installations/{installation_id}`
|
||||
- `GET /openclaw/connector/installations/resolve?platform=<platform>&workspace_id=<workspace_id>`
|
||||
- `GET /openclaw/connector/installations/audit`
|
||||
- `GET /openclaw/connector/extraction-contract`
|
||||
|
||||
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:
|
||||
|
||||
@@ -45,6 +58,16 @@ 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:
|
||||
|
||||
- Feishu bindings can be declared with a single app pair or a multi-account `OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON` manifest; each binding resolves to one normalized installation record with account/workspace identity.
|
||||
- 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
|
||||
|
||||
@@ -57,13 +80,14 @@ 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).
|
||||
- **WeChat Official Account**: Webhook (requires inbound HTTPS).
|
||||
- **KakaoTalk (Kakao i Open Builder)**: Webhook (requires inbound HTTPS).
|
||||
- **Slack (Events API)**: Webhook (requires inbound HTTPS).
|
||||
- **Feishu / Lark**: Webhook or long-connection transport; interactive callbacks require inbound HTTPS for the callback route.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -80,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.
|
||||
@@ -92,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:**
|
||||
|
||||
@@ -163,7 +195,7 @@ Set the following environment variables (or put them in a `.env` file if you use
|
||||
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_INSTALL_PATH`: Local install route (default `/slack/install`).
|
||||
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH`: Local callback route (default `/slack/oauth/callback`).
|
||||
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_SCOPES`: Comma-separated bot scopes used for install URL generation.
|
||||
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC`: TTL in seconds for single-use OAuth state tokens (default `600`).
|
||||
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC`: TTL in seconds for single-use OAuth state tokens (default `600`, clamped to `60..3600`).
|
||||
- `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS`: Comma-separated user IDs (e.g. `U12345, U67890`).
|
||||
- `OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS`: Comma-separated channel IDs (e.g. `C12345`).
|
||||
- `OPENCLAW_CONNECTOR_SLACK_BIND`: Host to bind (default `127.0.0.1`).
|
||||
@@ -172,12 +204,46 @@ Set the following environment variables (or put them in a `.env` file if you use
|
||||
- `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION`: `true` (default) to require `@Bot` mention in public channels.
|
||||
- `OPENCLAW_CONNECTOR_SLACK_REPLY_IN_THREAD`: `true` (default) to reply in threads.
|
||||
|
||||
**Feishu / Lark:**
|
||||
|
||||
*(Long connection or webhook; callback ingress still requires inbound HTTPS if interactive cards are enabled)*
|
||||
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_APP_ID`: App ID for the default Feishu/Lark binding.
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_APP_SECRET`: App secret for the default binding.
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_VERIFICATION_TOKEN`: Verification token for webhook event ingress.
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_ENCRYPT_KEY`: Optional encrypt key for encrypted webhook payloads.
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_ACCOUNT_ID`: Explicit account ID for the default binding.
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_DEFAULT_ACCOUNT_ID`: Fallback account ID when binding manifest entries omit one.
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_ID`: Workspace or tenant identifier associated with the default binding.
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_NAME`: Human-readable workspace name used in diagnostics.
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON`: JSON list of account bindings for multi-account / multi-workspace setups.
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS`: Comma-separated trusted user IDs.
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_CHATS`: Comma-separated trusted chat IDs.
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_BIND`: Host to bind (default `127.0.0.1`).
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_PORT`: Port (default `8094`).
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_PATH`: Event ingress route (default `/feishu/events`).
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH`: Interactive callback route (default `/feishu/callback`).
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_DOMAIN`: API domain selector (`feishu` or `lark`).
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_MODE`: Transport mode (`websocket` default, or `webhook`).
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_REQUIRE_MENTION`: Set `false` to allow commands without explicit mention in shared chats.
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_REPLY_IN_THREAD`: Set `false` to disable reply threading when the source chat supports it.
|
||||
|
||||
**Image Delivery:**
|
||||
|
||||
- `OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES`: Max completed images delivered per job (default `4`, clamped to `1..16`).
|
||||
- `OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES`: Per-image delivery cap in bytes (default `10485760`, clamped to `65536..52428800`).
|
||||
- `OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC`: Result delivery timeout in seconds (default `600`, clamped to `30..3600`).
|
||||
- `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL`: Public HTTPS URL of your connector (e.g. `https://your-tunnel.example.com`). Required for sending images.
|
||||
- `OPENCLAW_CONNECTOR_MEDIA_PATH`: URL path for serving temporary media (default `/media`).
|
||||
- `OPENCLAW_CONNECTOR_MEDIA_TTL_SEC`: Image expiry in seconds (default `300`).
|
||||
- `OPENCLAW_CONNECTOR_MEDIA_MAX_MB`: Max image size in MB (default `8`).
|
||||
- `OPENCLAW_CONNECTOR_MEDIA_TTL_SEC`: Image expiry in seconds (default `300`, clamped to `60..86400`).
|
||||
- `OPENCLAW_CONNECTOR_MEDIA_MAX_MB`: Max image size in MB (default `8`, clamped to `1..64`).
|
||||
|
||||
**Connector numeric guardrails:**
|
||||
|
||||
- Platform bind ports (`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 platform default port.
|
||||
- `OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM`: Per-user connector rate limit (default `10`, clamped to `1..600`).
|
||||
- `OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM`: Per-channel connector rate limit (default `30`, clamped to `1..600`).
|
||||
- `OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH`: Max accepted command text length (default `4096`, clamped to `128..32768`).
|
||||
|
||||
> **Note:** Media URLs are signed with a secret derived from `OPENCLAW_CONNECTOR_ADMIN_TOKEN` or a random key.
|
||||
> To ensure URLs remain valid after connector restarts, **you must set `OPENCLAW_CONNECTOR_ADMIN_TOKEN`**.
|
||||
@@ -185,6 +251,7 @@ Set the following environment variables (or put them in a `.env` file if you use
|
||||
> WeChat currently supports text-first control. Image/media upload delivery is not implemented in phase 1.
|
||||
> Kakao currently supports text-first control and quick replies. Rich media delivery is not enabled in the default Kakao webhook flow.
|
||||
> Slack supports text responses and image uploads (via `files.upload` API).
|
||||
> Feishu currently supports text replies plus interactive approval/command cards; richer card templates can be added on top of the same signed callback contract.
|
||||
|
||||
### Command authorization policy
|
||||
|
||||
@@ -222,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
|
||||
@@ -269,7 +358,7 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
|
||||
|
||||
```bash
|
||||
OPENCLAW_CONNECTOR_WECHAT_TOKEN=replace-with-your-wechat-token
|
||||
OPENCLAW_CONNECTOR_WECHAT_APP_ID=wx1234567890abcdef
|
||||
OPENCLAW_CONNECTOR_WECHAT_APP_ID=replace-with-your-wechat-app-id
|
||||
OPENCLAW_CONNECTOR_WECHAT_APP_SECRET=replace-with-app-secret
|
||||
OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS=openid_1,openid_2
|
||||
OPENCLAW_CONNECTOR_WECHAT_BIND=127.0.0.1
|
||||
@@ -277,6 +366,9 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
|
||||
OPENCLAW_CONNECTOR_WECHAT_PATH=/wechat/webhook
|
||||
```
|
||||
|
||||
Use descriptive placeholders for App IDs in docs and examples. Secret scanners can
|
||||
treat AppID-shaped samples as real credentials even when they are only documentation.
|
||||
|
||||
2. Start the connector:
|
||||
|
||||
```bash
|
||||
@@ -312,6 +404,7 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
|
||||
- The adapter validates WeChat signature on every request and applies replay/timestamp checks.
|
||||
- Timestamp skew outside policy window is rejected (`403 Stale Request`).
|
||||
- XML payload parsing is bounded (size/depth/field caps) and fails closed on parser budget violations.
|
||||
- DTD / ENTITY declarations are rejected before parser entry; the adapter does not attempt to recover from unsafe XML payloads.
|
||||
- Runtime XML security gate is fail-closed: unsafe/missing parser baseline blocks ingress startup.
|
||||
- Current command surface is text-first. Unsupported message/event types are ignored with success response.
|
||||
- Proactive outbound API messaging requires both `OPENCLAW_CONNECTOR_WECHAT_APP_ID` and `OPENCLAW_CONNECTOR_WECHAT_APP_SECRET`.
|
||||
@@ -391,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**
|
||||
|
||||
@@ -402,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
|
||||
@@ -413,20 +507,23 @@ 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.
|
||||
|
||||
4. **Start connector and expose webhook endpoint**
|
||||
- Start connector: `python -m connector`
|
||||
- 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.
|
||||
@@ -435,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.
|
||||
@@ -483,6 +583,72 @@ Notes:
|
||||
- Startup is fail-closed if `OPENCLAW_CONNECTOR_SLACK_APP_TOKEN` is missing or does not start with `xapp-`.
|
||||
- In multi-workspace mode, outbound replies still resolve the workspace-specific bot token from the installation registry even though the WebSocket connection itself uses the app-level token.
|
||||
|
||||
#### Feishu / Lark Setup (Detailed)
|
||||
|
||||
Feishu support can run in either long-connection (`websocket`) mode or webhook mode. Long-connection is usually the simpler default for message ingress, but interactive cards still need a reachable callback route if you want approval buttons and other signed actions.
|
||||
|
||||
1. **Create the Feishu or Lark app**
|
||||
- Create a bot app in the Feishu or Lark developer console.
|
||||
- Record the `App ID` and `App Secret`.
|
||||
- If you want webhook ingress, also configure the event subscription verification token.
|
||||
- If encrypted event delivery is enabled, record the encrypt key as well.
|
||||
|
||||
2. **Choose transport mode**
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_MODE=websocket`
|
||||
- Uses long connection for message ingress.
|
||||
- Recommended when you do not want to expose the event route publicly.
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_MODE=webhook`
|
||||
- Uses HTTPS webhook delivery for messages.
|
||||
- Requires a public HTTPS route for `OPENCLAW_CONNECTOR_FEISHU_PATH`.
|
||||
|
||||
3. **Configure the default binding**
|
||||
|
||||
```bash
|
||||
OPENCLAW_CONNECTOR_FEISHU_APP_ID=cli_xxx
|
||||
OPENCLAW_CONNECTOR_FEISHU_APP_SECRET=sec_xxx
|
||||
OPENCLAW_CONNECTOR_FEISHU_ACCOUNT_ID=acct-default
|
||||
OPENCLAW_CONNECTOR_FEISHU_DEFAULT_ACCOUNT_ID=acct-default
|
||||
OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_ID=tenant-alpha
|
||||
OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_NAME="Alpha Workspace"
|
||||
OPENCLAW_CONNECTOR_FEISHU_DOMAIN=feishu
|
||||
OPENCLAW_CONNECTOR_FEISHU_MODE=websocket
|
||||
OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS=ou_xxx,ou_yyy
|
||||
OPENCLAW_CONNECTOR_FEISHU_ALLOWED_CHATS=oc_xxx,oc_yyy
|
||||
```
|
||||
|
||||
4. **Optional: multi-account binding manifest**
|
||||
- Use `OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON` when one connector runtime should host more than one Feishu/Lark app or workspace binding.
|
||||
- Each entry may include:
|
||||
- `account_id`
|
||||
- `app_id`
|
||||
- `app_secret`
|
||||
- `workspace_id`
|
||||
- `workspace_name`
|
||||
- `verification_token`
|
||||
- `encrypt_key`
|
||||
- `domain`
|
||||
- `mode`
|
||||
|
||||
5. **Configure interactive callback ingress**
|
||||
- Set `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL` to your public HTTPS origin.
|
||||
- Expose `OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH` (default `/feishu/callback`) through your reverse proxy or tunnel.
|
||||
- In long-connection mode this callback route is still required for interactive approval cards; message ingress transport does not remove callback security requirements.
|
||||
|
||||
6. **Start connector**
|
||||
- `python -m connector`
|
||||
- Expect logs showing the chosen Feishu mode and callback/event route bindings.
|
||||
|
||||
7. **Verify runtime behavior**
|
||||
- Run `/status` from an allowlisted Feishu/Lark user.
|
||||
- Run `/approvals` and confirm the reply renders approval buttons as an interactive card.
|
||||
- Click `Approve` or `Reject` on a test approval and verify the callback succeeds once, then duplicate clicks are deduped.
|
||||
|
||||
Notes:
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_DOMAIN=lark` switches outbound API host behavior without changing the rest of the connector contract.
|
||||
- Untrusted users can still see bounded command responses, but run-affecting interactive actions are downgraded to approval flow instead of auto-executing.
|
||||
- Callback signing secrets are resolved from the bound Feishu installation record; diagnostics expose binding state, not raw secret material.
|
||||
- Callback/event wrapper failures intentionally return bounded external error codes; inspect logs and installation diagnostics for redacted detail instead of expecting stack traces in callback responses.
|
||||
|
||||
## Commands
|
||||
|
||||
**General:**
|
||||
@@ -494,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`)*
|
||||
@@ -541,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`.
|
||||
@@ -564,3 +739,27 @@ Notes:
|
||||
- **Slack commands ignored in channels**:
|
||||
- `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true` and message does not mention the bot.
|
||||
- Fix: mention bot explicitly (`@Bot /status`) or set `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=false` if policy allows.
|
||||
|
||||
- **Slack OAuth callback shows only a generic install failure**:
|
||||
- This is expected on current builds; callback responses intentionally suppress raw exception text.
|
||||
- Fix: inspect connector logs plus `GET /openclaw/connector/installations` / `/resolve` diagnostics to find the redacted root cause.
|
||||
|
||||
- **I need to know whether the connector is supported as a separate package/repo yet**:
|
||||
- Current builds intentionally keep the connector in-repo as an optional attached subsystem.
|
||||
- Fix: review `GET /openclaw/connector/extraction-contract` or [ADR-0003](adr/ADR-0003-connector-extraction-feasibility-and-seams.md) for the current no-split recommendation and the seam blockers that still need to be versioned first.
|
||||
|
||||
- **Feishu callback buttons fail with signature or stale-action errors**:
|
||||
- Callback route is not using the same bound app secret, request arrived too late, or the button payload was replayed.
|
||||
- Fix: verify binding diagnostics, public callback route, connector clock, and that the same action is not being resent by proxy/retry middleware.
|
||||
|
||||
- **Feishu long-connection messages work but card actions do nothing**:
|
||||
- `OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH` is not exposed publicly, or the callback URL is not routed to the connector bind host/port.
|
||||
- Fix: expose the callback route over HTTPS even when `OPENCLAW_CONNECTOR_FEISHU_MODE=websocket`.
|
||||
|
||||
- **Feishu `/run` action becomes approval instead of executing immediately**:
|
||||
- Callback actor is untrusted under current allowlist/policy mapping.
|
||||
- Fix: add the user/chat to `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS` or `_ALLOWED_CHATS`, or keep the approval downgrade as the intended posture.
|
||||
|
||||
- **Feishu callback returns a bounded code such as `callback_rejected` or `event_rejected`**:
|
||||
- This is expected on current builds; callback wrappers intentionally avoid echoing raw exception detail to the caller.
|
||||
- Fix: inspect connector logs and installation/binding diagnostics for the redacted failure context.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ No special configuration is required.
|
||||
- **Admin Token**: Not required for loopback-only operations (unless `OPENCLAW_ADMIN_TOKEN` is explicitly set).
|
||||
- **Webhooks**: Disabled by default.
|
||||
- **Local LLM (optional)**:
|
||||
- Ollama: `http://127.0.0.1:11434`
|
||||
- Ollama: `http://127.0.0.1:11434/v1`
|
||||
- LM Studio: `http://localhost:1234/v1`
|
||||
- Keep SSRF relax flags disabled:
|
||||
- `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=0`
|
||||
@@ -58,6 +58,6 @@ No special configuration is required.
|
||||
1. Open `http://127.0.0.1:8188` in your browser.
|
||||
2. Open the OpenClaw tab in the sidebar.
|
||||
3. Go to **Settings** -> **Health**.
|
||||
4. If using Ollama, verify `http://127.0.0.1:11434/api/tags` responds.
|
||||
5. In **Settings -> LLM**, set provider to `Ollama (Local)` and click **Load Models**.
|
||||
4. If using Ollama, verify the daemon is reachable first via the native Ollama health/list surface `http://127.0.0.1:11434/api/tags`.
|
||||
5. In **Settings -> LLM**, set provider to `Ollama (Local)`, leave **Base URL** empty to use the built-in `http://127.0.0.1:11434/v1` default (or set that exact loopback URL explicitly), and click **Load Models**.
|
||||
6. All checks should be green.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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,9 +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.
|
||||
- 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
|
||||
|
||||
@@ -71,6 +97,7 @@ If `assist_streaming` is unavailable or the stream transport degrades, Planner/R
|
||||
3. Clear token and verify write actions fail with explicit auth/policy errors.
|
||||
4. Connect SSE, then trigger a run; verify event stream updates and fallback polling still works.
|
||||
5. Confirm there is no blank/overflow breakage on narrow mobile widths.
|
||||
6. If you are validating desktop parity, confirm the page root resolves the expected host-surface metadata instead of silently defaulting to standalone assumptions.
|
||||
|
||||
## Quick Manual Checks
|
||||
|
||||
@@ -79,14 +106,22 @@ 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, 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
|
||||
|
||||
- Run: `npm test`
|
||||
- Tests live in: `tests/e2e/specs/`
|
||||
- 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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# OpenClaw API Contract (v1)
|
||||
|
||||
> **Status**: normative
|
||||
> **Version**: 1.0.6
|
||||
> **Date**: 2026-03-27
|
||||
> **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,11 +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, 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 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
|
||||
|
||||
@@ -127,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
|
||||
|
||||
@@ -138,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
|
||||
|
||||
@@ -175,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/`
|
||||
@@ -221,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:
|
||||
@@ -240,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)
|
||||
@@ -271,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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,10 +8,13 @@ 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 |
|
||||
| Backend unit tests | `python scripts/run_unittests.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json` | Validate backend behavior and skip governance |
|
||||
| Test debt governance | `python scripts/verify_test_debt_governance.py` | Fail closed on stale or under-documented skip-policy / mutation allowlist debt entries |
|
||||
| Backend unit coverage gate | `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` | Validate backend behavior, skip governance, and the active coverage floor from the shared local/CI artifact path |
|
||||
| Adversarial gate | `python scripts/run_adversarial_gate.py --profile auto --seed 42` | Enforce adaptive fuzz/mutation verification with smoke=>extended escalation on high-risk diffs |
|
||||
| Frontend E2E | `npm test` | Validate UI and frontend/backend integration |
|
||||
|
||||
@@ -37,12 +40,35 @@ 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
|
||||
- missing or broadened workflow token scope should be treated as CI-policy drift, not an acceptable implementation shortcut
|
||||
- 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 >= 35.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:
|
||||
- current enforced floor
|
||||
- next planned ratchet target
|
||||
- hotspot families and any temporary exceptions
|
||||
- `fail_under` must match the current stage floor declared in `tests/coverage_governance_policy.json`; do not ratchet the floor by editing `pyproject.toml` alone.
|
||||
- Coverage hotspot review should use:
|
||||
- `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:
|
||||
- no-skip modules in `tests/skip_policy.json` must keep explicit metadata (`reason` + `review_after`) and point at live test modules
|
||||
- mutation survivor allowlist entries must carry `review_after` dates and point at live repo files
|
||||
- review dates in the past are governance debt, not advisory comments
|
||||
- Mutation governance remains adaptive:
|
||||
- smoke profile threshold: `20.0%`
|
||||
- extended profile threshold: `80.0%`
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# CodeQL and Secret Scanning Policy Reference
|
||||
|
||||
Date: 2026-04-08
|
||||
Scope: Repository planning guidance for GitHub-native security scanning policy during and after the residual alert wave.
|
||||
|
||||
Current status:
|
||||
|
||||
- the repository now uses the committed advanced CodeQL workflow as its authoritative scanner baseline
|
||||
- GitHub `Code scanning` and `Secret scanning` were both brought back to `0` open findings during the `S91` closeout
|
||||
|
||||
## 1. Why CodeQL Belongs in GitHub Actions
|
||||
|
||||
This repository has a large security surface:
|
||||
|
||||
- Python backend services
|
||||
- JavaScript frontend and test helpers
|
||||
- GitHub Actions workflows
|
||||
- connector ingress paths
|
||||
- filesystem and model-management flows
|
||||
|
||||
The residual GitHub findings demonstrate that static security analysis is catching issues that ordinary local happy-path tests do not reliably surface.
|
||||
|
||||
The correct home for CodeQL in this repository is the GitHub Actions security-validation layer, not the mandatory local development loop.
|
||||
|
||||
## 2. Local vs Remote Validation Boundary
|
||||
|
||||
Local validation should focus on:
|
||||
|
||||
- targeted regressions for the changed bug surface
|
||||
- repo-local contract tests
|
||||
- the smallest credible transaction seam for each fix
|
||||
|
||||
GitHub-hosted validation should own:
|
||||
|
||||
- CodeQL scans
|
||||
- code-scanning alert lifecycle
|
||||
- long-running static dataflow analysis
|
||||
- alert triage over the default branch
|
||||
- secret-scanning closure workflow when a finding is confirmed to be historical or non-live
|
||||
|
||||
## 3. Recommended CodeQL Rollout Model
|
||||
|
||||
Recommended rollout order:
|
||||
|
||||
1. enable repository-native CodeQL in GitHub Actions
|
||||
2. start in visibility/baseline mode
|
||||
3. review new findings against changed files first
|
||||
4. graduate to stricter gating only after the backlog is reduced
|
||||
|
||||
Recommended initial policy:
|
||||
|
||||
- languages: Python, JavaScript/TypeScript, GitHub Actions
|
||||
- query suite: begin with standard security queries; expand only if runtime/cost remains acceptable
|
||||
- gating: report-only at first, then fail on new high-severity findings after baseline stabilization
|
||||
|
||||
## 4. Secret Scanning Policy
|
||||
|
||||
Secret-scanning findings must be handled differently from CodeQL findings:
|
||||
|
||||
- removing or editing repository content does not automatically guarantee closure
|
||||
- historical example values can continue to alert
|
||||
- closure requires provenance review
|
||||
|
||||
Before closing a secret-scanning alert:
|
||||
|
||||
1. determine whether the value was ever real or was always an example / placeholder
|
||||
2. confirm whether any rotation or revocation is required
|
||||
3. avoid copying raw secret material into planning docs, issue text, or commit messages
|
||||
4. record the closure rationale in planning and implementation evidence
|
||||
|
||||
## 5. Residual-Wave Review Checklist
|
||||
|
||||
For each remaining GitHub Security family:
|
||||
|
||||
- identify whether the finding is a true vulnerability, a scanner-visible dangerous pattern, or a probable false positive
|
||||
- prefer code changes that make the safe boundary obvious to both humans and scanners
|
||||
- add a hot-spot comment at the fix point when regression risk is high
|
||||
- add targeted regression coverage if a local seam exists
|
||||
- verify GitHub rescans after push
|
||||
- dismiss only as a last resort, with recorded rationale
|
||||
@@ -3,22 +3,39 @@
|
||||
```openclaw-compat-matrix-meta
|
||||
{
|
||||
"anchors": {
|
||||
"comfyui": "v0.18.1-19-g2a1f4026",
|
||||
"comfyui_frontend": "1.43.6+bcb39b1bf",
|
||||
"desktop": "0.8.26 (core 0.18.2 / frontend 1.41.21)"
|
||||
"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-20260327",
|
||||
"updated_at": "2026-03-26T16:00:00+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-03-26",
|
||||
"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** | `v0.18.1-19-g2a1f4026` reference anchor | Older snapshots | Current upstream reference repo head used for compatibility review |
|
||||
| **ComfyUI Frontend** | `1.43.6+bcb39b1bf` reference anchor | Minor drift around the anchor | Sidebar extension contract (`registerSidebarTab`) still matches this repo |
|
||||
| **ComfyUI Desktop** | `0.8.26 (core 0.18.2 / frontend 1.41.21)` 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 an older frontend bundle than the standalone frontend 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
|
||||
|
||||
|
||||
@@ -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,13 +58,17 @@ 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:
|
||||
- Local providers (`ollama`, `lmstudio`) are loopback-only by design and should use `localhost` / `127.0.0.1` / `::1`.
|
||||
- Built-in local-provider defaults are OpenAI-compatible URLs:
|
||||
- `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
|
||||
@@ -137,24 +141,69 @@ Controls the `connector` sidecar process and outbound delivery.
|
||||
| `OPENCLAW_CONNECTOR_SLACK_OAUTH_INSTALL_PATH` | Slack | Local install route path (default `/slack/install`). |
|
||||
| `OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH` | Slack | Local OAuth callback route path (default `/slack/oauth/callback`). |
|
||||
| `OPENCLAW_CONNECTOR_SLACK_OAUTH_SCOPES` | Slack | Comma-separated bot scopes for install URL generation. |
|
||||
| `OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC` | Slack | TTL for single-use OAuth state tokens (default `600`). |
|
||||
| `OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC` | Slack | TTL for single-use OAuth state tokens (default `600`, clamped to `60..3600`). |
|
||||
| `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS` | Slack | Comma-separated trusted user IDs. |
|
||||
| `OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS` | Slack | Comma-separated trusted channel IDs. |
|
||||
| `OPENCLAW_CONNECTOR_FEISHU_APP_ID` | Feishu/Lark | App ID for the default binding. |
|
||||
| `OPENCLAW_CONNECTOR_FEISHU_APP_SECRET` | Feishu/Lark | App secret for the default binding. |
|
||||
| `OPENCLAW_CONNECTOR_FEISHU_VERIFICATION_TOKEN` | Feishu/Lark | Verification token for webhook event ingress. |
|
||||
| `OPENCLAW_CONNECTOR_FEISHU_ENCRYPT_KEY` | Feishu/Lark | Optional encrypt key for encrypted webhook payloads. |
|
||||
| `OPENCLAW_CONNECTOR_FEISHU_ACCOUNT_ID` | Feishu/Lark | Explicit account ID for the default binding. |
|
||||
| `OPENCLAW_CONNECTOR_FEISHU_DEFAULT_ACCOUNT_ID` | Feishu/Lark | Fallback account ID for multi-binding setups. |
|
||||
| `OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_ID` | Feishu/Lark | Workspace or tenant identifier associated with the binding. |
|
||||
| `OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_NAME` | Feishu/Lark | Human-readable workspace name surfaced in diagnostics. |
|
||||
| `OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON` | Feishu/Lark | JSON list of account/workspace binding records for multi-account setups. |
|
||||
| `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS` | Feishu/Lark | Comma-separated trusted user IDs. |
|
||||
| `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_CHATS` | Feishu/Lark | Comma-separated trusted chat IDs. |
|
||||
| `OPENCLAW_CONNECTOR_FEISHU_DOMAIN` | Feishu/Lark | API domain selector (`feishu` or `lark`). |
|
||||
| `OPENCLAW_CONNECTOR_FEISHU_MODE` | Feishu/Lark | Transport mode (`websocket` or `webhook`). |
|
||||
| `OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH` | Feishu/Lark | Local interactive callback route path (default `/feishu/callback`). |
|
||||
| `OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM` | Core | Per-user connector rate limit (default `10`, clamped to `1..600`). |
|
||||
| `OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM` | Core | Per-channel connector rate limit (default `30`, clamped to `1..600`). |
|
||||
| `OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH` | Core | Max accepted connector command text length (default `4096`, clamped to `128..32768`). |
|
||||
|
||||
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:**
|
||||
|
||||
| Variable | Description |
|
||||
| :--- | :--- |
|
||||
| `OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC` | Timeout (sec) for delivering results to chat (default: `600`). |
|
||||
| `OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC` | Timeout (sec) for delivering results to chat (default `600`, clamped to `30..3600`). |
|
||||
| `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL` | Public base URL for serving images to LINE/Webhooks. |
|
||||
| `OPENCLAW_CONNECTOR_MEDIA_PATH` | Local directory for staging media files. |
|
||||
| `OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES` | Max completed images delivered per job (default `4`, clamped to `1..16`). |
|
||||
| `OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES` | Per-image delivery cap in bytes (default `10485760`, clamped to `65536..52428800`). |
|
||||
| `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.
|
||||
|
||||
@@ -168,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. |
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# GitHub CodeQL Baseline Reference
|
||||
|
||||
Date: 2026-04-08
|
||||
|
||||
## Purpose
|
||||
|
||||
This note records the repository decision for GitHub CodeQL rollout and the specific operational assumptions used in `S82`.
|
||||
|
||||
## Official GitHub References Reviewed
|
||||
|
||||
- GitHub Docs: `Configuring default setup for code scanning`
|
||||
- Default setup is recommended when first enabling code scanning because it chooses the simplest working analysis method automatically.
|
||||
- Default setup can be edited for languages/query-suite choices, but it remains UI-managed.
|
||||
- GitHub Docs: `Configuring advanced setup for code scanning`
|
||||
- Advanced setup is the path for repository-owned workflow configuration.
|
||||
- Advanced setup uses a committed workflow file and standard GitHub Actions syntax.
|
||||
- The generated workflow normally scans on `push`, `pull_request`, and schedule.
|
||||
- GitHub Docs: `CodeQL code scanning for compiled languages`
|
||||
- `build-mode: none` is valid for interpreted languages and is acceptable for Python and JavaScript/TypeScript in this repository.
|
||||
|
||||
## Repository Decision
|
||||
|
||||
- Keep CodeQL in the GitHub Actions security-validation layer, not as a new local mandatory SOP command.
|
||||
- Use an in-repo workflow (`.github/workflows/codeql.yml`) so security scanning policy is reviewable in git.
|
||||
- Start visibility-first:
|
||||
- no immediate merge-blocking policy based solely on CodeQL
|
||||
- use findings as triage input while the backlog is still being reduced
|
||||
- Cover the repository languages that matter for the current risk surface:
|
||||
- `python`
|
||||
- `javascript-typescript`
|
||||
- `actions`
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- The repository now uses the committed `.github/workflows/codeql.yml` workflow as the authoritative advanced CodeQL baseline.
|
||||
- GitHub `default setup` was switched to `not-configured` during `S91`, so scanner policy now lives in version control instead of the repository UI.
|
||||
- The first successful advanced-baseline runs completed on `main` during the residual security closeout wave.
|
||||
- Local acceptance for CodeQL workflow changes must use repo-local seam tests against the workflow contract; do not require a full local CodeQL run unless a future task explicitly asks for it.
|
||||
@@ -0,0 +1,99 @@
|
||||
# GitHub Security Residual Wave Reference
|
||||
|
||||
Date: 2026-04-08
|
||||
Scope: Residual GitHub Security findings that remained open after the first `S73-S81` remediation wave.
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This reference captures the follow-up repair strategy that was used for the residual GitHub Security findings that still pointed at `main` after the initial CodeQL / Dependabot cleanup wave.
|
||||
|
||||
This document is planning-only guidance. It is not itself an implementation record.
|
||||
|
||||
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. Intake Residual Findings Baseline
|
||||
|
||||
Authenticated GitHub Security review showed this residual baseline at intake:
|
||||
|
||||
- Dependabot: `0` open alerts
|
||||
- Code scanning: `19` open alerts
|
||||
- Secret scanning: `1` open alert
|
||||
|
||||
Residual CodeQL families at intake:
|
||||
|
||||
1. `py/path-injection`
|
||||
- intake concentration: `services/model_manager_transfer.py`
|
||||
- count at intake: `9`
|
||||
2. `py/weak-sensitive-data-hashing`
|
||||
- intake concentration: `services/redaction.py`, `services/audit.py`, `services/bridge_token_lifecycle.py`
|
||||
- count at intake: `3`
|
||||
3. `py/stack-trace-exposure`
|
||||
- intake concentration: `connector/platforms/slack_webhook.py`, `connector/platforms/feishu_webhook.py`
|
||||
- count at intake: `2`
|
||||
4. `py/clear-text-logging-sensitive-data`
|
||||
- intake concentration: `api/bridge.py`, `services/audit.py`
|
||||
- count at intake: `2`
|
||||
5. `py/clear-text-storage-sensitive-data`
|
||||
- intake concentration: `services/audit.py`
|
||||
- count at intake: `1`
|
||||
6. `py/xml-bomb`
|
||||
- intake concentration: `connector/platforms/wechat_webhook.py`
|
||||
- count at intake: `1`
|
||||
7. `js/incomplete-sanitization`
|
||||
- intake concentration: `tests/e2e/specs/notifications.spec.js`
|
||||
- count at intake: `1`
|
||||
|
||||
Residual secret-scanning family:
|
||||
|
||||
- `Tencent WeChat API App ID`
|
||||
- count at intake: `1`
|
||||
- observed as a historical docs/example-style finding pending confirmation and closure workflow
|
||||
|
||||
## 3. Repair Principles
|
||||
|
||||
- Prefer real code fixes over dismissals.
|
||||
- Keep the repair chain scoped to the exact residual findings.
|
||||
- Add focused guard comments at high-risk fix points.
|
||||
- Add or extend the smallest credible local regression seam for each repaired family.
|
||||
- Do not run local CodeQL; GitHub remains the source of truth for scanner retirement.
|
||||
- For this bug-fixing chain only, do not force unrelated full local test sweeps when no direct seam exists.
|
||||
|
||||
## 4. Proposed Execution Order
|
||||
|
||||
1. Residual logging / storage / identity-tag cleanup
|
||||
2. Residual model-transfer path-boundary cleanup
|
||||
3. Secret-scanning provenance review and closure handling
|
||||
4. GitHub Security rescan verification and dismiss/close workflow
|
||||
5. Repository-native CodeQL GitHub Actions baseline activation
|
||||
|
||||
## 5. Acceptance Model
|
||||
|
||||
Each implementation item in the chain should follow:
|
||||
|
||||
1. plan file
|
||||
2. targeted implementation
|
||||
3. targeted verification
|
||||
4. implementation record
|
||||
5. acceptance commit
|
||||
|
||||
The chain-wide source of truth for test procedure remains:
|
||||
|
||||
- `tests/TEST_SOP.md`
|
||||
- `tests/E2E_TESTING_NOTICE.md`
|
||||
- `tests/E2E_TESTING_SOP.md`
|
||||
|
||||
For this residual GitHub Security chain, the explicit local validation strategy is:
|
||||
|
||||
- reproduce and pin the exact local seam when possible
|
||||
- run targeted regressions for the affected contract surface
|
||||
- do not run local full CodeQL
|
||||
- use GitHub rescans after push as the authoritative scanner-retirement check
|
||||
|
||||
## 6. Closure Policy
|
||||
|
||||
- Code scanning alerts should be fixed in code first and then re-checked after GitHub rescans the pushed `main` branch.
|
||||
- Secret-scanning alerts require explicit provenance review before closure.
|
||||
- False positives may be dismissed only after the code/test surface is demonstrably safe and the dismissal rationale is recorded.
|
||||
@@ -50,7 +50,8 @@ interface BannerStatus {
|
||||
- **Monitoring**: Polls `/health` every 10s.
|
||||
- **Triggers**: Checks `stats.observability.total_dropped > 0`.
|
||||
- **Display**: Simple DOM injection of canonical `.openclaw-banner` markup; legacy `.moltbot-banner` compatibility selectors remain available through centralized runtime aliasing.
|
||||
- **Limitations**: No connectivity state handling, no 'info'/'success' states, simplistic dedupe.
|
||||
- **Connectivity posture**: Queue-monitor disconnect warnings should tolerate initial sidebar/bootstrap races and only escalate after bounded repeated failure or post-healthy disconnect evidence, so transient startup misses do not become durable incident noise.
|
||||
- **Limitations**: No 'info'/'success' states, simplistic dedupe.
|
||||
|
||||
## 1.1 Notification Center (F66)
|
||||
|
||||
@@ -84,6 +85,7 @@ interface NotificationEntry {
|
||||
- Entries are deduplicated by source-specific keys and persisted in local storage across reloads.
|
||||
- `Dismiss` hides an entry from the active list without deleting the historical record from storage.
|
||||
- `Acknowledge` clears unread state while keeping the entry visible.
|
||||
- Notification `message` / `source` fields are treated as untrusted text at the render sink and must stay escaped before DOM insertion; notification content is not a supported HTML surface.
|
||||
- Sources with jump targets should attach a tab/action deep link so operators can navigate directly to the affected surface.
|
||||
- Canonical `openclaw-*` DOM/class ownership should be authored once in the shell/templates; any retained `moltbot-*` class compatibility must come from shared runtime alias helpers instead of duplicated markup.
|
||||
|
||||
@@ -120,7 +122,7 @@ interface ContextAction {
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Parameter Lab (F52)
|
||||
## 3. Parameter Lab
|
||||
|
||||
Contracts for bounded parameter sweeps and experiment orchestration.
|
||||
|
||||
@@ -133,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
|
||||
|
||||
@@ -0,0 +1,866 @@
|
||||
# Recent Updates History
|
||||
|
||||
This file preserves the longer update archive that was previously embedded directly in the README.
|
||||
The README now keeps only the most recent 3-5 update summaries and links here for the full historical record.
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<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 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.
|
||||
- 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.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Output contract, outbound egress handling, Security Doctor structure, and audit verification tooling aligned with the current runtime</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.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Provider URL parity and CI harness resilience tightened for local LLM defaults and Playwright bootstrap stability</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.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>PNG Info sidebar workflow added with ComfyUI metadata extraction, better large-image handling, and lower-noise operator alerts</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.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Repo-native CodeQL baseline and residual GitHub Security verification chain completed</strong></summary>
|
||||
|
||||
- Added a versioned GitHub Actions `CodeQL` workflow that scans Python, JavaScript/TypeScript, and GitHub Actions on push, pull request, manual dispatch, and a weekly schedule, so static security analysis now has an explicit in-repo baseline instead of depending only on opaque UI configuration.
|
||||
- Kept the rollout visibility-first: CodeQL is now a GitHub Actions security lane and documented CI boundary, but it is not treated as a new local mandatory full-SOP command; local acceptance stays seam-first while GitHub-hosted scanning owns the repository-wide static-analysis baseline.
|
||||
- Closed the acceptance-gap that surfaced during the residual verification push by propagating `defusedxml` through `requirements.txt`, preflight checks, local acceptance bootstraps, and CI preflight installation, with a repo-local dependency-parity regression seam to prevent future drift.
|
||||
- Re-ran the full existing pre-push acceptance gate successfully after the parity fix: detect-secrets, pre-commit, governance verification, backend full suites, real-backend lanes, adaptive adversarial gate, and Playwright E2E.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Security hardening wave completed across CI permissions, path boundaries, redaction, connector ingress, notification rendering, and GitHub security closure</strong></summary>
|
||||
|
||||
- Verified the minimal Vite development-tooling hotfix path already merged cleanly, so the repo now resolves the patched `vite` version without broadening the frontend toolchain scope.
|
||||
- Added explicit least-privilege GitHub Actions `permissions:` declarations and a repo-local regression seam so workflow token scope drift is now treated as a tracked security regression instead of an implicit repository default.
|
||||
- Hardened checkpoint, integrity, and managed model-transfer path handling to fail closed on invalid IDs, traversal markers, and rebased install targets, with focused regression coverage on every flagged filesystem sink.
|
||||
- Replaced raw security-sensitive identifiers in bridge, auth, audit, proxy, and safe-IO diagnostics with stable redacted tags, and upgraded sensitive hashing paths to keyed constructions instead of plain or hardcoded hash inputs.
|
||||
- Tightened connector ingress failure handling so WeChat rejects unsafe XML declarations before parser entry, while Slack and Feishu return bounded external failure text/codes instead of echoing raw exception detail.
|
||||
- Added a targeted Playwright seam proving notification payloads render as escaped text rather than live markup, locking the production notification sink against future HTML-interpolation regressions.
|
||||
- Completed the GitHub-side closeout for the same wave by switching the repository from GitHub code-scanning default setup to the versioned advanced CodeQL workflow, dismissing the final residual CodeQL false positives with recorded rationale, resolving the historical docs-only secret-scanning false positive, and bringing GitHub `Code scanning` / `Secret scanning` back to `0` open findings as of `2026-04-08`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Desktop host parity lane, refreshed compatibility anchors, and live-backend mock parity completed</strong></summary>
|
||||
|
||||
- Added an executable desktop-host regression lane for the OpenClaw sidebar and Remote Admin Console, so desktop-specific runtime drift is now verified separately from standalone frontend assumptions instead of being left to unit-only host detection.
|
||||
- Added shared Playwright host/runtime shims and remote-admin baseline mocks so desktop-host metadata, approvals refresh behavior, and host-sensitive UI evidence stay deterministic under the test harness.
|
||||
- Refreshed the recorded compatibility anchors against the current reference ComfyUI, ComfyUI Frontend, and Desktop hosts, keeping desktop embedded-frontend lag explicit in the published compatibility matrix and governance checks.
|
||||
- Updated the mocked live-backend parity lane so image-output surfaces now return deterministic mocked output artifacts, closing the remaining preview/result gap in the real-backend-style E2E contract.
|
||||
- Re-validated the combined batch on WSL with the full SOP gate: detect-secrets, pre-commit, governance verification, backend full suites, strict implementation-record lint, real-backend lanes, adaptive adversarial gate, and Playwright E2E.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Feishu connector chain completed with long-connection transport, tenant-aware bindings, and interactive approval callbacks</strong></summary>
|
||||
|
||||
- Added a Feishu/Lark connector baseline that supports both long-connection and webhook ingress modes, keeps transport behavior aligned through the shared connector authorization model, and makes host-domain differences explicit through `feishu` vs `lark` account binding metadata instead of ad hoc runtime branching.
|
||||
- Added Feishu account/workspace installation bindings with fail-closed resolution, tenant-aware diagnostics, normalized installation records, and support for multi-account binding manifests so one connector runtime can host more than one Feishu workspace contract safely.
|
||||
- Added Feishu interactive-card callback handling for approval and command actions, including signed callback envelopes, stale/replay rejection, duplicate-action dedupe, actor-context mapping, and explicit approval downgrade when untrusted users press run-affecting actions.
|
||||
- Updated the connector runtime so websocket-mode Feishu deployments also host the callback ingress surface, keeping interactive-card approvals available even when message ingress is handled over long connection instead of pure webhook mode.
|
||||
- Re-validated the full Feishu batch on WSL with the full SOP gate: detect-secrets, pre-commit, governance verification, backend full suites, strict implementation-record lint, real-backend lanes, adaptive adversarial gate, and Playwright E2E.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Snapshot-first diagnostics, delta polling contracts, schema alignment, and optional-dependency import hardening completed</strong></summary>
|
||||
|
||||
- Moved Explorer inventory diagnostics onto a snapshot-first contract so `/openclaw/preflight/inventory` returns quickly with explicit `snapshot_ts`, `scan_state`, `stale`, and `last_error` metadata while deep refresh continues in the background.
|
||||
- Hardened event and managed-download polling around deterministic cursor metadata, so operator surfaces can resume from `effective` and `next` sequence markers instead of relying on duplicate-prone full refresh loops.
|
||||
- Unified webhook and managed-model request/documentation fixtures around one shared contract bundle, tightened model-import destination validation to reject traversal markers fail-closed, and kept the published API/OpenAPI surfaces aligned with the runtime validators.
|
||||
- Removed the remaining import-time `aiohttp` traps from high-impact route/service modules by moving them onto one bounded compatibility seam, so minimal environments degrade deterministically at call time instead of crashing on module import.
|
||||
- Re-validated the full batch on WSL with the full SOP gate: detect-secrets, pre-commit, governance verification, backend full suites, strict implementation-record lint, real-backend lanes, adaptive adversarial gate, and Playwright E2E.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Frontend host compatibility, asset-backed output interop, and CI audit alignment completed</strong></summary>
|
||||
|
||||
- Hardened frontend host compatibility against current standalone frontend and desktop bundle drift by moving graph/widget compatibility logic onto shared host helpers, adding explicit sidebar host-surface stamping, and surfacing desktop embedded-frontend parity through compatibility diagnostics instead of implicit assumptions.
|
||||
- Added a bounded asset-output interoperability seam so classic ComfyUI history refs and newer asset-backed refs both resolve through the existing `/view` contract, preserving current temp/output behavior while allowing hash-backed previews where upstream metadata provides them.
|
||||
- Updated output/history-facing frontend and backend parsers together, so `Jobs` previews, callback payload image refs, and history extraction follow one canonical path rather than duplicating view-URL assembly logic in separate layers.
|
||||
- Refreshed compatibility anchors against the current reference repos and fixed the CI Python dependency audit path so the enforced audit checks declared project requirements instead of scanning unrelated runner/toolchain packages.
|
||||
- Re-validated the implementation on WSL with the full SOP gate: detect-secrets, pre-commit, backend full suites, strict implementation-record lint, adaptive adversarial gate, and Playwright E2E.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<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 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.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Maintainability wave completed across routes, model operations, admin shell, and compatibility cleanup</strong></summary>
|
||||
|
||||
- Split route registration into focused route-family registrars while keeping one startup composition root and preserving legacy `/moltbot/*` plus `/api/*` fallback behavior.
|
||||
- Split Model Manager internals into dedicated catalog, task-lifecycle, and transfer/security service slices without changing the accepted managed-download, resume, import, and recovery contract.
|
||||
- Extracted sidebar notification/banner runtime and standalone admin-console browser logic into dedicated modules so the shell stays a composition root instead of a growing page-level hotspot.
|
||||
- Centralized runtime generation of legacy `moltbot-*` class aliases and removed residual duplicated node image-helper wrappers, so canonical `openclaw-*` markup and shared image encoding logic now have one maintained path.
|
||||
- Re-validated the full batch on WSL with the full SOP gate: detect-secrets, pre-commit, backend full suites, adaptive adversarial gate, and Playwright E2E.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Slack multi-workspace installation flow completed, with final egress and notification-center hardening</strong></summary>
|
||||
|
||||
- Added Slack multi-workspace OAuth install/callback handling with single-use state validation, workspace-scoped installation binding, encrypted token refs, and workspace-aware reply routing for inbound events and delayed result delivery.
|
||||
- Expanded connector diagnostics so Slack installation health now surfaces stable fail-closed states such as `ok`, `invalid_token`, `revoked`, `workspace_unbound`, and `degraded` without exposing token material.
|
||||
- Moved Slack OAuth token exchange onto the same SSRF-safe outbound layer used by other protected network paths, closing the late-stage egress policy regression found during the full acceptance sweep.
|
||||
- Fixed a notification-center persistence regression so dismissed model-manager failure alerts stay hidden after reload instead of being immediately re-created by repeated background refresh failures, while historical storage remains intact.
|
||||
- Re-validated the final implementation on WSL with the full SOP gate: detect-secrets, pre-commit, backend full suites, adaptive adversarial gate, and Playwright E2E.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Planning, startup/config hardening, compatibility governance, and frontend hotspot reduction batch</strong></summary>
|
||||
|
||||
- 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.
|
||||
- Split the frontend shell hotspot and LLM model-list helper logic into smaller seams, then fixed the timer-binding regression uncovered during full-gate Playwright validation.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<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, 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 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.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Inventory indexing moved to snapshot-first refresh with background deep-scan</strong></summary>
|
||||
|
||||
- Changed `/openclaw/preflight/inventory` to return a fast snapshot first, then refresh inventory state in the background instead of blocking on full directory traversal.
|
||||
- Added snapshot freshness/status metadata (`snapshot_ts`, `scan_state`, `stale`, `last_error`) so the API and explorer UI can surface refresh progress and degraded scan results explicitly.
|
||||
- Added bounded traversal checkpoints and background refresh scheduling to reduce latency spikes on large model directories while keeping later reads convergent.
|
||||
- Added backend regression coverage for snapshot, stale/error, and API-state transitions, then validated with the full SOP gate.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Model Manager reliability upgrade: resumable downloads and restart-safe recovery</strong></summary>
|
||||
|
||||
- Added resumable managed download support using staged `.part` artifacts plus checkpoint metadata, so interrupted transfers can continue via HTTP Range when upstream contracts are compatible.
|
||||
- Added deterministic fallback-to-full restart paths when resume preconditions fail (range unsupported, validator drift, content-range mismatch) without bypassing existing provenance/SHA256 import gates.
|
||||
- Added persisted download task registry with startup recovery replay and bounded replay limit control (`OPENCLAW_MODEL_DOWNLOAD_RECOVERY_REPLAY_LIMIT`) to prevent unbounded restart churn.
|
||||
- Added backend regression coverage for resume success, fallback behavior, and replay-limit overflow handling, then validated with the full SOP gate.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Reasoning trace redaction hardening and privileged local-debug reveal gate</strong></summary>
|
||||
|
||||
- Added a shared reasoning-redaction boundary helper so reasoning/thinking-like fields are stripped by default from assist responses, event/SSE payloads, trace responses, callback payloads, and connector-facing trace formatting.
|
||||
- Added an explicit privileged reveal path that now requires request opt-in, server-side debug enablement, admin authorization, loopback source, and permissive local posture, with audit visibility for reveal attempts.
|
||||
- Kept final user-visible answers intact while preventing internal reasoning traces from leaking through default operator-facing serializers.
|
||||
- Closed a serializer compatibility regression found during full-gate validation and hardened WSL `/mnt/*` Playwright stability with environment-aware worker and readiness-timeout guardrails.
|
||||
- Validated with the full SOP gate on WSL (detect-secrets, pre-commit, backend full suite, real-backend lanes, adversarial gate, and frontend Playwright E2E).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Embedded model operations UX update: new Model Manager tab and Parameter Lab icon fix</strong></summary>
|
||||
|
||||
- Added a dedicated `Model Manager` sidebar tab for model search, managed download task queueing, task lifecycle monitoring, and completed-task import into managed install paths.
|
||||
- Added frontend regression coverage for the new tab flow (sidebar visibility/switching plus queue/import interaction path in Playwright E2E).
|
||||
- Fixed the `Parameter Lab` tab icon contract by using a PrimeIcon class so the tab icon renders correctly in the sidebar.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Multi-tenant isolation baseline, optional local secret sourcing, and layered config unification completed</strong></summary>
|
||||
|
||||
- Added a fail-closed tenant boundary model with tenant-scoped config/secret resolution, connector installation isolation, approvals/presets/templates visibility boundaries, and per-tenant execution concurrency caps.
|
||||
- Added optional local 1Password CLI key sourcing with explicit enablement, command allowlist, template validation, and bounded fail-closed lookup behavior.
|
||||
- Unified config precedence across runtime/config/provider call paths around a shared layered resolver (`env > runtime override > persisted > default`) with compatibility aliases preserved.
|
||||
- Completed full verification gate pass on `dev` (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Optional local secret-manager baseline for safer key sourcing</strong></summary>
|
||||
|
||||
- Added a pluggable backend secret-provider chain for API keys (`env -> optional 1Password CLI -> encrypted server store -> none`) so operators can keep runtime keys out of plaintext deployment config where needed.
|
||||
- Added fail-closed 1Password guardrails requiring explicit enablement, executable allowlist, command path validation, and bounded lookup timeout behavior.
|
||||
- Added regression coverage for precedence resolution, allowlist/failure fallback behavior, and no-secret-leak logging expectations.
|
||||
- Completed full verification gate pass on `dev` (detect-secrets, pre-commit, backend unit suites, adversarial/retry/real-backend lanes, and frontend Playwright E2E).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Today’s implementation roundup across frontend quality, planner contracts, and connector security baselines</strong></summary>
|
||||
|
||||
- Completed the frontend quality bundle by stabilizing canonical style ownership, adding baseline frontend unit coverage, and expanding regression coverage for Library/Approvals/admin-console parity.
|
||||
- Completed SSRF pinning regression hardening with dedicated no-skip coverage for pinned connect paths, multi-IP failover ordering, and TLS wrap degradation branches.
|
||||
- Completed planner profile/system-prompt externalization with validated file-backed registry loading, runtime-safe fallback/reload behavior, and synchronized profile sourcing across API, node, and Planner tab.
|
||||
- Completed connector contract baseline with multi-workspace installation lifecycle registry, encrypted token references, fail-closed workspace resolution, and reusable interactive callback security decisions (signature/timestamp/hash/replay/idempotency/policy mapping) plus admin diagnostics APIs.
|
||||
- Completed full verification gate pass on `dev` (detect-secrets, pre-commit, backend unit suites, adversarial/retry/real-backend lanes, and frontend Playwright E2E).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Connector multi-workspace installation and interactive callback contract baseline</strong></summary>
|
||||
|
||||
- Added a persistent connector installation registry with normalized installation identity (`platform`, `workspace_id`, `installation_id`, `token_refs`, `status`, `updated_at`) and explicit lifecycle transitions (`created`, `active`, `rotating`, `revoked`, `deactivated`, `uninstalled`).
|
||||
- Enforced fail-closed workspace resolution for connector ingress (`missing`, `ambiguous`, `inactive`, and `stale token ref` bindings are rejected deterministically).
|
||||
- Added reusable interactive callback security contract primitives (signed envelope, timestamp window, payload hash verification, replay/idempotency enforcement, ack/deferred callback lifecycle, and policy mapping to `public` / `run` / `admin` with explicit force-approval handling).
|
||||
- Added admin read/diagnostic APIs for connector installation state, resolution evidence, and lifecycle audit visibility, with redacted outputs only.
|
||||
- Completed full verification gate pass on `dev` (detect-secrets, pre-commit, backend unit suites, adversarial/retry/real-backend lanes, and frontend Playwright E2E).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Planner registry externalization with runtime-safe profile alignment</strong></summary>
|
||||
|
||||
- Moved planner profiles and the planner system prompt into validated file-backed defaults under `data/planner/`, with state-dir override precedence for operator-managed customization without source edits.
|
||||
- Added a planner profile list API so the Assist planner route, Prompt Planner node, and Planner tab resolve profiles from one synchronized source-of-truth.
|
||||
- Kept runtime behavior fail-closed with schema validation, prompt placeholder validation, embedded fallback defaults, and lazy reload on planner file changes.
|
||||
- Completed full verification gate pass on `dev` (detect-secrets, pre-commit, backend unit suites, adversarial/retry/real-backend lanes, and frontend Playwright E2E).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Frontend quality baseline for Library and Approvals surfaces</strong></summary>
|
||||
|
||||
- Canonicalized active frontend styling ownership around `openclaw-*`, including shell/tab-manager cleanup and a deterministic split of `web/openclaw.css` into core and legacy-alias modules.
|
||||
- Added a frontend unit-test lane with Vitest + jsdom plus baseline coverage for shared UI helpers and extracted Library tab state logic.
|
||||
- Expanded Playwright coverage for `Library` and `Approvals`, including success/degraded paths and approvals parity between the sidebar and the Remote Admin Console.
|
||||
- Completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, adversarial/retry/real-backend lanes, and frontend Playwright E2E).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Audit event clarity and connector ingress fail-closed hardening</strong></summary>
|
||||
|
||||
- Normalized audit helper behavior so config/secret/LLM-test convenience wrappers now emit one canonical audit event per action, reducing duplicate noise while preserving legacy compatibility paths.
|
||||
- Added shared connector allowlist posture evaluation and enforced fail-closed startup behavior for public/hardened deployments when connector ingress is active without allowlist coverage.
|
||||
- Kept local/permissive posture as warning-only, with synchronized visibility across startup checks, deployment profile checks, and Security Doctor diagnostics.
|
||||
- Added focused regression coverage and completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Startup fail-closed bootstrap hardening and public boundary guardrail</strong></summary>
|
||||
|
||||
- Enforced strict fail-closed startup propagation so bootstrap security-gate failures are no longer logged-and-continued; route/worker registration now aborts deterministically on fatal startup failures.
|
||||
- Added an explicit public deployment boundary acknowledgement contract:
|
||||
- `OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1` (legacy alias supported)
|
||||
- public profile gate now fails deterministically when this acknowledgement is missing.
|
||||
- Added a dedicated Security Doctor boundary posture check and machine-readable environment marker so shared ComfyUI/OpenClaw surface risk is visible to operators.
|
||||
- Synchronized deployment/operator docs for public boundary controls (reverse proxy path allowlist + network ACL requirements).
|
||||
- Completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Core runtime maintainability and contract hardening batch</strong></summary>
|
||||
|
||||
- Refactored startup/bootstrap responsibilities into clearer service slices to keep the entry path thin and easier to validate.
|
||||
- Hardened provider adapter error contracts with safer HTTP error propagation and retry-after handling consistency.
|
||||
- Replaced fragile JSON object extraction logic in LLM output parsing with stdlib decoder-based behavior for stronger edge-case resilience.
|
||||
- Unified node/runtime consistency by converging shared image encoding helpers and internal node naming compatibility paths.
|
||||
- Added and aligned regression coverage, then completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Security and reliability hotfix chain: startup gate cleanup, atomic audit writes, and clearer CSRF override posture</strong></summary>
|
||||
|
||||
- Cleaned up unreachable startup security-gate code after fatal raise paths, keeping fail-closed behavior explicit and reducing maintenance ambiguity.
|
||||
- Hardened append-only audit integrity by making hash-chain write flow atomic under a process lock to avoid concurrent chain-fork risk.
|
||||
- Added explicit startup warning when localhost no-origin override is enabled, plus a dedicated Security Doctor posture check/violation mapping for operator visibility.
|
||||
- Added focused regression coverage for startup warning/doctor posture and audit lock path behavior.
|
||||
- Completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Standalone remote admin mobile console for phone/desktop operations</strong></summary>
|
||||
|
||||
- Added an independent remote admin entry page at `/openclaw/admin` (legacy `/moltbot/admin`), separate from the ComfyUI side panel.
|
||||
- Added a mobile-first admin console layout for operational flows:
|
||||
- dashboard (health, provider/key state, scheduler/runs summary, recent error lines)
|
||||
- jobs/events (recent runs + SSE connect/poll fallback)
|
||||
- approvals (approve/reject)
|
||||
- schedules/triggers (toggle/run/fire)
|
||||
- config (read + guarded write)
|
||||
- doctor/diagnostics and quick actions (retry/model refresh/drill via existing policy gates)
|
||||
- Preserved backend security boundaries: remote write actions still require explicit admin-token and remote-admin policy conditions.
|
||||
- Completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Executor lane split and callback I/O isolation for better saturation resilience</strong></summary>
|
||||
|
||||
- Added dedicated executor lanes for LLM vs I/O workloads with bounded worker controls.
|
||||
- Migrated callback delivery and outbound HTTP callback paths to the I/O lane, reducing interference with LLM execution paths.
|
||||
- Added queue/saturation diagnostics and executor metrics exposure in health/stat telemetry.
|
||||
- Added targeted regression coverage for lane split behavior and callback I/O lane migration.
|
||||
- Completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Runtime lifecycle consistency, structured logging opt-in, and generated OpenAPI spec</strong></summary>
|
||||
|
||||
- Completed a focused runtime operability and contract maturity batch with full SOP verification:
|
||||
- added graceful shutdown/reset consistency hooks so scheduler/failover runtime state flushes and resets are deterministic
|
||||
- added opt-in structured JSON logging for core execution paths (including queue submit and LLM client) with bounded metadata events
|
||||
- added machine-readable OpenAPI spec generation and committed `docs/openapi.yaml` for integrator/review tooling use
|
||||
- added regression coverage for runtime lifecycle state handling, structured logging behavior, and OpenAPI generation drift
|
||||
- completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Assist streaming UX and frontend fetch-wrapper safety hardening</strong></summary>
|
||||
|
||||
- Completed a focused assist UX + frontend transport reliability batch with full SOP verification:
|
||||
- added optional streaming assist paths for Planner/Refiner with incremental preview updates and staged progress events
|
||||
- added backend streaming endpoints for planner/refiner assist flows with capability-gated frontend enablement and safe fallback to the existing non-stream path
|
||||
- added frontend live preview rendering for Planner/Refiner while preserving cancel/stale-response safety behavior
|
||||
- added idempotent fetch-wrapper composition guards to prevent duplicate wrapper stacking during repeated frontend bootstrap/setup
|
||||
- added backend/parser/frontend regression coverage for streaming assist behavior and fetch-wrapper idempotence, plus full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Recent hardening and reliability improvements: runtime guardrails, crypto drills, compatibility governance, and safer management queries</strong></summary>
|
||||
|
||||
- Completed a focused reliability + operations hardening batch with full SOP verification:
|
||||
- consolidated shared frontend/backed helper paths to reduce duplicated cancellation, JSON parsing, and import-fallback logic
|
||||
- added runtime guardrails diagnostics/contract enforcement so runtime-only safety limits stay visible and cannot be persisted back into config
|
||||
- added cryptographic lifecycle drill automation with machine-readable evidence for rotation, revoke, key-loss recovery, and token-compromise scenarios
|
||||
- added compatibility matrix governance metadata plus a refresh workflow script and operator-doctor freshness/drift warnings
|
||||
- hardened management query pagination behavior with deterministic malformed-input handling, bounded scans, and clearer cursor diagnostics for admin/event list paths
|
||||
- completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Latest completion: automation composer endpoint, safer payload drafting, and full verification pass</strong></summary>
|
||||
|
||||
- Completed the automation payload composer flow for safe draft generation:
|
||||
- added a new admin-only compose endpoint for trigger/webhook payload drafts (generate-only, no execution side effects)
|
||||
- added strict server-side validation and normalization for trigger/webhook draft payloads
|
||||
- added tool-calling schema support for automation payload composition with deterministic fallback behavior
|
||||
- exposed composer capability flag for frontend/runtime feature probing
|
||||
- added and extended backend tests for API handler, composer service, schema/validator coverage, and capability contract
|
||||
- completed full validation gate pass (detect-secrets, pre-commit, backend test lanes, adversarial smoke gate, and frontend Playwright E2E)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Slack app support closeout: secure Events API ingress, connector parity, and no-skip verification lanes</strong></summary>
|
||||
|
||||
- Completed Slack implementation hardening chain with full SOP validation:
|
||||
- added Slack Events API adapter with signed ingress checks, replay/dedupe handling, bot-loop suppression, allowlist enforcement, and thread-aware reply delivery
|
||||
- wired Slack runtime policy into existing connector authorization boundaries so command trust behavior stays consistent with other platforms
|
||||
- added dedicated Slack verification lanes for ingress contract coverage and real-backend flow parity, both enforced by skip-policy and full-test scripts
|
||||
- added optional Slack Socket Mode fallback transport with fail-closed startup checks and transport-parity behavior aligned to Events API safety controls
|
||||
- expanded observability redaction coverage for Slack token families and added endpoint-level drift tests for logs/trace/config safety
|
||||
- aligned local full-test scripts so Slack phase-2 suites run explicitly as part of the Slack integration gate step
|
||||
- synchronized verification evidence through detect-secrets, pre-commit, backend unit + real lanes, adversarial gate, and frontend E2E full pass
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Post-Wave E closeout: Hardening chain completed</strong></summary>
|
||||
|
||||
- Completed on 2026-02 with full SOP validation:
|
||||
- Bundle A: established security invariants registry and startup/CI invariant gates, plus route-plane explicit-classification governance to prevent unmanaged endpoint exposure drift
|
||||
- Bundle B: converged outbound egress to a single safe path and added CI/local dependency parity preflight to prevent local-pass/CI-fail runtime drift
|
||||
- Bundle C: added adversarial verification execution gates (bounded fuzz + mutation smoke with artifacts) and dual-lane retry partition hardening for deterministic degrade/audit behavior
|
||||
- end-to-end verification evidence was synchronized across CI, local full-test scripts, and implementation records
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Wave E closeout: deployment guardrails, contract parity, and verification hardening chain completed</strong></summary>
|
||||
|
||||
- Completed Wave E with full SOP validation:
|
||||
- Bundle A delivered startup deployment gate enforcement and deployment-profile matrix parity, then locked critical operator flow parity (including degraded-path behavior)
|
||||
- Bundle B closed security contract parity gaps across token/mapping/route/signature state matrices and threat-intel resilience paths
|
||||
- Bundle C completed signed policy posture control, bounded security anomaly telemetry, deterministic adversarial fuzz harness coverage, and mutation-baseline evidence generation
|
||||
- full detect-secrets + pre-commit + backend unit + frontend E2E gate passed and evidence is recorded in the Bundle C implementation record
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Wave D closeout: control-plane split, ingress and supply-chain hardening, and verification governance baseline</strong></summary>
|
||||
|
||||
- Completed Wave D closeout full SOP validation:
|
||||
- enforced split-mode control-plane boundaries for public deployments while preserving embedded daily UX flows
|
||||
- finalized external control-plane adapter reliability behavior and split-mode degraded/blocked-action guidance
|
||||
- completed secrets-at-rest hardening v2 with split-compatible secret-reference behavior
|
||||
- closed bridge token lifecycle, legacy webhook ingress clamp, and public MAE route-plane enforcement gaps
|
||||
- replaced registry signature placeholder posture with trust-root based cryptographic verification and signer governance
|
||||
- established verification governance baseline with skip-budget enforcement, reject/degrade triple-assert contracts, and defect-first record lint gating
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Wave A/B/C closeout: stability baseline, high-risk security gates, and operator UX completion</strong></summary>
|
||||
|
||||
- Completed baseline runtime/config/connector stability improvements:
|
||||
- runtime provenance and manager-aware environment freshness checks
|
||||
- safer config merge behavior for object arrays
|
||||
- connector session invalidation resilience for 401/410 revoke paths
|
||||
- durable replay/idempotency storage for webhook/bridge flows
|
||||
- stricter outbound egress policy controls for callback and LLM targets
|
||||
- Completed high-risk security and supply-chain hardening:
|
||||
- stronger external tool path resolution and allowlist enforcement
|
||||
- bridge/device binding hardening with mTLS validation controls
|
||||
- pack archive canonicalization and full manifest coverage enforcement
|
||||
- global DoS governance (quota/priority/storage controls)
|
||||
- signed release provenance pipeline and SBOM-integrity validation
|
||||
- Completed Wave C operator UX and functionality closeout:
|
||||
- Wave C functionality closeout accepted on 2026-02-18 with full SOP validation
|
||||
- deterministic operator guidance banners and deep-link recovery behavior
|
||||
- capability-aware in-canvas quick actions with guarded mutation flow
|
||||
- Parameter Lab schema lock and bounded sweep/compare orchestration
|
||||
- compare winner-selection safety contract and expanded Wave C regression coverage
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Audit trail and external tool sandbox hardening closeout</strong></summary>
|
||||
|
||||
- Added non-repudiation audit coverage for sensitive config/secrets/tools/approvals/bridge and startup-dangerous-override paths.
|
||||
- Standardized audit envelopes and append-only hash-chain logging to improve forensic traceability.
|
||||
- Added stricter external tool sandbox controls:
|
||||
- hardened-mode fail-closed when sandbox posture/runtime is unsafe
|
||||
- explicit network allowlist requirement when tooling enables egress
|
||||
- pre-exec filesystem path allowlist enforcement for tool arguments
|
||||
- Expanded security regression coverage for audit contract paths and sandbox policy enforcement.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Endpoint inventory hardening and route drift detection coverage</strong></summary>
|
||||
|
||||
- Added explicit endpoint security metadata across API handlers so auth/risk posture is machine-readable and auditable.
|
||||
- Added route inventory manifest generation to inspect registered API surfaces consistently.
|
||||
- Added drift regression tests that fail when any registered endpoint is missing security metadata.
|
||||
- Extended drift coverage to include optional bridge and packs routes to prevent false-green route scans.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Operator UX improvements: context toolbox, parameter lab history/replay, and compare workflow baseline</strong></summary>
|
||||
|
||||
- Added in-canvas OpenClaw quick actions on node context menus: Inspect, Doctor, Queue Status, Compare, and Settings.
|
||||
- Improved operator recovery flow by wiring quick actions to capability-aware targets with deterministic fallback guidance when optional endpoints are unavailable.
|
||||
- Added Parameter Lab history flow so operators can browse saved experiments, load details, and replay run parameters back into the current graph.
|
||||
- Added compare workflow baseline in Parameter Lab, including a dedicated compare endpoint with bounded fan-out and stricter payload validation.
|
||||
- Expanded auth and regression coverage so compare routes remain admin-protected and route-registration drift is caught earlier.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Pack security hardening: path traversal defense and strict API validation</strong></summary>
|
||||
|
||||
- Added path traversal protection for pack uninstall and pack path resolution.
|
||||
- Hardened pack install path construction by validating pack metadata segments (`name`, `version`) and enforcing root-bounded path resolution.
|
||||
- Added stricter input validation on pack API route handlers for pack lifecycle operations.
|
||||
- Expanded regression coverage for traversal attempts and invalid input handling in pack flows.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Runtime profile hardening and bridge startup compatibility checks</strong></summary>
|
||||
|
||||
- Added explicit runtime profiles with centralized resolution so startup behavior is deterministic across environments.
|
||||
- Added a hardened startup security gate that fails closed when mandatory controls are not correctly configured.
|
||||
- Added module capability boundaries so routes/workers only boot when their owning module is enabled.
|
||||
- Added a bridge protocol handshake path with version compatibility checks during sidecar startup.
|
||||
- Expanded regression coverage for profile resolution, startup gating, module boundaries, and bridge handshake behavior.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Connector platform parity and sidecar worker runtime improvements</strong></summary>
|
||||
|
||||
- Added stronger KakaoTalk response handling:
|
||||
- strict QuickReply cap with safe truncation
|
||||
- empty-response guard to avoid invalid platform payloads
|
||||
- more predictable output shaping and sanitization behavior
|
||||
- Added WeChat Official Account encrypted webhook support:
|
||||
- AES encrypted ingress (`encrypt_type=aes`) with signature verification and fail-closed decrypt/app-id validation
|
||||
- expanded event normalization coverage (`subscribe`, `unsubscribe`, `CLICK`, `VIEW`, `SCAN`)
|
||||
- deterministic dedupe behavior for event payloads without `MsgId`
|
||||
- bounded ACK-first flow with deferred reply handling for slow paths
|
||||
- Added sidecar worker bridge alignment end-to-end:
|
||||
- worker poll/result/heartbeat bridge endpoints
|
||||
- contract-driven sidecar client endpoint resolution and idempotency header behavior
|
||||
- dedicated E2E test coverage for worker route registration, auth, and round-trip behavior
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Security Hardening: Auth/Observability boundaries, connector command controls, registry trust policy, transform isolation, integrity checks, and safe tooling controls</strong></summary>
|
||||
|
||||
- Delivered observability tier hardening with explicit sensitivity split:
|
||||
- Public-safe: `/openclaw/health`
|
||||
- Observability token: `/openclaw/config`, `/openclaw/events`, `/openclaw/events/stream`
|
||||
- Admin-only: `/openclaw/logs/tail`, `/openclaw/trace/{prompt_id}`, `/openclaw/secrets/status`, `/openclaw/security/doctor`
|
||||
- Delivered constrained transform isolation hardening:
|
||||
- process-boundary execution via `TransformProcessRunner`
|
||||
- timeout/output caps and network-deny worker posture
|
||||
- feature-gated default-off behavior for safer rollout
|
||||
- Delivered approval/checkpoint integrity hardening:
|
||||
- canonical JSON + SHA-256 integrity envelopes
|
||||
- tamper detection and fail-closed handling on integrity violations
|
||||
- migration-safe loading behavior for legacy persistence files
|
||||
- Delivered external tooling execution policy:
|
||||
- allowlist-driven tool definitions (`data/tools_allowlist.json`)
|
||||
- strict argument validation, bounded timeout/output, and redacted output handling
|
||||
- gated by `OPENCLAW_ENABLE_EXTERNAL_TOOLS` plus admin access policy
|
||||
- Extended security doctor coverage with wave-2 checks:
|
||||
- validates transform isolation posture
|
||||
- reports external tooling posture
|
||||
- verifies integrity module availability
|
||||
- Auth-coverage contract tests were updated to include new tool routes and prevent future route-auth drift regressions.
|
||||
- Added connector command authorization hardening:
|
||||
- separates command visibility from command execution privileges
|
||||
- centralizes per-command access checks to reduce cross-platform auth drift
|
||||
- supports explicit allow-list policy controls for sensitive command classes
|
||||
- adds operator-configurable command policy controls via `OPENCLAW_COMMAND_OVERRIDES` and `OPENCLAW_COMMAND_ALLOW_FROM_{PUBLIC|RUN|ADMIN}`
|
||||
- Added registry anti-abuse controls for remote distribution paths:
|
||||
- bounded request-rate controls and deduplication windows reduce abuse and accidental hot loops
|
||||
- stale anti-abuse state pruning keeps long-running deployments stable
|
||||
- Added registry preflight and trust-policy hardening:
|
||||
- static package safety checks are enforced before activation paths
|
||||
- policy-driven signature/trust posture supports audit and strict enforcement modes
|
||||
- registry trust mode is operator-controlled via `OPENCLAW_REGISTRY_POLICY` and preflight verification enforces fail-closed file-path requirements
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Sprint A: closes out with five concrete reliability and security improvements</strong></summary>
|
||||
|
||||
- Configuration save/apply now returns explicit apply metadata, so callers can see what was actually applied, what requires restart, and which effective provider/model is active.
|
||||
- The Settings update flow adds defensive guards against stale or partial state, reducing accidental overwrites.
|
||||
- Provider/model precedence is now deterministic across save, test, and chat paths, and prevents model contamination when switching providers.
|
||||
- In localhost convenience mode (no admin token configured), chat requests enforce same-origin CSRF protection: same-origin requests are allowed, cross-origin requests are denied.
|
||||
- Model-list fetching now uses a bounded in-memory cache keyed by provider and base URL, with a 5-minute TTL and LRU eviction cap to improve responsiveness and stability.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Sprint B: ships security doctor diagnostics, registry quarantine gates, and constrained transforms defaults</strong></summary>
|
||||
|
||||
- Added the Security Doctor surface (`GET /openclaw/security/doctor`) for operator-focused security posture checks across endpoint exposure, token boundaries, SSRF posture, state-dir permissions, redaction drift, runtime mode, feature flags, and API key posture.
|
||||
- Added optional remote pack registry quarantine controls with explicit lifecycle states, SHA256 integrity verification, bounded local persistence, and per-entry audit trail; this path remains disabled by default and fail-closed.
|
||||
- Added optional constrained transform execution with trusted-directory + integrity pinning, timeout and output-size caps, and bounded chain execution semantics; transforms remain disabled by default and mapping-only behavior remains intact unless explicitly enabled.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Settings contract, frontend graceful degradation, and provider drift governance</strong></summary>
|
||||
|
||||
- Enforced a strict settings write contract with schema-coerced values and explicit unknown-key rejection, reducing save/apply regressions across ComfyUI variants.
|
||||
- Hardened frontend behavior to degrade safely when optional routes or runtime capabilities are unavailable, with clearer recovery hints instead of brittle failures.
|
||||
- Added provider alias/deprecation governance and normalization coverage to reduce preset drift as upstream model IDs and endpoint shapes evolve.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Mapping v1, job event stream, and operator doctor</strong></summary>
|
||||
|
||||
- Added webhook mapping engine v1 with declarative field mapping + type coercion, enabling external payload normalization without custom adapter code paths.
|
||||
- Added real-time job event stream support via SSE (`/openclaw/events/stream`) with bounded buffering and polling fallback (`/openclaw/events`) for compatibility.
|
||||
- Added Operator Doctor diagnostics tooling for runtime/deployment checks (Python/Node environment, state-dir posture, and contract readiness signals).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> Security doctor, registry quarantine, and constrained transforms</strong></summary>
|
||||
|
||||
- Added Security Doctor diagnostics surface (`GET /openclaw/security/doctor`) for operator-focused security posture checks and guarded remediation flow.
|
||||
- Added optional remote registry quarantine lifecycle controls with integrity verification, bounded local persistence, and explicit trust/audit gates.
|
||||
- Added optional constrained transform execution with integrity pinning, timeout/output caps, and bounded chain semantics; default posture remains disabled/fail-closed.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,50 @@
|
||||
# Residual Security Execution Chain
|
||||
|
||||
Date: 2026-04-08
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
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.
|
||||
|
||||
Maintainer-only execution records remain internal; this page is limited to public-facing status and remediation areas.
|
||||
|
||||
## 2. Remediation Areas
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
- Add hotspot comments at every high-risk repair seam.
|
||||
- Update or add the smallest credible regression seam for each fix.
|
||||
- Use targeted local tests for the changed contract surface.
|
||||
- Use GitHub rescans after push as the source of truth for code-scanning retirement.
|
||||
- Do not dismiss unresolved true positives.
|
||||
- Do not close the historical secret-scanning alert until provenance and placeholder status are fully confirmed.
|
||||
|
||||
## 5. Closure Evidence
|
||||
|
||||
Authenticated GitHub evidence on 2026-04-08:
|
||||
|
||||
- `GET /code-scanning/default-setup` now returns `state=not-configured`
|
||||
- the in-repo `CodeQL` workflow completed successfully on `main` head `0abdafab73e42ea4503992e7bc8cf76ef05fae03`
|
||||
- `GET /code-scanning/alerts?state=open` now returns `0`
|
||||
- secret-scanning alert `#1` is now `resolved` with `resolution=false_positive`
|
||||
|
||||
## 6. Expected End State
|
||||
|
||||
This chain is now complete:
|
||||
|
||||
- the remaining code-scanning alerts were either fixed in code or dismissed with explicit false-positive rationale
|
||||
- the historical secret-scanning alert was closed with recorded provenance evidence
|
||||
- the repository now relies on the committed advanced CodeQL workflow rather than GitHub default setup
|
||||
- the final GitHub-side actions were performed with the required repository-administration and alert-write permissions
|
||||
@@ -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
|
||||
|
||||
@@ -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://).
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Verification Governance
|
||||
|
||||
This document summarizes the public verification-governance contract that sits on top of the repository test workflow.
|
||||
|
||||
For the authoritative execution order and acceptance rules, follow:
|
||||
|
||||
- `tests/TEST_SOP.md`
|
||||
- `tests/E2E_TESTING_NOTICE.md`
|
||||
- `tests/E2E_TESTING_SOP.md`
|
||||
- `docs/release/ci_regression_policy.md`
|
||||
|
||||
## Standard Governance Checks
|
||||
|
||||
The standard local and CI-parity validation flow includes two explicit governance checks:
|
||||
|
||||
- `python scripts/verify_quality_governance.py`
|
||||
- keeps `pyproject.toml` coverage settings aligned with the staged ratchet policy in `tests/coverage_governance_policy.json`
|
||||
- protects mutation-threshold, SOP-guidance, and coverage-policy drift
|
||||
- `python scripts/verify_test_debt_governance.py`
|
||||
- fails closed on stale or under-documented entries in `tests/skip_policy.json`
|
||||
- fails closed on stale or under-documented entries in `tests/mutation_survivor_allowlist.json`
|
||||
|
||||
Backend unit coverage should be gathered through the shared helper instead of ad hoc `coverage run` invocations:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## Coverage Review Surface
|
||||
|
||||
Before any future coverage-floor promotion, review hotspot-family coverage with:
|
||||
|
||||
```bash
|
||||
python scripts/report_coverage_governance.py --coverage-json <path-to-coverage.json>
|
||||
```
|
||||
|
||||
This report is the governed review surface for critical families such as:
|
||||
|
||||
- `safe_io`
|
||||
- security boundaries
|
||||
- connector config and ingress seams
|
||||
- config and bootstrap seams
|
||||
|
||||
Retained release-cycle review evidence lives in:
|
||||
|
||||
- `tests/coverage_promotion_reviews.json`
|
||||
|
||||
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`.
|
||||
@@ -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`),
|
||||
@@ -70,7 +119,7 @@ Gate behavior:
|
||||
|
||||
Connector token/enable markers activate ingress posture checks for:
|
||||
|
||||
- Telegram, Discord, LINE, WhatsApp, WeChat, KakaoTalk, Slack
|
||||
- Telegram, Discord, LINE, WhatsApp, WeChat, KakaoTalk, Slack, Feishu/Lark
|
||||
|
||||
When a platform is active, at least one platform-specific allowlist variable must be configured.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,8 +15,11 @@
|
||||
- WeChat: `OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS`
|
||||
- KakaoTalk: `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS`
|
||||
- Slack: `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS` / `_ALLOWED_CHANNELS`
|
||||
- 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)
|
||||
|
||||
@@ -42,7 +45,9 @@
|
||||
- [ ] 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.
|
||||
|
||||
### 6. Debug Mode
|
||||
|
||||
@@ -66,6 +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 |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
+3
-2
@@ -10,9 +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.
|
||||
- Standalone sidecar/gateway evolution is tracked in `.planning/roadmap.md`.
|
||||
- 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/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.
|
||||
|
||||
+110
-4
@@ -28,6 +28,25 @@ Notes:
|
||||
- If your pack folder name is not `comfyui-openclaw`, the smoke script may need `OPENCLAW_PACK_IMPORT_NAME=your-folder-name`.
|
||||
- If imports fail with a `services.*` module error, check for name collisions with other custom nodes and prefer package-relative imports.
|
||||
|
||||
## Imported workflow shows missing OpenClaw nodes
|
||||
|
||||
Current builds expose node portability metadata so "missing custom node" can be diagnosed separately from a generic import failure.
|
||||
|
||||
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
|
||||
|
||||
Run the built-in diagnostic tool to verify environment readiness (libraries, permissions, contract files):
|
||||
@@ -44,6 +63,89 @@ 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 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.
|
||||
|
||||
If a result ref only exposes an upstream asset-service identifier and cannot be represented through `/view`, OpenClaw keeps that ref explicit instead of silently guessing a direct `/api/assets` fetch.
|
||||
|
||||
What this means:
|
||||
|
||||
- `asset_api_required` is a bounded compatibility state, not a generic parser failure.
|
||||
- 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
|
||||
|
||||
Use the retained-chain verifier:
|
||||
|
||||
```bash
|
||||
python scripts/verify_audit_chain.py
|
||||
```
|
||||
|
||||
JSON output:
|
||||
|
||||
```bash
|
||||
python scripts/verify_audit_chain.py --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The verifier checks the current `audit.log` and any retained rotated audit segments in the state directory.
|
||||
- When no audit chain key is supplied from environment/config, OpenClaw persists `audit.log.key` so verification still works across restart and rotation.
|
||||
- Treat verification failure as an audit-integrity incident until proven otherwise.
|
||||
|
||||
## Webhooks return `403 auth_not_configured`
|
||||
|
||||
Set webhook auth environment variables as described in the README quick-start section, then restart ComfyUI.
|
||||
@@ -61,6 +163,10 @@ Checklist:
|
||||
3. In OpenClaw Settings:
|
||||
- Provider: `Ollama (Local)` or `LM Studio (Local)`
|
||||
- Base URL: leave empty to use the provider default, or set a loopback URL explicitly
|
||||
- Provider defaults:
|
||||
- `Ollama (Local)` -> `http://127.0.0.1:11434/v1`
|
||||
- `LM Studio (Local)` -> `http://localhost:1234/v1`
|
||||
- If an older saved Ollama URL is still set to `http://127.0.0.1:11434`, update it to `/v1` or clear the field so the built-in default can be applied
|
||||
4. Keep these flags disabled:
|
||||
- `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=0`
|
||||
- `OPENCLAW_ALLOW_INSECURE_BASE_URL=0`
|
||||
@@ -71,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.
|
||||
|
||||
@@ -81,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:
|
||||
|
||||
@@ -91,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
|
||||
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Stable portability metadata for exported OpenClaw nodes.
|
||||
|
||||
This module must stay dependency-light because it is imported from the package
|
||||
entrypoint during ComfyUI custom-node loading.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any, Dict
|
||||
|
||||
PORTABILITY_CONTRACT_VERSION = 1
|
||||
|
||||
_NODE_PORTABILITY_MAPPINGS: Dict[str, Dict[str, Any]] = {
|
||||
"MoltbotPromptPlanner": {
|
||||
"display_name": "openclaw: Prompt Planner",
|
||||
"portable_mode": "materialize_standard_fields",
|
||||
"fallback_kind": "manual_rewire",
|
||||
"portable_summary": (
|
||||
"Resolve planner outputs ahead of runtime and feed standard prompt/"
|
||||
"parameter fields into downstream ComfyUI nodes."
|
||||
),
|
||||
"standard_field_targets": ["positive", "negative", "params_json"],
|
||||
"return_names": ["positive", "negative", "params_json"],
|
||||
"replacement_hints": [
|
||||
"Run the planning step before export and persist the generated positive/negative prompts plus params_json as standard workflow inputs.",
|
||||
"Remove the OpenClaw planner node from the portable workflow and wire the downstream sampler from normal STRING/parameter fields.",
|
||||
],
|
||||
},
|
||||
"MoltbotPromptRefiner": {
|
||||
"display_name": "openclaw: Prompt Refiner",
|
||||
"portable_mode": "materialize_standard_fields",
|
||||
"fallback_kind": "manual_rewire",
|
||||
"portable_summary": (
|
||||
"Persist refined prompt text and param_patch_json outside the graph, "
|
||||
"then inject the reviewed values through standard ComfyUI fields."
|
||||
),
|
||||
"standard_field_targets": [
|
||||
"refined_positive",
|
||||
"refined_negative",
|
||||
"param_patch_json",
|
||||
"rationale",
|
||||
],
|
||||
"return_names": [
|
||||
"refined_positive",
|
||||
"refined_negative",
|
||||
"param_patch_json",
|
||||
"rationale",
|
||||
],
|
||||
"replacement_hints": [
|
||||
"Review the refinement result outside the portable workflow and store the refined prompt text plus param patch as static inputs.",
|
||||
"Keep the rationale as operator notes only; the portable workflow should consume the reviewed prompt fields, not the OpenClaw node.",
|
||||
],
|
||||
},
|
||||
"MoltbotImageToPrompt": {
|
||||
"display_name": "openclaw: Image to Prompt",
|
||||
"portable_mode": "materialize_standard_fields",
|
||||
"fallback_kind": "manual_rewire",
|
||||
"portable_summary": (
|
||||
"Precompute caption/tags/prompt suggestion externally and feed the "
|
||||
"chosen text into standard ComfyUI prompt fields."
|
||||
),
|
||||
"standard_field_targets": ["caption", "tags", "prompt_suggestion"],
|
||||
"return_names": ["caption", "tags", "prompt_suggestion"],
|
||||
"replacement_hints": [
|
||||
"Run the image-to-prompt step before export and store the selected text outputs as normal workflow inputs.",
|
||||
"Do not expect vanilla ComfyUI to reproduce the vision analysis step inside the graph; only the resulting text fields are portable.",
|
||||
],
|
||||
},
|
||||
"MoltbotBatchVariants": {
|
||||
"display_name": "openclaw: Batch Variants",
|
||||
"portable_mode": "materialize_standard_fields",
|
||||
"fallback_kind": "manual_rewire",
|
||||
"portable_summary": (
|
||||
"Expand variant lists before export and execute the resulting prompt/"
|
||||
"parameter combinations through standard batch or queue tooling."
|
||||
),
|
||||
"standard_field_targets": [
|
||||
"positive_list",
|
||||
"negative_list",
|
||||
"params_json_list",
|
||||
],
|
||||
"return_names": ["positive_list", "negative_list", "params_json_list"],
|
||||
"replacement_hints": [
|
||||
"Generate the variant list before export and save the expanded prompt/parameter combinations outside the portable workflow.",
|
||||
"Use standard ComfyUI batch/queue mechanisms to run each prepared variant instead of relying on the OpenClaw batch node.",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_node_portability_mappings() -> Dict[str, Dict[str, Any]]:
|
||||
return copy.deepcopy(_NODE_PORTABILITY_MAPPINGS)
|
||||
|
||||
|
||||
NODE_PORTABILITY_MAPPINGS = get_node_portability_mappings()
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+640
-947
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user