mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 08:52:45 +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 | ||
|
|
3806951006 | ||
|
|
f462f464e5 | ||
|
|
7eaf62ef29 | ||
|
|
ec11c16ac2 | ||
|
|
72c74c153f | ||
|
|
b46dbbebae | ||
|
|
fd0967ff78 | ||
|
|
d2126d193c | ||
|
|
f9d68de2bd | ||
|
|
4bc7a357bd | ||
|
|
e583a0e0aa | ||
|
|
b404d35e74 | ||
|
|
7217b3e0de | ||
|
|
1137112caf | ||
|
|
1c993cc64b | ||
|
|
d2512fd166 | ||
|
|
674d74ff5e | ||
|
|
a1a022a1ca | ||
|
|
85494dc2bf | ||
|
|
37a3f071d5 | ||
|
|
7e0c1ee074 | ||
|
|
bf2345310e | ||
|
|
a111886552 | ||
|
|
fdef19eb0d | ||
|
|
bd318eb0ac | ||
|
|
9c50ddc4e9 | ||
|
|
971f1e2c82 | ||
|
|
ee0f279f78 | ||
|
|
0bd091ab71 | ||
|
|
cd20efb010 | ||
|
|
5e51433d7e | ||
|
|
52cb09e4be | ||
|
|
0d7020211c | ||
|
|
1b104b170b | ||
|
|
7519eb0efd | ||
|
|
a58cf621fa | ||
|
|
e5e4af3657 | ||
|
|
3b4f0dedba | ||
|
|
c1365130d3 | ||
|
|
af9598f326 | ||
|
|
3099abaff0 | ||
|
|
f22c006aac |
+78
-34
@@ -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,33 +198,47 @@ 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
|
||||
python -m pip install -r requirements.txt
|
||||
- name: Install pip-audit
|
||||
run: pip install pip-audit
|
||||
- name: Backend Audit (pip)
|
||||
run: pip-audit
|
||||
run: |
|
||||
# IMPORTANT: audit declared project deps, not the whole CI tool environment.
|
||||
# Env-wide `pip-audit` also scans pip-audit's own transient dependencies and
|
||||
# can fail on toolchain-only packages that are outside the repo dependency contract.
|
||||
pip-audit -r requirements.txt
|
||||
|
||||
adversarial-smoke:
|
||||
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
|
||||
@@ -215,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/
|
||||
@@ -226,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
|
||||
@@ -245,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()
|
||||
|
||||
+5
-2
@@ -3,13 +3,14 @@ Approval API Endpoints (S7/F12).
|
||||
REST endpoints for managing approval requests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Callable, Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ..services.access_control import resolve_token_info
|
||||
from ..services.aiohttp_compat import import_aiohttp_web
|
||||
from ..services.approvals.models import ApprovalStatus
|
||||
from ..services.approvals.service import get_approval_service
|
||||
from ..services.audit import emit_audit_event
|
||||
@@ -19,6 +20,7 @@ try:
|
||||
except ImportError:
|
||||
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
|
||||
from services.access_control import resolve_token_info # type: ignore
|
||||
from services.aiohttp_compat import import_aiohttp_web
|
||||
from services.approvals.models import ApprovalStatus
|
||||
from services.approvals.service import get_approval_service
|
||||
from services.audit import emit_audit_event # type: ignore
|
||||
@@ -33,6 +35,7 @@ except ImportError:
|
||||
from services.webhook_auth import AuthError
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.approvals")
|
||||
web = import_aiohttp_web()
|
||||
|
||||
|
||||
class ApprovalHandlers:
|
||||
|
||||
+21
-6
@@ -1,18 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
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.automation_composer import AutomationComposerService
|
||||
from ..services.planner import PlannerService
|
||||
from ..services.planner_registry import get_planner_registry
|
||||
from ..services.rate_limit import check_rate_limit
|
||||
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from ..services.reasoning_redaction import (
|
||||
audit_reasoning_reveal,
|
||||
resolve_reasoning_reveal,
|
||||
@@ -22,11 +23,12 @@ try:
|
||||
except ImportError:
|
||||
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
|
||||
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.automation_composer import AutomationComposerService
|
||||
from services.planner import PlannerService
|
||||
from services.planner_registry import get_planner_registry
|
||||
from services.rate_limit import check_rate_limit
|
||||
from services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from services.reasoning_redaction import (
|
||||
audit_reasoning_reveal,
|
||||
resolve_reasoning_reveal,
|
||||
@@ -51,6 +53,7 @@ else:
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.assist")
|
||||
web = import_aiohttp_web()
|
||||
|
||||
# Payload size limits (character count for strings, base64 length for images)
|
||||
MAX_REQUIREMENTS_LEN = 8000
|
||||
@@ -90,7 +93,13 @@ class AssistHandlers:
|
||||
if not authorized:
|
||||
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return web.json_response({"error": "Rate limit exceeded"}, status=429)
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=False,
|
||||
)
|
||||
return None
|
||||
|
||||
async def _parse_json_body(
|
||||
@@ -557,7 +566,13 @@ class AssistHandlers:
|
||||
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return web.json_response({"error": "Rate limit exceeded"}, status=429)
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=False,
|
||||
)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
|
||||
+32
-8
@@ -25,7 +25,8 @@ try:
|
||||
from ..services.bridge_handshake import verify_handshake
|
||||
from ..services.execution_budgets import BudgetExceededError
|
||||
from ..services.idempotency_store import IdempotencyStore
|
||||
from ..services.rate_limit import check_rate_limit
|
||||
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,
|
||||
@@ -43,7 +44,8 @@ except ImportError:
|
||||
from services.bridge_handshake import verify_handshake
|
||||
from services.execution_budgets import BudgetExceededError
|
||||
from services.idempotency_store import IdempotencyStore
|
||||
from services.rate_limit import check_rate_limit
|
||||
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,
|
||||
)
|
||||
@@ -255,7 +261,13 @@ class BridgeHandlers:
|
||||
scope=BridgeScope.JOB_SUBMIT.value,
|
||||
details={"reason": "rate_limit"},
|
||||
)
|
||||
return web.json_response({"error": "Rate limit exceeded"}, status=429)
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"bridge",
|
||||
web_module=web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=False,
|
||||
)
|
||||
|
||||
# Parse payload
|
||||
try:
|
||||
@@ -484,7 +496,13 @@ class BridgeHandlers:
|
||||
scope=BridgeScope.DELIVERY.value,
|
||||
details={"reason": "rate_limit"},
|
||||
)
|
||||
return web.json_response({"error": "Rate limit exceeded"}, status=429)
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"bridge",
|
||||
web_module=web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=False,
|
||||
)
|
||||
|
||||
# Parse payload
|
||||
try:
|
||||
@@ -704,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,
|
||||
@@ -733,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(),
|
||||
}
|
||||
|
||||
@@ -749,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(
|
||||
|
||||
+5
-1
@@ -3,12 +3,14 @@ Capabilities API Handler (R19).
|
||||
GET /openclaw/capabilities (legacy: /moltbot/capabilities)
|
||||
"""
|
||||
|
||||
from aiohttp import web
|
||||
from __future__ import annotations
|
||||
|
||||
# Import discipline
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.aiohttp_compat import import_aiohttp_web
|
||||
from ..services.capabilities import get_capabilities
|
||||
else:
|
||||
from services.aiohttp_compat import import_aiohttp_web
|
||||
from services.capabilities import get_capabilities
|
||||
|
||||
|
||||
@@ -27,6 +29,8 @@ else:
|
||||
endpoint_metadata,
|
||||
)
|
||||
|
||||
web = import_aiohttp_web()
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.PUBLIC,
|
||||
|
||||
@@ -24,7 +24,7 @@ if __package__ and "." in __package__:
|
||||
get_checkpoint,
|
||||
list_checkpoints,
|
||||
)
|
||||
from ..services.rate_limit import check_rate_limit
|
||||
from ..services.rate_limit import build_rate_limit_payload, check_rate_limit
|
||||
from ..services.request_ip import get_client_ip
|
||||
else: # pragma: no cover (test-only import mode)
|
||||
from models.schemas import MAX_BODY_SIZE # type: ignore
|
||||
@@ -35,7 +35,10 @@ else: # pragma: no cover (test-only import mode)
|
||||
get_checkpoint,
|
||||
list_checkpoints,
|
||||
)
|
||||
from services.rate_limit import check_rate_limit # type: ignore
|
||||
from services.rate_limit import ( # type: ignore
|
||||
build_rate_limit_payload,
|
||||
check_rate_limit,
|
||||
)
|
||||
from services.request_ip import get_client_ip # type: ignore
|
||||
|
||||
|
||||
@@ -105,7 +108,15 @@ async def list_checkpoints_handler(request: web.Request) -> web.Response:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return _json_resp({"ok": False, "error": "rate_limit_exceeded"}, 429)
|
||||
return _json_resp(
|
||||
build_rate_limit_payload(
|
||||
request,
|
||||
"admin",
|
||||
error="rate_limit_exceeded",
|
||||
include_ok=True,
|
||||
),
|
||||
429,
|
||||
)
|
||||
|
||||
allowed, error = require_admin_token(request)
|
||||
if not allowed:
|
||||
@@ -136,7 +147,15 @@ async def create_checkpoint_handler(request: web.Request) -> web.Response:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return _json_resp({"ok": False, "error": "rate_limit_exceeded"}, 429)
|
||||
return _json_resp(
|
||||
build_rate_limit_payload(
|
||||
request,
|
||||
"admin",
|
||||
error="rate_limit_exceeded",
|
||||
include_ok=True,
|
||||
),
|
||||
429,
|
||||
)
|
||||
|
||||
# Body Size Check
|
||||
if request.content_length and request.content_length > MAX_BODY_SIZE:
|
||||
|
||||
+112
-986
File diff suppressed because it is too large
Load Diff
@@ -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,18 +25,27 @@ 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,
|
||||
)
|
||||
from ..services.rate_limit import check_rate_limit
|
||||
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
|
||||
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,
|
||||
)
|
||||
from services.rate_limit import check_rate_limit # type: ignore
|
||||
from services.rate_limit import ( # type: ignore
|
||||
build_rate_limit_response,
|
||||
check_rate_limit,
|
||||
)
|
||||
from services.tenant_context import ( # type: ignore
|
||||
TenantBoundaryError,
|
||||
request_tenant_scope,
|
||||
@@ -62,8 +71,12 @@ logger = logging.getLogger("ComfyUI-OpenClaw.api.connector_contracts")
|
||||
|
||||
def _require_admin(request) -> Optional[web.Response]:
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Rate limit exceeded"}, status=429
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
allowed, err = require_admin_token(request)
|
||||
if not allowed:
|
||||
@@ -234,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(),
|
||||
}
|
||||
)
|
||||
|
||||
+37
-10
@@ -30,7 +30,7 @@ if __package__ and "." in __package__:
|
||||
from ..services.job_events import get_job_event_store
|
||||
from ..services.management_query import normalize_cursor_limit
|
||||
from ..services.metrics import metrics
|
||||
from ..services.rate_limit import check_rate_limit
|
||||
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from ..services.reasoning_redaction import (
|
||||
audit_reasoning_reveal,
|
||||
resolve_reasoning_reveal,
|
||||
@@ -43,7 +43,10 @@ else: # pragma: no cover
|
||||
from services.job_events import get_job_event_store # type: ignore
|
||||
from services.management_query import normalize_cursor_limit # type: ignore
|
||||
from services.metrics import metrics # type: ignore
|
||||
from services.rate_limit import check_rate_limit # type: ignore
|
||||
from services.rate_limit import ( # type: ignore
|
||||
build_rate_limit_response,
|
||||
check_rate_limit,
|
||||
)
|
||||
from services.reasoning_redaction import ( # type: ignore
|
||||
audit_reasoning_reveal,
|
||||
resolve_reasoning_reveal,
|
||||
@@ -112,10 +115,12 @@ async def events_stream_handler(request: web.Request) -> web.StreamResponse:
|
||||
|
||||
# Rate limit
|
||||
if not check_rate_limit(request, "events"):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "rate_limit_exceeded"},
|
||||
status=429,
|
||||
headers={"Retry-After": "60"},
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"events",
|
||||
web_module=web,
|
||||
error="rate_limit_exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
# Access control (same as logs/tail)
|
||||
@@ -223,10 +228,12 @@ async def events_poll_handler(request: web.Request) -> web.Response:
|
||||
|
||||
# Rate limit
|
||||
if not check_rate_limit(request, "events"):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "rate_limit_exceeded"},
|
||||
status=429,
|
||||
headers={"Retry-After": "60"},
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"events",
|
||||
web_module=web,
|
||||
error="rate_limit_exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
# Access control
|
||||
@@ -316,6 +323,26 @@ async def events_poll_handler(request: web.Request) -> web.Response:
|
||||
"cursor_status": cursor_status,
|
||||
"warnings": page.warnings,
|
||||
},
|
||||
"delta": {
|
||||
"cursor_key": "since",
|
||||
"requested_since_seq": since_requested,
|
||||
"effective_since_seq": since_effective,
|
||||
"next_since_seq": (events[-1].seq if events else since_effective),
|
||||
"latest_seq": latest_seq,
|
||||
"earliest_retained_seq": scan.get("earliest_retained_seq"),
|
||||
"latest_retained_seq": scan.get("latest_retained_seq"),
|
||||
"cursor_status": cursor_status,
|
||||
"snapshot": since_requested == 0,
|
||||
"truncated": bool(
|
||||
scan.get("truncated")
|
||||
or (
|
||||
events
|
||||
and isinstance(scan.get("latest_retained_seq"), int)
|
||||
and int(scan.get("latest_retained_seq")) > int(events[-1].seq)
|
||||
)
|
||||
),
|
||||
"warnings": page.warnings,
|
||||
},
|
||||
"scan": scan,
|
||||
}
|
||||
)
|
||||
|
||||
+27
-3
@@ -7,16 +7,16 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
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.management_query import normalize_cursor_limit
|
||||
from ..services.model_manager import ModelManagerError, model_manager
|
||||
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
|
||||
except ImportError: # pragma: no cover
|
||||
@@ -24,12 +24,14 @@ except ImportError: # pragma: no cover
|
||||
require_admin_token,
|
||||
resolve_token_info,
|
||||
)
|
||||
from services.aiohttp_compat import import_aiohttp_web # type: ignore
|
||||
from services.endpoint_manifest import ( # type: ignore
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
from services.management_query import normalize_cursor_limit # type: ignore
|
||||
from services.model_manager import ModelManagerError, model_manager # type: ignore
|
||||
from services.tenant_context import ( # type: ignore
|
||||
TenantBoundaryError,
|
||||
@@ -37,6 +39,7 @@ except ImportError: # pragma: no cover
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.model_manager")
|
||||
web = import_aiohttp_web()
|
||||
|
||||
|
||||
def _json(data: Dict[str, Any], status: int = 200) -> web.Response:
|
||||
@@ -159,6 +162,22 @@ async def model_download_list_handler(request: web.Request) -> web.Response:
|
||||
if deny:
|
||||
return deny
|
||||
token_info = resolve_token_info(request)
|
||||
since_seq = None
|
||||
delta_warnings = []
|
||||
if "since_seq" in request.query:
|
||||
page = normalize_cursor_limit(
|
||||
request.query,
|
||||
cursor_key="since_seq",
|
||||
default_cursor=0,
|
||||
min_cursor=0,
|
||||
default_limit=100,
|
||||
max_limit=200,
|
||||
)
|
||||
since_seq = int(page.cursor or 0)
|
||||
delta_warnings = list(page.warnings)
|
||||
limit = page.limit
|
||||
else:
|
||||
limit = _parse_int(request.query.get("limit"), 100, 1, 200)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
@@ -166,9 +185,14 @@ async def model_download_list_handler(request: web.Request) -> web.Response:
|
||||
result = model_manager.list_download_tasks(
|
||||
tenant_id=tenant.tenant_id,
|
||||
state=request.query.get("state", ""),
|
||||
limit=_parse_int(request.query.get("limit"), 100, 1, 200),
|
||||
limit=limit,
|
||||
offset=_parse_int(request.query.get("offset"), 0, 0, 10_000),
|
||||
since_seq=since_seq,
|
||||
)
|
||||
if since_seq is not None:
|
||||
result.setdefault("pagination", {})["warnings"] = delta_warnings
|
||||
if "delta" in result:
|
||||
result["delta"]["warnings"] = delta_warnings
|
||||
return _json({"ok": True, **result})
|
||||
except TenantBoundaryError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
|
||||
|
||||
@@ -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)
|
||||
@@ -24,8 +24,9 @@ if __package__ and "." in __package__:
|
||||
get_model_inventory_snapshot,
|
||||
run_preflight_check,
|
||||
)
|
||||
from ..services.rate_limit import check_rate_limit
|
||||
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
|
||||
@@ -34,8 +35,14 @@ else: # pragma: no cover (test-only import mode)
|
||||
get_model_inventory_snapshot,
|
||||
run_preflight_check,
|
||||
)
|
||||
from services.rate_limit import check_rate_limit # type: ignore
|
||||
from services.rate_limit import ( # type: ignore
|
||||
build_rate_limit_response,
|
||||
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__:
|
||||
@@ -102,8 +109,12 @@ async def preflight_handler(request: web.Request) -> web.Response:
|
||||
|
||||
# Rate limit: admin-grade endpoint (inventory leak + CPU cost)
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "rate_limit_exceeded"}, status=429
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=web,
|
||||
error="rate_limit_exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
# Body Size Check
|
||||
@@ -176,8 +187,12 @@ async def inventory_handler(request: web.Request) -> web.Response:
|
||||
|
||||
# Rate Limit
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "rate_limit_exceeded"}, status=429
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=web,
|
||||
error="rate_limit_exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
# Admin boundary (localhost convenience mode if no token configured)
|
||||
@@ -202,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
-22
@@ -8,31 +8,44 @@ import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
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 ..services.access_control import require_admin_token, resolve_token_info
|
||||
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.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
from services.presets import Preset, preset_store
|
||||
from services.tenant_context import TenantBoundaryError, request_tenant_scope
|
||||
(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()
|
||||
|
||||
|
||||
class PresetHandlers:
|
||||
|
||||
+4
-2
@@ -7,9 +7,8 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.aiohttp_compat import import_aiohttp_web
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
@@ -17,6 +16,7 @@ if __package__ and "." in __package__:
|
||||
endpoint_metadata,
|
||||
)
|
||||
else: # pragma: no cover (test-only import mode)
|
||||
from services.aiohttp_compat import import_aiohttp_web # type: ignore
|
||||
from services.endpoint_manifest import ( # type: ignore
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
@@ -24,6 +24,8 @@ else: # pragma: no cover (test-only import mode)
|
||||
endpoint_metadata,
|
||||
)
|
||||
|
||||
web = import_aiohttp_web()
|
||||
|
||||
|
||||
# CRITICAL: resolve HTML path relative to this module's package root.
|
||||
# Do not switch to cwd-based resolution; ComfyUI may launch from arbitrary directories.
|
||||
|
||||
@@ -13,10 +13,9 @@ import logging
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
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,
|
||||
@@ -39,6 +38,7 @@ except ImportError:
|
||||
require_admin_token,
|
||||
resolve_token_info,
|
||||
)
|
||||
from services.aiohttp_compat import import_aiohttp_web # type: ignore
|
||||
from services.endpoint_manifest import ( # type: ignore
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
@@ -61,6 +61,7 @@ except ImportError:
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.rewrite_recipes")
|
||||
web = import_aiohttp_web()
|
||||
|
||||
|
||||
def _json(data: Dict[str, Any], status: int = 200) -> web.Response:
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
R151 route-family registrar helpers.
|
||||
|
||||
Keep route composition data-driven without importing api.routes back into this
|
||||
module, which would reintroduce circular bootstrap fragility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteSpec:
|
||||
method: str
|
||||
path: str
|
||||
handler: Any
|
||||
|
||||
|
||||
def register_route_family(
|
||||
server, register_route_fn, specs: Iterable[RouteSpec]
|
||||
) -> None:
|
||||
for spec in specs:
|
||||
register_route_fn(server, spec.method, spec.path, spec.handler)
|
||||
|
||||
|
||||
def build_core_route_specs(
|
||||
prefix: str, handlers: dict[str, Any]
|
||||
) -> tuple[RouteSpec, ...]:
|
||||
return (
|
||||
RouteSpec("GET", f"{prefix}/admin", handlers["remote_admin_page_handler"]),
|
||||
RouteSpec("GET", f"{prefix}/health", handlers["health_handler"]),
|
||||
RouteSpec("GET", f"{prefix}/logs/tail", handlers["logs_tail_handler"]),
|
||||
RouteSpec("GET", f"{prefix}/jobs", handlers["jobs_handler"]),
|
||||
RouteSpec("GET", f"{prefix}/trace/{{prompt_id}}", handlers["trace_handler"]),
|
||||
RouteSpec("POST", f"{prefix}/webhook", handlers["webhook_handler"]),
|
||||
RouteSpec(
|
||||
"POST",
|
||||
f"{prefix}/webhook/submit",
|
||||
handlers["webhook_submit_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"POST",
|
||||
f"{prefix}/webhook/validate",
|
||||
handlers["webhook_validate_handler"],
|
||||
),
|
||||
RouteSpec("GET", f"{prefix}/capabilities", handlers["capabilities_handler"]),
|
||||
RouteSpec("GET", f"{prefix}/config", handlers["config_get_handler"]),
|
||||
RouteSpec("PUT", f"{prefix}/config", handlers["config_put_handler"]),
|
||||
RouteSpec("POST", f"{prefix}/llm/test", handlers["llm_test_handler"]),
|
||||
RouteSpec("POST", f"{prefix}/llm/chat", handlers["llm_chat_handler"]),
|
||||
RouteSpec("GET", f"{prefix}/llm/models", handlers["llm_models_handler"]),
|
||||
RouteSpec("GET", f"{prefix}/templates", handlers["templates_list_handler"]),
|
||||
RouteSpec("POST", f"{prefix}/preflight", handlers["preflight_handler"]),
|
||||
RouteSpec(
|
||||
"GET",
|
||||
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",
|
||||
f"{prefix}/checkpoints",
|
||||
handlers["create_checkpoint_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"GET",
|
||||
f"{prefix}/checkpoints/{{id}}",
|
||||
handlers["get_checkpoint_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"DELETE",
|
||||
f"{prefix}/checkpoints/{{id}}",
|
||||
handlers["delete_checkpoint_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"GET",
|
||||
f"{prefix}/rewrite/recipes",
|
||||
handlers["rewrite_recipes_list_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"POST",
|
||||
f"{prefix}/rewrite/recipes",
|
||||
handlers["rewrite_recipe_create_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"GET",
|
||||
f"{prefix}/rewrite/recipes/{{recipe_id}}",
|
||||
handlers["rewrite_recipe_get_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"PUT",
|
||||
f"{prefix}/rewrite/recipes/{{recipe_id}}",
|
||||
handlers["rewrite_recipe_update_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"DELETE",
|
||||
f"{prefix}/rewrite/recipes/{{recipe_id}}",
|
||||
handlers["rewrite_recipe_delete_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"POST",
|
||||
f"{prefix}/rewrite/recipes/{{recipe_id}}/dry-run",
|
||||
handlers["rewrite_recipe_dry_run_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"POST",
|
||||
f"{prefix}/rewrite/recipes/{{recipe_id}}/apply",
|
||||
handlers["rewrite_recipe_apply_handler"],
|
||||
),
|
||||
RouteSpec("GET", f"{prefix}/models/search", handlers["model_search_handler"]),
|
||||
RouteSpec(
|
||||
"POST",
|
||||
f"{prefix}/models/downloads",
|
||||
handlers["model_download_create_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"GET",
|
||||
f"{prefix}/models/downloads",
|
||||
handlers["model_download_list_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"GET",
|
||||
f"{prefix}/models/downloads/{{task_id}}",
|
||||
handlers["model_download_get_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"POST",
|
||||
f"{prefix}/models/downloads/{{task_id}}/cancel",
|
||||
handlers["model_download_cancel_handler"],
|
||||
),
|
||||
RouteSpec("POST", f"{prefix}/models/import", handlers["model_import_handler"]),
|
||||
RouteSpec(
|
||||
"GET",
|
||||
f"{prefix}/models/installations",
|
||||
handlers["model_installations_list_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"GET",
|
||||
f"{prefix}/secrets/status",
|
||||
handlers["secrets_status_handler"],
|
||||
),
|
||||
RouteSpec("PUT", f"{prefix}/secrets", handlers["secrets_put_handler"]),
|
||||
RouteSpec(
|
||||
"GET",
|
||||
f"{prefix}/events/stream",
|
||||
handlers["events_stream_handler"],
|
||||
),
|
||||
RouteSpec("GET", f"{prefix}/events", handlers["events_poll_handler"]),
|
||||
RouteSpec(
|
||||
"DELETE",
|
||||
f"{prefix}/secrets/{{provider}}",
|
||||
handlers["secrets_delete_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"GET",
|
||||
f"{prefix}/security/doctor",
|
||||
handlers["security_doctor_handler"],
|
||||
),
|
||||
RouteSpec("GET", f"{prefix}/tools", handlers["tools_list_handler"]),
|
||||
RouteSpec(
|
||||
"POST",
|
||||
f"{prefix}/tools/{{name}}/run",
|
||||
handlers["tools_run_handler"],
|
||||
),
|
||||
RouteSpec("POST", f"{prefix}/lab/sweep", handlers["create_sweep_handler"]),
|
||||
RouteSpec("POST", f"{prefix}/lab/compare", handlers["create_compare_handler"]),
|
||||
RouteSpec(
|
||||
"GET",
|
||||
f"{prefix}/lab/experiments",
|
||||
handlers["list_experiments_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"GET",
|
||||
f"{prefix}/lab/experiments/{{exp_id}}",
|
||||
handlers["get_experiment_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"POST",
|
||||
f"{prefix}/lab/experiments/{{exp_id}}/runs/{{run_id}}",
|
||||
handlers["update_experiment_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"POST",
|
||||
f"{prefix}/lab/experiments/{{exp_id}}/winner",
|
||||
handlers["select_apply_winner_handler"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_assist_route_specs(prefix: str, assist) -> tuple[RouteSpec, ...]:
|
||||
return (
|
||||
RouteSpec(
|
||||
"GET",
|
||||
f"{prefix}/assist/planner/profiles",
|
||||
assist.planner_profiles_handler,
|
||||
),
|
||||
RouteSpec("POST", f"{prefix}/assist/planner", assist.planner_handler),
|
||||
RouteSpec(
|
||||
"POST",
|
||||
f"{prefix}/assist/planner/stream",
|
||||
assist.planner_stream_handler,
|
||||
),
|
||||
RouteSpec("POST", f"{prefix}/assist/refiner", assist.refiner_handler),
|
||||
RouteSpec(
|
||||
"POST",
|
||||
f"{prefix}/assist/refiner/stream",
|
||||
assist.refiner_stream_handler,
|
||||
),
|
||||
RouteSpec(
|
||||
"POST",
|
||||
f"{prefix}/assist/automation/compose",
|
||||
assist.compose_handler,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_connector_installation_route_specs(
|
||||
prefix: str, handlers: dict[str, Any]
|
||||
) -> tuple[RouteSpec, ...]:
|
||||
return (
|
||||
RouteSpec(
|
||||
"GET",
|
||||
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",
|
||||
handlers["connector_installation_resolve_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"GET",
|
||||
f"{prefix}/connector/installations/audit",
|
||||
handlers["connector_installation_audit_handler"],
|
||||
),
|
||||
RouteSpec(
|
||||
"GET",
|
||||
f"{prefix}/connector/installations/{{installation_id}}",
|
||||
handlers["connector_installation_get_handler"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_pack_route_specs(prefix: str, packs) -> tuple[RouteSpec, ...]:
|
||||
return (
|
||||
RouteSpec("GET", f"{prefix}/packs", packs.list_packs_handler),
|
||||
RouteSpec("POST", f"{prefix}/packs/import", packs.import_pack_handler),
|
||||
RouteSpec(
|
||||
"GET",
|
||||
f"{prefix}/packs/export/{{name}}/{{version}}",
|
||||
packs.export_pack_handler,
|
||||
),
|
||||
RouteSpec(
|
||||
"DELETE",
|
||||
f"{prefix}/packs/{{name}}/{{version}}",
|
||||
packs.delete_pack_handler,
|
||||
),
|
||||
)
|
||||
+324
-689
File diff suppressed because it is too large
Load Diff
+25
-3
@@ -3,11 +3,11 @@ Scheduler CRUD API (R4).
|
||||
REST endpoints for managing persistent schedules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
# Import discipline:
|
||||
# - ComfyUI runtime: package-relative imports only (prevents collisions with other custom nodes).
|
||||
# - Unit tests: allow top-level fallbacks.
|
||||
@@ -15,17 +15,32 @@ from aiohttp import web
|
||||
# IMPORTANT: Avoid a broad `try/except ImportError` here. Falling back to `services.*` in ComfyUI
|
||||
# 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
|
||||
from services.webhook_auth import AuthError # type: ignore
|
||||
|
||||
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():
|
||||
@@ -154,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)
|
||||
|
||||
@@ -206,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:
|
||||
@@ -215,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)
|
||||
|
||||
|
||||
+17
-8
@@ -15,22 +15,23 @@ Endpoints:
|
||||
- DELETE /openclaw/secrets/{provider}: Clear provider secret
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
# Import discipline:
|
||||
# - ComfyUI runtime: package-relative imports only.
|
||||
# - Unit tests: allow top-level fallbacks.
|
||||
if __package__ and "." in __package__:
|
||||
from ..models.schemas import MAX_BODY_SIZE
|
||||
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.csrf_protection import require_same_origin_if_no_token
|
||||
from ..services.metrics import metrics
|
||||
from ..services.rate_limit import check_rate_limit
|
||||
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from ..services.request_ip import get_client_ip
|
||||
from ..services.runtime_config import get_admin_token, is_loopback_client
|
||||
from ..services.secret_store import get_secret_store
|
||||
@@ -38,15 +39,21 @@ else: # pragma: no cover (test-only import mode)
|
||||
from models.schemas import MAX_BODY_SIZE # type: ignore
|
||||
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.csrf_protection import require_same_origin_if_no_token # type: ignore
|
||||
from services.metrics import metrics # type: ignore
|
||||
from services.rate_limit import check_rate_limit # type: ignore
|
||||
from services.rate_limit import ( # type: ignore
|
||||
build_rate_limit_response,
|
||||
check_rate_limit,
|
||||
)
|
||||
from services.request_ip import get_client_ip # type: ignore
|
||||
from services.runtime_config import get_admin_token # type: ignore
|
||||
from services.runtime_config import is_loopback_client
|
||||
from services.secret_store import get_secret_store # type: ignore
|
||||
|
||||
web = import_aiohttp_web()
|
||||
|
||||
# R98: Endpoint Metadata
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.endpoint_manifest import (
|
||||
@@ -120,10 +127,12 @@ def _rate_limit_admin(request: web.Request) -> Optional[web.Response]:
|
||||
if check_rate_limit(request, "admin"):
|
||||
return None
|
||||
metrics.increment("rate_limit_exceeded")
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "rate_limit_exceeded"},
|
||||
status=429,
|
||||
headers={"Retry-After": "60"},
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=web,
|
||||
error="rate_limit_exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+11
-4
@@ -41,11 +41,14 @@ except ImportError: # pragma: no cover
|
||||
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.access_control import require_admin_token
|
||||
from ..services.rate_limit import check_rate_limit
|
||||
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from ..services.security_doctor import run_security_doctor
|
||||
else: # pragma: no cover (test-only)
|
||||
from services.access_control import require_admin_token # type: ignore
|
||||
from services.rate_limit import check_rate_limit # type: ignore
|
||||
from services.rate_limit import ( # type: ignore
|
||||
build_rate_limit_response,
|
||||
check_rate_limit,
|
||||
)
|
||||
from services.security_doctor import run_security_doctor # type: ignore
|
||||
|
||||
# R98: Endpoint Metadata
|
||||
@@ -90,8 +93,12 @@ async def security_doctor_handler(request: web.Request) -> web.Response:
|
||||
|
||||
# S17: Rate limit
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Rate limit exceeded"}, status=429
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
# Admin boundary
|
||||
|
||||
+11
-6
@@ -22,7 +22,7 @@ if __package__ and "." in __package__:
|
||||
require_observability_access,
|
||||
resolve_token_info,
|
||||
)
|
||||
from ..services.rate_limit import check_rate_limit
|
||||
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from ..services.templates import get_template_service
|
||||
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
|
||||
else: # pragma: no cover (test-only import mode)
|
||||
@@ -30,7 +30,10 @@ else: # pragma: no cover (test-only import mode)
|
||||
require_observability_access,
|
||||
resolve_token_info,
|
||||
)
|
||||
from services.rate_limit import check_rate_limit # type: ignore
|
||||
from services.rate_limit import ( # type: ignore
|
||||
build_rate_limit_response,
|
||||
check_rate_limit,
|
||||
)
|
||||
from services.templates import get_template_service # type: ignore
|
||||
from services.tenant_context import ( # type: ignore
|
||||
TenantBoundaryError,
|
||||
@@ -108,10 +111,12 @@ async def templates_list_handler(request: web.Request) -> web.Response:
|
||||
|
||||
# Reuse the admin bucket to avoid unbounded enumeration from remote callers.
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Rate limit exceeded"},
|
||||
status=429,
|
||||
headers={"Retry-After": "60"},
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
+42
-30
@@ -3,38 +3,55 @@ S12: API Handlers for External Tools.
|
||||
Protected by Admin Token and Feature Flag.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ..services.access_control import require_admin_token, resolve_token_info
|
||||
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.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()
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
@@ -84,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
|
||||
|
||||
+5
-2
@@ -4,14 +4,14 @@ Endpoint for firing workflow triggers from external systems.
|
||||
With S7 approval gate support.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
# Import discipline:
|
||||
# - ComfyUI runtime: this pack is loaded as a package; MUST use package-relative imports to avoid
|
||||
# collisions with other custom nodes or other top-level modules named `services`.
|
||||
@@ -23,6 +23,7 @@ from aiohttp import web
|
||||
# can silently import the WRONG module (another custom node or ComfyUI-adjacent package), causing
|
||||
# template allowlists to appear "missing" even when `data/templates/manifest.json` is correct.
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.aiohttp_compat import import_aiohttp_web
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
@@ -34,6 +35,7 @@ if __package__ and "." in __package__:
|
||||
from ..services.trace import generate_trace_id
|
||||
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.endpoint_manifest import ( # type: ignore
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
@@ -46,6 +48,7 @@ else: # pragma: no cover (test-only import mode)
|
||||
from services.webhook_auth import AuthError # type: ignore
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.triggers")
|
||||
web = import_aiohttp_web()
|
||||
|
||||
# Default: require approval for external triggers (secure-by-default)
|
||||
REQUIRE_APPROVAL_DEFAULT = (
|
||||
|
||||
+70
-41
@@ -9,49 +9,77 @@ POST /moltbot/webhook
|
||||
- Returns normalized internal request
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
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 .errors import APIError, ErrorCode, create_error_response
|
||||
except ImportError:
|
||||
# Build-time / Test fallback
|
||||
from api.errors import APIError, ErrorCode, create_error_response
|
||||
|
||||
try:
|
||||
from ..models.schemas import MAX_BODY_SIZE, WebhookJobRequest
|
||||
from ..services.metrics import metrics
|
||||
from ..services.rate_limit import 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.metrics import metrics
|
||||
from services.rate_limit import 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")
|
||||
web = import_aiohttp_web()
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
@@ -71,11 +99,12 @@ async def webhook_handler(request: web.Request) -> web.Response:
|
||||
# S17: Rate Limit
|
||||
if not check_rate_limit(request, "webhook"):
|
||||
metrics.inc("webhook_denied")
|
||||
return create_error_response(
|
||||
message="Rate limit exceeded",
|
||||
code=ErrorCode.RATE_LIMIT_EXCEEDED,
|
||||
status=429,
|
||||
detail={"retry_after": "60"},
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"webhook",
|
||||
web_module=web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
+14
-8
@@ -6,8 +6,6 @@ Connects S2 (Auth) -> R8 (Normalization) -> R3 (Idempotency) -> F5 (Execution).
|
||||
import json
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
# Import discipline:
|
||||
# - ComfyUI runtime: package-relative imports only (prevents collisions with other custom nodes).
|
||||
# - Unit tests: allow top-level fallbacks.
|
||||
@@ -17,13 +15,14 @@ from aiohttp import web
|
||||
# import another pack's top-level `services` module and break allowlists/auth in surprising ways.
|
||||
if __package__ and "." in __package__:
|
||||
from ..models.schemas import MAX_BODY_SIZE, WebhookJobRequest
|
||||
from ..services.aiohttp_compat import import_aiohttp_web
|
||||
from ..services.callback_delivery import start_callback_watch
|
||||
from ..services.execution_budgets import BudgetExceededError
|
||||
from ..services.idempotency_store import IdempotencyStore
|
||||
from ..services.job_events import JobEventType, get_job_event_store
|
||||
from ..services.metrics import metrics
|
||||
from ..services.queue_submit import submit_prompt
|
||||
from ..services.rate_limit import check_rate_limit
|
||||
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from ..services.templates import get_template_service
|
||||
from ..services.trace import get_effective_trace_id
|
||||
from ..services.trace_store import trace_store
|
||||
@@ -35,13 +34,17 @@ if __package__ and "." in __package__:
|
||||
)
|
||||
else: # pragma: no cover (test-only import mode)
|
||||
from models.schemas import MAX_BODY_SIZE, WebhookJobRequest
|
||||
from services.aiohttp_compat import import_aiohttp_web # type: ignore
|
||||
from services.callback_delivery import start_callback_watch # type: ignore
|
||||
from services.execution_budgets import BudgetExceededError # type: ignore
|
||||
from services.idempotency_store import IdempotencyStore # type: ignore
|
||||
from services.job_events import JobEventType, get_job_event_store # type: ignore
|
||||
from services.metrics import metrics # type: ignore
|
||||
from services.queue_submit import submit_prompt # type: ignore
|
||||
from services.rate_limit import check_rate_limit # type: ignore
|
||||
from services.rate_limit import ( # type: ignore
|
||||
build_rate_limit_response,
|
||||
check_rate_limit,
|
||||
)
|
||||
from services.templates import get_template_service # type: ignore
|
||||
from services.trace import get_effective_trace_id # type: ignore
|
||||
from services.trace_store import trace_store # type: ignore
|
||||
@@ -69,6 +72,7 @@ else:
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.webhook_submit")
|
||||
web = import_aiohttp_web()
|
||||
|
||||
|
||||
def safe_error_response(status: int, error: str, detail: str = "") -> web.Response:
|
||||
@@ -110,10 +114,12 @@ async def webhook_submit_handler(request: web.Request) -> web.Response:
|
||||
# S17: Rate Limit
|
||||
if not check_rate_limit(request, "webhook"):
|
||||
metrics.inc("webhook_denied")
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "rate_limit_exceeded"},
|
||||
status=429,
|
||||
headers={"Retry-After": "60"},
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"webhook",
|
||||
web_module=web,
|
||||
error="rate_limit_exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
+11
-6
@@ -39,7 +39,7 @@ if __package__ and "." in __package__:
|
||||
from ..models.schemas import MAX_BODY_SIZE, WebhookJobRequest
|
||||
from ..services.execution_budgets import BudgetExceededError, check_render_size
|
||||
from ..services.metrics import metrics
|
||||
from ..services.rate_limit import check_rate_limit
|
||||
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from ..services.templates import get_template_service
|
||||
from ..services.trace import get_effective_trace_id
|
||||
from ..services.webhook_auth import require_auth
|
||||
@@ -51,7 +51,10 @@ else: # pragma: no cover (test-only import mode)
|
||||
check_render_size,
|
||||
)
|
||||
from services.metrics import metrics # type: ignore
|
||||
from services.rate_limit import check_rate_limit # type: ignore
|
||||
from services.rate_limit import ( # type: ignore
|
||||
build_rate_limit_response,
|
||||
check_rate_limit,
|
||||
)
|
||||
from services.templates import get_template_service # type: ignore
|
||||
from services.trace import get_effective_trace_id # type: ignore
|
||||
from services.webhook_auth import require_auth # type: ignore
|
||||
@@ -106,10 +109,12 @@ async def webhook_validate_handler(request: web.Request) -> web.Response:
|
||||
# S17: Rate limit (same bucket as submit)
|
||||
if not check_rate_limit(request, "webhook"):
|
||||
metrics.inc("webhook_denied")
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "rate_limit_exceeded"},
|
||||
status=429,
|
||||
headers={"Retry-After": "60"},
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"webhook",
|
||||
web_module=web,
|
||||
error="rate_limit_exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
# S2: Content-Type + body size
|
||||
|
||||
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 |
@@ -11,12 +11,14 @@ try:
|
||||
GENERIC_LLM_API_KEY_ENV_KEYS,
|
||||
get_first_present_env,
|
||||
)
|
||||
from .services.effective_config import get_effective_llm_api_key
|
||||
except Exception:
|
||||
try:
|
||||
from services.config_layers import ( # type: ignore
|
||||
GENERIC_LLM_API_KEY_ENV_KEYS,
|
||||
get_first_present_env,
|
||||
)
|
||||
from services.effective_config import get_effective_llm_api_key # type: ignore
|
||||
except Exception:
|
||||
GENERIC_LLM_API_KEY_ENV_KEYS = (
|
||||
"OPENCLAW_LLM_API_KEY",
|
||||
@@ -31,28 +33,40 @@ except Exception:
|
||||
return env_map.get(key)
|
||||
return None
|
||||
|
||||
def get_effective_llm_api_key(provider=None, tenant_id=None): # type: ignore
|
||||
return get_first_present_env(GENERIC_LLM_API_KEY_ENV_KEYS)
|
||||
|
||||
|
||||
# Pack metadata
|
||||
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:
|
||||
@@ -67,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"
|
||||
|
||||
@@ -93,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.
|
||||
@@ -146,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.
|
||||
@@ -165,14 +224,12 @@ class RedactedFormatter(logging.Formatter):
|
||||
|
||||
def get_api_key() -> Optional[str]:
|
||||
"""
|
||||
Retrieves the LLM API key from environment variables.
|
||||
Retrieves the effective LLM API key via the unified config facade.
|
||||
|
||||
Preference order:
|
||||
1) OPENCLAW_LLM_API_KEY
|
||||
2) (legacy) MOLTBOT_LLM_API_KEY
|
||||
3) (legacy) CLAWDBOT_LLM_API_KEY
|
||||
This keeps logger redaction aligned with the same provider/key resolution
|
||||
path used by runtime consumers.
|
||||
"""
|
||||
value = get_first_present_env(GENERIC_LLM_API_KEY_ENV_KEYS)
|
||||
value = get_effective_llm_api_key()
|
||||
return value or None
|
||||
|
||||
|
||||
@@ -191,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 []
|
||||
@@ -205,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",
|
||||
@@ -223,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()
|
||||
|
||||
+358
-49
@@ -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,12 +239,51 @@ 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
|
||||
slack_app_token: Optional[str] = None # F57: required in socket mode (xapp-...)
|
||||
slack_client_id: Optional[str] = None
|
||||
slack_client_secret: Optional[str] = None
|
||||
slack_oauth_redirect_uri: Optional[str] = None
|
||||
slack_oauth_install_path: str = "/slack/install"
|
||||
slack_oauth_callback_path: str = "/slack/oauth/callback"
|
||||
slack_oauth_scopes: List[str] = field(
|
||||
default_factory=lambda: [
|
||||
"app_mentions:read",
|
||||
"channels:history",
|
||||
"chat:write",
|
||||
"files:write",
|
||||
"groups:history",
|
||||
"im:history",
|
||||
"mpim:history",
|
||||
]
|
||||
)
|
||||
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)
|
||||
@@ -109,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
|
||||
@@ -148,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
|
||||
@@ -191,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"
|
||||
)
|
||||
@@ -216,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"
|
||||
)
|
||||
@@ -233,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"
|
||||
)
|
||||
@@ -245,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"
|
||||
)
|
||||
@@ -262,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"
|
||||
@@ -280,31 +500,120 @@ def load_config() -> ConnectorConfig:
|
||||
cfg.slack_reply_in_thread = False
|
||||
cfg.slack_mode = os.environ.get("OPENCLAW_CONNECTOR_SLACK_MODE", "events").lower()
|
||||
cfg.slack_app_token = os.environ.get("OPENCLAW_CONNECTOR_SLACK_APP_TOKEN")
|
||||
cfg.slack_client_id = os.environ.get("OPENCLAW_CONNECTOR_SLACK_CLIENT_ID")
|
||||
cfg.slack_client_secret = os.environ.get("OPENCLAW_CONNECTOR_SLACK_CLIENT_SECRET")
|
||||
cfg.slack_oauth_redirect_uri = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_SLACK_OAUTH_REDIRECT_URI"
|
||||
)
|
||||
cfg.slack_oauth_install_path = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_SLACK_OAUTH_INSTALL_PATH", "/slack/install"
|
||||
)
|
||||
cfg.slack_oauth_callback_path = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH", "/slack/oauth/callback"
|
||||
)
|
||||
if slack_scopes := os.environ.get("OPENCLAW_CONNECTOR_SLACK_OAUTH_SCOPES"):
|
||||
parsed_scopes = [
|
||||
scope.strip() for scope in slack_scopes.split(",") if scope.strip()
|
||||
]
|
||||
if parsed_scopes:
|
||||
cfg.slack_oauth_scopes = parsed_scopes
|
||||
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"
|
||||
):
|
||||
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
|
||||
|
||||
+11
-2
@@ -4,7 +4,7 @@ Shared data models for request/response.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -16,6 +16,9 @@ class CommandRequest:
|
||||
message_id: str
|
||||
text: str
|
||||
timestamp: float
|
||||
workspace_id: str = ""
|
||||
thread_id: str = ""
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -42,10 +45,16 @@ class Platform:
|
||||
image_data: bytes,
|
||||
filename: str = "image.png",
|
||||
caption: Optional[str] = None,
|
||||
delivery_context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""Send an image to the channel."""
|
||||
pass
|
||||
|
||||
async def send_message(self, channel_id: str, text: str):
|
||||
async def send_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
text: str,
|
||||
delivery_context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""Send a text message to the channel."""
|
||||
pass
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -230,6 +230,7 @@ class DiscordGateway:
|
||||
image_data: bytes,
|
||||
filename: str = "image.png",
|
||||
caption: Optional[str] = None,
|
||||
delivery_context: Optional[dict] = None,
|
||||
):
|
||||
"""Send image via Discord API."""
|
||||
if not self.session:
|
||||
@@ -279,7 +280,12 @@ class DiscordGateway:
|
||||
logger.error(f"Discord send_image error: {e}")
|
||||
raise
|
||||
|
||||
async def send_message(self, channel_id: str, text: str):
|
||||
async def send_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
text: str,
|
||||
delivery_context: Optional[dict] = None,
|
||||
):
|
||||
"""Send text message."""
|
||||
if not self.session:
|
||||
return
|
||||
|
||||
@@ -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."""
|
||||
@@ -286,6 +287,7 @@ class LINEWebhookServer:
|
||||
image_data: bytes,
|
||||
filename: str = "image.png",
|
||||
caption: Optional[str] = None,
|
||||
delivery_context: Optional[dict] = None,
|
||||
):
|
||||
"""
|
||||
Send image via LINE using public URL.
|
||||
@@ -368,7 +370,12 @@ class LINEWebhookServer:
|
||||
err = await resp.text()
|
||||
logger.error(f"LINE image push failed: {resp.status} {err}")
|
||||
|
||||
async def send_message(self, channel_id: str, text: str):
|
||||
async def send_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
text: str,
|
||||
delivery_context: Optional[dict] = None,
|
||||
):
|
||||
"""Send push message."""
|
||||
if self._session_invalid:
|
||||
logger.warning("R93: Connector session invalid - blocking outbound message")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,389 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from connector.config import ConnectorConfig
|
||||
|
||||
try:
|
||||
from services.audit import emit_audit_event
|
||||
from services.connector_installation_registry import (
|
||||
ConnectorInstallation,
|
||||
ConnectorInstallationRegistry,
|
||||
InstallationResolution,
|
||||
InstallationStatus,
|
||||
get_connector_installation_registry,
|
||||
)
|
||||
from services.safe_io import (
|
||||
STANDARD_OUTBOUND_POLICY,
|
||||
SafeIOHTTPError,
|
||||
safe_request_json,
|
||||
)
|
||||
from services.secret_store import SecretStore, get_secret_store
|
||||
from services.state_dir import get_state_dir
|
||||
except ImportError: # pragma: no cover
|
||||
from services.audit import emit_audit_event # type: ignore
|
||||
from services.connector_installation_registry import ( # type: ignore
|
||||
ConnectorInstallation,
|
||||
ConnectorInstallationRegistry,
|
||||
InstallationResolution,
|
||||
InstallationStatus,
|
||||
get_connector_installation_registry,
|
||||
)
|
||||
from services.safe_io import ( # type: ignore
|
||||
STANDARD_OUTBOUND_POLICY,
|
||||
SafeIOHTTPError,
|
||||
safe_request_json,
|
||||
)
|
||||
from services.secret_store import SecretStore, get_secret_store # type: ignore
|
||||
from services.state_dir import get_state_dir # type: ignore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SLACK_AUTHORIZE_URL = "https://slack.com/oauth/v2/authorize"
|
||||
SLACK_OAUTH_ACCESS_URL = "https://slack.com/api/oauth.v2.access"
|
||||
SLACK_OAUTH_STATE_FILE = "slack_oauth_states.json"
|
||||
|
||||
_INVALID_TOKEN_ERRORS = frozenset(
|
||||
{"account_inactive", "invalid_auth", "not_authed", "token_revoked"}
|
||||
)
|
||||
_DEGRADED_TOKEN_ERRORS = frozenset({"ratelimited", "request_timeout", "fatal_error"})
|
||||
|
||||
|
||||
class SlackInstallationManager:
|
||||
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 = Path(state_dir or get_state_dir())
|
||||
self._state_path = self._state_dir / SLACK_OAUTH_STATE_FILE
|
||||
self._registry = registry or get_connector_installation_registry(
|
||||
state_dir=str(self._state_dir)
|
||||
)
|
||||
self._secret_store = secret_store or get_secret_store(str(self._state_dir))
|
||||
self._lock = threading.RLock()
|
||||
self._states: Dict[str, Dict[str, Any]] = {}
|
||||
self._load_states()
|
||||
|
||||
@property
|
||||
def oauth_enabled(self) -> bool:
|
||||
return bool(self.config.slack_client_id and self.config.slack_client_secret)
|
||||
|
||||
def resolve_redirect_uri(self) -> str:
|
||||
if self.config.slack_oauth_redirect_uri:
|
||||
return str(self.config.slack_oauth_redirect_uri).strip()
|
||||
if self.config.public_base_url:
|
||||
base = self.config.public_base_url.rstrip("/")
|
||||
path = self.config.slack_oauth_callback_path or "/slack/oauth/callback"
|
||||
return f"{base}{path}"
|
||||
return ""
|
||||
|
||||
def can_handle_oauth(self) -> bool:
|
||||
return self.oauth_enabled and bool(self.resolve_redirect_uri())
|
||||
|
||||
def _save_states(self) -> None:
|
||||
self._state_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_path = self._state_path.with_suffix(".tmp")
|
||||
temp_path.write_text(json.dumps(self._states, indent=2), encoding="utf-8")
|
||||
os.replace(temp_path, self._state_path)
|
||||
|
||||
def _load_states(self) -> None:
|
||||
if not self._state_path.exists():
|
||||
return
|
||||
try:
|
||||
data = json.loads(self._state_path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
self._states = data
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load Slack OAuth state store: %s", exc)
|
||||
self._states = {}
|
||||
self._prune_expired_states()
|
||||
|
||||
def _prune_expired_states(self) -> None:
|
||||
now = time.time()
|
||||
ttl = max(60, int(self.config.slack_oauth_state_ttl_sec or 600))
|
||||
changed = False
|
||||
for key, payload in list(self._states.items()):
|
||||
created_at = float(payload.get("created_at", 0) or 0)
|
||||
if not created_at or (now - created_at) > ttl:
|
||||
self._states.pop(key, None)
|
||||
changed = True
|
||||
if changed:
|
||||
self._save_states()
|
||||
|
||||
def issue_install_state(self) -> str:
|
||||
if not self.can_handle_oauth():
|
||||
raise RuntimeError("Slack OAuth flow not configured")
|
||||
with self._lock:
|
||||
self._prune_expired_states()
|
||||
state = secrets.token_urlsafe(32)
|
||||
self._states[state] = {"created_at": time.time()}
|
||||
self._save_states()
|
||||
return state
|
||||
|
||||
def consume_install_state(self, state: str) -> bool:
|
||||
with self._lock:
|
||||
self._prune_expired_states()
|
||||
payload = self._states.pop(str(state or "").strip(), None)
|
||||
if payload is None:
|
||||
return False
|
||||
self._save_states()
|
||||
return True
|
||||
|
||||
def build_install_url(self, state: str) -> str:
|
||||
params = {
|
||||
"client_id": self.config.slack_client_id or "",
|
||||
"scope": ",".join(self.config.slack_oauth_scopes or []),
|
||||
"redirect_uri": self.resolve_redirect_uri(),
|
||||
"state": state,
|
||||
}
|
||||
return f"{SLACK_AUTHORIZE_URL}?{urlencode(params)}"
|
||||
|
||||
async def exchange_code(self, code: str) -> Dict[str, Any]:
|
||||
payload = {
|
||||
"client_id": self.config.slack_client_id or "",
|
||||
"client_secret": self.config.slack_client_secret or "",
|
||||
"code": str(code or "").strip(),
|
||||
"redirect_uri": self.resolve_redirect_uri(),
|
||||
}
|
||||
try:
|
||||
return safe_request_json(
|
||||
method="POST",
|
||||
url=SLACK_OAUTH_ACCESS_URL,
|
||||
raw_body=urlencode(payload).encode("utf-8"),
|
||||
headers={"Accept": "application/json"},
|
||||
content_type="application/x-www-form-urlencoded",
|
||||
timeout_sec=15,
|
||||
allow_hosts={"slack.com"},
|
||||
policy=STANDARD_OUTBOUND_POLICY,
|
||||
)
|
||||
except SafeIOHTTPError as exc:
|
||||
error_code = "unknown"
|
||||
try:
|
||||
body = exc.body or ""
|
||||
parsed = json.loads(body) if body else {}
|
||||
if isinstance(parsed, dict):
|
||||
error_code = str(parsed.get("error", "") or error_code)
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(
|
||||
f"slack_oauth_exchange_failed:{exc.status_code}:{error_code}"
|
||||
) from exc
|
||||
|
||||
def _normalize_workspace_id(self, payload: Dict[str, Any]) -> str:
|
||||
workspace_id = (
|
||||
(payload.get("team") or {}).get("id")
|
||||
or payload.get("team_id")
|
||||
or (
|
||||
(payload.get("enterprise") or {}).get("id")
|
||||
if payload.get("enterprise")
|
||||
else ""
|
||||
)
|
||||
)
|
||||
workspace_id = str(workspace_id or "").strip()
|
||||
if not workspace_id:
|
||||
raise ValueError("workspace_id_missing")
|
||||
return workspace_id
|
||||
|
||||
def installation_id_for_workspace(self, workspace_id: str) -> str:
|
||||
return f"slack:{str(workspace_id or '').strip()}"
|
||||
|
||||
def metadata_from_oauth_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
team = dict(payload.get("team", {}) or {})
|
||||
enterprise = dict(payload.get("enterprise", {}) or {})
|
||||
authed_user = dict(payload.get("authed_user", {}) or {})
|
||||
metadata = {
|
||||
"workspace_name": str(team.get("name", "") or "").strip(),
|
||||
"enterprise_id": str(enterprise.get("id", "") or "").strip(),
|
||||
"enterprise_name": str(enterprise.get("name", "") or "").strip(),
|
||||
"bot_user_id": str(payload.get("bot_user_id", "") or "").strip(),
|
||||
"app_id": str(payload.get("app_id", "") or "").strip(),
|
||||
"scope": str(payload.get("scope", "") or "").strip(),
|
||||
"authed_user_id": str(authed_user.get("id", "") or "").strip(),
|
||||
"token_type": str(payload.get("token_type", "") or "").strip(),
|
||||
"transport_mode": self.config.slack_mode,
|
||||
}
|
||||
return {key: value for key, value in metadata.items() if value}
|
||||
|
||||
def upsert_from_oauth_payload(
|
||||
self, payload: Dict[str, Any]
|
||||
) -> ConnectorInstallation:
|
||||
workspace_id = self._normalize_workspace_id(payload)
|
||||
installation_id = self.installation_id_for_workspace(workspace_id)
|
||||
token_values = {"bot_token": str(payload.get("access_token", "") or "").strip()}
|
||||
if self.config.slack_app_token:
|
||||
token_values["app_token"] = self.config.slack_app_token
|
||||
if not token_values["bot_token"]:
|
||||
raise ValueError("bot_token_missing")
|
||||
|
||||
metadata = self.metadata_from_oauth_payload(payload)
|
||||
existing = self._registry.get_installation(installation_id)
|
||||
if existing is not None:
|
||||
rotated = self._registry.rotate_installation_tokens(
|
||||
installation_id,
|
||||
token_values,
|
||||
reason="slack_oauth_reinstall",
|
||||
)
|
||||
inst = self._registry.upsert_installation(
|
||||
platform="slack",
|
||||
workspace_id=workspace_id,
|
||||
installation_id=installation_id,
|
||||
token_refs=rotated.token_refs,
|
||||
status=rotated.status,
|
||||
metadata=metadata,
|
||||
status_reason="slack_oauth_reinstall",
|
||||
)
|
||||
else:
|
||||
inst = self._registry.upsert_installation(
|
||||
platform="slack",
|
||||
workspace_id=workspace_id,
|
||||
installation_id=installation_id,
|
||||
token_values=token_values,
|
||||
status=InstallationStatus.CREATED.value,
|
||||
metadata=metadata,
|
||||
status_reason="slack_oauth_install",
|
||||
)
|
||||
inst = self._registry.activate_installation(
|
||||
installation_id, reason="slack_oauth_complete"
|
||||
)
|
||||
inst = self._registry.update_installation_health(
|
||||
installation_id,
|
||||
health_code="ok",
|
||||
reason="slack_oauth_complete",
|
||||
details={"workspace_id": workspace_id},
|
||||
)
|
||||
emit_audit_event(
|
||||
action="connector.slack.oauth.install",
|
||||
target=installation_id,
|
||||
outcome="allow",
|
||||
status_code=200,
|
||||
details={
|
||||
"workspace_id": workspace_id,
|
||||
"workspace_name": metadata.get("workspace_name", ""),
|
||||
"transport_mode": self.config.slack_mode,
|
||||
},
|
||||
)
|
||||
return inst
|
||||
|
||||
def extract_workspace_id(self, payload: Dict[str, Any]) -> str:
|
||||
if isinstance(payload.get("team_id"), str) and payload.get("team_id"):
|
||||
return str(payload["team_id"]).strip()
|
||||
team = payload.get("team") or {}
|
||||
if isinstance(team, dict) and team.get("id"):
|
||||
return str(team.get("id")).strip()
|
||||
authorizations = payload.get("authorizations") or []
|
||||
if isinstance(authorizations, list) and authorizations:
|
||||
workspace_id = str((authorizations[0] or {}).get("team_id", "")).strip()
|
||||
if workspace_id:
|
||||
return workspace_id
|
||||
event = payload.get("event") or {}
|
||||
workspace_id = str(event.get("team", "") or "").strip()
|
||||
return workspace_id
|
||||
|
||||
def resolve_workspace_tokens(
|
||||
self, workspace_id: str
|
||||
) -> Tuple[InstallationResolution, Dict[str, str]]:
|
||||
resolution = self._registry.resolve_installation("slack", workspace_id)
|
||||
if not resolution.ok or resolution.installation is None:
|
||||
emit_audit_event(
|
||||
action="connector.slack.resolve",
|
||||
target=workspace_id or "unknown_workspace",
|
||||
outcome="deny",
|
||||
status_code=409,
|
||||
details={
|
||||
"workspace_id": workspace_id,
|
||||
"reject_reason": resolution.reject_reason,
|
||||
"health_code": resolution.health_code,
|
||||
},
|
||||
)
|
||||
return resolution, {}
|
||||
|
||||
tokens: Dict[str, str] = {}
|
||||
for token_name, ref in resolution.installation.token_refs.items():
|
||||
secret = self._secret_store.get_secret(
|
||||
ref, tenant_id=resolution.installation.tenant_id
|
||||
)
|
||||
if secret:
|
||||
tokens[token_name] = secret
|
||||
return resolution, tokens
|
||||
|
||||
def bot_user_id_for_installation(
|
||||
self, installation: Optional[ConnectorInstallation]
|
||||
) -> str:
|
||||
if installation is None:
|
||||
return ""
|
||||
return str(
|
||||
(
|
||||
installation.metadata.get("bot_user_id", "")
|
||||
if installation.metadata
|
||||
else ""
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
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 uninstall_installation(self, installation_id: str, *, reason: str) -> None:
|
||||
self._registry.uninstall_installation(installation_id, reason=reason)
|
||||
|
||||
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 normalized in _INVALID_TOKEN_ERRORS or status_code in (401, 403):
|
||||
return "invalid_token"
|
||||
if (
|
||||
normalized in _DEGRADED_TOKEN_ERRORS
|
||||
or status_code == 429
|
||||
or status_code >= 500
|
||||
):
|
||||
return "degraded"
|
||||
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
|
||||
@@ -36,12 +36,30 @@ import time
|
||||
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) -----------
|
||||
|
||||
@@ -90,6 +108,52 @@ def _make_json_response(web_mod, data: dict, *, status: int = 200):
|
||||
)
|
||||
|
||||
|
||||
def _make_redirect_response(web_mod, url: str):
|
||||
if web_mod is not None:
|
||||
raise web_mod.HTTPFound(location=url)
|
||||
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).
|
||||
@@ -139,7 +203,11 @@ def verify_slack_signature(
|
||||
# -- Slack adapter ----------------------------------------------------------
|
||||
|
||||
|
||||
class SlackWebhookServer:
|
||||
class SlackWebhookServer(
|
||||
SlackInstallationMixin,
|
||||
SlackIngressMixin,
|
||||
SlackDeliveryMixin,
|
||||
):
|
||||
"""
|
||||
F56 -- Slack Events API adapter.
|
||||
|
||||
@@ -166,15 +234,78 @@ 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)
|
||||
self._channel_allowlist = AllowlistPolicy(
|
||||
config.slack_allowed_channels, strict=False
|
||||
)
|
||||
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
|
||||
@@ -186,11 +317,19 @@ class SlackWebhookServer:
|
||||
logger.warning("aiohttp not installed. Skipping Slack adapter.")
|
||||
return
|
||||
|
||||
if not self.config.slack_bot_token or not self.config.slack_signing_secret:
|
||||
if not self.config.slack_signing_secret:
|
||||
logger.info(
|
||||
"Slack adapter disabled "
|
||||
"(OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN or "
|
||||
"OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET missing)"
|
||||
"(OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET missing)"
|
||||
)
|
||||
return
|
||||
if (
|
||||
not self.config.slack_bot_token
|
||||
and not self._installation_manager.can_handle_oauth()
|
||||
):
|
||||
logger.info(
|
||||
"Slack adapter disabled "
|
||||
"(legacy bot token missing and Slack OAuth flow not configured)"
|
||||
)
|
||||
return
|
||||
|
||||
@@ -202,6 +341,16 @@ 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
|
||||
)
|
||||
self.app.router.add_get(
|
||||
self.config.slack_oauth_callback_path, self.handle_oauth_callback
|
||||
)
|
||||
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
@@ -219,248 +368,3 @@ class SlackWebhookServer:
|
||||
# ------------------------------------------------------------------
|
||||
# Event handler
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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", "")
|
||||
|
||||
# -- 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.
|
||||
if self._bot_user_id is None:
|
||||
auths = payload.get("authorizations", [])
|
||||
if auths and isinstance(auths, list):
|
||||
self._bot_user_id = auths[0].get("user_id", "")
|
||||
|
||||
sender_id = event.get("user", "")
|
||||
if sender_id and sender_id == self._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 self._bot_user_id and f"<@{self._bot_user_id}>" not in text:
|
||||
return
|
||||
|
||||
if self._bot_user_id:
|
||||
text = text.replace(f"<@{self._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(),
|
||||
)
|
||||
|
||||
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=thread_ts
|
||||
or (message_ts if self.config.slack_reply_in_thread else ""),
|
||||
)
|
||||
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 = "",
|
||||
) -> 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
|
||||
|
||||
url = "https://slack.com/api/chat.postMessage"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.config.slack_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()
|
||||
logger.warning(
|
||||
f"Slack API error: status={resp.status} body={body[:200]}"
|
||||
)
|
||||
else:
|
||||
data = await resp.json()
|
||||
if not data.get("ok"):
|
||||
logger.warning(
|
||||
f"Slack API error: {data.get('error', 'unknown')}"
|
||||
)
|
||||
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):
|
||||
"""Platform contract: send text message."""
|
||||
await self._send_reply(channel_id=channel_id, text=text)
|
||||
|
||||
async def send_image(
|
||||
self,
|
||||
channel_id: str,
|
||||
image_data: bytes,
|
||||
filename: str = "image.png",
|
||||
caption: Optional[str] = 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
|
||||
|
||||
url = "https://slack.com/api/files.upload"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.config.slack_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)
|
||||
|
||||
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:
|
||||
logger.warning(f"Slack file upload error: status={resp.status}")
|
||||
else:
|
||||
resp_data = await resp.json()
|
||||
if not resp_data.get("ok"):
|
||||
logger.warning(
|
||||
f"Slack file upload error: {resp_data.get('error')}"
|
||||
)
|
||||
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,
|
||||
@@ -182,6 +311,7 @@ class TelegramPolling:
|
||||
image_data: bytes,
|
||||
filename: str = "image.png",
|
||||
caption: Optional[str] = None,
|
||||
delivery_context: Optional[dict] = None,
|
||||
):
|
||||
"""Send photo via Telegram sendPhoto."""
|
||||
if not self.session:
|
||||
@@ -192,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)
|
||||
|
||||
@@ -205,15 +342,40 @@ class TelegramPolling:
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram send_image error: {e}")
|
||||
|
||||
async def send_message(self, channel_id: str, text: str):
|
||||
async def send_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
text: str,
|
||||
delivery_context: Optional[dict] = None,
|
||||
):
|
||||
"""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
|
||||
@@ -680,7 +691,12 @@ class WeChatWebhookServer:
|
||||
# Outbound: Text (Customer Service Message API)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def send_message(self, recipient_openid: str, text: str):
|
||||
async def send_message(
|
||||
self,
|
||||
recipient_openid: str,
|
||||
text: str,
|
||||
delivery_context: Optional[dict] = None,
|
||||
):
|
||||
"""
|
||||
Send text via WeChat Customer Service Message API.
|
||||
|
||||
@@ -736,6 +752,7 @@ class WeChatWebhookServer:
|
||||
image_data: bytes,
|
||||
filename: str = "image.png",
|
||||
caption: Optional[str] = None,
|
||||
delivery_context: Optional[dict] = None,
|
||||
):
|
||||
"""
|
||||
Send image via WeChat.
|
||||
|
||||
@@ -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
|
||||
@@ -296,7 +297,12 @@ class WhatsAppWebhookServer:
|
||||
# Outbound: Text
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def send_message(self, recipient_id: str, text: str):
|
||||
async def send_message(
|
||||
self,
|
||||
recipient_id: str,
|
||||
text: str,
|
||||
delivery_context: Optional[dict] = None,
|
||||
):
|
||||
"""Send text message via WhatsApp Cloud API."""
|
||||
if self._session_invalid:
|
||||
logger.warning("R93: Connector session invalid - blocking outbound")
|
||||
@@ -350,6 +356,7 @@ class WhatsAppWebhookServer:
|
||||
image_data: bytes,
|
||||
filename: str = "image.png",
|
||||
caption: Optional[str] = None,
|
||||
delivery_context: Optional[dict] = None,
|
||||
):
|
||||
"""
|
||||
Send image via WhatsApp using public media URL.
|
||||
|
||||
@@ -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
|
||||
+127
-21
@@ -1,11 +1,12 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, Optional
|
||||
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__)
|
||||
|
||||
@@ -26,10 +27,10 @@ class ResultsPoller:
|
||||
self.platforms = platforms # map "telegram" -> TelegramPolling, etc.
|
||||
self.queue = (
|
||||
asyncio.Queue()
|
||||
) # (prompt_id, platform_name, channel_id, sender_id)
|
||||
) # (prompt_id, platform_name, channel_id, sender_id, delivery_context)
|
||||
self.approval_queue = (
|
||||
asyncio.Queue()
|
||||
) # (approval_id, platform_name, channel_id, sender_id)
|
||||
) # (approval_id, platform_name, channel_id, sender_id, delivery_context)
|
||||
self.active_polls = {} # prompt_id -> task
|
||||
self.active_approval_polls = {} # approval_id -> task
|
||||
|
||||
@@ -55,17 +56,35 @@ class ResultsPoller:
|
||||
logger.info("ResultsPoller stopped.")
|
||||
|
||||
def track_job(
|
||||
self, prompt_id: str, platform_name: str, channel_id: str, sender_id: str
|
||||
self,
|
||||
prompt_id: str,
|
||||
platform_name: str,
|
||||
channel_id: str,
|
||||
sender_id: str,
|
||||
delivery_context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""Enqueue a job for result monitoring."""
|
||||
if not prompt_id:
|
||||
return
|
||||
|
||||
logger.info(f"Tracking job {prompt_id} for {platform_name} in {channel_id}")
|
||||
self.queue.put_nowait((prompt_id, platform_name, channel_id, sender_id))
|
||||
self.queue.put_nowait(
|
||||
(
|
||||
prompt_id,
|
||||
platform_name,
|
||||
channel_id,
|
||||
sender_id,
|
||||
dict(delivery_context or {}),
|
||||
)
|
||||
)
|
||||
|
||||
def track_approval(
|
||||
self, approval_id: str, platform_name: str, channel_id: str, sender_id: str
|
||||
self,
|
||||
approval_id: str,
|
||||
platform_name: str,
|
||||
channel_id: str,
|
||||
sender_id: str,
|
||||
delivery_context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
if not approval_id:
|
||||
return
|
||||
@@ -85,16 +104,28 @@ class ResultsPoller:
|
||||
f"Tracking approval {approval_id} for {platform_name} in {channel_id}"
|
||||
)
|
||||
self.approval_queue.put_nowait(
|
||||
(approval_id, platform_name, channel_id, sender_id)
|
||||
(
|
||||
approval_id,
|
||||
platform_name,
|
||||
channel_id,
|
||||
sender_id,
|
||||
dict(delivery_context or {}),
|
||||
)
|
||||
)
|
||||
|
||||
async def _job_consumer(self):
|
||||
while True:
|
||||
item = await self.queue.get()
|
||||
try:
|
||||
prompt_id, platform_name, channel_id, sender_id = item
|
||||
prompt_id, platform_name, channel_id, sender_id, delivery_context = item
|
||||
task = asyncio.create_task(
|
||||
self._poll_job(prompt_id, platform_name, channel_id, sender_id)
|
||||
self._poll_job(
|
||||
prompt_id,
|
||||
platform_name,
|
||||
channel_id,
|
||||
sender_id,
|
||||
delivery_context,
|
||||
)
|
||||
)
|
||||
self.active_polls[prompt_id] = task
|
||||
task.add_done_callback(
|
||||
@@ -107,10 +138,20 @@ class ResultsPoller:
|
||||
while True:
|
||||
item = await self.approval_queue.get()
|
||||
try:
|
||||
approval_id, platform_name, channel_id, sender_id = item
|
||||
(
|
||||
approval_id,
|
||||
platform_name,
|
||||
channel_id,
|
||||
sender_id,
|
||||
delivery_context,
|
||||
) = item
|
||||
task = asyncio.create_task(
|
||||
self._poll_approval(
|
||||
approval_id, platform_name, channel_id, sender_id
|
||||
approval_id,
|
||||
platform_name,
|
||||
channel_id,
|
||||
sender_id,
|
||||
delivery_context,
|
||||
)
|
||||
)
|
||||
self.active_approval_polls[approval_id] = task
|
||||
@@ -121,7 +162,12 @@ class ResultsPoller:
|
||||
self.approval_queue.task_done()
|
||||
|
||||
async def _poll_approval(
|
||||
self, approval_id: str, platform_name: str, channel_id: str, sender_id: str
|
||||
self,
|
||||
approval_id: str,
|
||||
platform_name: str,
|
||||
channel_id: str,
|
||||
sender_id: str,
|
||||
delivery_context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
start_time = time.time()
|
||||
delay = 2.0
|
||||
@@ -137,6 +183,7 @@ class ResultsPoller:
|
||||
platform_name,
|
||||
channel_id,
|
||||
f"❌ Approval {approval_id} {status}.",
|
||||
delivery_context=delivery_context,
|
||||
)
|
||||
return
|
||||
if status == "approved":
|
||||
@@ -150,7 +197,11 @@ class ResultsPoller:
|
||||
)
|
||||
if prompt_id:
|
||||
self.track_job(
|
||||
prompt_id, platform_name, channel_id, sender_id
|
||||
prompt_id,
|
||||
platform_name,
|
||||
channel_id,
|
||||
sender_id,
|
||||
delivery_context=delivery_context,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
@@ -166,10 +217,16 @@ class ResultsPoller:
|
||||
platform_name,
|
||||
channel_id,
|
||||
f"⚠️ Approval {approval_id} timed out waiting for execution.",
|
||||
delivery_context=delivery_context,
|
||||
)
|
||||
|
||||
async def _poll_job(
|
||||
self, prompt_id: str, platform_name: str, channel_id: str, sender_id: str
|
||||
self,
|
||||
prompt_id: str,
|
||||
platform_name: str,
|
||||
channel_id: str,
|
||||
sender_id: str,
|
||||
delivery_context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""Poll history with backoff until complete or timeout."""
|
||||
start_time = time.time()
|
||||
@@ -185,7 +242,11 @@ class ResultsPoller:
|
||||
if prompt_id in data:
|
||||
job_data = data[prompt_id]
|
||||
await self._deliver_results(
|
||||
prompt_id, job_data, platform_name, channel_id
|
||||
prompt_id,
|
||||
job_data,
|
||||
platform_name,
|
||||
channel_id,
|
||||
delivery_context=delivery_context,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
@@ -203,10 +264,16 @@ class ResultsPoller:
|
||||
platform_name,
|
||||
channel_id,
|
||||
f"⚠️ Job {prompt_id} timed out waiting for results.",
|
||||
delivery_context=delivery_context,
|
||||
)
|
||||
|
||||
async def _deliver_results(
|
||||
self, prompt_id: str, job_data: dict, platform_name: str, channel_id: str
|
||||
self,
|
||||
prompt_id: str,
|
||||
job_data: dict,
|
||||
platform_name: str,
|
||||
channel_id: str,
|
||||
delivery_context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""Download images and send to platform."""
|
||||
outputs = job_data.get("outputs", {})
|
||||
@@ -216,6 +283,7 @@ class ResultsPoller:
|
||||
platform_name,
|
||||
channel_id,
|
||||
f"✅ Job {prompt_id} finished (No output images).",
|
||||
delivery_context=delivery_context,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -231,7 +299,10 @@ class ResultsPoller:
|
||||
if not images_to_send:
|
||||
logger.info(f"Job {prompt_id} finished but no images found.")
|
||||
await self._send_text(
|
||||
platform_name, channel_id, f"✅ Job {prompt_id} finished (No images)."
|
||||
platform_name,
|
||||
channel_id,
|
||||
f"✅ Job {prompt_id} finished (No images).",
|
||||
delivery_context=delivery_context,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -263,23 +334,58 @@ class ResultsPoller:
|
||||
platform_name,
|
||||
channel_id,
|
||||
f"⚠️ Image {filename} skipped (too large).",
|
||||
delivery_context=delivery_context,
|
||||
)
|
||||
continue
|
||||
|
||||
# Send with error handling
|
||||
try:
|
||||
await platform.send_image(channel_id, content, filename=filename)
|
||||
await platform.send_image(
|
||||
channel_id,
|
||||
content,
|
||||
filename=filename,
|
||||
delivery_context=delivery_context,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to deliver image to {platform_name}: {e}")
|
||||
# Fallback text
|
||||
await self._send_text(
|
||||
platform_name, channel_id, f"⚠️ Failed to send image: {filename}"
|
||||
platform_name,
|
||||
channel_id,
|
||||
f"⚠️ Failed to send image: {filename}",
|
||||
delivery_context=delivery_context,
|
||||
)
|
||||
|
||||
async def _send_text(self, platform_name: str, channel_id: str, text: str):
|
||||
async def _send_text(
|
||||
self,
|
||||
platform_name: str,
|
||||
channel_id: str,
|
||||
text: str,
|
||||
*,
|
||||
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)
|
||||
await platform.send_message(
|
||||
channel_id,
|
||||
text,
|
||||
delivery_context=ctx,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send text to {platform_name}: {e}")
|
||||
|
||||
+20
-894
@@ -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,879 +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 _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
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
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)
|
||||
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.
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
# ADR-0001: Configuration Surface Unification (R139)
|
||||
# ADR-0001: Configuration Surface Unification
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-03-07
|
||||
- Owners: OpenClaw maintainers
|
||||
- Related roadmap item: `R139`
|
||||
- Related roadmap items: `R139`, phase-2 follow-up completed 2026-03-19
|
||||
|
||||
## Context
|
||||
|
||||
OpenClaw currently has distributed configuration logic across `config.py`, `services/runtime_config.py`, and selected call sites that still read env vars directly. This increases precedence drift risk and makes behavior harder to reason about.
|
||||
|
||||
`R139` requires a phased, backward-compatible unification, not a single destructive rewrite.
|
||||
The unification work required a phased, backward-compatible rollout rather than a single destructive rewrite.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt one authoritative layered model for runtime LLM config resolution, exposed through a unified resolver and consumed by `services/runtime_config.py` compatibility APIs.
|
||||
Adopt one authoritative layered model for runtime LLM config resolution, exposed through a unified resolver and then through a single effective-config facade consumed by compatibility APIs and downstream readers.
|
||||
|
||||
Layer precedence (highest to lowest):
|
||||
1. `env` (`OPENCLAW_*` first, `MOLTBOT_*` fallback)
|
||||
@@ -32,6 +32,7 @@ Positive:
|
||||
- Deterministic precedence and source attribution.
|
||||
- Reduced duplicated merge logic in primary runtime paths.
|
||||
- Safer phased migration with compatibility facade intact.
|
||||
- Shared read seams make parity testing and future deprecation work narrower.
|
||||
|
||||
Trade-offs:
|
||||
- Temporary coexistence of migrated and non-migrated call sites during phased rollout.
|
||||
@@ -45,8 +46,13 @@ Phase 1 (`R139`):
|
||||
- Migrate core LLM call sites to stop duplicating env precedence.
|
||||
- Add precedence/compatibility regression tests.
|
||||
|
||||
Phase 2+ (future follow-ups):
|
||||
- Continue migrating remaining direct env readers where they overlap with runtime config contract.
|
||||
Phase 2 (completed 2026-03-19):
|
||||
- Introduced `services/effective_config.py` as the supported effective-config read facade.
|
||||
- Migrated remaining mixed-path readers touched by the phase onto the shared facade/compatibility shims.
|
||||
- Added parity coverage so env/runtime/persisted/default precedence is asserted once at the shared seam instead of at each consumer.
|
||||
|
||||
Future follow-ups:
|
||||
- Continue migrating any remaining direct env readers that still overlap with the runtime config contract.
|
||||
- Remove obsolete adapter/shim code once migration reaches stable completion.
|
||||
|
||||
## Rejected Alternatives
|
||||
|
||||
@@ -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.
|
||||
+249
-17
@@ -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
|
||||
|
||||
@@ -27,7 +32,10 @@ OpenClaw now includes a platform-agnostic baseline for multi-workspace connector
|
||||
- `platform`, `workspace_id`, `installation_id`, `token_refs`, `status`, `updated_at`
|
||||
- token material is kept in encrypted server-side secret storage; registry and diagnostics expose token references only
|
||||
- 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:
|
||||
|
||||
@@ -35,6 +43,31 @@ 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:
|
||||
|
||||
- Slack OAuth installs bind one workspace per installation record and persist only encrypted token refs.
|
||||
- OAuth callback state is single-use and replay/invalid-state callbacks fail closed instead of reusing a prior install session.
|
||||
- `GET /openclaw/connector/installations/resolve?platform=slack&workspace_id=<team_id>` returns the fail-closed resolution view for a specific Slack workspace.
|
||||
- `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
|
||||
|
||||
@@ -47,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
|
||||
|
||||
@@ -70,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.
|
||||
@@ -82,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:**
|
||||
|
||||
@@ -145,8 +187,15 @@ Set the following environment variables (or put them in a `.env` file if you use
|
||||
|
||||
*(Requires Inbound Connectivity - see below)*
|
||||
|
||||
- `OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN`: Bot User OAuth Token (`xoxb-...`).
|
||||
- `OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN`: Optional legacy single-workspace Bot User OAuth Token (`xoxb-...`). When Slack OAuth is configured, per-workspace tokens are resolved from the installation registry instead.
|
||||
- `OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET`: Signing Secret (from App Credentials).
|
||||
- `OPENCLAW_CONNECTOR_SLACK_CLIENT_ID`: OAuth client ID for multi-workspace installation flow.
|
||||
- `OPENCLAW_CONNECTOR_SLACK_CLIENT_SECRET`: OAuth client secret for multi-workspace installation flow.
|
||||
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_REDIRECT_URI`: Explicit OAuth callback URL. If omitted, connector derives it from `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL` + callback path.
|
||||
- `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`, 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`).
|
||||
@@ -155,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`**.
|
||||
@@ -168,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
|
||||
|
||||
@@ -205,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
|
||||
@@ -252,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
|
||||
@@ -260,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
|
||||
@@ -295,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`.
|
||||
@@ -373,14 +483,19 @@ Slack uses the Events API webhook mode in OpenClaw. You must expose the endpoint
|
||||
- `im:history` (DM support)
|
||||
- `channels:history` (public channel messages)
|
||||
- `groups:history` (private channel messages)
|
||||
- Click **Install to Workspace**.
|
||||
- Copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||
- For legacy single-workspace mode, click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||
- For multi-workspace mode, configure a redirect URL and let OpenClaw handle installs through its OAuth routes.
|
||||
|
||||
3. **Configure connector environment variables**
|
||||
|
||||
```bash
|
||||
OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN=xoxb-your-token
|
||||
OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET=your-signing-secret
|
||||
OPENCLAW_CONNECTOR_SLACK_CLIENT_ID=1234567890.1234567890
|
||||
OPENCLAW_CONNECTOR_SLACK_CLIENT_SECRET=replace-with-client-secret
|
||||
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
|
||||
@@ -392,16 +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-...`; 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.
|
||||
@@ -410,17 +532,23 @@ 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.
|
||||
|
||||
7. **Security checklist before production**
|
||||
- Keep `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS`/`OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS` restricted.
|
||||
- Keep `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true` unless intentionally running command-style channels.
|
||||
- Rotate Slack bot token/signing secret on incident response.
|
||||
- Rotate Slack signing secret and OAuth client secret on incident response.
|
||||
- Do not expose connector without HTTPS termination.
|
||||
|
||||
#### Slack Socket Mode Setup (Optional)
|
||||
@@ -437,9 +565,13 @@ Use Socket Mode when you cannot expose a public HTTPS webhook endpoint.
|
||||
```bash
|
||||
OPENCLAW_CONNECTOR_SLACK_MODE=socket
|
||||
OPENCLAW_CONNECTOR_SLACK_APP_TOKEN=xapp-your-token
|
||||
# Bot token + signing secret are still required for parity and safety checks
|
||||
OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN=xoxb-your-token
|
||||
# Signing secret remains required for parity/security checks.
|
||||
OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET=your-signing-secret
|
||||
# Either configure legacy single-workspace token...
|
||||
OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN=xoxb-your-token
|
||||
# ...or configure multi-workspace OAuth install flow:
|
||||
OPENCLAW_CONNECTOR_SLACK_CLIENT_ID=1234567890.1234567890
|
||||
OPENCLAW_CONNECTOR_SLACK_CLIENT_SECRET=replace-with-client-secret
|
||||
```
|
||||
|
||||
3. **Start connector**
|
||||
@@ -449,6 +581,73 @@ Use Socket Mode when you cannot expose a public HTTPS webhook endpoint.
|
||||
Notes:
|
||||
- Socket Mode uses outbound WebSocket, so `OPENCLAW_CONNECTOR_SLACK_BIND`, `OPENCLAW_CONNECTOR_SLACK_PORT`, and `OPENCLAW_CONNECTOR_SLACK_PATH` are ignored in this mode.
|
||||
- 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
|
||||
|
||||
@@ -461,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`)*
|
||||
@@ -508,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`.
|
||||
@@ -531,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,12 +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.
|
||||
- Shell: `web/openclaw_ui.js` renders the header, tab bar, and tab panes.
|
||||
- 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).
|
||||
- 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, 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: `web/openclaw_utils.js` provides `showError()` / `clearError()` helpers.
|
||||
- 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.
|
||||
- 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)
|
||||
|
||||
@@ -25,10 +59,25 @@ This document summarizes the current OpenClaw sidebar UI structure and how to ve
|
||||
If capabilities are unavailable, the full tab set is registered to surface actionable errors (instead of “missing tabs”).
|
||||
If `assist_streaming` is unavailable or the stream transport degrades, Planner/Refiner automatically fall back to the existing non-stream request path.
|
||||
|
||||
## Host-Surface Contract
|
||||
|
||||
- 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
|
||||
|
||||
- Entry route: `GET /openclaw/admin` (legacy `GET /moltbot/admin` still works).
|
||||
- HTML shell: `web/admin_console.html`
|
||||
- Runtime app module: `web/admin_console_app.js`
|
||||
- Runtime API module: `web/admin_console_api.js`
|
||||
- Purpose: mobile-friendly standalone operations UI for non-sidebar workflows.
|
||||
- Security model:
|
||||
- The page itself is a static shell and can render without authentication.
|
||||
@@ -36,6 +85,7 @@ If `assist_streaming` is unavailable or the stream transport degrades, Planner/R
|
||||
- Runtime behaviors:
|
||||
- Dashboard summary + health/config snapshots
|
||||
- Jobs/Events polling + SSE stream connect/fallback
|
||||
- Delta-aware polling cursors for events and managed-task refresh loops
|
||||
- Approvals/Schedules/Triggers control actions
|
||||
- Config read/partial write and diagnostics access
|
||||
- Quick Actions (retry/refresh/drill) remain backend-authorized
|
||||
@@ -47,19 +97,31 @@ 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
|
||||
|
||||
1. Open ComfyUI and confirm OpenClaw appears in the sidebar.
|
||||
2. Switch between all visible tabs multiple times (and reopen the sidebar if possible) and ensure panes do not go blank.
|
||||
3. 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.
|
||||
4. 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.
|
||||
5. Library/Approvals: if backend endpoints are not enabled, confirm the UI shows a clear error state (no crashes).
|
||||
6. 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.
|
||||
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 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.
|
||||
+203
-18
@@ -1,8 +1,8 @@
|
||||
openapi: "3.0.3"
|
||||
info:
|
||||
title: "ComfyUI-OpenClaw API"
|
||||
version: "1.0.5"
|
||||
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,44 @@ 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:
|
||||
operationId: "get_preflight_inventory"
|
||||
summary: "Snapshot-first inventory of nodes/models for operator diagnostics, including refresh-state metadata."
|
||||
responses:
|
||||
200:
|
||||
description: "OK"
|
||||
x-openclaw-auth: "Admin"
|
||||
x-openclaw-section: "1.1 Core Observability & System"
|
||||
x-openclaw-legacy-path: "/moltbot/preflight/inventory"
|
||||
x-openclaw-auth-tier: "admin"
|
||||
security:
|
||||
- OpenClawAdminToken:
|
||||
[]
|
||||
/webhook:
|
||||
post:
|
||||
@@ -235,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:
|
||||
@@ -253,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:
|
||||
@@ -271,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:
|
||||
@@ -290,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:
|
||||
@@ -362,6 +390,129 @@ 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"
|
||||
summary: "Search normalized model entries across managed installs and catalog sources."
|
||||
responses:
|
||||
200:
|
||||
description: "OK"
|
||||
x-openclaw-auth: "Admin"
|
||||
x-openclaw-section: "1.3C Model Management & Installations"
|
||||
x-openclaw-legacy-path: "/moltbot/models/search"
|
||||
x-openclaw-auth-tier: "admin"
|
||||
security:
|
||||
- OpenClawAdminToken:
|
||||
[]
|
||||
/models/downloads:
|
||||
post:
|
||||
operationId: "post_models_downloads"
|
||||
summary: "Create a managed model download task with progress/cancel lifecycle."
|
||||
responses:
|
||||
200:
|
||||
description: "OK"
|
||||
x-openclaw-auth: "Admin"
|
||||
x-openclaw-section: "1.3C Model Management & Installations"
|
||||
x-openclaw-legacy-path: "/moltbot/models/downloads"
|
||||
x-openclaw-auth-tier: "admin"
|
||||
security:
|
||||
- OpenClawAdminToken:
|
||||
[]
|
||||
get:
|
||||
operationId: "get_models_downloads"
|
||||
summary: "List model download tasks with snapshot or delta cursor semantics (`since_seq`)."
|
||||
responses:
|
||||
200:
|
||||
description: "OK"
|
||||
x-openclaw-auth: "Admin"
|
||||
x-openclaw-section: "1.3C Model Management & Installations"
|
||||
x-openclaw-legacy-path: "/moltbot/models/downloads"
|
||||
x-openclaw-auth-tier: "admin"
|
||||
security:
|
||||
- OpenClawAdminToken:
|
||||
[]
|
||||
/models/downloads/{task_id}:
|
||||
get:
|
||||
operationId: "get_models_downloads_task_id"
|
||||
summary: "Get one model download task by id."
|
||||
responses:
|
||||
200:
|
||||
description: "OK"
|
||||
x-openclaw-auth: "Admin"
|
||||
x-openclaw-section: "1.3C Model Management & Installations"
|
||||
x-openclaw-legacy-path: "/moltbot/models/downloads/{task_id}"
|
||||
x-openclaw-auth-tier: "admin"
|
||||
security:
|
||||
- OpenClawAdminToken:
|
||||
[]
|
||||
parameters:
|
||||
- name: "task_id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
/models/downloads/{task_id}/cancel:
|
||||
post:
|
||||
operationId: "post_models_downloads_task_id_cancel"
|
||||
summary: "Cancel a queued or running model download task."
|
||||
responses:
|
||||
200:
|
||||
description: "OK"
|
||||
x-openclaw-auth: "Admin"
|
||||
x-openclaw-section: "1.3C Model Management & Installations"
|
||||
x-openclaw-legacy-path: "/moltbot/models/downloads/{task_id}/cancel"
|
||||
x-openclaw-auth-tier: "admin"
|
||||
security:
|
||||
- OpenClawAdminToken:
|
||||
[]
|
||||
parameters:
|
||||
- name: "task_id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
/models/import:
|
||||
post:
|
||||
operationId: "post_models_import"
|
||||
summary: "Import a completed managed download into the bounded install root after provenance and hash verification."
|
||||
responses:
|
||||
200:
|
||||
description: "OK"
|
||||
x-openclaw-auth: "Admin"
|
||||
x-openclaw-section: "1.3C Model Management & Installations"
|
||||
x-openclaw-legacy-path: "/moltbot/models/import"
|
||||
x-openclaw-auth-tier: "admin"
|
||||
security:
|
||||
- OpenClawAdminToken:
|
||||
[]
|
||||
/models/installations:
|
||||
get:
|
||||
operationId: "get_models_installations"
|
||||
summary: "List managed model installations."
|
||||
responses:
|
||||
200:
|
||||
description: "OK"
|
||||
x-openclaw-auth: "Admin"
|
||||
x-openclaw-section: "1.3C Model Management & Installations"
|
||||
x-openclaw-legacy-path: "/moltbot/models/installations"
|
||||
x-openclaw-auth-tier: "admin"
|
||||
security:
|
||||
- OpenClawAdminToken:
|
||||
[]
|
||||
/chat:
|
||||
post:
|
||||
operationId: "post_chat"
|
||||
@@ -370,7 +521,7 @@ paths:
|
||||
200:
|
||||
description: "OK"
|
||||
x-openclaw-auth: "Admin/Local"
|
||||
x-openclaw-section: "1.3C LLM Management & Chat"
|
||||
x-openclaw-section: "1.3D LLM Management & Chat"
|
||||
x-openclaw-legacy-path: "/moltbot/llm/chat"
|
||||
x-openclaw-auth-tier: "admin"
|
||||
security:
|
||||
@@ -384,7 +535,7 @@ paths:
|
||||
200:
|
||||
description: "OK"
|
||||
x-openclaw-auth: "Admin"
|
||||
x-openclaw-section: "1.3C LLM Management & Chat"
|
||||
x-openclaw-section: "1.3D LLM Management & Chat"
|
||||
x-openclaw-legacy-path: "/moltbot/llm/test"
|
||||
x-openclaw-auth-tier: "admin"
|
||||
security:
|
||||
@@ -393,12 +544,12 @@ 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"
|
||||
x-openclaw-auth: "Admin"
|
||||
x-openclaw-section: "1.3C LLM Management & Chat"
|
||||
x-openclaw-section: "1.3D LLM Management & Chat"
|
||||
x-openclaw-legacy-path: "/moltbot/llm/models"
|
||||
x-openclaw-auth-tier: "admin"
|
||||
security:
|
||||
@@ -668,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"
|
||||
|
||||
+132
-11
@@ -1,12 +1,12 @@
|
||||
# OpenClaw API Contract (v1)
|
||||
|
||||
> **Status**: normative
|
||||
> **Version**: 1.0.5
|
||||
> **Date**: 2026-03-12
|
||||
> **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,11 +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`
|
||||
@@ -58,6 +79,33 @@ 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
|
||||
|
||||
@@ -70,6 +118,13 @@ Reasoning-content redaction 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/`
|
||||
@@ -84,8 +139,9 @@ Reasoning-content redaction 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
|
||||
|
||||
@@ -98,8 +154,38 @@ 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. |
|
||||
|
||||
### 1.3C LLM Management & Chat
|
||||
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
|
||||
|
||||
**Base Path**: `/openclaw/`
|
||||
**Auth**: Admin Token Required
|
||||
|
||||
| Method | Path | Legacy Path | Auth | Description |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/models/search` | `/moltbot/models/search` | Admin | Search normalized model entries across managed installs and catalog sources. |
|
||||
| `POST` | `/models/downloads` | `/moltbot/models/downloads` | Admin | Create a managed model download task with progress/cancel lifecycle. |
|
||||
| `GET` | `/models/downloads` | `/moltbot/models/downloads` | Admin | List model download tasks with snapshot or delta cursor semantics (`since_seq`). |
|
||||
| `GET` | `/models/downloads/{task_id}` | `/moltbot/models/downloads/{task_id}` | Admin | Get one model download task by id. |
|
||||
| `POST` | `/models/downloads/{task_id}/cancel` | `/moltbot/models/downloads/{task_id}/cancel` | Admin | Cancel a queued or running model download task. |
|
||||
| `POST` | `/models/import` | `/moltbot/models/import` | Admin | Import a completed managed download into the bounded install root after provenance and hash verification. |
|
||||
| `GET` | `/models/installations` | `/moltbot/models/installations` | Admin | List managed model installations. |
|
||||
|
||||
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
|
||||
|
||||
### 1.3D LLM Management & Chat
|
||||
|
||||
**LLM Base Path**: `/openclaw/llm/`
|
||||
|
||||
@@ -107,7 +193,7 @@ Assist payload redaction contract:
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `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
|
||||
|
||||
@@ -144,6 +230,36 @@ Assist payload redaction contract:
|
||||
| `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/`
|
||||
@@ -190,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:
|
||||
@@ -209,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)
|
||||
@@ -219,6 +336,10 @@ Tenant-boundary error notes:
|
||||
- normalized limit/offset/cursor values
|
||||
- stale/future cursor resets
|
||||
- bounded scan truncation or malformed-record skips
|
||||
- `GET /openclaw/preflight/inventory` may return snapshot-state diagnostics so clients/operators can detect:
|
||||
- whether the response came from a previous cached snapshot
|
||||
- whether a background refresh is currently running
|
||||
- whether the current snapshot is stale or the last deep scan failed
|
||||
- Backend/runtime errors outside pagination normalization are still surfaced explicitly (not silently swallowed).
|
||||
|
||||
---
|
||||
@@ -236,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,9 +8,15 @@ 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Contract tests | `python -m pytest tests/contract -v` | Validate API/config contracts |
|
||||
|
||||
## Public MAE Hard-Guarantee Suites
|
||||
|
||||
@@ -29,3 +35,41 @@ If a change intentionally modifies contract behavior:
|
||||
1. Update affected tests and docs in the same PR.
|
||||
2. Record the behavior change and migration impact in release notes.
|
||||
3. Keep security-path tests on triple-assert semantics (status + machine code + audit signal).
|
||||
|
||||
## Governance Baseline
|
||||
|
||||
- Coverage governance is part of the standard gate, not an optional reporting step.
|
||||
- Dependency-audit governance is part of CI parity:
|
||||
- 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 >= 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%`
|
||||
- Known equivalent mutation survivors must stay explicitly allowlisted in `tests/mutation_survivor_allowlist.json`; drift is a gate failure, not a warning.
|
||||
|
||||
@@ -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,35 +3,74 @@
|
||||
```openclaw-compat-matrix-meta
|
||||
{
|
||||
"anchors": {
|
||||
"comfyui": "unknown",
|
||||
"comfyui_frontend": "unknown",
|
||||
"desktop": "unknown"
|
||||
"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-20260225",
|
||||
"updated_at": "2026-02-25T00: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-02-25",
|
||||
"matrix_version": "v0.2.1",
|
||||
"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
|
||||
}
|
||||
```
|
||||
|
||||
This document outlines the validated environments for ComfyUI-OpenClaw `v0.2.1` (M1 Release).
|
||||
This document tracks the current reference anchors and validated environments for the active ComfyUI-OpenClaw branch.
|
||||
|
||||
## Core Dependencies
|
||||
|
||||
| Component | Validated Range | Best Effort / Experimental | Notes |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **ComfyUI** | v0.2.2+ | v0.1.x | Recommend latest release |
|
||||
| **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, 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
|
||||
|
||||
| OS | Status | CI Validation | Notes |
|
||||
|
||||
@@ -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
|
||||
@@ -131,22 +135,75 @@ Controls the `connector` sidecar process and outbound delivery.
|
||||
| `OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN` | Slack | Bot OAuth token (`xoxb-*`). |
|
||||
| `OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET` | Slack | Ingress signature secret. |
|
||||
| `OPENCLAW_CONNECTOR_SLACK_APP_TOKEN` | Slack | Optional Socket Mode app token (`xapp-*`). |
|
||||
| `OPENCLAW_CONNECTOR_SLACK_CLIENT_ID` | Slack | OAuth client ID for multi-workspace installation flow. |
|
||||
| `OPENCLAW_CONNECTOR_SLACK_CLIENT_SECRET` | Slack | OAuth client secret for multi-workspace installation flow. |
|
||||
| `OPENCLAW_CONNECTOR_SLACK_OAUTH_REDIRECT_URI` | Slack | Explicit OAuth callback URL. Falls back to `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL + OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH` when omitted. |
|
||||
| `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`, 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.
|
||||
|
||||
@@ -160,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.
|
||||
@@ -49,8 +49,45 @@ interface BannerStatus {
|
||||
|
||||
- **Monitoring**: Polls `/health` every 10s.
|
||||
- **Triggers**: Checks `stats.observability.total_dropped > 0`.
|
||||
- **Display**: Simple DOM injection of `.moltbot-banner`.
|
||||
- **Limitations**: No connectivity state handling, no 'info'/'success' states, simplistic dedupe.
|
||||
- **Display**: Simple DOM injection of canonical `.openclaw-banner` markup; legacy `.moltbot-banner` compatibility selectors remain available through centralized runtime aliasing.
|
||||
- **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)
|
||||
|
||||
Persistent operator notifications are the durable counterpart to transient banners and toasts.
|
||||
|
||||
### Schema (TypeScript)
|
||||
|
||||
```typescript
|
||||
interface NotificationEntry {
|
||||
id: string;
|
||||
severity: BannerSeverity;
|
||||
message: string;
|
||||
source: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
count: number;
|
||||
acknowledged_at?: string | null;
|
||||
dismissed_at?: string | null;
|
||||
action?: {
|
||||
label: string;
|
||||
type: 'url' | 'tab' | 'action';
|
||||
payload: string;
|
||||
};
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### F66 Baseline
|
||||
|
||||
- Warning/error banners and selected operator toasts are mirrored into the in-app notification center.
|
||||
- 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.
|
||||
|
||||
## 2. Context Actions (F51)
|
||||
|
||||
@@ -85,7 +122,7 @@ interface ContextAction {
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Parameter Lab (F52)
|
||||
## 3. Parameter Lab
|
||||
|
||||
Contracts for bounded parameter sweeps and experiment orchestration.
|
||||
|
||||
@@ -98,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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user