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 | ||
|
|
a0d37c371e | ||
|
|
4911537536 | ||
|
|
f1eac335ef | ||
|
|
1ef822810f | ||
|
|
9473a9e2e2 | ||
|
|
2b0dd5d9a4 | ||
|
|
5b187b2d05 | ||
|
|
0a9d0429ec | ||
|
|
9904ecea49 | ||
|
|
d641c04bde | ||
|
|
91423bc320 | ||
|
|
069635a1f7 | ||
|
|
a5a795d6be | ||
|
|
f70e01145e | ||
|
|
1d971df5fc | ||
|
|
a62d0f9349 | ||
|
|
f65f374a71 | ||
|
|
1fdd856c7f | ||
|
|
9ffdcee190 | ||
|
|
6d89b42021 | ||
|
|
5c0a65b869 | ||
|
|
1880eb8bbd | ||
|
|
81d18b9185 | ||
|
|
2fb1207a1a | ||
|
|
c4dd5f5276 | ||
|
|
b86ae18ea2 | ||
|
|
9a0d554bcb | ||
|
|
28db66d4c0 | ||
|
|
cdf2b0b5f6 | ||
|
|
44ef61ca10 | ||
|
|
e0bfe64530 | ||
|
|
911399d9cc | ||
|
|
9c68aefb38 | ||
|
|
ecc173780a | ||
|
|
f135051859 | ||
|
|
0368ce8356 | ||
|
|
cce635789c | ||
|
|
3415591b24 | ||
|
|
bf84b90f08 | ||
|
|
7277575289 | ||
|
|
450e978a9c | ||
|
|
807a78e76f | ||
|
|
6d0359a6a7 | ||
|
|
87d99bbafe | ||
|
|
44ceaf1196 | ||
|
|
714a5efc5d | ||
|
|
3b69b0c358 | ||
|
|
df03ecb173 | ||
|
|
ea38c96432 | ||
|
|
80ae3a83a0 | ||
|
|
9aef5eb2d4 | ||
|
|
bbc2bc10b8 | ||
|
|
8f45f6364a | ||
|
|
6bfcf37508 | ||
|
|
9d4965c5ac | ||
|
|
06a933dcd0 | ||
|
|
0de70c2188 | ||
|
|
2e788cb434 | ||
|
|
9c72671cef | ||
|
|
ba64113a37 | ||
|
|
4f95dd0bca | ||
|
|
218356c29d | ||
|
|
5c914b2068 | ||
|
|
3671be8b99 | ||
|
|
4651bbb5ff | ||
|
|
d1a0b460e1 | ||
|
|
f6b8b2e0e9 | ||
|
|
28126613e2 | ||
|
|
6785ad7150 | ||
|
|
63b95086db | ||
|
|
ceb282085e | ||
|
|
b3a0f7d2ad | ||
|
|
8c8d65e982 | ||
|
|
13aa463286 | ||
|
|
8033500e50 | ||
|
|
a99f2aa673 | ||
|
|
c35fe05d3e | ||
|
|
4c0ac67b37 | ||
|
|
4f593471c4 | ||
|
|
361b28e227 | ||
|
|
68e7d06133 | ||
|
|
f3fadd6d8a | ||
|
|
a88a3320f8 | ||
|
|
20cacd9399 | ||
|
|
e6adcacadf | ||
|
|
db40c0c538 | ||
|
|
06bca8df03 | ||
|
|
d5063c2169 | ||
|
|
242bd98db4 | ||
|
|
b948852eaa | ||
|
|
90319ffc8d | ||
|
|
3376f44d33 | ||
|
|
93439ff095 | ||
|
|
0f487a72dd | ||
|
|
5cbfcbdb46 | ||
|
|
9486c358bb | ||
|
|
8d19fab12a | ||
|
|
4c91d90abc | ||
|
|
9554d2a614 | ||
|
|
866489329e | ||
|
|
db7fe477f6 | ||
|
|
2520f7426a | ||
|
|
5927ad54aa | ||
|
|
1a40255501 | ||
|
|
13e4d4ca21 | ||
|
|
67f9dc317a | ||
|
|
ace9e63af5 | ||
|
|
0632361e6a | ||
|
|
d20a4f7818 | ||
|
|
4a3c8d3a38 | ||
|
|
3b880ca98b | ||
|
|
1769546648 | ||
|
|
df91b68e3a | ||
|
|
606c5d76e5 | ||
|
|
7a2bde5b2e | ||
|
|
4e26f88a0e | ||
|
|
d7ef0278bd | ||
|
|
7e9f4985cf | ||
|
|
c796dde48e | ||
|
|
2049af4ae8 | ||
|
|
5be595738f | ||
|
|
3acdbfba83 | ||
|
|
788ab32293 | ||
|
|
b8a5ef7875 | ||
|
|
3e6f4c0e4c | ||
|
|
f7b2c42cdb | ||
|
|
ca2552fe15 | ||
|
|
9d86dff86a | ||
|
|
e23580bbac | ||
|
|
f3cd94204e |
+196
-21
@@ -6,6 +6,12 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 3 * * *' # nightly 03:00 UTC for adversarial-extended
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -17,14 +23,24 @@ 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@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
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pip install numpy pillow
|
||||
- name: R120 preflight
|
||||
run: |
|
||||
python scripts/preflight_check.py --strict
|
||||
- name: Import smoke test
|
||||
env:
|
||||
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_smoke
|
||||
@@ -40,16 +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 -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
|
||||
@@ -62,22 +88,81 @@ 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@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.
|
||||
python -m pip install numpy pillow aiohttp
|
||||
# CRITICAL: Python 3.10 coverage reads pyproject.toml only when the
|
||||
# TOML extra is present; do not downgrade this back to plain coverage.
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pip install -r requirements-quality.txt
|
||||
python -m pip install numpy pillow aiohttp "coverage[toml]"
|
||||
- name: R120 preflight
|
||||
run: |
|
||||
python scripts/preflight_check.py --strict
|
||||
- name: Static-analysis policy
|
||||
run: |
|
||||
python scripts/verify_static_analysis_policy.py
|
||||
- name: Run MAE hard-guarantee suites
|
||||
env:
|
||||
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_mae
|
||||
run: |
|
||||
# CRITICAL: keep these as explicit CI gates so public MAE route-plane
|
||||
# classification/regression drift cannot silently bypass full discovery.
|
||||
python scripts/run_unittests.py --module tests.test_s60_mae_route_segmentation --enforce-skip-policy tests/skip_policy.json --max-skipped 0
|
||||
python scripts/run_unittests.py --module tests.test_s60_routes_startup_gate --enforce-skip-policy tests/skip_policy.json --max-skipped 0
|
||||
python scripts/run_unittests.py --module tests.security.test_endpoint_drift --enforce-skip-policy tests/skip_policy.json --max-skipped 0
|
||||
- name: Run unit tests
|
||||
|
||||
env:
|
||||
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_unit
|
||||
run: |
|
||||
python scripts/run_unittests.py --start-dir tests --pattern "test_*.py"
|
||||
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@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- 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
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pip install numpy pillow aiohttp
|
||||
- name: R120 preflight
|
||||
run: |
|
||||
python scripts/preflight_check.py --strict
|
||||
- name: Run real-backend lane
|
||||
env:
|
||||
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_backend_e2e_real
|
||||
run: |
|
||||
# CRITICAL: this lane must stay low-mock and exercise real aiohttp request flow.
|
||||
python scripts/run_unittests.py --module tests.test_r122_real_backend_lane --enforce-skip-policy tests/skip_policy.json --max-skipped 0
|
||||
# R123: model-list loopback/private-IP SSRF parity must remain no-skip.
|
||||
python scripts/run_unittests.py --module tests.test_r123_real_backend_model_list_lane --enforce-skip-policy tests/skip_policy.json --max-skipped 0
|
||||
|
||||
contract-tests:
|
||||
name: Contract Tests (R52)
|
||||
@@ -87,14 +172,24 @@ 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@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
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pip install numpy pillow aiohttp pytest-asyncio
|
||||
- name: R120 preflight
|
||||
run: |
|
||||
python scripts/preflight_check.py --strict
|
||||
- name: Run contract tests
|
||||
run: |
|
||||
python -m pytest tests/contract -v
|
||||
@@ -103,19 +198,99 @@ 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@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- 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
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pip install numpy pillow aiohttp
|
||||
- name: R120 preflight
|
||||
run: |
|
||||
python scripts/preflight_check.py --strict
|
||||
- name: R118 adversarial adaptive (auto profile)
|
||||
env:
|
||||
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_adversarial
|
||||
OPENCLAW_DIFF_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
OPENCLAW_DIFF_HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: |
|
||||
python scripts/run_adversarial_gate.py --profile auto --seed 42 --artifact-dir .tmp/adversarial
|
||||
- name: Upload adversarial artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: adversarial-adaptive-manifest
|
||||
path: .tmp/adversarial/
|
||||
retention-days: 30
|
||||
|
||||
adversarial-extended:
|
||||
name: Adversarial Gate (extended, nightly/manual)
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
|
||||
steps:
|
||||
- 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
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pip install numpy pillow aiohttp
|
||||
- name: R120 preflight
|
||||
run: |
|
||||
python scripts/preflight_check.py --strict
|
||||
- name: R118 adversarial extended
|
||||
env:
|
||||
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_adversarial_ext
|
||||
run: |
|
||||
python scripts/run_adversarial_gate.py --profile extended --artifact-dir .tmp/adversarial-extended
|
||||
- name: Upload adversarial artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: adversarial-extended-manifest
|
||||
path: .tmp/adversarial-extended/
|
||||
retention-days: 90
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+58
-22
@@ -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/
|
||||
AGENT_CONTEXT.md
|
||||
ROADMAP.md
|
||||
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
|
||||
|
||||
@@ -29,6 +29,34 @@ repos:
|
||||
language: python
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: regen-openapi-spec
|
||||
name: regenerate OpenAPI spec (staged)
|
||||
entry: python -B scripts/regenerate_openapi_if_needed.py --staged
|
||||
language: python
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: guard-openapi-sync
|
||||
name: guard generated OpenAPI sync (staged)
|
||||
entry: python -B scripts/check_openapi_sync.py --staged
|
||||
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
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
# Release Checklist (DoD)
|
||||
|
||||
This document contains the authoritative checklist for releasing **ComfyUI-OpenClaw**.
|
||||
A release candidate must pass **Gate A** to be considered for Public Release v1.
|
||||
If the deployment enables remote control or bridge features, it must also pass **Gate B**.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The validation workflow in `tests/TEST_SOP.md` is **mandatory** for all releases.
|
||||
|
||||
---
|
||||
|
||||
## Gate A: Public Release v1 Baseline (Required)
|
||||
|
||||
**Goal**: Safe-by-default for internet-exposed deployments (assuming they follow the deployment recipes in `docs/deploy/`).
|
||||
|
||||
### 1. Security & configuration
|
||||
|
||||
- [ ] **Admin Boundary**: `OPENCLAW_CONNECTOR_ADMIN_TOKEN` is required for sensitive operations if remote access is enabled.
|
||||
- [ ] **Webhooks**: Listening webhooks (Discord/Line/Telegram) are disabled unless their respective tokens are configured (`OPENCLAW_CONNECTOR_DISCORD_TOKEN`, etc.).
|
||||
- [ ] **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 URLs require `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1` or explicit allowlist.
|
||||
- [ ] **Budgets**: `OPENCLAW_MAX_INFLIGHT_SUBMITS_TOTAL` (concurrency) and `OPENCLAW_MAX_RENDERED_WORKFLOW_BYTES` (payloads) are enforced.
|
||||
- [ ] **Contracts**: API endpoints match `docs/release/api_contract.md`; Configuration follows `docs/release/config_secrets_contract.md`.
|
||||
|
||||
### 2. Documentation & Recipes
|
||||
|
||||
- [ ] **Deployment**: `docs/deploy/` contains recipes for:
|
||||
- [ ] Local-only (Default)
|
||||
- [ ] Tailscale (Recommended Remote)
|
||||
- [ ] LAN (Restricted)
|
||||
- [ ] **Security**: `SECURITY.md` is up-to-date and linked from README.
|
||||
- [ ] **Feature Flags**: `docs/release/feature_flags.md` accurately reflects the codebase defaults.
|
||||
|
||||
### 3. Validation (Must Pass)
|
||||
|
||||
Run the full regression suite:
|
||||
|
||||
```bash
|
||||
# 1. Secret Scanning
|
||||
pre-commit run detect-secrets --all-files
|
||||
|
||||
# 2. Lint & Formatting
|
||||
pre-commit run --all-files --show-diff-on-failure
|
||||
|
||||
# 3. Backend Unit Tests
|
||||
MOLTBOT_STATE_DIR="$(pwd)/moltbot_state/_local_unit" python -m unittest discover -s tests -p "test_*.py" -v
|
||||
|
||||
# 4. Frontend E2E (Unit/Integration)
|
||||
# Ensure Node 18+
|
||||
node -v
|
||||
npm test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gate B: Bridge / Remote Control Safety (Conditional)
|
||||
|
||||
**Goal**: Safe operation when `OPENCLAW_BRIDGE_ENABLED=1` or remote commands are active.
|
||||
|
||||
- [ ] **Explicit Enable**: Bridge features are off unless `OPENCLAW_BRIDGE_ENABLED=1` is set.
|
||||
- [ ] **Auth**: Bridge endpoints require `OPENCLAW_BRIDGE_TOKEN` (or device pairing).
|
||||
- [ ] **CSRF**: State-changing endpoints (admin/bridge) enforce Origin checks or require Token on loopback.
|
||||
- [ ] **Callback Safety**: Delivery targets are validated against DNS/IP allowlists (no internal network access).
|
||||
- [ ] **DoD**: Operator docs include "Red Lines" (never expose Bridge port directly to internet without auth).
|
||||
|
||||
---
|
||||
|
||||
## Release Metadata
|
||||
|
||||
- [ ] **Version**: `pyproject.toml` version matches git tag.
|
||||
- [ ] **Changelog**: Updated `CHANGELOG.md` (if present) with user-facing changes.
|
||||
- [ ] **Migration**: If config/storage schema changed, explicit migration notes are in `docs/migration/` (Optional).
|
||||
|
||||
---
|
||||
|
||||
## Sign-off
|
||||
|
||||
- [ ] **Gate A Passed**: (Date/Initials)
|
||||
- [ ] **Gate B Passed** (if applicable): (Date/Initials)
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the latest version of ComfyUI-OpenClaw is supported for security updates.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| Latest | :white_check_mark: |
|
||||
| < 0.2.0 | :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.
|
||||
|
||||
---
|
||||
|
||||
# Safe Deployment Guide (S18)
|
||||
|
||||
OpenClaw is a powerful extension that interacts with LLMs and the filesystem (via ComfyUI). **By default, it is designed for local (localhost) use.** Exposing it to the public internet requires careful configuration.
|
||||
|
||||
## ⚠️ Warning
|
||||
|
||||
**Do NOT expose your ComfyUI instance directly to the public internet** (e.g., port forwarding 8188) without a secure reverse proxy or VPN.
|
||||
|
||||
## Recommended Deployment
|
||||
|
||||
1. **Localhost (Default)**: Use on your own machine. No extra config needed.
|
||||
2. **VPN / Tailscale**: Best for private remote access.
|
||||
3. **SSH Tunnel**: `ssh -L 8188:localhost:8188 user@remote`
|
||||
|
||||
## Reverse Proxy Setup (Advanced)
|
||||
|
||||
If you must expose OpenClaw via a reverse proxy (Nginx, Caddy, Cloudflare Tunnel), you MUST configure the following:
|
||||
|
||||
### 1. Observability Access Control (S14)
|
||||
|
||||
Logs (`/openclaw/logs/tail`) and Config (`/openclaw/config`) are restricted to loopback clients by default. (Legacy `/moltbot/*` endpoints are also supported.) To allow remote access via proxy, set a secure token:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_OBSERVABILITY_TOKEN="your-secure-random-token-here"
|
||||
# Legacy compatibility (optional):
|
||||
# export MOLTBOT_OBSERVABILITY_TOKEN="your-secure-random-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`).
|
||||
|
||||
### 2. Trusted Proxies (S6)
|
||||
|
||||
If using a reverse proxy, OpenClaw needs to know the *real* client IP for rate limiting enforcement.
|
||||
|
||||
Configure your proxy (e.g., Nginx) to send `X-Forwarded-For`. Then tell Moltbot to trust your proxy's IP:
|
||||
|
||||
```bash
|
||||
export MOLTBOT_TRUST_X_FORWARDED_FOR=1
|
||||
# Comma-separated list of trusted proxy IPs or CIDRs
|
||||
export MOLTBOT_TRUSTED_PROXIES="127.0.0.1,10.0.0.0/8"
|
||||
```
|
||||
New names (preferred):
|
||||
```bash
|
||||
export OPENCLAW_TRUST_X_FORWARDED_FOR=1
|
||||
export OPENCLAW_TRUSTED_PROXIES="127.0.0.1,10.0.0.0/8"
|
||||
```
|
||||
|
||||
### 3. SSRF Protection (S16)
|
||||
|
||||
OpenClaw validates custom LLM `base_url` settings to prevent Server-Side Request Forgery (SSRF).
|
||||
|
||||
* **Default**: Only known providers (OpenAI, Anthropic, etc.) and Localhost (Ollama) are allowed.
|
||||
* **Custom URLs**: Must be explicitly enabled:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_ALLOW_CUSTOM_BASE_URL=1
|
||||
# Legacy compatibility (optional):
|
||||
# export MOLTBOT_ALLOW_CUSTOM_BASE_URL=1
|
||||
```
|
||||
|
||||
Even when enabled, private IPs (LAN) are blocked by default. To allow insecure/LAN base URLs (risky):
|
||||
|
||||
```bash
|
||||
export OPENCLAW_ALLOW_INSECURE_BASE_URL=1
|
||||
# Legacy compatibility (optional):
|
||||
# export MOLTBOT_ALLOW_INSECURE_BASE_URL=1
|
||||
```
|
||||
|
||||
### 4. Rate Limiting (S17)
|
||||
|
||||
OpenClaw enforces internal rate limits even if you don't.
|
||||
|
||||
* Webhooks: 30/min
|
||||
* Logs: 60/min
|
||||
* Admin: 20/min
|
||||
|
||||
* Admin: 20/min
|
||||
|
||||
### 5. Sidecar Bridge (S19)
|
||||
|
||||
OpenClaw supports a "Sidecar Bridge" (F10) for safe interaction with external bots (Discord/Slack).
|
||||
|
||||
* **Default**: **DISABLED**.
|
||||
* **Enable**: Set `OPENCLAW_BRIDGE_ENABLED=1` (legacy `MOLTBOT_BRIDGE_ENABLED=1`).
|
||||
* **Authentication**: Requires `OPENCLAW_BRIDGE_DEVICE_TOKEN` (legacy `MOLTBOT_BRIDGE_DEVICE_TOKEN`) (shared secret).
|
||||
* **Network**: Bridge endpoints (`/bridge/*`) are sensitive. **Do not expose to public internet.** Use a private network (Tailscale) or restrict access via reverse proxy.
|
||||
* **SSRF**: Callback delivery blocks internal IPs. To allow specific external callback hosts, set `OPENCLAW_BRIDGE_CALLBACK_HOST_ALLOWLIST` (legacy: `MOLTBOT_BRIDGE_CALLBACK_HOST_ALLOWLIST`).
|
||||
|
||||
## Security Checklist
|
||||
|
||||
* [ ] **Authentication**: Your reverse proxy should handle general auth (Basic Auth, OAuth).
|
||||
* [ ] **HTTPS**: Always use TLS/SSL.
|
||||
* [ ] **Tokens**: Set `OPENCLAW_OBSERVABILITY_TOKEN` and `OPENCLAW_ADMIN_TOKEN` (for config writes). (Legacy `MOLTBOT_*` vars still work.)
|
||||
* [ ] **Isolation**: Don't run as root.
|
||||
+42
-252
@@ -2,21 +2,23 @@ import os
|
||||
import sys
|
||||
|
||||
# Ensure this custom node root is on sys.path (ComfyUI loads modules by path, not package)
|
||||
_MOLTBOT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
if _MOLTBOT_ROOT not in sys.path:
|
||||
sys.path.insert(0, _MOLTBOT_ROOT)
|
||||
_OPENCLAW_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
if _OPENCLAW_ROOT not in sys.path:
|
||||
sys.path.insert(0, _OPENCLAW_ROOT)
|
||||
|
||||
if __package__:
|
||||
from .nodes.batch_variants import MoltbotBatchVariants
|
||||
from .nodes.image_to_prompt import MoltbotImageToPrompt
|
||||
from .nodes.prompt_planner import MoltbotPromptPlanner
|
||||
from .nodes.prompt_refiner import MoltbotPromptRefiner
|
||||
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
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"MoltbotPromptPlanner": MoltbotPromptPlanner,
|
||||
"MoltbotBatchVariants": MoltbotBatchVariants,
|
||||
"MoltbotImageToPrompt": MoltbotImageToPrompt,
|
||||
"MoltbotPromptRefiner": MoltbotPromptRefiner,
|
||||
# IMPORTANT: keep legacy mapping keys stable for existing workflows.
|
||||
"MoltbotPromptPlanner": OpenClawPromptPlanner,
|
||||
"MoltbotBatchVariants": OpenClawBatchVariants,
|
||||
"MoltbotImageToPrompt": OpenClawImageToPrompt,
|
||||
"MoltbotPromptRefiner": OpenClawPromptRefiner,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -26,257 +28,45 @@ 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"
|
||||
|
||||
# Register API routes (observability endpoints)
|
||||
_routes_registered = False
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
def _bootstrap_openclaw_routes() -> None:
|
||||
# IMPORTANT: keep entrypoint thin; heavy startup orchestration lives in
|
||||
# services.route_bootstrap to prevent __init__.py from regressing into a
|
||||
# large mixed-responsibility module again.
|
||||
try:
|
||||
if __package__:
|
||||
from .services.route_bootstrap import register_routes_once
|
||||
else:
|
||||
from services.route_bootstrap import register_routes_once
|
||||
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
|
||||
|
||||
|
||||
def _register_routes_once():
|
||||
global _routes_registered
|
||||
if _routes_registered:
|
||||
mark_bootstrap_import_failed(exc)
|
||||
except Exception:
|
||||
# IMPORTANT: diagnostics must not mask the original compatibility fallback.
|
||||
pass
|
||||
return
|
||||
|
||||
# R23: Register Plugins
|
||||
# This needs to happen regardless of PromptServer availability, as plugins might register other things.
|
||||
try:
|
||||
from .services.plugins.builtin import register_all
|
||||
|
||||
register_all()
|
||||
except Exception as e:
|
||||
logging.getLogger("ComfyUI-OpenClaw").error(f"Failed to register plugins: {e}")
|
||||
|
||||
def _do_full_registration(server):
|
||||
"""Register all Moltbot routes including Bridge and Scheduler."""
|
||||
from .api.approvals import register_approval_routes
|
||||
from .api.bridge import BridgeHandlers
|
||||
from .api.presets import register_preset_routes
|
||||
from .api.routes import register_routes
|
||||
from .api.schedules import register_schedule_routes
|
||||
from .api.triggers import register_trigger_routes
|
||||
from .services.access_control import require_admin_token
|
||||
from .services.plugins.async_bridge import run_async_in_sync_context
|
||||
from .services.queue_submit import submit_prompt
|
||||
from .services.templates import get_template_service
|
||||
|
||||
register_routes(server)
|
||||
register_preset_routes(server.app)
|
||||
|
||||
# R4: Register schedule CRUD routes
|
||||
register_schedule_routes(server.app, require_admin_token_fn=require_admin_token)
|
||||
|
||||
# Bridge: Adapt functional submit_prompt to service interface
|
||||
class QueueSubmitService:
|
||||
def submit(self, job_req):
|
||||
tmpl_svc = get_template_service()
|
||||
workflow = tmpl_svc.render_template(job_req.template_id, job_req.inputs)
|
||||
|
||||
async def _do_submit():
|
||||
return await submit_prompt(
|
||||
workflow,
|
||||
client_id=job_req.session_id or "bridge",
|
||||
extra_data={
|
||||
"openclaw": {"trace_id": job_req.trace_id},
|
||||
# Legacy key kept for existing tooling that expects this blob.
|
||||
"moltbot": {"trace_id": job_req.trace_id},
|
||||
},
|
||||
source="bridge",
|
||||
trace_id=job_req.trace_id,
|
||||
)
|
||||
|
||||
return run_async_in_sync_context(_do_submit())
|
||||
|
||||
bridge_handlers = BridgeHandlers(submit_service=QueueSubmitService())
|
||||
# Dual registration for bridge (R26)
|
||||
if hasattr(server.app.router, "add_post"): # Redundant check but safe
|
||||
# Legacy
|
||||
server.app.router.add_post(
|
||||
"/moltbot/bridge/submit", bridge_handlers.submit_handler
|
||||
)
|
||||
server.app.router.add_post(
|
||||
"/moltbot/bridge/deliver", bridge_handlers.deliver_handler
|
||||
)
|
||||
server.app.router.add_get(
|
||||
"/moltbot/bridge/health", bridge_handlers.health_handler
|
||||
)
|
||||
# New prefix
|
||||
server.app.router.add_post(
|
||||
"/openclaw/bridge/submit", bridge_handlers.submit_handler
|
||||
)
|
||||
server.app.router.add_post(
|
||||
"/openclaw/bridge/deliver", bridge_handlers.deliver_handler
|
||||
)
|
||||
server.app.router.add_get(
|
||||
"/openclaw/bridge/health", bridge_handlers.health_handler
|
||||
)
|
||||
# /api Prefixed
|
||||
try:
|
||||
server.app.router.add_post(
|
||||
"/api/moltbot/bridge/submit", bridge_handlers.submit_handler
|
||||
)
|
||||
server.app.router.add_post(
|
||||
"/api/moltbot/bridge/deliver", bridge_handlers.deliver_handler
|
||||
)
|
||||
server.app.router.add_get(
|
||||
"/api/moltbot/bridge/health", bridge_handlers.health_handler
|
||||
)
|
||||
server.app.router.add_post(
|
||||
"/api/openclaw/bridge/submit", bridge_handlers.submit_handler
|
||||
)
|
||||
server.app.router.add_post(
|
||||
"/api/openclaw/bridge/deliver", bridge_handlers.deliver_handler
|
||||
)
|
||||
server.app.router.add_get(
|
||||
"/api/openclaw/bridge/health", bridge_handlers.health_handler
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
# Shared submit function for scheduler and triggers
|
||||
async def unified_submit_fn(
|
||||
template_id,
|
||||
inputs,
|
||||
trace_id,
|
||||
idempotency_key,
|
||||
delivery=None,
|
||||
source="unknown",
|
||||
):
|
||||
"""Submit function for scheduler and trigger-triggered runs."""
|
||||
# NOTE: Use IdempotencyStore API (check_and_record/update_prompt_id).
|
||||
# Avoid legacy get_store/get/set usage; wrong API here breaks route registration at runtime.
|
||||
from .services.idempotency_store import IdempotencyStore
|
||||
from .services.queue_submit import submit_prompt
|
||||
from .services.templates import get_template_service
|
||||
|
||||
# Check idempotency
|
||||
store = IdempotencyStore()
|
||||
is_dup, existing_prompt_id = store.check_and_record(idempotency_key)
|
||||
if is_dup:
|
||||
return {"prompt_id": existing_prompt_id, "deduped": True}
|
||||
|
||||
# Render template
|
||||
tmpl_svc = get_template_service()
|
||||
workflow = tmpl_svc.render_template(template_id, inputs)
|
||||
|
||||
# Submit
|
||||
result = await submit_prompt(
|
||||
workflow,
|
||||
extra_data={
|
||||
"openclaw": {"trace_id": trace_id, "source": "automation"},
|
||||
"moltbot": {"trace_id": trace_id, "source": "automation"},
|
||||
},
|
||||
source=source,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
# Store for dedupe
|
||||
if result.get("prompt_id"):
|
||||
store.update_prompt_id(idempotency_key, result["prompt_id"])
|
||||
|
||||
return result
|
||||
|
||||
# R4: Start scheduler daemon
|
||||
from .services.scheduler.runner import get_scheduler_runner, start_scheduler
|
||||
|
||||
runner = get_scheduler_runner()
|
||||
runner._submit_fn = unified_submit_fn
|
||||
start_scheduler()
|
||||
|
||||
# F6: Register trigger routes
|
||||
register_trigger_routes(
|
||||
server.app,
|
||||
require_admin_token_fn=require_admin_token,
|
||||
submit_fn=unified_submit_fn,
|
||||
)
|
||||
|
||||
# S7: Register approval routes (with execution capability)
|
||||
register_approval_routes(
|
||||
server.app,
|
||||
require_admin_token_fn=require_admin_token,
|
||||
submit_fn=unified_submit_fn,
|
||||
)
|
||||
|
||||
def start_registration_retry_loop():
|
||||
"""
|
||||
R25: Background loop to ensure routes are registered even if PromptServer is slow to init.
|
||||
Attempts 10 times with exponential backoff.
|
||||
"""
|
||||
|
||||
def _retry_worker():
|
||||
global _routes_registered
|
||||
attempts = 0
|
||||
max_attempts = 10
|
||||
delay = 2.0
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw")
|
||||
|
||||
while not _routes_registered and attempts < max_attempts:
|
||||
try:
|
||||
ps_mod = sys.modules.get("server")
|
||||
PromptServer = (
|
||||
getattr(ps_mod, "PromptServer", None) if ps_mod else None
|
||||
)
|
||||
if (
|
||||
PromptServer
|
||||
and getattr(PromptServer, "instance", None) is not None
|
||||
):
|
||||
_do_full_registration(PromptServer.instance)
|
||||
_routes_registered = True
|
||||
logger.info(
|
||||
f"Routes registered successfully on attempt {attempts + 1}"
|
||||
)
|
||||
return
|
||||
logger.debug(
|
||||
f"PromptServer.instance not ready (attempt {attempts + 1})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Error registering routes (attempt {attempts + 1})"
|
||||
)
|
||||
|
||||
time.sleep(delay)
|
||||
delay = min(delay * 1.5, 30)
|
||||
attempts += 1
|
||||
|
||||
if not _routes_registered:
|
||||
logger.error(
|
||||
f"Failed to register routes after {max_attempts} attempts. API endpoints unavailable."
|
||||
)
|
||||
|
||||
t = threading.Thread(
|
||||
target=_retry_worker, name="openclaw-route-retry", daemon=True
|
||||
)
|
||||
t.start()
|
||||
|
||||
# Initial attempt + Start background retry
|
||||
try:
|
||||
ps_mod = sys.modules.get("server")
|
||||
PromptServer = getattr(ps_mod, "PromptServer", None) if ps_mod else None
|
||||
|
||||
if PromptServer and getattr(PromptServer, "instance", None) is not None:
|
||||
_do_full_registration(PromptServer.instance)
|
||||
_routes_registered = True
|
||||
logging.getLogger("ComfyUI-OpenClaw").info(
|
||||
"Routes registered successfully on initial attempt."
|
||||
)
|
||||
else:
|
||||
logging.getLogger("ComfyUI-OpenClaw").info(
|
||||
"PromptServer not ready, starting background registration retry loop..."
|
||||
)
|
||||
start_registration_retry_loop()
|
||||
|
||||
except Exception as e:
|
||||
logging.getLogger("ComfyUI-OpenClaw").exception("Route registration failed")
|
||||
register_routes_once()
|
||||
|
||||
|
||||
_register_routes_once()
|
||||
_bootstrap_openclaw_routes()
|
||||
|
||||
+295
-70
@@ -3,22 +3,39 @@ 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
|
||||
from ..services.management_query import bounded_scan_collect, normalize_limit_offset
|
||||
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
|
||||
from ..services.webhook_auth import AuthError
|
||||
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
|
||||
from services.management_query import ( # type: ignore
|
||||
bounded_scan_collect,
|
||||
normalize_limit_offset,
|
||||
)
|
||||
from services.tenant_context import ( # type: ignore
|
||||
TenantBoundaryError,
|
||||
request_tenant_scope,
|
||||
)
|
||||
from services.webhook_auth import AuthError
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.approvals")
|
||||
web = import_aiohttp_web()
|
||||
|
||||
|
||||
class ApprovalHandlers:
|
||||
@@ -51,19 +68,64 @@ class ApprovalHandlers:
|
||||
if not allowed:
|
||||
raise AuthError(error or "Unauthorized")
|
||||
|
||||
def _audit(
|
||||
self,
|
||||
*,
|
||||
request: web.Request,
|
||||
action: str,
|
||||
target: str,
|
||||
outcome: str,
|
||||
status_code: int,
|
||||
details: Optional[dict] = None,
|
||||
) -> None:
|
||||
try:
|
||||
token_info = resolve_token_info(request)
|
||||
except Exception:
|
||||
token_info = None
|
||||
emit_audit_event(
|
||||
action=action,
|
||||
target=target,
|
||||
outcome=outcome,
|
||||
token_info=token_info,
|
||||
status_code=status_code,
|
||||
details=details or {},
|
||||
request=request,
|
||||
)
|
||||
|
||||
async def list_approvals(self, request: web.Request) -> web.Response:
|
||||
"""GET /moltbot/approvals - List approval requests."""
|
||||
try:
|
||||
await self._check_auth(request)
|
||||
except AuthError as e:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="approvals.list",
|
||||
target="approvals",
|
||||
outcome="deny",
|
||||
status_code=403,
|
||||
details={"reason": str(e)},
|
||||
)
|
||||
return web.json_response({"error": str(e)}, status=403)
|
||||
except Exception:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="approvals.list",
|
||||
target="approvals",
|
||||
outcome="deny",
|
||||
status_code=403,
|
||||
details={"reason": "unauthorized"},
|
||||
)
|
||||
return web.json_response({"error": "Unauthorized"}, status=403)
|
||||
|
||||
# Parse query params
|
||||
status_filter = request.query.get("status")
|
||||
limit = int(request.query.get("limit", "100"))
|
||||
offset = int(request.query.get("offset", "0"))
|
||||
page = normalize_limit_offset(
|
||||
request.query,
|
||||
default_limit=100,
|
||||
max_limit=500,
|
||||
default_offset=0,
|
||||
max_offset=5000,
|
||||
)
|
||||
|
||||
# Validate and convert status
|
||||
status = None
|
||||
@@ -75,45 +137,125 @@ class ApprovalHandlers:
|
||||
{"error": f"Invalid status: {status_filter}"}, status=400
|
||||
)
|
||||
|
||||
# Get approvals
|
||||
approvals = self._service.list_all(
|
||||
status=status,
|
||||
limit=min(limit, 500),
|
||||
offset=offset,
|
||||
)
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
allow_default_when_missing=True,
|
||||
) as tenant:
|
||||
# Get approvals
|
||||
# R95: bounded scan window protects API serialization path and keeps
|
||||
# malformed-record behavior deterministic without swallowing service errors.
|
||||
scan_cap = max(page.offset + page.limit + 200, page.limit * 10)
|
||||
approvals = self._service.list_all(
|
||||
status=status,
|
||||
limit=min(scan_cap, 5000),
|
||||
offset=0,
|
||||
tenant_id=tenant.tenant_id,
|
||||
)
|
||||
page_result = bounded_scan_collect(
|
||||
approvals,
|
||||
skip=page.offset,
|
||||
take=page.limit,
|
||||
scan_cap=min(scan_cap, 5000),
|
||||
serializer=lambda a: a.to_dict(),
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"approvals": [a.to_dict() for a in approvals],
|
||||
"count": len(approvals),
|
||||
"pending_count": self._service.count_pending(),
|
||||
}
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"approvals": page_result.items,
|
||||
"count": len(page_result.items),
|
||||
"pending_count": self._service.count_pending(
|
||||
tenant_id=tenant.tenant_id
|
||||
),
|
||||
"pagination": {
|
||||
"limit": page.limit,
|
||||
"offset": page.offset,
|
||||
"warnings": page.warnings,
|
||||
},
|
||||
"scan": page_result.to_dict(),
|
||||
}
|
||||
)
|
||||
except TenantBoundaryError as exc:
|
||||
return web.json_response(
|
||||
{"error": exc.code, "message": str(exc)},
|
||||
status=403,
|
||||
)
|
||||
|
||||
async def get_approval(self, request: web.Request) -> web.Response:
|
||||
"""GET /moltbot/approvals/{approval_id} - Get a single approval."""
|
||||
try:
|
||||
await self._check_auth(request)
|
||||
except AuthError as e:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="approvals.get",
|
||||
target=request.match_info.get("approval_id", ""),
|
||||
outcome="deny",
|
||||
status_code=403,
|
||||
details={"reason": str(e)},
|
||||
)
|
||||
return web.json_response({"error": str(e)}, status=403)
|
||||
except Exception:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="approvals.get",
|
||||
target=request.match_info.get("approval_id", ""),
|
||||
outcome="deny",
|
||||
status_code=403,
|
||||
details={"reason": "unauthorized"},
|
||||
)
|
||||
return web.json_response({"error": "Unauthorized"}, status=403)
|
||||
|
||||
approval_id = request.match_info.get("approval_id", "")
|
||||
approval = self._service.get(approval_id)
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
allow_default_when_missing=True,
|
||||
) as tenant:
|
||||
approval = self._service.get(approval_id, tenant_id=tenant.tenant_id)
|
||||
|
||||
if not approval:
|
||||
return web.json_response({"error": "Approval not found"}, status=404)
|
||||
if not approval:
|
||||
return web.json_response(
|
||||
{"error": "Approval not found"}, status=404
|
||||
)
|
||||
|
||||
return web.json_response({"approval": approval.to_dict()})
|
||||
return web.json_response(
|
||||
{"tenant_id": tenant.tenant_id, "approval": approval.to_dict()}
|
||||
)
|
||||
except TenantBoundaryError as exc:
|
||||
return web.json_response(
|
||||
{"error": exc.code, "message": str(exc)},
|
||||
status=403,
|
||||
)
|
||||
|
||||
async def approve_request(self, request: web.Request) -> web.Response:
|
||||
"""POST /moltbot/approvals/{approval_id}/approve - Approve and execute request."""
|
||||
try:
|
||||
await self._check_auth(request)
|
||||
except AuthError as e:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="approvals.approve",
|
||||
target=request.match_info.get("approval_id", ""),
|
||||
outcome="deny",
|
||||
status_code=403,
|
||||
details={"reason": str(e)},
|
||||
)
|
||||
return web.json_response({"error": str(e)}, status=403)
|
||||
except Exception:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="approvals.approve",
|
||||
target=request.match_info.get("approval_id", ""),
|
||||
outcome="deny",
|
||||
status_code=403,
|
||||
details={"reason": "unauthorized"},
|
||||
)
|
||||
return web.json_response({"error": "Unauthorized"}, status=403)
|
||||
|
||||
approval_id = request.match_info.get("approval_id", "")
|
||||
@@ -128,60 +270,97 @@ class ApprovalHandlers:
|
||||
except Exception:
|
||||
pass # No body is fine
|
||||
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
# First approve the request
|
||||
approval = self._service.approve(approval_id, actor=actor)
|
||||
logger.info(f"Approved request: {approval_id}")
|
||||
with request_tenant_scope(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
allow_default_when_missing=True,
|
||||
) as tenant:
|
||||
# First approve the request
|
||||
approval = self._service.approve(
|
||||
approval_id, actor=actor, tenant_id=tenant.tenant_id
|
||||
)
|
||||
logger.info(f"Approved request: {approval_id}")
|
||||
|
||||
result = {
|
||||
"approved": True,
|
||||
"approval": approval.to_dict(),
|
||||
}
|
||||
result = {
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"approved": True,
|
||||
"approval": approval.to_dict(),
|
||||
}
|
||||
|
||||
# Execute if requested and submit_fn is available
|
||||
if auto_execute and self._submit_fn:
|
||||
try:
|
||||
from .triggers import execute_approved_trigger
|
||||
# Execute if requested and submit_fn is available
|
||||
if auto_execute and self._submit_fn:
|
||||
try:
|
||||
from .triggers import execute_approved_trigger
|
||||
|
||||
exec_result = await execute_approved_trigger(
|
||||
approval_id=approval_id,
|
||||
submit_fn=self._submit_fn,
|
||||
)
|
||||
exec_result = await execute_approved_trigger(
|
||||
approval_id=approval_id,
|
||||
submit_fn=self._submit_fn,
|
||||
)
|
||||
|
||||
result["executed"] = True
|
||||
result["prompt_id"] = exec_result.get("prompt_id")
|
||||
result["trace_id"] = exec_result.get("trace_id")
|
||||
result["executed"] = True
|
||||
result["prompt_id"] = exec_result.get("prompt_id")
|
||||
result["trace_id"] = exec_result.get("trace_id")
|
||||
|
||||
if result.get("prompt_id"):
|
||||
# NOTE: Persist executed_prompt_id so connector can deliver results
|
||||
# after UI approvals. Do not remove without updating connector.
|
||||
try:
|
||||
self._service.record_execution(
|
||||
approval_id,
|
||||
prompt_id=result.get("prompt_id"),
|
||||
trace_id=result.get("trace_id"),
|
||||
actor=actor,
|
||||
)
|
||||
except Exception as record_error:
|
||||
logger.error(
|
||||
"Failed to record approval execution metadata: "
|
||||
f"{record_error}"
|
||||
)
|
||||
if result.get("prompt_id"):
|
||||
# NOTE: Persist executed_prompt_id so connector can deliver results
|
||||
# after UI approvals. Do not remove without updating connector.
|
||||
try:
|
||||
self._service.record_execution(
|
||||
approval_id,
|
||||
prompt_id=result.get("prompt_id"),
|
||||
trace_id=result.get("trace_id"),
|
||||
actor=actor,
|
||||
tenant_id=tenant.tenant_id,
|
||||
)
|
||||
except Exception as record_error:
|
||||
logger.error(
|
||||
"Failed to record approval execution metadata: "
|
||||
f"{record_error}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Executed approved trigger: {approval_id} -> {result.get('prompt_id')}"
|
||||
)
|
||||
logger.info(
|
||||
f"Executed approved trigger: {approval_id} -> {result.get('prompt_id')}"
|
||||
)
|
||||
|
||||
except Exception as exec_error:
|
||||
logger.error(f"Failed to execute approved trigger: {exec_error}")
|
||||
except Exception as exec_error:
|
||||
logger.error(
|
||||
f"Failed to execute approved trigger: {exec_error}"
|
||||
)
|
||||
result["executed"] = False
|
||||
result["execution_error"] = str(exec_error)
|
||||
else:
|
||||
result["executed"] = False
|
||||
result["execution_error"] = str(exec_error)
|
||||
else:
|
||||
result["executed"] = False
|
||||
|
||||
return web.json_response(result)
|
||||
self._audit(
|
||||
request=request,
|
||||
action="approvals.approve",
|
||||
target=approval_id,
|
||||
outcome="allow",
|
||||
status_code=200,
|
||||
details={
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"executed": result.get("executed", False),
|
||||
"actor": actor,
|
||||
},
|
||||
)
|
||||
return web.json_response(result)
|
||||
|
||||
except TenantBoundaryError as exc:
|
||||
return web.json_response(
|
||||
{"error": exc.code, "message": str(exc)},
|
||||
status=403,
|
||||
)
|
||||
except ValueError as e:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="approvals.approve",
|
||||
target=approval_id,
|
||||
outcome="error",
|
||||
status_code=400,
|
||||
details={"error": str(e), "actor": actor},
|
||||
)
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
async def reject_request(self, request: web.Request) -> web.Response:
|
||||
@@ -189,8 +368,24 @@ class ApprovalHandlers:
|
||||
try:
|
||||
await self._check_auth(request)
|
||||
except AuthError as e:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="approvals.reject",
|
||||
target=request.match_info.get("approval_id", ""),
|
||||
outcome="deny",
|
||||
status_code=403,
|
||||
details={"reason": str(e)},
|
||||
)
|
||||
return web.json_response({"error": str(e)}, status=403)
|
||||
except Exception:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="approvals.reject",
|
||||
target=request.match_info.get("approval_id", ""),
|
||||
outcome="deny",
|
||||
status_code=403,
|
||||
details={"reason": "unauthorized"},
|
||||
)
|
||||
return web.json_response({"error": "Unauthorized"}, status=403)
|
||||
|
||||
approval_id = request.match_info.get("approval_id", "")
|
||||
@@ -203,18 +398,48 @@ class ApprovalHandlers:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
approval = self._service.reject(approval_id, actor=actor)
|
||||
with request_tenant_scope(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
allow_default_when_missing=True,
|
||||
) as tenant:
|
||||
approval = self._service.reject(
|
||||
approval_id, actor=actor, tenant_id=tenant.tenant_id
|
||||
)
|
||||
|
||||
logger.info(f"Rejected request: {approval_id}")
|
||||
logger.info(f"Rejected request: {approval_id}")
|
||||
self._audit(
|
||||
request=request,
|
||||
action="approvals.reject",
|
||||
target=approval_id,
|
||||
outcome="allow",
|
||||
status_code=200,
|
||||
details={"tenant_id": tenant.tenant_id, "actor": actor},
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"rejected": True,
|
||||
"approval": approval.to_dict(),
|
||||
}
|
||||
)
|
||||
|
||||
except TenantBoundaryError as exc:
|
||||
return web.json_response(
|
||||
{
|
||||
"rejected": True,
|
||||
"approval": approval.to_dict(),
|
||||
}
|
||||
{"error": exc.code, "message": str(exc)},
|
||||
status=403,
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="approvals.reject",
|
||||
target=approval_id,
|
||||
outcome="error",
|
||||
status_code=400,
|
||||
details={"error": str(e), "actor": actor},
|
||||
)
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
|
||||
|
||||
+572
-68
@@ -1,103 +1,168 @@
|
||||
import logging
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp import web
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, 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.automation_composer import AutomationComposerService
|
||||
from ..services.planner import PlannerService
|
||||
from ..services.rate_limit import check_rate_limit
|
||||
from ..services.planner_registry import get_planner_registry
|
||||
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from ..services.reasoning_redaction import (
|
||||
audit_reasoning_reveal,
|
||||
resolve_reasoning_reveal,
|
||||
sanitize_operator_payload,
|
||||
)
|
||||
from ..services.refiner import RefinerService
|
||||
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.rate_limit import check_rate_limit
|
||||
from services.planner_registry import get_planner_registry
|
||||
from services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from services.reasoning_redaction import (
|
||||
audit_reasoning_reveal,
|
||||
resolve_reasoning_reveal,
|
||||
sanitize_operator_payload,
|
||||
)
|
||||
from services.refiner import RefinerService
|
||||
|
||||
# R98: Endpoint Metadata
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
else:
|
||||
from services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
|
||||
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
|
||||
MAX_STYLE_LEN = 2000
|
||||
MAX_IMAGE_B64_LEN = 5 * 1024 * 1024 # ~5MB base64 string length
|
||||
MAX_STREAM_DELTA_CHARS = 256
|
||||
MAX_STREAM_PREVIEW_CHARS = 16_000
|
||||
STREAM_KEEPALIVE_SEC = 1.0
|
||||
|
||||
|
||||
def _planner_profiles_payload() -> Dict[str, Any]:
|
||||
registry = get_planner_registry()
|
||||
return {
|
||||
"profiles": [
|
||||
{
|
||||
"id": profile.id,
|
||||
"label": profile.label,
|
||||
"description": profile.description,
|
||||
"version": profile.version,
|
||||
}
|
||||
for profile in registry.list_profiles()
|
||||
],
|
||||
"default_profile": registry.get_default_profile_id(),
|
||||
}
|
||||
|
||||
|
||||
class AssistHandlers:
|
||||
def __init__(self):
|
||||
self.planner = PlannerService()
|
||||
self.refiner = RefinerService()
|
||||
self.composer = AutomationComposerService()
|
||||
|
||||
async def planner_handler(self, request):
|
||||
"""
|
||||
POST /openclaw/assist/planner (legacy: /moltbot/assist/planner)
|
||||
JSON: { profile, requirements, style_directives, seed }
|
||||
"""
|
||||
# Security: Admin Token required
|
||||
authorized, err_msg = require_admin_token(request)
|
||||
async def _require_admin_and_rate_limit(
|
||||
self, request: web.Request
|
||||
) -> Optional[web.Response]:
|
||||
authorized, _err_msg = require_admin_token(request)
|
||||
if not authorized:
|
||||
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||
|
||||
# Security: Rate Limit
|
||||
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(
|
||||
self, request: web.Request
|
||||
) -> tuple[Optional[dict], Optional[web.Response]]:
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
return None, web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
if not isinstance(data, dict):
|
||||
return None, web.json_response(
|
||||
{"error": "JSON object required"}, status=400
|
||||
)
|
||||
return data, None
|
||||
|
||||
profile = data.get("profile", "SDXL-v1")
|
||||
def _validate_planner_payload(
|
||||
self, data: dict
|
||||
) -> tuple[Optional[dict], Optional[web.Response]]:
|
||||
registry = get_planner_registry()
|
||||
profile = data.get("profile", registry.get_default_profile_id())
|
||||
requirements = data.get("requirements", "")
|
||||
style = data.get("style_directives", "")
|
||||
seed = data.get("seed", 0)
|
||||
|
||||
# Security: Payload size clamps
|
||||
if not isinstance(profile, str):
|
||||
return None, web.json_response(
|
||||
{"error": "profile must be string"}, status=400
|
||||
)
|
||||
if not registry.get_profile(profile):
|
||||
return None, web.json_response(
|
||||
{"error": f"Unknown profile: {profile}"}, status=400
|
||||
)
|
||||
if not isinstance(requirements, str):
|
||||
return None, web.json_response(
|
||||
{"error": "requirements must be string"}, status=400
|
||||
)
|
||||
if not isinstance(style, str):
|
||||
return None, web.json_response(
|
||||
{"error": "style_directives must be string"}, status=400
|
||||
)
|
||||
if len(requirements) > MAX_REQUIREMENTS_LEN:
|
||||
return web.json_response(
|
||||
return None, web.json_response(
|
||||
{"error": f"requirements exceeds {MAX_REQUIREMENTS_LEN} chars"},
|
||||
status=400,
|
||||
)
|
||||
if len(style) > MAX_STYLE_LEN:
|
||||
return web.json_response(
|
||||
return None, web.json_response(
|
||||
{"error": f"style_directives exceeds {MAX_STYLE_LEN} chars"}, status=400
|
||||
)
|
||||
|
||||
try:
|
||||
# Run sync LLM call in thread pool to avoid blocking event loop
|
||||
pos, neg, params = await run_in_thread(
|
||||
self.planner.plan_generation, profile, requirements, style, seed
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{"positive": pos, "negative": neg, "params": params}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Planner API failed")
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
async def refiner_handler(self, request):
|
||||
"""
|
||||
POST /openclaw/assist/refiner (legacy: /moltbot/assist/refiner)
|
||||
JSON: { image_b64, orig_positive, orig_negative, issue, params_json, goal }
|
||||
"""
|
||||
# Security checks
|
||||
authorized, err_msg = require_admin_token(request)
|
||||
if not authorized:
|
||||
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||
|
||||
# Security: Rate Limit
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return web.json_response({"error": "Rate limit exceeded"}, status=429)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
seed = int(seed)
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
seed = 0
|
||||
return {
|
||||
"profile": profile,
|
||||
"requirements": requirements,
|
||||
"style_directives": style,
|
||||
"seed": seed,
|
||||
}, None
|
||||
|
||||
# Extract payload (aligned with service signature)
|
||||
def _validate_refiner_payload(
|
||||
self, data: dict
|
||||
) -> tuple[Optional[dict], Optional[web.Response]]:
|
||||
image_b64 = data.get("image_b64", "")
|
||||
orig_pos = data.get("orig_positive", "")
|
||||
orig_neg = data.get("orig_negative", "")
|
||||
@@ -105,37 +170,476 @@ class AssistHandlers:
|
||||
params_json = data.get("params_json", "{}")
|
||||
goal = data.get("goal", "Fix issues")
|
||||
|
||||
# Validation & Size clamps
|
||||
if not image_b64:
|
||||
return web.json_response({"error": "image_b64 required"}, status=400)
|
||||
if not isinstance(image_b64, str) or not image_b64:
|
||||
return None, web.json_response({"error": "image_b64 required"}, status=400)
|
||||
if len(image_b64) > MAX_IMAGE_B64_LEN:
|
||||
return web.json_response(
|
||||
return None, web.json_response(
|
||||
{"error": f"image_b64 exceeds {MAX_IMAGE_B64_LEN // 1024 // 1024}MB"},
|
||||
status=400,
|
||||
)
|
||||
for key, value in (
|
||||
("orig_positive", orig_pos),
|
||||
("orig_negative", orig_neg),
|
||||
("issue", issue),
|
||||
("params_json", params_json),
|
||||
("goal", goal),
|
||||
):
|
||||
if not isinstance(value, str):
|
||||
return None, web.json_response(
|
||||
{"error": f"{key} must be string"}, status=400
|
||||
)
|
||||
if len(orig_pos) > MAX_REQUIREMENTS_LEN or len(orig_neg) > MAX_REQUIREMENTS_LEN:
|
||||
return web.json_response({"error": "Prompt too long"}, status=400)
|
||||
return None, web.json_response({"error": "Prompt too long"}, status=400)
|
||||
|
||||
return {
|
||||
"image_b64": image_b64,
|
||||
"orig_positive": orig_pos,
|
||||
"orig_negative": orig_neg,
|
||||
"issue": issue,
|
||||
"params_json": params_json,
|
||||
"goal": goal,
|
||||
}, None
|
||||
|
||||
def _finalize_operator_payload(
|
||||
self,
|
||||
request: web.Request,
|
||||
*,
|
||||
target: str,
|
||||
payload: Dict[str, Any],
|
||||
service: Any,
|
||||
) -> Dict[str, Any]:
|
||||
admin_allowed, _ = require_admin_token(request)
|
||||
reveal = resolve_reasoning_reveal(request, admin_authorized=admin_allowed)
|
||||
audit_reasoning_reveal(request, target=target, decision=reveal)
|
||||
|
||||
final_payload = sanitize_operator_payload(payload)
|
||||
consume_debug = getattr(service, "consume_last_reasoning_debug", None)
|
||||
reasoning_debug = consume_debug() if callable(consume_debug) else None
|
||||
if reveal["allowed"] and reasoning_debug not in (None, {}, []):
|
||||
final_payload = dict(final_payload)
|
||||
final_payload["debug"] = {"reasoning": reasoning_debug}
|
||||
return final_payload
|
||||
|
||||
@staticmethod
|
||||
def _sse_frame(event: str, payload: Dict[str, Any]) -> bytes:
|
||||
return (
|
||||
f"event: {event}\n"
|
||||
f"data: {json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
||||
).encode("utf-8")
|
||||
|
||||
async def _write_sse_event(
|
||||
self, response: web.StreamResponse, event: str, payload: Dict[str, Any]
|
||||
) -> bool:
|
||||
try:
|
||||
await response.write(self._sse_frame(event, payload))
|
||||
return True
|
||||
except (ConnectionError, RuntimeError):
|
||||
return False
|
||||
|
||||
async def _assist_stream_session(
|
||||
self,
|
||||
request: web.Request,
|
||||
*,
|
||||
kind: str,
|
||||
worker_fn,
|
||||
worker_kwargs: Dict[str, Any],
|
||||
) -> web.StreamResponse:
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
await response.prepare(request)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
preview_chars = 0
|
||||
|
||||
def emit(event: str, payload: Dict[str, Any]) -> None:
|
||||
try:
|
||||
loop.call_soon_threadsafe(queue.put_nowait, (event, payload))
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def on_text_delta(delta: str) -> None:
|
||||
nonlocal preview_chars
|
||||
if not isinstance(delta, str) or not delta:
|
||||
return
|
||||
remaining = MAX_STREAM_PREVIEW_CHARS - preview_chars
|
||||
if remaining <= 0:
|
||||
return
|
||||
clipped = delta[: min(remaining, MAX_STREAM_DELTA_CHARS)]
|
||||
if not clipped:
|
||||
return
|
||||
preview_chars += len(clipped)
|
||||
emit("delta", {"text": clipped, "preview_chars": preview_chars})
|
||||
|
||||
async def runner() -> None:
|
||||
emit("ready", {"ok": True, "kind": kind, "mode": "sse"})
|
||||
emit(
|
||||
"stage", {"phase": "dispatch", "message": "Dispatching assist request"}
|
||||
)
|
||||
try:
|
||||
call_kwargs = dict(worker_kwargs)
|
||||
call_kwargs["on_text_delta"] = on_text_delta
|
||||
result = await run_in_thread(worker_fn, **call_kwargs)
|
||||
emit(
|
||||
"stage",
|
||||
{"phase": "finalize", "message": "Parsing and validating output"},
|
||||
)
|
||||
if kind == "planner":
|
||||
pos, neg, params = result
|
||||
final_payload = {
|
||||
"positive": pos,
|
||||
"negative": neg,
|
||||
"params": params,
|
||||
}
|
||||
elif kind == "refiner":
|
||||
new_pos, new_neg, patch, rationale = result
|
||||
final_payload = {
|
||||
"refined_positive": new_pos,
|
||||
"refined_negative": new_neg,
|
||||
"param_patch": patch,
|
||||
"rationale": rationale,
|
||||
}
|
||||
else:
|
||||
final_payload = {"result": result}
|
||||
service = (
|
||||
self.planner
|
||||
if kind == "planner"
|
||||
else self.refiner if kind == "refiner" else self.composer
|
||||
)
|
||||
final_payload = self._finalize_operator_payload(
|
||||
request,
|
||||
target=f"assist.{kind}.stream",
|
||||
payload=final_payload,
|
||||
service=service,
|
||||
)
|
||||
emit(
|
||||
"final",
|
||||
{
|
||||
"ok": True,
|
||||
"kind": kind,
|
||||
"result": final_payload,
|
||||
"streaming": {
|
||||
"preview_chars": preview_chars,
|
||||
"preview_truncated": preview_chars
|
||||
>= MAX_STREAM_PREVIEW_CHARS,
|
||||
},
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Assist streaming API failed (%s)", kind)
|
||||
emit(
|
||||
"error",
|
||||
{"ok": False, "kind": kind, "error": "Internal server error"},
|
||||
)
|
||||
finally:
|
||||
emit("__done__", {})
|
||||
|
||||
runner_task = asyncio.create_task(runner())
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event, payload = await asyncio.wait_for(
|
||||
queue.get(), timeout=STREAM_KEEPALIVE_SEC
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
if runner_task.done():
|
||||
break
|
||||
if not await self._write_sse_event(
|
||||
response, "keepalive", {"ok": True}
|
||||
):
|
||||
break
|
||||
continue
|
||||
|
||||
if event == "__done__":
|
||||
break
|
||||
if not await self._write_sse_event(response, event, payload):
|
||||
break
|
||||
finally:
|
||||
if not runner_task.done():
|
||||
runner_task.cancel()
|
||||
with contextlib.suppress(BaseException):
|
||||
await runner_task
|
||||
return response
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.LOW,
|
||||
summary="List planner profiles",
|
||||
description="Returns Prompt Planner profiles from the active registry.",
|
||||
audit="assist.planner_profiles",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def planner_profiles_handler(self, request):
|
||||
auth_resp = await self._require_admin_and_rate_limit(request)
|
||||
if auth_resp:
|
||||
return auth_resp
|
||||
return web.json_response(_planner_profiles_payload())
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Run planner",
|
||||
description="Generate prompts from requirements via LLM.",
|
||||
audit="assist.planner",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def planner_handler(self, request):
|
||||
"""
|
||||
POST /openclaw/assist/planner (legacy: /moltbot/assist/planner)
|
||||
JSON: { profile, requirements, style_directives, seed }
|
||||
"""
|
||||
# Security: Admin Token required
|
||||
auth_resp = await self._require_admin_and_rate_limit(request)
|
||||
if auth_resp:
|
||||
return auth_resp
|
||||
data, error_resp = await self._parse_json_body(request)
|
||||
if error_resp:
|
||||
return error_resp
|
||||
assert data is not None
|
||||
payload, payload_err = self._validate_planner_payload(data)
|
||||
if payload_err:
|
||||
return payload_err
|
||||
assert payload is not None
|
||||
|
||||
try:
|
||||
# Run sync LLM call in thread pool to avoid blocking event loop
|
||||
pos, neg, params = await run_in_thread(
|
||||
self.planner.plan_generation,
|
||||
payload["profile"],
|
||||
payload["requirements"],
|
||||
payload["style_directives"],
|
||||
payload["seed"],
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
self._finalize_operator_payload(
|
||||
request,
|
||||
target="assist.planner",
|
||||
payload={"positive": pos, "negative": neg, "params": params},
|
||||
service=self.planner,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Planner API failed")
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Run refiner",
|
||||
description="Refine prompt/parameters based on feedback.",
|
||||
audit="assist.refiner",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def refiner_handler(self, request):
|
||||
"""
|
||||
POST /openclaw/assist/refiner (legacy: /moltbot/assist/refiner)
|
||||
JSON: { image_b64, orig_positive, orig_negative, issue, params_json, goal }
|
||||
"""
|
||||
# Security checks
|
||||
auth_resp = await self._require_admin_and_rate_limit(request)
|
||||
if auth_resp:
|
||||
return auth_resp
|
||||
data, error_resp = await self._parse_json_body(request)
|
||||
if error_resp:
|
||||
return error_resp
|
||||
assert data is not None
|
||||
payload, payload_err = self._validate_refiner_payload(data)
|
||||
if payload_err:
|
||||
return payload_err
|
||||
assert payload is not None
|
||||
|
||||
try:
|
||||
# Run sync LLM call in thread pool
|
||||
new_pos, new_neg, patch, rationale = await run_in_thread(
|
||||
self.refiner.refine_prompt,
|
||||
image_b64=image_b64,
|
||||
orig_positive=orig_pos,
|
||||
orig_negative=orig_neg,
|
||||
issue=issue,
|
||||
params_json=params_json,
|
||||
goal=goal,
|
||||
**payload,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"refined_positive": new_pos,
|
||||
"refined_negative": new_neg,
|
||||
"param_patch": patch,
|
||||
"rationale": rationale,
|
||||
}
|
||||
self._finalize_operator_payload(
|
||||
request,
|
||||
target="assist.refiner",
|
||||
payload={
|
||||
"refined_positive": new_pos,
|
||||
"refined_negative": new_neg,
|
||||
"param_patch": patch,
|
||||
"rationale": rationale,
|
||||
},
|
||||
service=self.refiner,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Refiner API failed")
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Run planner (streaming)",
|
||||
description="Generate prompts from requirements via LLM with SSE-style incremental updates.",
|
||||
audit="assist.planner.stream",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def planner_stream_handler(self, request):
|
||||
auth_resp = await self._require_admin_and_rate_limit(request)
|
||||
if auth_resp:
|
||||
return auth_resp
|
||||
data, error_resp = await self._parse_json_body(request)
|
||||
if error_resp:
|
||||
return error_resp
|
||||
assert data is not None
|
||||
payload, payload_err = self._validate_planner_payload(data)
|
||||
if payload_err:
|
||||
return payload_err
|
||||
assert payload is not None
|
||||
|
||||
return await self._assist_stream_session(
|
||||
request,
|
||||
kind="planner",
|
||||
worker_fn=self.planner.plan_generation,
|
||||
worker_kwargs=payload,
|
||||
)
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Run refiner (streaming)",
|
||||
description="Refine prompt/parameters with SSE-style incremental updates.",
|
||||
audit="assist.refiner.stream",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def refiner_stream_handler(self, request):
|
||||
auth_resp = await self._require_admin_and_rate_limit(request)
|
||||
if auth_resp:
|
||||
return auth_resp
|
||||
data, error_resp = await self._parse_json_body(request)
|
||||
if error_resp:
|
||||
return error_resp
|
||||
assert data is not None
|
||||
payload, payload_err = self._validate_refiner_payload(data)
|
||||
if payload_err:
|
||||
return payload_err
|
||||
assert payload is not None
|
||||
|
||||
return await self._assist_stream_session(
|
||||
request,
|
||||
kind="refiner",
|
||||
worker_fn=self.refiner.refine_prompt,
|
||||
worker_kwargs=payload,
|
||||
)
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Compose automation payload",
|
||||
description="Generate-only automation payload draft for trigger/webhook endpoints.",
|
||||
audit="assist.compose",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def compose_handler(self, request):
|
||||
"""
|
||||
POST /openclaw/assist/automation/compose (legacy: /moltbot/assist/automation/compose)
|
||||
JSON:
|
||||
{
|
||||
kind: "trigger" | "webhook",
|
||||
template_id: str,
|
||||
intent: str,
|
||||
inputs_hint?: object,
|
||||
profile_id?: str,
|
||||
require_approval?: bool,
|
||||
trace_id?: str,
|
||||
callback?: object
|
||||
}
|
||||
"""
|
||||
authorized, err_msg = require_admin_token(request)
|
||||
if not authorized:
|
||||
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||
|
||||
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:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
|
||||
kind = data.get("kind")
|
||||
template_id = data.get("template_id")
|
||||
intent = data.get("intent")
|
||||
inputs_hint = data.get("inputs_hint", {})
|
||||
profile_id = data.get("profile_id")
|
||||
require_approval = data.get("require_approval")
|
||||
trace_id = data.get("trace_id")
|
||||
callback = data.get("callback")
|
||||
|
||||
if not isinstance(kind, str) or kind.strip().lower() not in {
|
||||
"trigger",
|
||||
"webhook",
|
||||
}:
|
||||
return web.json_response(
|
||||
{"error": "kind must be 'trigger' or 'webhook'"}, status=400
|
||||
)
|
||||
if not isinstance(template_id, str) or not template_id.strip():
|
||||
return web.json_response({"error": "template_id is required"}, status=400)
|
||||
if not isinstance(intent, str) or not intent.strip():
|
||||
return web.json_response({"error": "intent is required"}, status=400)
|
||||
if len(intent) > MAX_REQUIREMENTS_LEN:
|
||||
return web.json_response(
|
||||
{"error": f"intent exceeds {MAX_REQUIREMENTS_LEN} chars"}, status=400
|
||||
)
|
||||
if not isinstance(inputs_hint, dict):
|
||||
return web.json_response(
|
||||
{"error": "inputs_hint must be object"}, status=400
|
||||
)
|
||||
if profile_id is not None and not isinstance(profile_id, str):
|
||||
return web.json_response({"error": "profile_id must be string"}, status=400)
|
||||
if require_approval is not None and not isinstance(require_approval, bool):
|
||||
return web.json_response(
|
||||
{"error": "require_approval must be boolean"}, status=400
|
||||
)
|
||||
if trace_id is not None and not isinstance(trace_id, str):
|
||||
return web.json_response({"error": "trace_id must be string"}, status=400)
|
||||
if callback is not None and not isinstance(callback, dict):
|
||||
return web.json_response({"error": "callback must be object"}, status=400)
|
||||
|
||||
try:
|
||||
result = await run_in_thread(
|
||||
self.composer.compose_payload,
|
||||
kind=kind,
|
||||
template_id=template_id,
|
||||
intent=intent,
|
||||
inputs_hint=inputs_hint,
|
||||
profile_id=profile_id,
|
||||
require_approval=require_approval,
|
||||
trace_id=trace_id,
|
||||
callback=callback,
|
||||
)
|
||||
return web.json_response(
|
||||
self._finalize_operator_payload(
|
||||
request,
|
||||
target="assist.compose",
|
||||
payload={"ok": True, **result},
|
||||
service=self.composer,
|
||||
)
|
||||
)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception:
|
||||
logger.exception("Automation compose API failed")
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
+438
-27
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
try:
|
||||
@@ -17,9 +18,15 @@ except ModuleNotFoundError: # pragma: no cover (optional for unit tests)
|
||||
|
||||
try:
|
||||
from ..services.async_utils import run_in_thread
|
||||
from ..services.cache import TTLCache
|
||||
from ..services.audit import emit_audit_event
|
||||
|
||||
# CRITICAL: handshake verifier must be imported in package mode;
|
||||
# missing this causes NameError at runtime on /bridge/handshake.
|
||||
from ..services.bridge_handshake import verify_handshake
|
||||
from ..services.execution_budgets import BudgetExceededError
|
||||
from ..services.rate_limit import check_rate_limit
|
||||
from ..services.idempotency_store import IdempotencyStore
|
||||
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from ..services.redaction import stable_redaction_tag
|
||||
from ..services.sidecar.auth import is_bridge_enabled, require_bridge_auth
|
||||
from ..services.sidecar.bridge_contract import (
|
||||
BRIDGE_ENDPOINTS,
|
||||
@@ -33,9 +40,12 @@ try:
|
||||
except ImportError:
|
||||
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
|
||||
from services.async_utils import run_in_thread
|
||||
from services.cache import TTLCache
|
||||
from services.audit import emit_audit_event
|
||||
from services.bridge_handshake import verify_handshake
|
||||
from services.execution_budgets import BudgetExceededError
|
||||
from services.rate_limit import check_rate_limit
|
||||
from services.idempotency_store import IdempotencyStore
|
||||
from services.rate_limit import build_rate_limit_response, check_rate_limit
|
||||
from services.redaction import stable_redaction_tag
|
||||
from services.sidecar.auth import is_bridge_enabled, require_bridge_auth
|
||||
from services.sidecar.bridge_contract import (
|
||||
BRIDGE_ENDPOINTS,
|
||||
@@ -47,6 +57,22 @@ except ImportError:
|
||||
from services.trace import get_effective_trace_id
|
||||
from services.trace_store import trace_store
|
||||
|
||||
# R98: Endpoint Metadata
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
else:
|
||||
from services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.bridge")
|
||||
|
||||
# Payload limits
|
||||
@@ -58,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."""
|
||||
|
||||
@@ -70,10 +100,9 @@ class BridgeHandlers:
|
||||
self.submit_service = submit_service
|
||||
self.delivery_router = delivery_router
|
||||
# R22: Bounded Idempotency Store
|
||||
# Key: idempotency_key -> Response Dict
|
||||
self._idempotency_store = TTLCache[dict](
|
||||
max_size=1000, ttl_sec=86400
|
||||
) # 24h retention
|
||||
# S50: Durable Idempotency Backend
|
||||
self._idempotency_store = IdempotencyStore()
|
||||
|
||||
# F46: Worker job queue (in-memory stub, production would use persistent store)
|
||||
self._worker_job_queue: list = []
|
||||
# F46: Worker result store
|
||||
@@ -81,6 +110,43 @@ class BridgeHandlers:
|
||||
# F46: Worker heartbeats
|
||||
self._worker_heartbeats: dict = {}
|
||||
|
||||
def _bridge_token(self, device_id: Optional[str], scope: Optional[str] = None):
|
||||
scopes = {scope} if scope else set()
|
||||
return SimpleNamespace(
|
||||
token_id=f"bridge:{_bridge_sensitive_tag(device_id, label='device')}",
|
||||
role="bridge",
|
||||
scopes=scopes,
|
||||
)
|
||||
|
||||
def _audit(
|
||||
self,
|
||||
*,
|
||||
request: web.Request,
|
||||
action: str,
|
||||
target: str,
|
||||
outcome: str,
|
||||
status_code: int,
|
||||
device_id: Optional[str] = None,
|
||||
scope: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
emit_audit_event(
|
||||
action=action,
|
||||
target=target,
|
||||
outcome=outcome,
|
||||
token_info=self._bridge_token(device_id, scope),
|
||||
status_code=status_code,
|
||||
details=details or {},
|
||||
request=request,
|
||||
)
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.PUBLIC, # Guarded by is_bridge_enabled internally, effectively public if enabled
|
||||
risk=RiskTier.LOW,
|
||||
summary="Bridge health",
|
||||
description="Returns bridge health status.",
|
||||
plane=RoutePlane.USER,
|
||||
)
|
||||
async def health_handler(self, request: web.Request) -> web.Response:
|
||||
"""
|
||||
GET /bridge/health
|
||||
@@ -114,26 +180,109 @@ class BridgeHandlers:
|
||||
}
|
||||
)
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.PUBLIC,
|
||||
risk=RiskTier.LOW,
|
||||
summary="Bridge handshake",
|
||||
description="Negotiate protocol version.",
|
||||
plane=RoutePlane.USER,
|
||||
)
|
||||
async def handshake_handler(self, request: web.Request) -> web.Response:
|
||||
"""
|
||||
POST /bridge/handshake
|
||||
Negotiate protocol version compatibility.
|
||||
"""
|
||||
try:
|
||||
data = await request.json()
|
||||
client_version = int(data.get("version", 0))
|
||||
except (ValueError, TypeError, Exception):
|
||||
return web.json_response({"error": "Invalid version format"}, status=400)
|
||||
|
||||
ok, msg, meta = verify_handshake(client_version)
|
||||
|
||||
status_code = 200 if ok else 409 # 409 Conflict for version mismatch
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": ok,
|
||||
"message": msg,
|
||||
"metadata": meta,
|
||||
},
|
||||
status=status_code,
|
||||
)
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.BRIDGE,
|
||||
risk=RiskTier.HIGH,
|
||||
summary="Bridge submit",
|
||||
description="Submit a job via sidecar bridge.",
|
||||
audit="bridge.submit",
|
||||
plane=RoutePlane.INTERNAL,
|
||||
)
|
||||
async def submit_handler(self, request: web.Request) -> web.Response:
|
||||
"""
|
||||
POST /bridge/submit
|
||||
Submit a job via sidecar bridge.
|
||||
"""
|
||||
# S62: Block webhook 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("webhook_execute", request)
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
# Auth check
|
||||
is_valid, error_resp, device_id = require_bridge_auth(
|
||||
request, BridgeScope.JOB_SUBMIT
|
||||
)
|
||||
if not is_valid:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.submit",
|
||||
target="bridge.submit",
|
||||
outcome="deny",
|
||||
status_code=getattr(error_resp, "status", 403),
|
||||
details={"reason": "auth_failed"},
|
||||
)
|
||||
return error_resp
|
||||
|
||||
# Rate limit
|
||||
if not check_rate_limit(request, "bridge"):
|
||||
return web.json_response({"error": "Rate limit exceeded"}, status=429)
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.submit",
|
||||
target="bridge.submit",
|
||||
outcome="deny",
|
||||
status_code=429,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.JOB_SUBMIT.value,
|
||||
details={"reason": "rate_limit"},
|
||||
)
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"bridge",
|
||||
web_module=web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=False,
|
||||
)
|
||||
|
||||
# Parse payload
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.submit",
|
||||
target="bridge.submit",
|
||||
outcome="deny",
|
||||
status_code=400,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.JOB_SUBMIT.value,
|
||||
details={"reason": "invalid_json"},
|
||||
)
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
|
||||
# R25: Trace context
|
||||
@@ -145,8 +294,28 @@ class BridgeHandlers:
|
||||
idempotency_key = data.get("idempotency_key")
|
||||
|
||||
if not template_id:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.submit",
|
||||
target="bridge.submit",
|
||||
outcome="deny",
|
||||
status_code=400,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.JOB_SUBMIT.value,
|
||||
details={"reason": "missing_template_id"},
|
||||
)
|
||||
return web.json_response({"error": "template_id required"}, status=400)
|
||||
if not idempotency_key:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.submit",
|
||||
target="bridge.submit",
|
||||
outcome="deny",
|
||||
status_code=400,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.JOB_SUBMIT.value,
|
||||
details={"reason": "missing_idempotency_key"},
|
||||
)
|
||||
return web.json_response({"error": "idempotency_key required"}, status=400)
|
||||
|
||||
# Payload size check
|
||||
@@ -154,15 +323,38 @@ class BridgeHandlers:
|
||||
|
||||
inputs_size = len(json.dumps(inputs))
|
||||
if inputs_size > MAX_INPUTS_SIZE:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.submit",
|
||||
target="bridge.submit",
|
||||
outcome="deny",
|
||||
status_code=400,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.JOB_SUBMIT.value,
|
||||
details={"reason": "inputs_too_large", "size": inputs_size},
|
||||
)
|
||||
return web.json_response(
|
||||
{"error": f"inputs exceeds {MAX_INPUTS_SIZE // 1024}KB"}, status=400
|
||||
)
|
||||
|
||||
# Idempotency check
|
||||
cached = self._idempotency_store.get(idempotency_key)
|
||||
if cached:
|
||||
logger.info(f"Duplicate bridge submit suppressed: {idempotency_key}")
|
||||
return web.json_response(cached)
|
||||
# Idempotency check (S50 Durable)
|
||||
store_key = f"bridge:{idempotency_key}"
|
||||
is_dup, existing_pid = self._idempotency_store.check_and_record(
|
||||
store_key, ttl=86400
|
||||
)
|
||||
|
||||
if is_dup:
|
||||
logger.info(f"Duplicate bridge submit suppressed: {store_key}")
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"deduped": True,
|
||||
"prompt_id": existing_pid,
|
||||
"trace_id": trace_id,
|
||||
"status": "queued",
|
||||
"message": "Duplicate request suppressed",
|
||||
}
|
||||
)
|
||||
|
||||
# Build request object
|
||||
job_request = BridgeJobRequest(
|
||||
@@ -207,13 +399,34 @@ class BridgeHandlers:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Cache response
|
||||
self._idempotency_store.put(idempotency_key, response_data)
|
||||
# Update durable store with prompt_id
|
||||
if prompt_id:
|
||||
self._idempotency_store.update_prompt_id(store_key, prompt_id)
|
||||
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.submit",
|
||||
target=template_id,
|
||||
outcome="allow",
|
||||
status_code=200,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.JOB_SUBMIT.value,
|
||||
details={"prompt_id": prompt_id, "trace_id": trace_id},
|
||||
)
|
||||
return web.json_response(response_data)
|
||||
|
||||
except BudgetExceededError as e:
|
||||
logger.warning(f"Bridge submit denied by execution budget: {e}")
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.submit",
|
||||
target=template_id,
|
||||
outcome="deny",
|
||||
status_code=429,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.JOB_SUBMIT.value,
|
||||
details={"reason": "budget_exceeded", "error": str(e)},
|
||||
)
|
||||
return web.json_response(
|
||||
{"error": "budget_exceeded", "detail": str(e)},
|
||||
status=429,
|
||||
@@ -221,28 +434,90 @@ class BridgeHandlers:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Bridge submit failed")
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.submit",
|
||||
target=template_id or "bridge.submit",
|
||||
outcome="error",
|
||||
status_code=500,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.JOB_SUBMIT.value,
|
||||
details={"error": str(e)},
|
||||
)
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.BRIDGE,
|
||||
risk=RiskTier.HIGH,
|
||||
summary="Bridge deliver",
|
||||
description="Request outbound delivery via sidecar.",
|
||||
audit="bridge.deliver",
|
||||
plane=RoutePlane.INTERNAL,
|
||||
)
|
||||
async def deliver_handler(self, request: web.Request) -> web.Response:
|
||||
"""
|
||||
POST /bridge/deliver
|
||||
Request outbound delivery via sidecar.
|
||||
"""
|
||||
# S62: Block callback egress 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("callback_egress", request)
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
# Auth check
|
||||
is_valid, error_resp, device_id = require_bridge_auth(
|
||||
request, BridgeScope.DELIVERY
|
||||
)
|
||||
if not is_valid:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.deliver",
|
||||
target="bridge.deliver",
|
||||
outcome="deny",
|
||||
status_code=getattr(error_resp, "status", 403),
|
||||
details={"reason": "auth_failed"},
|
||||
)
|
||||
return error_resp
|
||||
|
||||
# Rate limit
|
||||
if not check_rate_limit(request, "bridge"):
|
||||
return web.json_response({"error": "Rate limit exceeded"}, status=429)
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.deliver",
|
||||
target="bridge.deliver",
|
||||
outcome="deny",
|
||||
status_code=429,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.DELIVERY.value,
|
||||
details={"reason": "rate_limit"},
|
||||
)
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"bridge",
|
||||
web_module=web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=False,
|
||||
)
|
||||
|
||||
# Parse payload
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.deliver",
|
||||
target="bridge.deliver",
|
||||
outcome="deny",
|
||||
status_code=400,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.DELIVERY.value,
|
||||
details={"reason": "invalid_json"},
|
||||
)
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
|
||||
# R25: Trace context
|
||||
@@ -255,16 +530,56 @@ class BridgeHandlers:
|
||||
files = data.get("files", [])
|
||||
|
||||
if not target:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.deliver",
|
||||
target="bridge.deliver",
|
||||
outcome="deny",
|
||||
status_code=400,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.DELIVERY.value,
|
||||
details={"reason": "missing_target"},
|
||||
)
|
||||
return web.json_response({"error": "target required"}, status=400)
|
||||
if not idempotency_key:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.deliver",
|
||||
target=target or "bridge.deliver",
|
||||
outcome="deny",
|
||||
status_code=400,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.DELIVERY.value,
|
||||
details={"reason": "missing_idempotency_key"},
|
||||
)
|
||||
return web.json_response({"error": "idempotency_key required"}, status=400)
|
||||
|
||||
# Payload size checks
|
||||
if len(text) > MAX_TEXT_LENGTH:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.deliver",
|
||||
target=target,
|
||||
outcome="deny",
|
||||
status_code=400,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.DELIVERY.value,
|
||||
details={"reason": "text_too_large", "length": len(text)},
|
||||
)
|
||||
return web.json_response(
|
||||
{"error": f"text exceeds {MAX_TEXT_LENGTH} chars"}, status=400
|
||||
)
|
||||
if len(files) > MAX_FILES_COUNT:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.deliver",
|
||||
target=target,
|
||||
outcome="deny",
|
||||
status_code=400,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.DELIVERY.value,
|
||||
details={"reason": "too_many_files", "count": len(files)},
|
||||
)
|
||||
return web.json_response(
|
||||
{"error": f"files exceeds {MAX_FILES_COUNT}"}, status=400
|
||||
)
|
||||
@@ -286,6 +601,17 @@ class BridgeHandlers:
|
||||
logger.warning("BridgeHandlers.delivery_router not wired")
|
||||
success = True
|
||||
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.deliver",
|
||||
target=target,
|
||||
outcome="allow" if success else "error",
|
||||
status_code=200 if success else 500,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.DELIVERY.value,
|
||||
details={"trace_id": trace_id},
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": success,
|
||||
@@ -295,12 +621,29 @@ class BridgeHandlers:
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Bridge deliver failed")
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.deliver",
|
||||
target=target if "target" in locals() else "bridge.deliver",
|
||||
outcome="error",
|
||||
status_code=500,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.DELIVERY.value,
|
||||
details={"error": str(e)},
|
||||
)
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# F46 — Worker-facing endpoints
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.BRIDGE,
|
||||
risk=RiskTier.LOW,
|
||||
summary="Worker poll",
|
||||
description="Worker polls for pending jobs.",
|
||||
plane=RoutePlane.INTERNAL,
|
||||
)
|
||||
async def worker_poll_handler(self, request: web.Request) -> web.Response:
|
||||
"""
|
||||
GET /bridge/worker/poll
|
||||
@@ -331,6 +674,14 @@ class BridgeHandlers:
|
||||
|
||||
return web.json_response({"jobs": jobs})
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.BRIDGE,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Worker result",
|
||||
description="Worker submits completed job result.",
|
||||
audit="bridge.worker.result",
|
||||
plane=RoutePlane.INTERNAL,
|
||||
)
|
||||
async def worker_result_handler(self, request: web.Request) -> web.Response:
|
||||
"""
|
||||
POST /bridge/worker/result/{job_id}
|
||||
@@ -340,41 +691,98 @@ class BridgeHandlers:
|
||||
request, BridgeScope.JOB_SUBMIT
|
||||
)
|
||||
if not is_valid:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.worker.result",
|
||||
target="bridge.worker.result",
|
||||
outcome="deny",
|
||||
status_code=getattr(error_resp, "status", 403),
|
||||
details={"reason": "auth_failed"},
|
||||
)
|
||||
return error_resp
|
||||
|
||||
job_id = request.match_info.get("job_id", "")
|
||||
if not job_id:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.worker.result",
|
||||
target="bridge.worker.result",
|
||||
outcome="deny",
|
||||
status_code=400,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.JOB_SUBMIT.value,
|
||||
details={"reason": "missing_job_id"},
|
||||
)
|
||||
return web.json_response({"error": "job_id required"}, status=400)
|
||||
|
||||
# Idempotency check
|
||||
# S50: Durable idempotency check for worker result ingress.
|
||||
# IMPORTANT: use check_and_record (durable path), do not rely on TTLCache-style get/put.
|
||||
idempotency_key = request.headers.get("X-Idempotency-Key", "")
|
||||
if idempotency_key:
|
||||
cached = self._idempotency_store.get(f"wr:{idempotency_key}")
|
||||
if cached:
|
||||
logger.info(f"Duplicate worker result suppressed: {idempotency_key}")
|
||||
return web.json_response(cached)
|
||||
store_key = f"wr:{idempotency_key}"
|
||||
is_dup, _ = self._idempotency_store.check_and_record(store_key, ttl=86400)
|
||||
if is_dup:
|
||||
logger.info(
|
||||
"Duplicate worker result suppressed for %s",
|
||||
_bridge_sensitive_tag(idempotency_key, label="idem"),
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"job_id": job_id,
|
||||
"status": "accepted",
|
||||
"deduped": True,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.worker.result",
|
||||
target=job_id,
|
||||
outcome="deny",
|
||||
status_code=400,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.JOB_SUBMIT.value,
|
||||
details={"reason": "invalid_json"},
|
||||
)
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
|
||||
# Store result
|
||||
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(),
|
||||
}
|
||||
|
||||
response_data = {"ok": True, "job_id": job_id, "status": "accepted"}
|
||||
|
||||
if idempotency_key:
|
||||
self._idempotency_store.put(f"wr:{idempotency_key}", response_data)
|
||||
|
||||
logger.info(f"F46: Worker result accepted for job={job_id} from={device_id}")
|
||||
self._audit(
|
||||
request=request,
|
||||
action="bridge.worker.result",
|
||||
target=job_id,
|
||||
outcome="allow",
|
||||
status_code=201,
|
||||
device_id=device_id,
|
||||
scope=BridgeScope.JOB_SUBMIT.value,
|
||||
details={"status": data.get("status", "completed")},
|
||||
)
|
||||
# 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(
|
||||
auth=AuthTier.BRIDGE,
|
||||
risk=RiskTier.LOW,
|
||||
summary="Worker heartbeat",
|
||||
description="Worker reports its status.",
|
||||
plane=RoutePlane.INTERNAL,
|
||||
)
|
||||
async def worker_heartbeat_handler(self, request: web.Request) -> web.Response:
|
||||
"""
|
||||
POST /bridge/worker/heartbeat
|
||||
@@ -413,6 +821,9 @@ def register_bridge_routes(
|
||||
app.router.add_get(BRIDGE_ENDPOINTS["health"]["path"], handlers.health_handler)
|
||||
app.router.add_post(BRIDGE_ENDPOINTS["submit"]["path"], handlers.submit_handler)
|
||||
app.router.add_post(BRIDGE_ENDPOINTS["deliver"]["path"], handlers.deliver_handler)
|
||||
app.router.add_post(
|
||||
BRIDGE_ENDPOINTS["handshake"]["path"], handlers.handshake_handler
|
||||
)
|
||||
|
||||
# F46: Worker-facing endpoints
|
||||
app.router.add_get(
|
||||
|
||||
+32
-3
@@ -3,14 +3,43 @@ Capabilities API Handler (R19).
|
||||
GET /openclaw/capabilities (legacy: /moltbot/capabilities)
|
||||
"""
|
||||
|
||||
from aiohttp import web
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
# Import discipline
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.aiohttp_compat import import_aiohttp_web
|
||||
from ..services.capabilities import get_capabilities
|
||||
except ImportError:
|
||||
else:
|
||||
from services.aiohttp_compat import import_aiohttp_web
|
||||
from services.capabilities import get_capabilities
|
||||
|
||||
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
else:
|
||||
from services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
|
||||
web = import_aiohttp_web()
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.PUBLIC,
|
||||
risk=RiskTier.LOW,
|
||||
summary="Get capabilities",
|
||||
description="Returns API version and feature flags.",
|
||||
audit="capabilities.list",
|
||||
plane=RoutePlane.USER,
|
||||
)
|
||||
async def capabilities_handler(request: web.Request) -> web.Response:
|
||||
"""
|
||||
GET /openclaw/capabilities (legacy: /moltbot/capabilities)
|
||||
|
||||
@@ -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,10 +35,30 @@ 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
|
||||
|
||||
|
||||
# R98: Endpoint Metadata
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
else:
|
||||
from services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.checkpoints")
|
||||
|
||||
|
||||
@@ -74,13 +94,29 @@ def _deny_remote_admin_if_needed(request: web.Request) -> web.Response | None:
|
||||
)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.LOW,
|
||||
summary="List checkpoints",
|
||||
description="List available workflow checkpoints.",
|
||||
audit="checkpoints.list",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def list_checkpoints_handler(request: web.Request) -> web.Response:
|
||||
"""GET /openclaw/checkpoints"""
|
||||
if web is None:
|
||||
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:
|
||||
@@ -97,13 +133,29 @@ async def list_checkpoints_handler(request: web.Request) -> web.Response:
|
||||
return _json_resp({"ok": False, "error": str(e)}, 500)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Create checkpoint",
|
||||
description="Create a new workflow checkpoint.",
|
||||
audit="checkpoints.create",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def create_checkpoint_handler(request: web.Request) -> web.Response:
|
||||
"""POST /openclaw/checkpoints"""
|
||||
if web is None:
|
||||
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:
|
||||
@@ -142,6 +194,14 @@ async def create_checkpoint_handler(request: web.Request) -> web.Response:
|
||||
return _json_resp({"ok": False, "error": str(e)}, 500)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.LOW,
|
||||
summary="Get checkpoint",
|
||||
description="Retrieve specific checkpoint details.",
|
||||
audit="checkpoints.get",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def get_checkpoint_handler(request: web.Request) -> web.Response:
|
||||
"""GET /openclaw/checkpoints/{id}"""
|
||||
if web is None:
|
||||
@@ -168,6 +228,14 @@ async def get_checkpoint_handler(request: web.Request) -> web.Response:
|
||||
return _json_resp({"ok": False, "error": str(e)}, 500)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Delete checkpoint",
|
||||
description="Delete a workflow checkpoint.",
|
||||
audit="checkpoints.delete",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def delete_checkpoint_handler(request: web.Request) -> web.Response:
|
||||
"""DELETE /openclaw/checkpoints/{id}"""
|
||||
if web is None:
|
||||
|
||||
+285
-751
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
|
||||
)
|
||||
@@ -0,0 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
from aiohttp import web
|
||||
except ImportError: # pragma: no cover
|
||||
|
||||
class _MockResponse:
|
||||
def __init__(self, payload: dict, status: int = 200):
|
||||
self.status = status
|
||||
self.body = json.dumps(payload).encode("utf-8")
|
||||
|
||||
class _MockWeb:
|
||||
class Request:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def json_response(payload: dict, status: int = 200):
|
||||
return _MockResponse(payload, status=status)
|
||||
|
||||
web = _MockWeb() # type: ignore
|
||||
|
||||
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 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 ( # type: ignore
|
||||
build_rate_limit_response,
|
||||
check_rate_limit,
|
||||
)
|
||||
from services.tenant_context import ( # type: ignore
|
||||
TenantBoundaryError,
|
||||
request_tenant_scope,
|
||||
)
|
||||
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
else:
|
||||
from services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.connector_contracts")
|
||||
|
||||
|
||||
def _require_admin(request) -> Optional[web.Response]:
|
||||
if not check_rate_limit(request, "admin"):
|
||||
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:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": err or "Unauthorized"}, status=403
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="List connector installations",
|
||||
description="Returns redacted multi-workspace connector installation diagnostics.",
|
||||
audit="connector.installations.list",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def connector_installations_list_handler(request):
|
||||
if (guard := _require_admin(request)) is not None:
|
||||
return guard
|
||||
registry = get_connector_installation_registry()
|
||||
platform = request.query.get("platform")
|
||||
workspace_id = request.query.get("workspace_id")
|
||||
status = request.query.get("status")
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
installations = registry.list_installations(
|
||||
platform=platform,
|
||||
tenant_id=tenant.tenant_id,
|
||||
workspace_id=workspace_id,
|
||||
status=status,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"installations": [inst.to_public_dict() for inst in installations],
|
||||
"diagnostics": registry.diagnostics(tenant_id=tenant.tenant_id),
|
||||
}
|
||||
)
|
||||
except TenantBoundaryError as exc:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": exc.code, "message": str(exc)},
|
||||
status=403,
|
||||
)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Get connector installation",
|
||||
description="Returns a single redacted connector installation record.",
|
||||
audit="connector.installations.get",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def connector_installation_get_handler(request):
|
||||
if (guard := _require_admin(request)) is not None:
|
||||
return guard
|
||||
installation_id = request.match_info.get("installation_id", "")
|
||||
registry = get_connector_installation_registry()
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
installation = registry.get_installation(
|
||||
installation_id, tenant_id=tenant.tenant_id
|
||||
)
|
||||
if installation is None:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "not_found"}, status=404
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"installation": installation.to_public_dict(),
|
||||
}
|
||||
)
|
||||
except TenantBoundaryError as exc:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": exc.code, "message": str(exc)},
|
||||
status=403,
|
||||
)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Resolve connector installation",
|
||||
description="Runs fail-closed workspace resolution for diagnostics without exposing token material.",
|
||||
audit="connector.installations.resolve",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def connector_installation_resolve_handler(request):
|
||||
if (guard := _require_admin(request)) is not None:
|
||||
return guard
|
||||
platform = (request.query.get("platform") or "").strip()
|
||||
workspace_id = (request.query.get("workspace_id") or "").strip()
|
||||
if not platform or not workspace_id:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "platform and workspace_id are required"},
|
||||
status=400,
|
||||
)
|
||||
registry = get_connector_installation_registry()
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
resolution = registry.resolve_installation(
|
||||
platform, workspace_id, tenant_id=tenant.tenant_id
|
||||
)
|
||||
status_code = 200 if resolution.ok else 409
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": resolution.ok,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"resolution": resolution.to_public_dict(),
|
||||
},
|
||||
status=status_code,
|
||||
)
|
||||
except TenantBoundaryError as exc:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": exc.code, "message": str(exc)},
|
||||
status=403,
|
||||
)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Connector installation audit",
|
||||
description="Returns installation lifecycle audit evidence.",
|
||||
audit="connector.installations.audit",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def connector_installation_audit_handler(request):
|
||||
if (guard := _require_admin(request)) is not None:
|
||||
return guard
|
||||
registry = get_connector_installation_registry()
|
||||
installation_id = request.query.get("installation_id")
|
||||
try:
|
||||
limit = int(request.query.get("limit") or 100)
|
||||
except Exception:
|
||||
limit = 100
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"events": registry.get_audit_trail(
|
||||
installation_id=installation_id,
|
||||
tenant_id=tenant.tenant_id,
|
||||
limit=limit,
|
||||
),
|
||||
}
|
||||
)
|
||||
except TenantBoundaryError as exc:
|
||||
return web.json_response(
|
||||
{"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(),
|
||||
}
|
||||
)
|
||||
+191
-31
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from inspect import signature
|
||||
from typing import Any, Dict
|
||||
|
||||
try:
|
||||
@@ -22,15 +23,34 @@ except ModuleNotFoundError: # pragma: no cover
|
||||
web = None # type: ignore
|
||||
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.access_control import require_observability_access
|
||||
from ..services.access_control import (
|
||||
require_admin_token,
|
||||
require_observability_access,
|
||||
)
|
||||
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,
|
||||
)
|
||||
else: # pragma: no cover
|
||||
from services.access_control import require_observability_access # type: ignore
|
||||
from services.access_control import ( # type: ignore
|
||||
require_admin_token,
|
||||
require_observability_access,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.events")
|
||||
|
||||
@@ -40,6 +60,48 @@ SSE_KEEPALIVE_SEC = 15
|
||||
SSE_MAX_DURATION_SEC = 300 # 5 minutes
|
||||
|
||||
|
||||
def _call_event_serializer(
|
||||
event: Any,
|
||||
method_name: str,
|
||||
*,
|
||||
include_reasoning: bool,
|
||||
) -> Any:
|
||||
"""Use enhanced serializers when supported, but stay compatible with old test doubles."""
|
||||
serializer = getattr(event, method_name)
|
||||
try:
|
||||
params = signature(serializer).parameters
|
||||
except (TypeError, ValueError):
|
||||
params = {}
|
||||
if "include_reasoning" in params:
|
||||
return serializer(include_reasoning=include_reasoning)
|
||||
return serializer()
|
||||
|
||||
|
||||
# R98: Endpoint Metadata
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
else:
|
||||
from services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.OBSERVABILITY,
|
||||
risk=RiskTier.LOW,
|
||||
summary="Stream job events",
|
||||
description="SSE endpoint for job lifecycle events.",
|
||||
audit="events.stream",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def events_stream_handler(request: web.Request) -> web.StreamResponse:
|
||||
"""
|
||||
GET /openclaw/events/stream
|
||||
@@ -53,16 +115,21 @@ 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)
|
||||
allowed, error = require_observability_access(request)
|
||||
if not allowed:
|
||||
return web.json_response({"ok": False, "error": error}, status=403)
|
||||
admin_allowed, _ = require_admin_token(request)
|
||||
reveal = resolve_reasoning_reveal(request, admin_authorized=admin_allowed)
|
||||
audit_reasoning_reveal(request, target="events.stream", decision=reveal)
|
||||
|
||||
store = get_job_event_store()
|
||||
|
||||
@@ -112,7 +179,13 @@ async def events_stream_handler(request: web.Request) -> web.StreamResponse:
|
||||
|
||||
if events:
|
||||
for evt in events:
|
||||
await response.write(evt.to_sse().encode("utf-8"))
|
||||
await response.write(
|
||||
_call_event_serializer(
|
||||
evt,
|
||||
"to_sse",
|
||||
include_reasoning=reveal["allowed"],
|
||||
).encode("utf-8")
|
||||
)
|
||||
last_seq = evt.seq
|
||||
else:
|
||||
# Send keep-alive header only if interval exceeded
|
||||
@@ -132,6 +205,14 @@ async def events_stream_handler(request: web.Request) -> web.StreamResponse:
|
||||
return response
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.OBSERVABILITY,
|
||||
risk=RiskTier.LOW,
|
||||
summary="Poll job events",
|
||||
description="JSON polling fallback for job events.",
|
||||
audit="events.poll",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def events_poll_handler(request: web.Request) -> web.Response:
|
||||
"""
|
||||
GET /openclaw/events
|
||||
@@ -147,42 +228,121 @@ 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
|
||||
allowed, error = require_observability_access(request)
|
||||
if not allowed:
|
||||
return web.json_response({"ok": False, "error": error}, status=403)
|
||||
admin_allowed, _ = require_admin_token(request)
|
||||
reveal = resolve_reasoning_reveal(request, admin_authorized=admin_allowed)
|
||||
audit_reasoning_reveal(request, target="events.poll", decision=reveal)
|
||||
|
||||
store = get_job_event_store()
|
||||
|
||||
# Parse query params
|
||||
try:
|
||||
since = int(request.query.get("since", "0"))
|
||||
except ValueError:
|
||||
since = 0
|
||||
|
||||
# R95: deterministic pagination normalization + bounded scan diagnostics
|
||||
prompt_id = request.query.get("prompt_id")
|
||||
|
||||
try:
|
||||
limit = max(1, min(int(request.query.get("limit", "50")), 200))
|
||||
except ValueError:
|
||||
limit = 50
|
||||
|
||||
events = store.events_since(
|
||||
last_seq=since,
|
||||
limit=limit,
|
||||
prompt_id=prompt_id,
|
||||
page = normalize_cursor_limit(
|
||||
request.query,
|
||||
cursor_key="since",
|
||||
default_cursor=0,
|
||||
min_cursor=0,
|
||||
default_limit=50,
|
||||
max_limit=200,
|
||||
)
|
||||
since_requested = int(page.cursor or 0)
|
||||
latest_seq = store.latest_seq()
|
||||
|
||||
cursor_status = "ok"
|
||||
since_effective = since_requested
|
||||
if since_requested > latest_seq:
|
||||
cursor_status = "future_cursor_reset"
|
||||
since_effective = latest_seq
|
||||
page.warnings.append(
|
||||
{
|
||||
"code": "R95_STALE_CURSOR_FUTURE",
|
||||
"field": "since",
|
||||
"raw": str(since_requested),
|
||||
"normalized": since_effective,
|
||||
}
|
||||
)
|
||||
|
||||
scan_cap = max(page.limit * 10, 500)
|
||||
events, scan = store.events_since_bounded(
|
||||
last_seq=since_effective,
|
||||
limit=page.limit,
|
||||
prompt_id=prompt_id,
|
||||
scan_cap=scan_cap,
|
||||
)
|
||||
|
||||
earliest_retained = scan.get("earliest_retained_seq")
|
||||
if (
|
||||
isinstance(earliest_retained, int)
|
||||
and since_effective != 0
|
||||
and since_effective < (earliest_retained - 1)
|
||||
):
|
||||
cursor_status = "stale_cursor_reset"
|
||||
since_effective = max(0, earliest_retained - 1)
|
||||
page.warnings.append(
|
||||
{
|
||||
"code": "R95_STALE_CURSOR_RESET",
|
||||
"field": "since",
|
||||
"raw": str(since_requested),
|
||||
"normalized": since_effective,
|
||||
}
|
||||
)
|
||||
events, scan = store.events_since_bounded(
|
||||
last_seq=since_effective,
|
||||
limit=page.limit,
|
||||
prompt_id=prompt_id,
|
||||
scan_cap=scan_cap,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"events": [e.to_dict() for e in events],
|
||||
"latest_seq": store.latest_seq(),
|
||||
"events": [
|
||||
_call_event_serializer(
|
||||
e,
|
||||
"to_dict",
|
||||
include_reasoning=reveal["allowed"],
|
||||
)
|
||||
for e in events
|
||||
],
|
||||
"latest_seq": latest_seq,
|
||||
"pagination": {
|
||||
"limit": page.limit,
|
||||
"since_requested": since_requested,
|
||||
"since_effective": since_effective,
|
||||
"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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
F54 model manager API handlers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
try:
|
||||
from ..services.access_control import require_admin_token, resolve_token_info
|
||||
from ..services.aiohttp_compat import import_aiohttp_web
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
from ..services.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
|
||||
from services.access_control import ( # type: ignore
|
||||
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,
|
||||
request_tenant_scope,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.model_manager")
|
||||
web = import_aiohttp_web()
|
||||
|
||||
|
||||
def _json(data: Dict[str, Any], status: int = 200) -> web.Response:
|
||||
return web.json_response(data, 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)
|
||||
|
||||
|
||||
def _parse_int(raw: Any, default: int, minimum: int, maximum: int) -> int:
|
||||
try:
|
||||
value = int(str(raw).strip())
|
||||
except Exception:
|
||||
return default
|
||||
return max(minimum, min(maximum, value))
|
||||
|
||||
|
||||
def _parse_optional_bool(raw: Optional[str]) -> Optional[bool]:
|
||||
if raw is None:
|
||||
return None
|
||||
text = str(raw).strip().lower()
|
||||
if text in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if text in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.LOW,
|
||||
summary="Search models",
|
||||
description="Search normalized model entries across managed installs and catalog sources.",
|
||||
audit="models.search",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def model_search_handler(request: web.Request) -> web.Response:
|
||||
deny = _require_admin(request)
|
||||
if deny:
|
||||
return deny
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
result = model_manager.search_models(
|
||||
query=request.query.get("q", ""),
|
||||
source=request.query.get("source", ""),
|
||||
model_type=request.query.get("model_type", ""),
|
||||
installed=_parse_optional_bool(request.query.get("installed")),
|
||||
limit=_parse_int(request.query.get("limit"), 50, 1, 200),
|
||||
offset=_parse_int(request.query.get("offset"), 0, 0, 10_000),
|
||||
tenant_id=tenant.tenant_id,
|
||||
)
|
||||
return _json({"ok": True, **result})
|
||||
except TenantBoundaryError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.HIGH,
|
||||
summary="Create model download task",
|
||||
description="Create a managed model download task with progress/cancel lifecycle.",
|
||||
audit="models.download.create",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def model_download_create_handler(request: web.Request) -> web.Response:
|
||||
deny = _require_admin(request)
|
||||
if deny:
|
||||
return deny
|
||||
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)
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
task = model_manager.create_download_task(
|
||||
model_id=payload.get("model_id") or payload.get("id") or "",
|
||||
name=payload.get("name") or "",
|
||||
model_type=payload.get("model_type") or "",
|
||||
source=payload.get("source") or "",
|
||||
source_label=payload.get("source_label") or "",
|
||||
download_url=payload.get("download_url") or "",
|
||||
expected_sha256=payload.get("expected_sha256") or "",
|
||||
provenance=payload.get("provenance") or {},
|
||||
destination_subdir=payload.get("destination_subdir"),
|
||||
filename=payload.get("filename"),
|
||||
tenant_id=tenant.tenant_id,
|
||||
)
|
||||
return _json({"ok": True, "task": task}, 201)
|
||||
except TenantBoundaryError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
|
||||
except ModelManagerError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": exc.detail}, exc.status)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to create model download task")
|
||||
return _json({"ok": False, "error": "internal_error", "detail": str(exc)}, 500)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.LOW,
|
||||
summary="List model download tasks",
|
||||
description="List model download task states.",
|
||||
audit="models.download.list",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def model_download_list_handler(request: web.Request) -> web.Response:
|
||||
deny = _require_admin(request)
|
||||
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
|
||||
) as tenant:
|
||||
result = model_manager.list_download_tasks(
|
||||
tenant_id=tenant.tenant_id,
|
||||
state=request.query.get("state", ""),
|
||||
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)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.LOW,
|
||||
summary="Get model download task",
|
||||
description="Get one model download task by task id.",
|
||||
audit="models.download.get",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def model_download_get_handler(request: web.Request) -> web.Response:
|
||||
deny = _require_admin(request)
|
||||
if deny:
|
||||
return deny
|
||||
task_id = request.match_info.get("task_id")
|
||||
if not task_id:
|
||||
return _json({"ok": False, "error": "missing_task_id"}, 400)
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
task = model_manager.get_download_task(task_id, tenant_id=tenant.tenant_id)
|
||||
return _json({"ok": True, "task": task})
|
||||
except TenantBoundaryError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
|
||||
except ModelManagerError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": exc.detail}, exc.status)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.HIGH,
|
||||
summary="Cancel model download task",
|
||||
description="Cancel a queued/running model download task.",
|
||||
audit="models.download.cancel",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def model_download_cancel_handler(request: web.Request) -> web.Response:
|
||||
deny = _require_admin(request)
|
||||
if deny:
|
||||
return deny
|
||||
task_id = request.match_info.get("task_id")
|
||||
if not task_id:
|
||||
return _json({"ok": False, "error": "missing_task_id"}, 400)
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
task = model_manager.cancel_download_task(
|
||||
task_id, tenant_id=tenant.tenant_id
|
||||
)
|
||||
return _json({"ok": True, "task": task})
|
||||
except TenantBoundaryError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
|
||||
except ModelManagerError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": exc.detail}, exc.status)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.HIGH,
|
||||
summary="Import downloaded model",
|
||||
description="Activate/import a completed model download with policy checks.",
|
||||
audit="models.import",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def model_import_handler(request: web.Request) -> web.Response:
|
||||
deny = _require_admin(request)
|
||||
if deny:
|
||||
return deny
|
||||
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)
|
||||
task_id = str(payload.get("task_id") or "").strip()
|
||||
if not task_id:
|
||||
return _json({"ok": False, "error": "missing_task_id"}, 400)
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
rec = model_manager.import_downloaded_model(
|
||||
task_id=task_id,
|
||||
tenant_id=tenant.tenant_id,
|
||||
destination_subdir=payload.get("destination_subdir"),
|
||||
filename=payload.get("filename"),
|
||||
tags=(
|
||||
payload.get("tags")
|
||||
if isinstance(payload.get("tags"), list)
|
||||
else None
|
||||
),
|
||||
)
|
||||
return _json({"ok": True, "installation": rec})
|
||||
except TenantBoundaryError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
|
||||
except ModelManagerError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": exc.detail}, exc.status)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to import model download")
|
||||
return _json({"ok": False, "error": "internal_error", "detail": str(exc)}, 500)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.LOW,
|
||||
summary="List installed models",
|
||||
description="List managed model installations.",
|
||||
audit="models.installations.list",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def model_installations_list_handler(request: web.Request) -> web.Response:
|
||||
deny = _require_admin(request)
|
||||
if deny:
|
||||
return deny
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
result = model_manager.list_installations(
|
||||
tenant_id=tenant.tenant_id,
|
||||
model_type=request.query.get("model_type", ""),
|
||||
limit=_parse_int(request.query.get("limit"), 100, 1, 200),
|
||||
offset=_parse_int(request.query.get("offset"), 0, 0, 10_000),
|
||||
)
|
||||
return _json({"ok": True, **result})
|
||||
except TenantBoundaryError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
|
||||
+70
-1
@@ -32,6 +32,7 @@ except ImportError:
|
||||
web = MockWeb()
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
@@ -45,6 +46,32 @@ else:
|
||||
from services.packs.pack_archive import PackArchive, PackError
|
||||
from services.packs.pack_registry import PackRegistry
|
||||
|
||||
# R98: Endpoint Metadata
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
else:
|
||||
from services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
|
||||
# Strict pattern for pack name/version URL route parameters.
|
||||
_SAFE_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
|
||||
|
||||
|
||||
def _is_safe_pack_segment(value: str) -> bool:
|
||||
"""Check if a pack name or version segment is safe for filesystem use."""
|
||||
if not value or value in (".", ".."):
|
||||
return False
|
||||
return bool(_SAFE_SEGMENT_RE.match(value))
|
||||
|
||||
|
||||
if web:
|
||||
|
||||
@@ -70,6 +97,14 @@ class PacksHandlers:
|
||||
def __init__(self, state_dir: str):
|
||||
self.registry = PackRegistry(state_dir)
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.LOW,
|
||||
summary="List packs",
|
||||
description="List installed packs.",
|
||||
audit="packs.list",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def list_packs_handler(self, request: web.Request) -> web.Response:
|
||||
"""GET /packs - List installed packs."""
|
||||
if getattr(web, "_IS_MOCKWEB", False) is True:
|
||||
@@ -95,6 +130,14 @@ class PacksHandlers:
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.HIGH,
|
||||
summary="Import pack",
|
||||
description="Install pack from zip upload.",
|
||||
audit="packs.import",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def import_pack_handler(self, request: web.Request) -> web.Response:
|
||||
"""POST /packs/import - Install pack from zip upload."""
|
||||
if getattr(web, "_IS_MOCKWEB", False) is True:
|
||||
@@ -139,6 +182,14 @@ class PacksHandlers:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.HIGH,
|
||||
summary="Delete pack",
|
||||
description="Uninstall pack.",
|
||||
audit="packs.delete",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def delete_pack_handler(self, request: web.Request) -> web.Response:
|
||||
"""DELETE /packs/{name}/{version} - Uninstall pack."""
|
||||
if getattr(web, "_IS_MOCKWEB", False) is True:
|
||||
@@ -155,6 +206,11 @@ class PacksHandlers:
|
||||
{"ok": False, "error": "Missing name/version"}, status=400
|
||||
)
|
||||
|
||||
if not _is_safe_pack_segment(name) or not _is_safe_pack_segment(version):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Invalid name or version format"}, status=400
|
||||
)
|
||||
|
||||
try:
|
||||
success = self.registry.uninstall_pack(name, version)
|
||||
if success:
|
||||
@@ -166,6 +222,14 @@ class PacksHandlers:
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Export pack",
|
||||
description="Download pack zip.",
|
||||
audit="packs.export",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def export_pack_handler(self, request: web.Request) -> web.Response:
|
||||
"""GET /packs/export/{name}/{version} - Download pack zip."""
|
||||
if getattr(web, "_IS_MOCKWEB", False) is True:
|
||||
@@ -182,6 +246,11 @@ class PacksHandlers:
|
||||
{"ok": False, "error": "Missing name/version"}, status=400
|
||||
)
|
||||
|
||||
if not _is_safe_pack_segment(name) or not _is_safe_pack_segment(version):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Invalid name or version format"}, status=400
|
||||
)
|
||||
|
||||
pack_path = self.registry.get_pack_path(name, version)
|
||||
if not pack_path:
|
||||
return web.json_response(
|
||||
@@ -206,7 +275,7 @@ class PacksHandlers:
|
||||
return CleanupFileResponse(
|
||||
temp_zip,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{name}-{version}.zip"',
|
||||
"Content-Disposition": f'attachment; filename="{name.replace(chr(34), "")}-{version.replace(chr(34), "")}.zip"',
|
||||
"Content-Type": "application/zip",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
+66
-11
@@ -20,22 +20,45 @@ if __package__ and "." in __package__:
|
||||
from ..models.schemas import MAX_BODY_SIZE
|
||||
from ..services.access_control import is_loopback, require_admin_token
|
||||
from ..services.preflight import (
|
||||
_get_model_inventory,
|
||||
_get_node_class_mappings,
|
||||
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
|
||||
from services.preflight import ( # type: ignore
|
||||
_get_model_inventory,
|
||||
_get_node_class_mappings,
|
||||
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__:
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
else:
|
||||
from services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.preflight")
|
||||
|
||||
@@ -68,6 +91,14 @@ def _deny_remote_admin_if_needed(request: web.Request) -> web.Response | None:
|
||||
)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.LOW, # Read-only analysis
|
||||
summary="Run preflight check",
|
||||
description="Analyze workflow JSON for missing nodes and models.",
|
||||
audit="preflight.analyze",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def preflight_handler(request: web.Request) -> web.Response:
|
||||
"""
|
||||
POST /openclaw/preflight
|
||||
@@ -78,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
|
||||
@@ -134,6 +169,14 @@ async def preflight_handler(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.LOW,
|
||||
summary="Get inventory",
|
||||
description="Returns a snapshot of available nodes and models.",
|
||||
audit="preflight.inventory",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def inventory_handler(request: web.Request) -> web.Response:
|
||||
"""
|
||||
GET /openclaw/preflight/inventory
|
||||
@@ -144,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)
|
||||
@@ -163,11 +210,19 @@ async def inventory_handler(request: web.Request) -> web.Response:
|
||||
nodes_map = _get_node_class_mappings()
|
||||
node_classes = sorted(list(nodes_map.keys()))
|
||||
|
||||
# Models
|
||||
models_map = _get_model_inventory()
|
||||
inventory_snapshot = get_model_inventory_snapshot()
|
||||
|
||||
return web.json_response(
|
||||
{"ok": True, "nodes": node_classes, "models": models_map}
|
||||
{
|
||||
"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"],
|
||||
"last_error": inventory_snapshot["last_error"],
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Inventory fetch failed")
|
||||
|
||||
+192
-63
@@ -8,22 +8,57 @@ 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
|
||||
from ..services.presets import Preset, preset_store
|
||||
except ImportError:
|
||||
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
|
||||
from services.access_control import require_admin_token
|
||||
from services.presets import Preset, preset_store
|
||||
(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:
|
||||
"""Handlers for preset API."""
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.PUBLIC, # Conditionally public
|
||||
risk=RiskTier.LOW,
|
||||
summary="List presets",
|
||||
description="List available presets (dynamic auth).",
|
||||
audit="presets.list",
|
||||
plane=RoutePlane.USER,
|
||||
)
|
||||
async def list_presets(self, request: web.Request) -> web.Response:
|
||||
"""
|
||||
GET /moltbot/presets
|
||||
@@ -53,10 +88,33 @@ class PresetHandlers:
|
||||
|
||||
category = request.query.get("category")
|
||||
tag = request.query.get("tag")
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
allow_default_when_missing=True,
|
||||
) as tenant:
|
||||
presets = preset_store.list_presets(
|
||||
category=category,
|
||||
tag=tag,
|
||||
tenant_id=tenant.tenant_id,
|
||||
)
|
||||
return web.json_response([p.to_dict() for p in presets])
|
||||
except TenantBoundaryError as exc:
|
||||
return web.json_response(
|
||||
{"error": exc.code, "message": str(exc)},
|
||||
status=403,
|
||||
)
|
||||
|
||||
presets = preset_store.list_presets(category=category, tag=tag)
|
||||
return web.json_response([p.to_dict() for p in presets])
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.PUBLIC, # Conditionally public
|
||||
risk=RiskTier.LOW,
|
||||
summary="Get preset",
|
||||
description="Get preset details (dynamic auth).",
|
||||
audit="presets.get",
|
||||
plane=RoutePlane.USER,
|
||||
)
|
||||
async def get_preset(self, request: web.Request) -> web.Response:
|
||||
"""GET /moltbot/presets/{preset_id}"""
|
||||
# Milestone B: Auth Check
|
||||
@@ -80,12 +138,32 @@ class PresetHandlers:
|
||||
if not preset_id:
|
||||
return web.json_response({"error": "Missing ID"}, status=400)
|
||||
|
||||
preset = preset_store.get_preset(preset_id)
|
||||
if not preset:
|
||||
return web.json_response({"error": "Not Found"}, status=404)
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
allow_default_when_missing=True,
|
||||
) as tenant:
|
||||
preset = preset_store.get_preset(preset_id, tenant_id=tenant.tenant_id)
|
||||
if not preset:
|
||||
return web.json_response({"error": "Not Found"}, status=404)
|
||||
|
||||
return web.json_response(preset.to_dict())
|
||||
return web.json_response(preset.to_dict())
|
||||
except TenantBoundaryError as exc:
|
||||
return web.json_response(
|
||||
{"error": exc.code, "message": str(exc)},
|
||||
status=403,
|
||||
)
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Create preset",
|
||||
description="Create a new preset.",
|
||||
audit="presets.create",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def create_preset(self, request: web.Request) -> web.Response:
|
||||
"""POST /moltbot/presets"""
|
||||
allowed, error = require_admin_token(request)
|
||||
@@ -102,32 +180,52 @@ class PresetHandlers:
|
||||
|
||||
if not name or not content:
|
||||
return web.json_response({"error": "Name and Content required"}, status=400)
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
# Create object
|
||||
preset = Preset.new(
|
||||
name=data["name"],
|
||||
content=data["content"],
|
||||
category=data.get("category", "general"),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
|
||||
# Milestone E: Schema Validation
|
||||
try:
|
||||
preset.validate_content()
|
||||
except ValueError as e:
|
||||
return web.json_response(
|
||||
{"error": f"Validation Error: {str(e)}"}, status=400
|
||||
with request_tenant_scope(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
allow_default_when_missing=True,
|
||||
) as tenant:
|
||||
# Create object
|
||||
preset = Preset.new(
|
||||
name=data["name"],
|
||||
content=data["content"],
|
||||
category=data.get("category", "general"),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
preset.tenant_id = tenant.tenant_id
|
||||
|
||||
# Save
|
||||
preset_store.save_preset(preset)
|
||||
logger.info(f"Created preset {preset.id} ({preset.name})")
|
||||
# Milestone E: Schema Validation
|
||||
try:
|
||||
preset.validate_content()
|
||||
except ValueError as e:
|
||||
return web.json_response(
|
||||
{"error": f"Validation Error: {str(e)}"}, status=400
|
||||
)
|
||||
|
||||
return web.json_response(preset.to_dict(), status=201)
|
||||
# Save
|
||||
preset_store.save_preset(preset)
|
||||
logger.info(f"Created preset {preset.id} ({preset.name})")
|
||||
|
||||
return web.json_response(preset.to_dict(), status=201)
|
||||
except TenantBoundaryError as exc:
|
||||
return web.json_response(
|
||||
{"error": exc.code, "message": str(exc)},
|
||||
status=403,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create preset: {e}")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Update preset",
|
||||
description="Update an existing preset.",
|
||||
audit="presets.update",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def update_preset(self, request: web.Request) -> web.Response:
|
||||
"""PUT /moltbot/presets/{preset_id}"""
|
||||
allowed, error = require_admin_token(request)
|
||||
@@ -138,38 +236,58 @@ class PresetHandlers:
|
||||
if not preset_id:
|
||||
return web.json_response({"error": "Missing ID"}, status=400)
|
||||
|
||||
preset = preset_store.get_preset(preset_id)
|
||||
if not preset:
|
||||
return web.json_response({"error": "Not Found"}, status=404)
|
||||
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
with request_tenant_scope(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
allow_default_when_missing=True,
|
||||
) as tenant:
|
||||
preset = preset_store.get_preset(preset_id, tenant_id=tenant.tenant_id)
|
||||
if not preset:
|
||||
return web.json_response({"error": "Not Found"}, status=404)
|
||||
|
||||
# Update fields
|
||||
if "name" in data:
|
||||
preset.name = data["name"]
|
||||
if "content" in data:
|
||||
preset.content = data["content"]
|
||||
if "category" in data:
|
||||
preset.category = data["category"]
|
||||
if "tags" in data:
|
||||
preset.tags = data["tags"]
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
|
||||
# Milestone E: Schema Validation
|
||||
try:
|
||||
preset.validate_content()
|
||||
except ValueError as e:
|
||||
# Update fields
|
||||
if "name" in data:
|
||||
preset.name = data["name"]
|
||||
if "content" in data:
|
||||
preset.content = data["content"]
|
||||
if "category" in data:
|
||||
preset.category = data["category"]
|
||||
if "tags" in data:
|
||||
preset.tags = data["tags"]
|
||||
|
||||
# Milestone E: Schema Validation
|
||||
try:
|
||||
preset.validate_content()
|
||||
except ValueError as e:
|
||||
return web.json_response(
|
||||
{"error": f"Validation Error: {str(e)}"}, status=400
|
||||
)
|
||||
|
||||
preset.updated_at = time.time()
|
||||
preset_store.save_preset(preset)
|
||||
|
||||
return web.json_response(preset.to_dict())
|
||||
except TenantBoundaryError as exc:
|
||||
return web.json_response(
|
||||
{"error": f"Validation Error: {str(e)}"}, status=400
|
||||
{"error": exc.code, "message": str(exc)},
|
||||
status=403,
|
||||
)
|
||||
|
||||
preset.updated_at = time.time()
|
||||
preset_store.save_preset(preset)
|
||||
|
||||
return web.json_response(preset.to_dict())
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.HIGH,
|
||||
summary="Delete preset",
|
||||
description="Delete a preset.",
|
||||
audit="presets.delete",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def delete_preset(self, request: web.Request) -> web.Response:
|
||||
"""DELETE /moltbot/presets/{preset_id}"""
|
||||
allowed, error = require_admin_token(request)
|
||||
@@ -180,10 +298,21 @@ class PresetHandlers:
|
||||
if not preset_id:
|
||||
return web.json_response({"error": "Missing ID"}, status=400)
|
||||
|
||||
if preset_store.delete_preset(preset_id):
|
||||
return web.json_response({"ok": True})
|
||||
else:
|
||||
return web.json_response({"error": "Not Found or Failed"}, status=404)
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
allow_default_when_missing=True,
|
||||
) as tenant:
|
||||
if preset_store.delete_preset(preset_id, tenant_id=tenant.tenant_id):
|
||||
return web.json_response({"ok": True})
|
||||
return web.json_response({"error": "Not Found or Failed"}, status=404)
|
||||
except TenantBoundaryError as exc:
|
||||
return web.json_response(
|
||||
{"error": exc.code, "message": str(exc)},
|
||||
status=403,
|
||||
)
|
||||
|
||||
|
||||
def register_preset_routes(app: web.Application):
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
F61 Remote Admin Console static page handler.
|
||||
Serves a standalone mobile-friendly admin UI for remote operators.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.aiohttp_compat import import_aiohttp_web
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
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,
|
||||
RoutePlane,
|
||||
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.
|
||||
def _admin_console_html_path() -> Path:
|
||||
return Path(__file__).resolve().parents[1] / "web" / "admin_console.html"
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.PUBLIC,
|
||||
risk=RiskTier.LOW,
|
||||
summary="Remote admin console page",
|
||||
description="Serves the standalone remote admin console HTML shell.",
|
||||
audit="admin.console.page",
|
||||
plane=RoutePlane.USER,
|
||||
)
|
||||
async def remote_admin_page_handler(request: web.Request) -> web.Response:
|
||||
path = _admin_console_html_path()
|
||||
if not path.exists():
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "remote_admin_console_not_found",
|
||||
"path": str(path),
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
html = path.read_text(encoding="utf-8")
|
||||
return web.Response(
|
||||
text=html,
|
||||
content_type="text/html",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
@@ -0,0 +1,400 @@
|
||||
"""
|
||||
F53 rewrite recipe API handlers.
|
||||
|
||||
Admin-only workflow:
|
||||
- Recipe CRUD
|
||||
- Dry-run preview with structured diff
|
||||
- Guarded apply with rollback snapshot on failure
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
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.rewrite_recipes import (
|
||||
RecipeApplyError,
|
||||
RecipeValidationError,
|
||||
RewriteConstraints,
|
||||
RewriteOperation,
|
||||
RewriteRecipe,
|
||||
dry_run_recipe,
|
||||
guarded_apply_recipe,
|
||||
rewrite_recipe_store,
|
||||
)
|
||||
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
|
||||
except ImportError:
|
||||
from services.access_control import ( # type: ignore
|
||||
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.rewrite_recipes import ( # type: ignore
|
||||
RecipeApplyError,
|
||||
RecipeValidationError,
|
||||
RewriteConstraints,
|
||||
RewriteOperation,
|
||||
RewriteRecipe,
|
||||
dry_run_recipe,
|
||||
guarded_apply_recipe,
|
||||
rewrite_recipe_store,
|
||||
)
|
||||
from services.tenant_context import ( # type: ignore
|
||||
TenantBoundaryError,
|
||||
request_tenant_scope,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.rewrite_recipes")
|
||||
web = import_aiohttp_web()
|
||||
|
||||
|
||||
def _json(data: Dict[str, Any], status: int = 200) -> web.Response:
|
||||
return web.json_response(data, status=status)
|
||||
|
||||
|
||||
def _require_admin(request: web.Request) -> web.Response | None:
|
||||
allowed, error = require_admin_token(request)
|
||||
if not allowed:
|
||||
return _json({"ok": False, "error": error or "unauthorized"}, 403)
|
||||
return None
|
||||
|
||||
|
||||
def _build_recipe_from_payload(
|
||||
payload: Dict[str, Any], existing: RewriteRecipe | None = None
|
||||
) -> RewriteRecipe:
|
||||
if existing is None:
|
||||
return RewriteRecipe.new(
|
||||
name=payload.get("name") or "",
|
||||
prompt_template=payload.get("prompt_template") or "",
|
||||
description=payload.get("description") or "",
|
||||
tags=payload.get("tags") or [],
|
||||
operations=payload.get("operations") or [],
|
||||
constraints=payload.get("constraints") or {},
|
||||
tenant_id=payload.get("tenant_id") or "default",
|
||||
)
|
||||
|
||||
if "name" in payload:
|
||||
existing.name = payload["name"] or ""
|
||||
if "prompt_template" in payload:
|
||||
existing.prompt_template = payload.get("prompt_template") or ""
|
||||
if "description" in payload:
|
||||
existing.description = payload.get("description") or ""
|
||||
if "tags" in payload:
|
||||
existing.tags = payload.get("tags") or []
|
||||
if "operations" in payload:
|
||||
existing.operations = [
|
||||
RewriteOperation.from_dict(item)
|
||||
for item in (payload.get("operations") or [])
|
||||
]
|
||||
if "constraints" in payload:
|
||||
existing.constraints = RewriteConstraints.from_dict(
|
||||
payload.get("constraints") or {}
|
||||
)
|
||||
existing.updated_at = time.time()
|
||||
existing.validate()
|
||||
return existing
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.LOW,
|
||||
summary="List rewrite recipes",
|
||||
description="List workflow rewrite recipes.",
|
||||
audit="rewrite_recipes.list",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def rewrite_recipes_list_handler(request: web.Request) -> web.Response:
|
||||
deny = _require_admin(request)
|
||||
if deny:
|
||||
return deny
|
||||
tag = request.query.get("tag")
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
allow_default_when_missing=True,
|
||||
) as tenant:
|
||||
recipes = rewrite_recipe_store.list_recipes(
|
||||
tag=tag,
|
||||
tenant_id=tenant.tenant_id,
|
||||
)
|
||||
return _json({"ok": True, "recipes": [item.to_dict() for item in recipes]})
|
||||
except TenantBoundaryError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.LOW,
|
||||
summary="Get rewrite recipe",
|
||||
description="Get a single workflow rewrite recipe.",
|
||||
audit="rewrite_recipes.get",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def rewrite_recipe_get_handler(request: web.Request) -> web.Response:
|
||||
deny = _require_admin(request)
|
||||
if deny:
|
||||
return deny
|
||||
recipe_id = request.match_info.get("recipe_id")
|
||||
if not recipe_id:
|
||||
return _json({"ok": False, "error": "missing_id"}, 400)
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
allow_default_when_missing=True,
|
||||
) as tenant:
|
||||
recipe = rewrite_recipe_store.get_recipe(
|
||||
recipe_id, tenant_id=tenant.tenant_id
|
||||
)
|
||||
if recipe is None:
|
||||
return _json({"ok": False, "error": "not_found"}, 404)
|
||||
return _json({"ok": True, "recipe": recipe.to_dict()})
|
||||
except TenantBoundaryError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Create rewrite recipe",
|
||||
description="Create a workflow rewrite recipe.",
|
||||
audit="rewrite_recipes.create",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def rewrite_recipe_create_handler(request: web.Request) -> web.Response:
|
||||
deny = _require_admin(request)
|
||||
if deny:
|
||||
return deny
|
||||
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)
|
||||
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
allow_default_when_missing=True,
|
||||
) as tenant:
|
||||
payload["tenant_id"] = tenant.tenant_id
|
||||
recipe = _build_recipe_from_payload(payload)
|
||||
rewrite_recipe_store.save_recipe(recipe)
|
||||
return _json({"ok": True, "recipe": recipe.to_dict()}, 201)
|
||||
except TenantBoundaryError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
|
||||
except RecipeValidationError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": exc.detail}, 400)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to create rewrite recipe")
|
||||
return _json({"ok": False, "error": "internal_error", "detail": str(exc)}, 500)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Update rewrite recipe",
|
||||
description="Update an existing workflow rewrite recipe.",
|
||||
audit="rewrite_recipes.update",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def rewrite_recipe_update_handler(request: web.Request) -> web.Response:
|
||||
deny = _require_admin(request)
|
||||
if deny:
|
||||
return deny
|
||||
recipe_id = request.match_info.get("recipe_id")
|
||||
if not recipe_id:
|
||||
return _json({"ok": False, "error": "missing_id"}, 400)
|
||||
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)
|
||||
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
allow_default_when_missing=True,
|
||||
) as tenant:
|
||||
recipe = rewrite_recipe_store.get_recipe(
|
||||
recipe_id, tenant_id=tenant.tenant_id
|
||||
)
|
||||
if recipe is None:
|
||||
return _json({"ok": False, "error": "not_found"}, 404)
|
||||
updated = _build_recipe_from_payload(payload, existing=recipe)
|
||||
rewrite_recipe_store.save_recipe(updated)
|
||||
return _json({"ok": True, "recipe": updated.to_dict()})
|
||||
except TenantBoundaryError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
|
||||
except RecipeValidationError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": exc.detail}, 400)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to update rewrite recipe")
|
||||
return _json({"ok": False, "error": "internal_error", "detail": str(exc)}, 500)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.HIGH,
|
||||
summary="Delete rewrite recipe",
|
||||
description="Delete a workflow rewrite recipe.",
|
||||
audit="rewrite_recipes.delete",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def rewrite_recipe_delete_handler(request: web.Request) -> web.Response:
|
||||
deny = _require_admin(request)
|
||||
if deny:
|
||||
return deny
|
||||
recipe_id = request.match_info.get("recipe_id")
|
||||
if not recipe_id:
|
||||
return _json({"ok": False, "error": "missing_id"}, 400)
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
allow_default_when_missing=True,
|
||||
) as tenant:
|
||||
if not rewrite_recipe_store.delete_recipe(
|
||||
recipe_id, tenant_id=tenant.tenant_id
|
||||
):
|
||||
return _json({"ok": False, "error": "not_found"}, 404)
|
||||
return _json({"ok": True})
|
||||
except TenantBoundaryError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Dry-run rewrite recipe",
|
||||
description="Preview rewrite result and structured diff without applying changes.",
|
||||
audit="rewrite_recipes.dry_run",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def rewrite_recipe_dry_run_handler(request: web.Request) -> web.Response:
|
||||
deny = _require_admin(request)
|
||||
if deny:
|
||||
return deny
|
||||
recipe_id = request.match_info.get("recipe_id")
|
||||
if not recipe_id:
|
||||
return _json({"ok": False, "error": "missing_id"}, 400)
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
return _json({"ok": False, "error": "invalid_json"}, 400)
|
||||
workflow = payload.get("workflow") if isinstance(payload, dict) else None
|
||||
inputs = payload.get("inputs", {}) if isinstance(payload, dict) else {}
|
||||
if not isinstance(workflow, dict):
|
||||
return _json({"ok": False, "error": "missing_workflow"}, 400)
|
||||
if not isinstance(inputs, dict):
|
||||
return _json({"ok": False, "error": "invalid_inputs"}, 400)
|
||||
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
allow_default_when_missing=True,
|
||||
) as tenant:
|
||||
recipe = rewrite_recipe_store.get_recipe(
|
||||
recipe_id, tenant_id=tenant.tenant_id
|
||||
)
|
||||
if recipe is None:
|
||||
return _json({"ok": False, "error": "not_found"}, 404)
|
||||
result = dry_run_recipe(recipe, workflow=workflow, inputs=inputs)
|
||||
return _json({"ok": True, **result})
|
||||
except TenantBoundaryError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
|
||||
except RecipeValidationError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": exc.detail}, 400)
|
||||
except Exception as exc:
|
||||
logger.exception("Rewrite dry-run failed")
|
||||
return _json({"ok": False, "error": "internal_error", "detail": str(exc)}, 500)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.HIGH,
|
||||
summary="Apply rewrite recipe",
|
||||
description="Guarded apply with validation and rollback snapshot on failure.",
|
||||
audit="rewrite_recipes.apply",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def rewrite_recipe_apply_handler(request: web.Request) -> web.Response:
|
||||
deny = _require_admin(request)
|
||||
if deny:
|
||||
return deny
|
||||
recipe_id = request.match_info.get("recipe_id")
|
||||
if not recipe_id:
|
||||
return _json({"ok": False, "error": "missing_id"}, 400)
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
return _json({"ok": False, "error": "invalid_json"}, 400)
|
||||
workflow = payload.get("workflow") if isinstance(payload, dict) else None
|
||||
inputs = payload.get("inputs", {}) if isinstance(payload, dict) else {}
|
||||
confirm = bool(payload.get("confirm")) if isinstance(payload, dict) else False
|
||||
if not isinstance(workflow, dict):
|
||||
return _json({"ok": False, "error": "missing_workflow"}, 400)
|
||||
if not isinstance(inputs, dict):
|
||||
return _json({"ok": False, "error": "invalid_inputs"}, 400)
|
||||
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
allow_default_when_missing=True,
|
||||
) as tenant:
|
||||
recipe = rewrite_recipe_store.get_recipe(
|
||||
recipe_id, tenant_id=tenant.tenant_id
|
||||
)
|
||||
if recipe is None:
|
||||
return _json({"ok": False, "error": "not_found"}, 404)
|
||||
result = guarded_apply_recipe(
|
||||
recipe,
|
||||
workflow=workflow,
|
||||
inputs=inputs,
|
||||
confirm=confirm,
|
||||
)
|
||||
return _json({"ok": True, **result})
|
||||
except TenantBoundaryError as exc:
|
||||
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
|
||||
except RecipeApplyError as exc:
|
||||
return _json(
|
||||
{
|
||||
"ok": False,
|
||||
"error": exc.code,
|
||||
"detail": exc.detail,
|
||||
"rollback_snapshot": exc.rollback_snapshot,
|
||||
},
|
||||
400,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Rewrite apply failed")
|
||||
return _json({"ok": False, "error": "internal_error", "detail": str(exc)}, 500)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
+645
-509
File diff suppressed because it is too large
Load Diff
+60
-15
@@ -3,15 +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
|
||||
|
||||
from ..services.scheduler.models import Schedule, TriggerType
|
||||
from ..services.scheduler.storage import get_schedule_store
|
||||
from ..services.templates import is_template_allowed
|
||||
|
||||
# Import discipline:
|
||||
# - ComfyUI runtime: package-relative imports only (prevents collisions with other custom nodes).
|
||||
# - Unit tests: allow top-level fallbacks.
|
||||
@@ -19,11 +15,48 @@ from ..services.templates import is_template_allowed
|
||||
# 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():
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.scheduler.runner import get_scheduler_runner
|
||||
else: # pragma: no cover (test-only import mode)
|
||||
from services.scheduler.runner import get_scheduler_runner # type: ignore
|
||||
return get_scheduler_runner()
|
||||
|
||||
|
||||
def _get_run_history():
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.scheduler.history import get_run_history
|
||||
else: # pragma: no cover (test-only import mode)
|
||||
from services.scheduler.history import get_run_history # type: ignore
|
||||
return get_run_history()
|
||||
|
||||
|
||||
class ScheduleHandlers:
|
||||
@@ -136,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)
|
||||
|
||||
@@ -188,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:
|
||||
@@ -197,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)
|
||||
|
||||
@@ -278,9 +318,18 @@ class ScheduleHandlers:
|
||||
# Trigger immediate execution via scheduler runner
|
||||
import time
|
||||
|
||||
from ..services.scheduler.runner import get_scheduler_runner
|
||||
runner = _get_scheduler_runner()
|
||||
if runner.is_execution_delegated():
|
||||
# IMPORTANT: in public+split mode, embedded scheduler execution must remain blocked.
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Scheduler execution is delegated to external control plane",
|
||||
"code": "scheduler_delegated",
|
||||
"remediation": "Use external scheduler control plane in split mode.",
|
||||
},
|
||||
status=503,
|
||||
)
|
||||
|
||||
runner = get_scheduler_runner()
|
||||
try:
|
||||
runner._execute_schedule(schedule, time.time())
|
||||
return web.json_response(
|
||||
@@ -309,9 +358,7 @@ class ScheduleHandlers:
|
||||
if not self._store.get(schedule_id):
|
||||
return web.json_response({"error": "Schedule not found"}, status=404)
|
||||
|
||||
from ..services.scheduler.history import get_run_history
|
||||
|
||||
history = get_run_history()
|
||||
history = _get_run_history()
|
||||
limit = int(request.query.get("limit", "100"))
|
||||
offset = int(request.query.get("offset", "0"))
|
||||
status = request.query.get("status")
|
||||
@@ -340,9 +387,7 @@ class ScheduleHandlers:
|
||||
except Exception:
|
||||
return web.json_response({"error": "Unauthorized"}, status=403)
|
||||
|
||||
from ..services.scheduler.history import get_run_history
|
||||
|
||||
history = get_run_history()
|
||||
history = _get_run_history()
|
||||
limit = int(request.query.get("limit", "100"))
|
||||
offset = int(request.query.get("offset", "0"))
|
||||
status = request.query.get("status")
|
||||
|
||||
+148
-18
@@ -15,38 +15,61 @@ 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
|
||||
from ..services.audit import audit_secret_delete, audit_secret_write
|
||||
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
|
||||
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.audit import audit_secret_delete # type: ignore
|
||||
from services.audit import audit_secret_write
|
||||
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 (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
else:
|
||||
from services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.secrets")
|
||||
|
||||
_TRUTHY = ("1", "true", "yes", "on")
|
||||
@@ -62,6 +85,15 @@ def _deny_if_remote_admin_not_allowed(request: web.Request) -> Optional[web.Resp
|
||||
return None
|
||||
remote = request.remote or ""
|
||||
if not is_loopback_client(remote):
|
||||
# R99: audit deny path
|
||||
emit_audit_event(
|
||||
action="secrets.access",
|
||||
target="secrets",
|
||||
outcome="deny",
|
||||
status_code=403,
|
||||
details={"reason": "remote_admin_denied", "remote": remote},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
@@ -76,6 +108,15 @@ def _deny_if_remote_admin_not_allowed(request: web.Request) -> Optional[web.Resp
|
||||
def _require_admin(request: web.Request) -> Optional[web.Response]:
|
||||
allowed, error = require_admin_token(request)
|
||||
if not allowed:
|
||||
# R99: audit deny path
|
||||
emit_audit_event(
|
||||
action="secrets.access",
|
||||
target="secrets",
|
||||
outcome="deny",
|
||||
status_code=403,
|
||||
details={"reason": error or "admin_token_required"},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{"ok": False, "error": error or "Unauthorized"}, status=403
|
||||
)
|
||||
@@ -86,13 +127,23 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.LOW,
|
||||
summary="Get secret status",
|
||||
description="Returns secret configuration status (NO ACTUAL VALUES).",
|
||||
audit="secrets.status",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def secrets_status_handler(request: web.Request) -> web.Response:
|
||||
"""
|
||||
GET /openclaw/secrets/status
|
||||
@@ -128,6 +179,14 @@ async def secrets_status_handler(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.HIGH,
|
||||
summary="Write secret",
|
||||
description="Save API key to server store.",
|
||||
audit="secrets.write",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def secrets_put_handler(request: web.Request) -> web.Response:
|
||||
"""
|
||||
PUT /openclaw/secrets
|
||||
@@ -142,6 +201,16 @@ async def secrets_put_handler(request: web.Request) -> web.Response:
|
||||
- CSRF-protected (same-origin if no token)
|
||||
- Request body NEVER logged
|
||||
"""
|
||||
# S62: Block secrets write 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("secrets_write", request)
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
# S26+: CSRF protection for convenience mode
|
||||
admin_token_configured = bool(get_admin_token())
|
||||
resp = require_same_origin_if_no_token(request, admin_token_configured)
|
||||
@@ -156,6 +225,8 @@ async def secrets_put_handler(request: web.Request) -> web.Response:
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
token_info = resolve_token_info(request)
|
||||
|
||||
resp = _rate_limit_admin(request)
|
||||
if resp:
|
||||
return resp
|
||||
@@ -219,20 +290,43 @@ async def secrets_put_handler(request: web.Request) -> web.Response:
|
||||
# Never log the secret value
|
||||
logger.info(f"S25: Saved secret for provider '{provider}' via UI")
|
||||
|
||||
# S26+: Audit event (no secrets)
|
||||
audit_secret_write(actor_ip, provider, ok=True)
|
||||
emit_audit_event(
|
||||
action="secrets.write",
|
||||
target=provider,
|
||||
outcome="allow",
|
||||
token_info=token_info,
|
||||
status_code=200,
|
||||
details={"actor_ip": actor_ip},
|
||||
request=request,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{"ok": True, "message": f"Secret saved for provider '{provider}'"}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"S25: Failed to save secret: {e}")
|
||||
audit_secret_write(actor_ip, provider, ok=False, error=str(e))
|
||||
emit_audit_event(
|
||||
action="secrets.write",
|
||||
target=provider,
|
||||
outcome="error",
|
||||
token_info=token_info,
|
||||
status_code=500,
|
||||
details={"actor_ip": actor_ip, "error": str(e)},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "save_failed", "message": str(e)}, status=500
|
||||
)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.HIGH,
|
||||
summary="Delete secret",
|
||||
description="Clear provider secret.",
|
||||
audit="secrets.delete",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def secrets_delete_handler(request: web.Request) -> web.Response:
|
||||
"""
|
||||
DELETE /openclaw/secrets/{provider}
|
||||
@@ -244,6 +338,16 @@ async def secrets_delete_handler(request: web.Request) -> web.Response:
|
||||
- Rate-limited
|
||||
- CSRF-protected (same-origin if no token)
|
||||
"""
|
||||
# S62: Block secrets write 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("secrets_write", request)
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
# S26+: CSRF protection for convenience mode
|
||||
admin_token_configured = bool(get_admin_token())
|
||||
resp = require_same_origin_if_no_token(request, admin_token_configured)
|
||||
@@ -258,6 +362,8 @@ async def secrets_delete_handler(request: web.Request) -> web.Response:
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
token_info = resolve_token_info(request)
|
||||
|
||||
resp = _rate_limit_admin(request)
|
||||
if resp:
|
||||
return resp
|
||||
@@ -279,12 +385,28 @@ async def secrets_delete_handler(request: web.Request) -> web.Response:
|
||||
|
||||
if removed:
|
||||
logger.info(f"S25: Cleared secret for provider '{provider}' via UI")
|
||||
audit_secret_delete(actor_ip, provider, ok=True)
|
||||
emit_audit_event(
|
||||
action="secrets.delete",
|
||||
target=provider,
|
||||
outcome="allow",
|
||||
token_info=token_info,
|
||||
status_code=200,
|
||||
details={"actor_ip": actor_ip},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{"ok": True, "message": f"Secret cleared for provider '{provider}'"}
|
||||
)
|
||||
else:
|
||||
audit_secret_delete(actor_ip, provider, ok=False, error="not_found")
|
||||
emit_audit_event(
|
||||
action="secrets.delete",
|
||||
target=provider,
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=404,
|
||||
details={"actor_ip": actor_ip, "reason": "not_found"},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
@@ -295,7 +417,15 @@ async def secrets_delete_handler(request: web.Request) -> web.Response:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"S25: Failed to clear secret: {e}")
|
||||
audit_secret_delete(actor_ip, provider, ok=False, error=str(e))
|
||||
emit_audit_event(
|
||||
action="secrets.delete",
|
||||
target=provider,
|
||||
outcome="error",
|
||||
token_info=token_info,
|
||||
status_code=500,
|
||||
details={"actor_ip": actor_ip, "error": str(e)},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "clear_failed", "message": str(e)}, status=500
|
||||
)
|
||||
|
||||
+35
-4
@@ -41,16 +41,43 @@ 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
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
else:
|
||||
from services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.security_doctor")
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.HIGH, # Can perform remediation actions
|
||||
summary="Security Doctor",
|
||||
description="Run security posture diagnostics and remediation.",
|
||||
audit="security.doctor",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def security_doctor_handler(request: web.Request) -> web.Response:
|
||||
"""
|
||||
GET /openclaw/security/doctor
|
||||
@@ -66,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
|
||||
|
||||
+82
-33
@@ -18,13 +18,43 @@ except ImportError: # pragma: no cover (optional for unit tests)
|
||||
# - ComfyUI runtime: package-relative imports only (prevents collisions with other custom nodes).
|
||||
# - Unit tests: allow top-level fallbacks.
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.access_control import require_observability_access
|
||||
from ..services.rate_limit import check_rate_limit
|
||||
from ..services.access_control import (
|
||||
require_observability_access,
|
||||
resolve_token_info,
|
||||
)
|
||||
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)
|
||||
from services.access_control import require_observability_access # type: ignore
|
||||
from services.rate_limit import check_rate_limit # type: ignore
|
||||
from services.access_control import ( # type: ignore
|
||||
require_observability_access,
|
||||
resolve_token_info,
|
||||
)
|
||||
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,
|
||||
request_tenant_scope,
|
||||
)
|
||||
|
||||
# R98: Endpoint Metadata
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
else:
|
||||
from services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.templates")
|
||||
|
||||
@@ -52,6 +82,14 @@ def _ensure_templates_api_deps_ready() -> tuple[bool, str | None]:
|
||||
return True, None
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.OBSERVABILITY, # Actually guarded by require_observability_access (token/loopback)
|
||||
risk=RiskTier.LOW,
|
||||
summary="List templates",
|
||||
description="Returns templates visible to the backend.",
|
||||
audit="templates.list",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def templates_list_handler(request: web.Request) -> web.Response:
|
||||
"""
|
||||
GET /openclaw/templates (legacy: /moltbot/templates)
|
||||
@@ -73,41 +111,52 @@ 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:
|
||||
svc = get_template_service()
|
||||
items = []
|
||||
# Prefer runtime discovery (file-based templates) so operators don't need
|
||||
# to maintain a separate allowlist file.
|
||||
for template_id in svc.get_debug_info().get("discovered_template_ids", []): # type: ignore[call-arg]
|
||||
cfg = svc.get_template_config(template_id) # type: ignore[arg-type]
|
||||
if cfg is None:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"id": template_id,
|
||||
"allowed_inputs": list(cfg.allowed_inputs or []),
|
||||
"defaults": dict(cfg.defaults or {}),
|
||||
}
|
||||
)
|
||||
items.sort(key=lambda x: x["id"])
|
||||
resp: dict = {"ok": True, "templates": items, "count": len(items)}
|
||||
token_info = resolve_token_info(request)
|
||||
with request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
):
|
||||
items = []
|
||||
# Prefer runtime discovery (file-based templates) so operators don't need
|
||||
# to maintain a separate allowlist file.
|
||||
for template_id in svc.get_debug_info().get("discovered_template_ids", []): # type: ignore[call-arg]
|
||||
cfg = svc.get_template_config(template_id) # type: ignore[arg-type]
|
||||
if cfg is None:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"id": template_id,
|
||||
"allowed_inputs": list(cfg.allowed_inputs or []),
|
||||
"defaults": dict(cfg.defaults or {}),
|
||||
}
|
||||
)
|
||||
items.sort(key=lambda x: x["id"])
|
||||
resp: dict = {"ok": True, "templates": items, "count": len(items)}
|
||||
|
||||
# Optional diagnostics. This reveals absolute paths, so keep it opt-in.
|
||||
debug = request.query.get("debug", "").strip() in ("1", "true", "yes")
|
||||
if debug:
|
||||
try:
|
||||
resp["debug"] = svc.get_debug_info() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
# If TemplateService interface changes, don't break the endpoint.
|
||||
resp["debug"] = {"error": "debug_info_unavailable"}
|
||||
# Optional diagnostics. This reveals absolute paths, so keep it opt-in.
|
||||
debug = request.query.get("debug", "").strip() in ("1", "true", "yes")
|
||||
if debug:
|
||||
try:
|
||||
resp["debug"] = svc.get_debug_info() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
# If TemplateService interface changes, don't break the endpoint.
|
||||
resp["debug"] = {"error": "debug_info_unavailable"}
|
||||
|
||||
return web.json_response(resp)
|
||||
return web.json_response(resp)
|
||||
except TenantBoundaryError as e:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": e.code, "message": str(e)},
|
||||
status=403,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to list templates")
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
+132
-7
@@ -3,21 +3,65 @@ 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
|
||||
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
|
||||
from ..services.tool_runner import get_tool_runner, is_tools_enabled
|
||||
except ImportError:
|
||||
from services.access_control import require_admin_token
|
||||
from services.tool_runner import get_tool_runner, is_tools_enabled
|
||||
(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(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="List tools",
|
||||
description="List allowed external tools.",
|
||||
audit="tools.list",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def tools_list_handler(request: web.Request) -> web.Response:
|
||||
"""
|
||||
GET /openclaw/tools
|
||||
@@ -41,6 +85,14 @@ async def tools_list_handler(request: web.Request) -> web.Response:
|
||||
return web.json_response({"ok": True, "tools": tools})
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.HIGH,
|
||||
summary="Run tool",
|
||||
description="Execute an external tool.",
|
||||
audit="tools.run",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def tools_run_handler(request: web.Request) -> web.Response:
|
||||
"""
|
||||
POST /openclaw/tools/{name}/run
|
||||
@@ -48,18 +100,51 @@ async def tools_run_handler(request: web.Request) -> web.Response:
|
||||
Body: {"args": {"arg1": "val1", ...}}
|
||||
Requires: Admin Token.
|
||||
"""
|
||||
# S62: Block tool execution in public+split mode
|
||||
blocked = check_surface("tool_execution", request)
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
if not is_tools_enabled():
|
||||
emit_audit_event(
|
||||
action="tools.run",
|
||||
target="tools",
|
||||
outcome="deny",
|
||||
status_code=404,
|
||||
details={"reason": "tools_disabled"},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "External tooling is disabled."}, status=404
|
||||
)
|
||||
|
||||
token_info = resolve_token_info(request)
|
||||
|
||||
# Admin check
|
||||
allowed, error = require_admin_token(request)
|
||||
if not allowed:
|
||||
emit_audit_event(
|
||||
action="tools.run",
|
||||
target="tools",
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=403,
|
||||
details={"reason": error or "unauthorized"},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response({"ok": False, "error": error}, status=403)
|
||||
|
||||
tool_name = request.match_info.get("name")
|
||||
if not tool_name:
|
||||
emit_audit_event(
|
||||
action="tools.run",
|
||||
target="unknown",
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=400,
|
||||
details={"reason": "missing_tool_name"},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Tool name required"}, status=400
|
||||
)
|
||||
@@ -67,12 +152,30 @@ async def tools_run_handler(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
emit_audit_event(
|
||||
action="tools.run",
|
||||
target=tool_name,
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=400,
|
||||
details={"reason": "invalid_json"},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Invalid JSON body"}, status=400
|
||||
)
|
||||
|
||||
args = body.get("args", {})
|
||||
if not isinstance(args, dict):
|
||||
emit_audit_event(
|
||||
action="tools.run",
|
||||
target=tool_name,
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=400,
|
||||
details={"reason": "invalid_args_shape"},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "'args' must be a dictionary"}, status=400
|
||||
)
|
||||
@@ -81,6 +184,19 @@ async def tools_run_handler(request: web.Request) -> web.Response:
|
||||
result = runner.execute_tool(tool_name, args)
|
||||
|
||||
if not result.success:
|
||||
emit_audit_event(
|
||||
action="tools.run",
|
||||
target=tool_name,
|
||||
outcome="error",
|
||||
token_info=token_info,
|
||||
status_code=500 if result.error else 400,
|
||||
details={
|
||||
"exit_code": result.exit_code,
|
||||
"error": result.error,
|
||||
"duration_ms": result.duration_ms,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
@@ -93,6 +209,15 @@ async def tools_run_handler(request: web.Request) -> web.Response:
|
||||
status=500 if result.error else 400,
|
||||
) # 500 for runtime error, 400 for validation?
|
||||
|
||||
emit_audit_event(
|
||||
action="tools.run",
|
||||
target=tool_name,
|
||||
outcome="allow",
|
||||
token_info=token_info,
|
||||
status_code=200,
|
||||
details={"duration_ms": result.duration_ms},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
|
||||
+25
-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,17 +23,32 @@ 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,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
from ..services.execution_budgets import BudgetExceededError
|
||||
from ..services.templates import is_template_allowed
|
||||
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,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
from services.execution_budgets import BudgetExceededError # type: ignore
|
||||
from services.templates import is_template_allowed # type: ignore
|
||||
from services.trace import generate_trace_id # type: ignore
|
||||
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 = (
|
||||
@@ -76,6 +91,14 @@ class TriggerHandlers:
|
||||
if not allowed:
|
||||
raise AuthError(error or "Unauthorized")
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.HIGH,
|
||||
summary="Fire trigger",
|
||||
description="Fire an ad-hoc workflow trigger.",
|
||||
audit="triggers.fire",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def fire_trigger(self, request: web.Request) -> web.Response:
|
||||
"""
|
||||
POST /moltbot/triggers/fire
|
||||
|
||||
+78
-29
@@ -9,39 +9,87 @@ 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
|
||||
except ImportError:
|
||||
from services.diagnostics_flags import diagnostics
|
||||
(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(
|
||||
auth=AuthTier.WEBHOOK,
|
||||
risk=RiskTier.HIGH,
|
||||
summary="Webhook submit",
|
||||
description="Authenticated endpoint for external job requests (legacy pipeline).",
|
||||
audit="webhook.submit.legacy",
|
||||
plane=RoutePlane.EXTERNAL,
|
||||
)
|
||||
async def webhook_handler(request: web.Request) -> web.Response:
|
||||
"""
|
||||
POST /moltbot/webhook
|
||||
@@ -51,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:
|
||||
|
||||
+64
-9
@@ -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,27 +15,36 @@ 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
|
||||
from ..services.webhook_auth import require_auth
|
||||
from ..services.webhook_mapping import apply_mapping, resolve_profile # F40
|
||||
from ..services.webhook_mapping import ( # F40/S59
|
||||
apply_mapping,
|
||||
resolve_profile,
|
||||
validate_canonical_schema,
|
||||
)
|
||||
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
|
||||
@@ -45,9 +52,27 @@ else: # pragma: no cover (test-only import mode)
|
||||
from services.webhook_mapping import ( # F40 # type: ignore
|
||||
apply_mapping,
|
||||
resolve_profile,
|
||||
validate_canonical_schema,
|
||||
)
|
||||
|
||||
# R98: Endpoint Metadata
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
else:
|
||||
from services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.webhook_submit")
|
||||
web = import_aiohttp_web()
|
||||
|
||||
|
||||
def safe_error_response(status: int, error: str, detail: str = "") -> web.Response:
|
||||
@@ -57,6 +82,14 @@ def safe_error_response(status: int, error: str, detail: str = "") -> web.Respon
|
||||
return web.json_response(body, status=status)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.WEBHOOK,
|
||||
risk=RiskTier.HIGH,
|
||||
summary="Webhook submit",
|
||||
description="Authenticated endpoint for external job requests (modern pipeline).",
|
||||
audit="webhook.submit",
|
||||
plane=RoutePlane.EXTERNAL,
|
||||
)
|
||||
async def webhook_submit_handler(request: web.Request) -> web.Response:
|
||||
"""
|
||||
POST /moltbot/webhook/submit
|
||||
@@ -68,13 +101,25 @@ async def webhook_submit_handler(request: web.Request) -> web.Response:
|
||||
5. Render Template (F5)
|
||||
6. Submit to Queue (F5)
|
||||
"""
|
||||
# S62: Block webhook 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("webhook_execute", request)
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
# 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:
|
||||
@@ -157,6 +202,16 @@ async def webhook_submit_handler(request: web.Request) -> web.Response:
|
||||
return safe_error_response(400, "mapping_error", str(e))
|
||||
|
||||
# Validate against schema
|
||||
# S59: enforce canonical post-map schema gate before typed parsing.
|
||||
canonical_ok, canonical_errors = validate_canonical_schema(data)
|
||||
if not canonical_ok:
|
||||
metrics.inc("webhook_denied")
|
||||
return safe_error_response(
|
||||
400,
|
||||
"validation_error",
|
||||
"; ".join(canonical_errors),
|
||||
)
|
||||
|
||||
try:
|
||||
job_request = WebhookJobRequest.from_dict(data)
|
||||
normalized = job_request.to_normalized()
|
||||
|
||||
+35
-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
|
||||
@@ -60,6 +63,22 @@ else: # pragma: no cover (test-only import mode)
|
||||
resolve_profile,
|
||||
)
|
||||
|
||||
# R98: Endpoint Metadata
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
else:
|
||||
from services.endpoint_manifest import (
|
||||
AuthTier,
|
||||
RiskTier,
|
||||
RoutePlane,
|
||||
endpoint_metadata,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.webhook_validate")
|
||||
|
||||
PLACEHOLDER_PATTERN = re.compile(r"\{\{[^{}]+\}\}")
|
||||
@@ -72,6 +91,14 @@ def _safe_error_response(status: int, error: str, detail: str = "") -> web.Respo
|
||||
return web.json_response(body, status=status)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.WEBHOOK,
|
||||
risk=RiskTier.LOW,
|
||||
summary="Webhook validate",
|
||||
description="Dry-run validation for webhook requests.",
|
||||
audit="webhook.validate",
|
||||
plane=RoutePlane.EXTERNAL,
|
||||
)
|
||||
async def webhook_validate_handler(request: web.Request) -> web.Response:
|
||||
"""
|
||||
POST /openclaw/webhook/validate (legacy: /moltbot/webhook/validate)
|
||||
@@ -82,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.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 330 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -5,27 +5,68 @@ import time
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Optional
|
||||
|
||||
# R139: centralized env-alias helpers for config surface compatibility.
|
||||
try:
|
||||
from .services.config_layers import (
|
||||
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",
|
||||
"MOLTBOT_LLM_API_KEY",
|
||||
"CLAWDBOT_LLM_API_KEY",
|
||||
)
|
||||
|
||||
def get_first_present_env(keys, *, env=None): # type: ignore
|
||||
env_map = env or os.environ
|
||||
for key in keys:
|
||||
if key in env_map:
|
||||
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:
|
||||
@@ -40,50 +81,129 @@ 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"
|
||||
|
||||
# Environment variable for the API key
|
||||
ENV_API_KEY = "OPENCLAW_LLM_API_KEY"
|
||||
LEGACY_ENV_API_KEY = "MOLTBOT_LLM_API_KEY"
|
||||
LEGACY2_ENV_API_KEY = "CLAWDBOT_LLM_API_KEY"
|
||||
ENV_API_KEY = GENERIC_LLM_API_KEY_ENV_KEYS[0]
|
||||
LEGACY_ENV_API_KEY = GENERIC_LLM_API_KEY_ENV_KEYS[1]
|
||||
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.
|
||||
_LOG_TRUNCATE_APPLIED = False
|
||||
|
||||
|
||||
def _is_env_enabled(*keys: str) -> bool:
|
||||
for key in keys:
|
||||
val = (os.environ.get(key) or "").strip().lower()
|
||||
if val in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _maybe_truncate_log_on_start(logger: logging.Logger) -> None:
|
||||
global _LOG_TRUNCATE_APPLIED
|
||||
if _LOG_TRUNCATE_APPLIED:
|
||||
return
|
||||
if not _is_env_enabled(
|
||||
"OPENCLAW_LOG_TRUNCATE_ON_START", "MOLTBOT_LOG_TRUNCATE_ON_START"
|
||||
):
|
||||
return
|
||||
try:
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
with open(LOG_FILE, "w", encoding="utf-8"):
|
||||
pass
|
||||
logger.info(
|
||||
f"Startup log truncation applied for {LOG_FILE} "
|
||||
"(OPENCLAW_LOG_TRUNCATE_ON_START=1)"
|
||||
)
|
||||
_LOG_TRUNCATE_APPLIED = True
|
||||
except Exception as e:
|
||||
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):
|
||||
"""
|
||||
@@ -104,21 +224,13 @@ 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.
|
||||
"""
|
||||
# Respect explicit empty string overrides by checking env var presence.
|
||||
if ENV_API_KEY in os.environ:
|
||||
return os.environ.get(ENV_API_KEY) or None
|
||||
if LEGACY_ENV_API_KEY in os.environ:
|
||||
return os.environ.get(LEGACY_ENV_API_KEY) or None
|
||||
if LEGACY2_ENV_API_KEY in os.environ:
|
||||
return os.environ.get(LEGACY2_ENV_API_KEY) or None
|
||||
return None
|
||||
value = get_effective_llm_api_key()
|
||||
return value or None
|
||||
|
||||
|
||||
def setup_logger(name: str = "ComfyUI-OpenClaw") -> logging.Logger:
|
||||
@@ -127,9 +239,17 @@ def setup_logger(name: str = "ComfyUI-OpenClaw") -> logging.Logger:
|
||||
Includes both console and file handlers with rotation.
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
# CRITICAL: keep propagate disabled.
|
||||
# If this is changed to True, ComfyUI/root handlers re-emit the same record,
|
||||
# and terminal output regresses to duplicated spam:
|
||||
# [openclaw.LLMClient] WARNING: ...
|
||||
# No API key found for provider ...
|
||||
logger.propagate = False
|
||||
|
||||
# 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 []
|
||||
formatter = RedactedFormatter(
|
||||
@@ -143,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",
|
||||
@@ -161,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")
|
||||
|
||||
+114
-1
@@ -10,8 +10,12 @@ 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
|
||||
from .platforms.telegram_polling import TelegramPolling
|
||||
from .platforms.wechat_webhook import WeChatWebhookServer
|
||||
from .platforms.whatsapp_webhook import WhatsAppWebhookServer
|
||||
@@ -42,6 +46,8 @@ def _print_security_banner(config):
|
||||
or config.whatsapp_allowed_users
|
||||
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)
|
||||
|
||||
@@ -104,6 +110,9 @@ async def main():
|
||||
whatsapp_server = None
|
||||
wechat_server = None
|
||||
kakao_server = None
|
||||
slack_server = None
|
||||
feishu_server = None
|
||||
feishu_long_clients = []
|
||||
|
||||
# 3. Platforms
|
||||
if config.telegram_bot_token:
|
||||
@@ -171,15 +180,113 @@ async def main():
|
||||
else:
|
||||
logger.info("Kakao adapter disabled.")
|
||||
|
||||
if config.slack_bot_token and config.slack_signing_secret:
|
||||
if config.slack_mode == "socket":
|
||||
# CRITICAL: Socket Mode must remain explicit opt-in; do not auto-fallback
|
||||
# from webhook mode or security/ingress assumptions can drift silently.
|
||||
from .platforms.slack_socket_mode import SlackSocketModeClient
|
||||
|
||||
slack_server = SlackSocketModeClient(config, router)
|
||||
elif config.slack_mode == "events":
|
||||
slack_server = SlackWebhookServer(config, router)
|
||||
else:
|
||||
logger.error(
|
||||
"Invalid OPENCLAW_CONNECTOR_SLACK_MODE=%r. Expected 'events' or 'socket'.",
|
||||
config.slack_mode,
|
||||
)
|
||||
slack_server = None
|
||||
logger.error("Slack adapter startup aborted (fail-closed).")
|
||||
# Keep connector alive for other platforms; Slack remains disabled.
|
||||
# If Slack is the only platform, global no-platform guard below will exit.
|
||||
pass
|
||||
|
||||
if slack_server is None:
|
||||
logger.warning("Slack adapter disabled due to invalid mode config.")
|
||||
else:
|
||||
platforms["slack"] = slack_server
|
||||
await slack_server.start()
|
||||
if not tasks:
|
||||
tasks.append(asyncio.create_task(asyncio.sleep(3600 * 24 * 365)))
|
||||
elif config.slack_bot_token:
|
||||
logger.warning("Slack configured but Signing Secret missing. Skipping.")
|
||||
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
|
||||
and not whatsapp_server
|
||||
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 or KAKAO_ENABLED."
|
||||
"No platforms configured! Set TELEGRAM_TOKEN, DISCORD_TOKEN, "
|
||||
"LINE_SECRET, WHATSAPP_ACCESS_TOKEN, WECHAT_TOKEN, "
|
||||
"KAKAO_ENABLED, SLACK_BOT_TOKEN or FEISHU_APP_ID."
|
||||
)
|
||||
await client.close()
|
||||
return
|
||||
@@ -206,6 +313,12 @@ async def main():
|
||||
await wechat_server.stop()
|
||||
if kakao_server:
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
R97 Command Firewall.
|
||||
|
||||
Implements the runtime safety layer for connector chat:
|
||||
- Canonical command parsing (assistant output -> internal structure).
|
||||
- Allowlist/Denylist validation for flags and values.
|
||||
- Normalized safe rendering (internal structure -> user-facing command string).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DANGEROUS_PATTERNS = (r";", r"`", r"\$\(", r"\|")
|
||||
_VALID_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedCommand:
|
||||
command: str
|
||||
args: List[str] = field(default_factory=list)
|
||||
flags: Dict[str, str] = field(default_factory=dict)
|
||||
is_safe: bool = False
|
||||
safety_reason: str = "unvalidated"
|
||||
code: str = "unvalidated"
|
||||
severity: str = "medium"
|
||||
action: str = "deny"
|
||||
|
||||
def to_string(self) -> str:
|
||||
"""Render deterministic safe command string."""
|
||||
parts = [self.command]
|
||||
# Canonical flag order
|
||||
for k in sorted(self.flags.keys()):
|
||||
v = self.flags[k]
|
||||
# Simple quoting heuristic
|
||||
if " " in v or not v:
|
||||
v = f'"{v}"'
|
||||
parts.append(f"{k}={v}")
|
||||
|
||||
# Positional args
|
||||
parts.extend(self.args)
|
||||
return " ".join(parts)
|
||||
|
||||
def to_contract(self) -> Dict[str, str]:
|
||||
return {
|
||||
"code": self.code,
|
||||
"severity": self.severity,
|
||||
"action": self.action,
|
||||
"reason": self.safety_reason,
|
||||
}
|
||||
|
||||
|
||||
class CommandFirewall:
|
||||
"""
|
||||
Validates and normalizes assistant-generated command suggestions.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# TODO: Load policy from config
|
||||
self.allowed_commands = {"/run", "/status", "/help", "/jobs"}
|
||||
self.unsafe_pattern_deny = set(_DANGEROUS_PATTERNS)
|
||||
|
||||
def validate_suggestion(self, raw_suggestion: str) -> NormalizedCommand:
|
||||
"""
|
||||
Parse and validate a raw command string from LLM output.
|
||||
Returns a NormalizedCommand object marked safe or unsafe.
|
||||
"""
|
||||
clean_text = raw_suggestion.strip()
|
||||
|
||||
# 0. Pre-parsing unsafe pattern check (Denylist)
|
||||
for pattern in self.unsafe_pattern_deny:
|
||||
if re.search(pattern, clean_text):
|
||||
return NormalizedCommand(
|
||||
command="error",
|
||||
is_safe=False,
|
||||
safety_reason=f"unsafe_pattern_detected: {pattern}",
|
||||
code="firewall_unsafe_pattern",
|
||||
severity="high",
|
||||
action="deny",
|
||||
)
|
||||
|
||||
# 1. Basic Parse
|
||||
try:
|
||||
lexer = shlex.shlex(clean_text, posix=True)
|
||||
lexer.whitespace_split = True
|
||||
lexer.quotes = '"' # strict double quotes per router contract
|
||||
parts = list(lexer)
|
||||
except ValueError as e:
|
||||
return NormalizedCommand(
|
||||
command="error",
|
||||
is_safe=False,
|
||||
safety_reason=f"parse_error: {str(e)}",
|
||||
code="firewall_parse_error",
|
||||
severity="medium",
|
||||
action="deny",
|
||||
)
|
||||
|
||||
if not parts:
|
||||
return NormalizedCommand(
|
||||
command="",
|
||||
is_safe=False,
|
||||
safety_reason="empty_command",
|
||||
code="firewall_empty_command",
|
||||
severity="medium",
|
||||
action="deny",
|
||||
)
|
||||
|
||||
cmd = parts[0].lower()
|
||||
|
||||
# 2. Allowlist Check
|
||||
if cmd not in self.allowed_commands:
|
||||
return NormalizedCommand(
|
||||
command=cmd,
|
||||
is_safe=False,
|
||||
safety_reason=f"command_not_allowed: {cmd}",
|
||||
code="firewall_command_not_allowed",
|
||||
severity="high",
|
||||
action="deny",
|
||||
)
|
||||
|
||||
# 3. Argument Parsing & Normalization
|
||||
args = parts[1:]
|
||||
clean_args = []
|
||||
flags = {}
|
||||
|
||||
for arg in args:
|
||||
if arg.startswith("-"):
|
||||
if "=" in arg and not arg.startswith("--"):
|
||||
k, v = arg.split("=", 1)
|
||||
if not _VALID_KEY_RE.match(k):
|
||||
return NormalizedCommand(
|
||||
command=cmd,
|
||||
is_safe=False,
|
||||
safety_reason=f"invalid_key: {k}",
|
||||
code="firewall_invalid_key",
|
||||
severity="medium",
|
||||
action="deny",
|
||||
)
|
||||
if len(v) > 1000:
|
||||
return NormalizedCommand(
|
||||
command=cmd,
|
||||
is_safe=False,
|
||||
safety_reason=f"value_too_long: {k}",
|
||||
code="firewall_value_too_long",
|
||||
severity="medium",
|
||||
action="deny",
|
||||
)
|
||||
flags[k] = v
|
||||
elif arg.startswith("--"):
|
||||
clean_args.append(arg)
|
||||
else:
|
||||
clean_args.append(arg)
|
||||
elif "=" in arg:
|
||||
k, v = arg.split("=", 1)
|
||||
if not _VALID_KEY_RE.match(k):
|
||||
return NormalizedCommand(
|
||||
command=cmd,
|
||||
is_safe=False,
|
||||
safety_reason=f"invalid_key: {k}",
|
||||
code="firewall_invalid_key",
|
||||
severity="medium",
|
||||
action="deny",
|
||||
)
|
||||
if len(v) > 1000:
|
||||
return NormalizedCommand(
|
||||
command=cmd,
|
||||
is_safe=False,
|
||||
safety_reason=f"value_too_long: {k}",
|
||||
code="firewall_value_too_long",
|
||||
severity="medium",
|
||||
action="deny",
|
||||
)
|
||||
flags[k] = v
|
||||
else:
|
||||
clean_args.append(arg)
|
||||
|
||||
return NormalizedCommand(
|
||||
command=cmd,
|
||||
args=clean_args,
|
||||
flags=flags,
|
||||
is_safe=True,
|
||||
safety_reason="valid",
|
||||
code="firewall_allow",
|
||||
severity="info",
|
||||
action="allow",
|
||||
)
|
||||
+404
-45
@@ -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,29 +223,87 @@ 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)
|
||||
|
||||
# Slack (F56 / S67)
|
||||
slack_bot_token: Optional[str] = None
|
||||
slack_signing_secret: Optional[str] = None
|
||||
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 = 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)
|
||||
|
||||
# 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
|
||||
@@ -112,6 +313,16 @@ class ConnectorConfig:
|
||||
debug: bool = False
|
||||
state_path: Optional[str] = None
|
||||
|
||||
def __repr__(self):
|
||||
"""R117: redact secret/token/key fields in logs and debug output."""
|
||||
d = self.__dict__.copy()
|
||||
for k in d:
|
||||
if "token" in k or "secret" in k or "key" in k:
|
||||
if d[k]:
|
||||
d[k] = "***REDACTED***"
|
||||
fields = ", ".join(f"{k}={v!r}" for k, v in d.items())
|
||||
return f"{self.__class__.__name__}({fields})"
|
||||
|
||||
|
||||
def load_config() -> ConnectorConfig:
|
||||
"""Load configuration from environment variables."""
|
||||
@@ -125,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
|
||||
@@ -168,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"
|
||||
)
|
||||
@@ -193,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"
|
||||
)
|
||||
@@ -210,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"
|
||||
)
|
||||
@@ -222,39 +454,166 @@ 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"
|
||||
)
|
||||
if ku := os.environ.get("OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS"):
|
||||
cfg.kakao_allowed_users = [u.strip() for u in ku.split(",") if u.strip()]
|
||||
|
||||
# Slack (F56 / S67)
|
||||
cfg.slack_bot_token = os.environ.get("OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN")
|
||||
cfg.slack_signing_secret = os.environ.get("OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET")
|
||||
if su := os.environ.get("OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS"):
|
||||
cfg.slack_allowed_users = [u.strip() for u in su.split(",") if u.strip()]
|
||||
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")
|
||||
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"
|
||||
):
|
||||
cfg.slack_require_mention = False
|
||||
if (
|
||||
os.environ.get("OPENCLAW_CONNECTOR_SLACK_REPLY_IN_THREAD", "").lower()
|
||||
== "false"
|
||||
):
|
||||
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,8 +13,10 @@ 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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,6 +48,7 @@ class LINEWebhookServer:
|
||||
self.runner = None
|
||||
self.site = None
|
||||
self.session = None
|
||||
self._session_invalid = False # R93: Track session validity
|
||||
|
||||
# S32: shared replay guard (replaces inline F32 nonce cache)
|
||||
self._replay_guard = ReplayGuard(
|
||||
@@ -84,6 +87,7 @@ class LINEWebhookServer:
|
||||
f"Starting LINE Webhook on {self.config.line_bind_host}:{self.config.line_bind_port}{self.config.line_webhook_path}"
|
||||
)
|
||||
self.session = aiohttp.ClientSession()
|
||||
self._session_invalid = False # Reset on start
|
||||
|
||||
self.app = web.Application()
|
||||
self.app.router.add_post(self.config.line_webhook_path, self.handle_webhook)
|
||||
@@ -172,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."""
|
||||
@@ -236,6 +240,10 @@ class LINEWebhookServer:
|
||||
|
||||
async def _reply_message(self, reply_token: str, text: str):
|
||||
"""Send reply via LINE Messaging API."""
|
||||
if self._session_invalid:
|
||||
logger.warning("R93: Connector session invalid - blocking outbound")
|
||||
return
|
||||
|
||||
aiohttp, _ = _import_aiohttp_web()
|
||||
if aiohttp is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
@@ -257,6 +265,13 @@ class LINEWebhookServer:
|
||||
# Remediation: Use persistent session
|
||||
try:
|
||||
async with self.session.post(url, headers=headers, json=body) as resp:
|
||||
if RelayResponseClassifier.is_auth_invalid(resp.status):
|
||||
self._session_invalid = True
|
||||
logger.error(
|
||||
f"R93: Auth Invalid (LINE {resp.status}) - Locking session"
|
||||
)
|
||||
return
|
||||
|
||||
if resp.status == 429: # Check Rate Limit first
|
||||
logger.warning("LINE API Rate Limit Hit")
|
||||
elif resp.status != 200:
|
||||
@@ -272,10 +287,15 @@ 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.
|
||||
"""
|
||||
if self._session_invalid:
|
||||
logger.warning("R93: Connector session invalid - blocking outbound image")
|
||||
return
|
||||
|
||||
if not self.config.public_base_url:
|
||||
logger.warning("LINE send_image: No public_base_url configured.")
|
||||
text = (
|
||||
@@ -314,6 +334,9 @@ class LINEWebhookServer:
|
||||
self, channel_id: str, url: str, preview_url: Optional[str] = None
|
||||
):
|
||||
"""Low-level push image."""
|
||||
if self._session_invalid:
|
||||
return
|
||||
|
||||
aiohttp, _ = _import_aiohttp_web()
|
||||
if not self.session:
|
||||
return
|
||||
@@ -336,12 +359,28 @@ class LINEWebhookServer:
|
||||
}
|
||||
|
||||
async with self.session.post(api_url, headers=headers, json=body) as resp:
|
||||
if RelayResponseClassifier.is_auth_invalid(resp.status):
|
||||
self._session_invalid = True
|
||||
logger.error(
|
||||
f"R93: Auth Invalid (LINE {resp.status}) - Locking session"
|
||||
)
|
||||
return
|
||||
|
||||
if resp.status != 200:
|
||||
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")
|
||||
return
|
||||
|
||||
aiohttp, _ = _import_aiohttp_web()
|
||||
if not aiohttp or not self.session:
|
||||
return
|
||||
@@ -359,6 +398,13 @@ class LINEWebhookServer:
|
||||
|
||||
try:
|
||||
async with self.session.post(url, headers=headers, json=body) as resp:
|
||||
if RelayResponseClassifier.is_auth_invalid(resp.status):
|
||||
self._session_invalid = True
|
||||
logger.error(
|
||||
f"R93: Auth Invalid (LINE {resp.status}) - Locking session"
|
||||
)
|
||||
return
|
||||
|
||||
if resp.status != 200:
|
||||
err = await resp.text()
|
||||
logger.error(f"LINE send_message failed: {resp.status} {err}")
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
F57 -- Slack Socket Mode Adapter.
|
||||
|
||||
Implements Slack Socket Mode ingress and reuses Slack webhook processing logic
|
||||
to keep transport behavior parity.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from ..config import ConnectorConfig
|
||||
from ..router import CommandRouter
|
||||
from .slack_webhook import SlackWebhookServer, _import_aiohttp_web
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SlackSocketModeClient(SlackWebhookServer):
|
||||
"""
|
||||
Socket Mode implementation for Slack.
|
||||
Uses `apps.connections.open` to establish a websocket connection.
|
||||
"""
|
||||
|
||||
def __init__(self, config: ConnectorConfig, router: CommandRouter):
|
||||
super().__init__(config, router)
|
||||
self.ws_task: Optional[asyncio.Task] = None
|
||||
self.should_stop = False
|
||||
|
||||
async def start(self):
|
||||
aiohttp, _ = _import_aiohttp_web()
|
||||
if aiohttp is None:
|
||||
logger.warning("aiohttp not installed. Skipping Slack Socket Mode.")
|
||||
return
|
||||
|
||||
# CRITICAL: Socket Mode must fail closed when app token is absent/invalid.
|
||||
if not self.config.slack_app_token:
|
||||
logger.error(
|
||||
"Slack Socket Mode enabled but OPENCLAW_CONNECTOR_SLACK_APP_TOKEN "
|
||||
"missing. Set it to an xapp- token."
|
||||
)
|
||||
return
|
||||
|
||||
if not self.config.slack_app_token.startswith("xapp-"):
|
||||
logger.error("Invalid Slack App Token (must start with xapp-).")
|
||||
return
|
||||
|
||||
logger.info("Starting Slack Socket Mode client...")
|
||||
self.should_stop = False
|
||||
self.ws_task = asyncio.create_task(self._run_socket_mode_loop(aiohttp))
|
||||
|
||||
async def stop(self):
|
||||
self.should_stop = True
|
||||
if self.ws_task:
|
||||
self.ws_task.cancel()
|
||||
try:
|
||||
await self.ws_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await super().stop()
|
||||
|
||||
async def _run_socket_mode_loop(self, aiohttp):
|
||||
retry_delay = 1
|
||||
while not self.should_stop:
|
||||
try:
|
||||
wss_url = await self._get_wss_url(aiohttp)
|
||||
if not wss_url:
|
||||
await asyncio.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, 60)
|
||||
continue
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.ws_connect(wss_url) as ws:
|
||||
logger.info("Slack Socket Mode connected.")
|
||||
retry_delay = 1
|
||||
|
||||
async for msg in ws:
|
||||
if self.should_stop:
|
||||
break
|
||||
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
await self._handle_socket_message(ws, msg.data)
|
||||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||
logger.warning(f"Socket error: {msg.data}")
|
||||
break
|
||||
|
||||
logger.info("Slack Socket Mode disconnected.")
|
||||
except Exception as e:
|
||||
logger.error(f"Slack Socket Mode loop error: {e}")
|
||||
if self.should_stop:
|
||||
break
|
||||
await asyncio.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, 60)
|
||||
|
||||
async def _get_wss_url(self, aiohttp) -> Optional[str]:
|
||||
url = "https://slack.com/api/apps.connections.open"
|
||||
headers = {"Authorization": f"Bearer {self.config.slack_app_token}"}
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"Failed to open connection: status={resp.status}")
|
||||
return None
|
||||
data = await resp.json()
|
||||
if not data.get("ok"):
|
||||
logger.error(f"Connection open failed: {data.get('error')}")
|
||||
return None
|
||||
return data.get("url")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch WSS URL: {e}")
|
||||
return None
|
||||
|
||||
async def _handle_socket_message(self, ws, data: str):
|
||||
try:
|
||||
payload = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
|
||||
envelope_id = payload.get("envelope_id")
|
||||
if envelope_id:
|
||||
await ws.send_json({"envelope_id": envelope_id})
|
||||
|
||||
msg_type = payload.get("type")
|
||||
if msg_type == "hello":
|
||||
logger.debug("Socket Mode hello received.")
|
||||
elif msg_type == "disconnect":
|
||||
logger.warning("Slack requested disconnect. Reconnecting...")
|
||||
elif msg_type == "events_api":
|
||||
inner_payload = payload.get("payload", {})
|
||||
# IMPORTANT: use shared processing path to prevent webhook/socket drift.
|
||||
await self.process_event_payload(inner_payload)
|
||||
elif msg_type == "slash_commands":
|
||||
# Out of scope for F57 closeout.
|
||||
pass
|
||||
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
Slack Events API Webhook Adapter (F56).
|
||||
|
||||
Implements:
|
||||
- Events API POST ingress with ``url_verification`` challenge response.
|
||||
- Slack request authenticity via ``X-Slack-Signature`` + ``X-Slack-Request-Timestamp``
|
||||
(``v0:{ts}:{raw_body}`` HMAC-SHA256).
|
||||
- Replay / duplicate guard (event_id + timestamp window).
|
||||
- ``message`` / ``app_mention`` event normalization with de-duplication
|
||||
(avoid double-trigger when bot is mentioned in a regular message).
|
||||
- CommandRequest conversion -> CommandRouter.
|
||||
- Slack Web API thread or channel reply.
|
||||
|
||||
S67 Safety Profile:
|
||||
- AllowlistPolicy for users and channels (fail-closed when configured).
|
||||
- Bot-loop prevention (ignore messages from bot itself).
|
||||
- Rate-limit delegation to CommandRouter (R80 authz + F32 rate limiter).
|
||||
- Require-mention policy for group conversations.
|
||||
|
||||
Setup:
|
||||
1. Create a Slack App at https://api.slack.com/apps.
|
||||
2. Enable Events API; set Request URL to ``https://<host>/slack/events``.
|
||||
3. Subscribe to ``message.channels``, ``message.groups``, ``message.im``,
|
||||
``app_mention`` bot events.
|
||||
4. Install app to workspace; copy Bot Token and Signing Secret.
|
||||
5. Set env vars:
|
||||
- ``OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN``
|
||||
- ``OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET``
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ..config import ConnectorConfig
|
||||
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) -----------
|
||||
|
||||
|
||||
def _import_aiohttp_web():
|
||||
try:
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
except ModuleNotFoundError:
|
||||
return None, None
|
||||
return aiohttp, web
|
||||
|
||||
|
||||
class _CompatResponse:
|
||||
"""Minimal response shim for unit tests when aiohttp is unavailable."""
|
||||
|
||||
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, *, 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 _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).
|
||||
SLACK_TIMESTAMP_MAX_DRIFT_SEC = 300
|
||||
SLACK_SIGNING_VERSION = "v0"
|
||||
|
||||
|
||||
def verify_slack_signature(
|
||||
*,
|
||||
signing_secret: str,
|
||||
timestamp: str,
|
||||
body: bytes,
|
||||
signature: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Verify Slack ``X-Slack-Signature`` using ``v0:{ts}:{body}`` HMAC-SHA256.
|
||||
|
||||
Fail-closed: returns False on any missing/invalid input.
|
||||
"""
|
||||
if not signing_secret or not timestamp or not signature:
|
||||
return False
|
||||
|
||||
# Timestamp freshness check
|
||||
try:
|
||||
ts_int = int(timestamp)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
if abs(time.time() - ts_int) > SLACK_TIMESTAMP_MAX_DRIFT_SEC:
|
||||
return False
|
||||
|
||||
# Compute expected signature
|
||||
sig_basestring = f"{SLACK_SIGNING_VERSION}:{timestamp}:{body.decode('utf-8')}"
|
||||
expected = (
|
||||
SLACK_SIGNING_VERSION
|
||||
+ "="
|
||||
+ hmac.new(
|
||||
signing_secret.encode("utf-8"),
|
||||
sig_basestring.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
)
|
||||
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
# -- Slack adapter ----------------------------------------------------------
|
||||
|
||||
|
||||
class SlackWebhookServer(
|
||||
SlackInstallationMixin,
|
||||
SlackIngressMixin,
|
||||
SlackDeliveryMixin,
|
||||
):
|
||||
"""
|
||||
F56 -- Slack Events API adapter.
|
||||
|
||||
Security invariants (S67 / R124):
|
||||
- CRITICAL: Reject unsigned or replay requests (fail-closed).
|
||||
- CRITICAL: Ignore bot's own messages (bot-loop prevention).
|
||||
- IMPORTANT: Deduplicate ``message`` + ``app_mention`` for the same event
|
||||
to prevent double command execution.
|
||||
- IMPORTANT: Respect ``require_mention`` policy for group channels.
|
||||
"""
|
||||
|
||||
REPLAY_WINDOW_SEC = 300
|
||||
NONCE_CACHE_SIZE = 5000
|
||||
|
||||
def __init__(self, config: ConnectorConfig, router: CommandRouter):
|
||||
self.config = config
|
||||
self.router = router
|
||||
self.app = None
|
||||
self.runner = None
|
||||
self.site = None
|
||||
|
||||
# S67: Replay / dedupe guard keyed by Slack event_id
|
||||
self._replay_guard = ReplayGuard(
|
||||
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 # 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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start(self):
|
||||
aiohttp, web = _import_aiohttp_web()
|
||||
if aiohttp is None or web is None:
|
||||
logger.warning("aiohttp not installed. Skipping Slack adapter.")
|
||||
return
|
||||
|
||||
if not self.config.slack_signing_secret:
|
||||
logger.info(
|
||||
"Slack adapter disabled "
|
||||
"(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
|
||||
|
||||
logger.info(
|
||||
f"Starting Slack Webhook on "
|
||||
f"{self.config.slack_bind_host}:{self.config.slack_bind_port}"
|
||||
f"{self.config.slack_webhook_path}"
|
||||
)
|
||||
|
||||
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()
|
||||
self.site = web.TCPSite(
|
||||
self.runner, self.config.slack_bind_host, self.config.slack_bind_port
|
||||
)
|
||||
await self.site.start()
|
||||
|
||||
async def stop(self):
|
||||
if self.site:
|
||||
await self.site.stop()
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handler
|
||||
# ------------------------------------------------------------------
|
||||
@@ -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,10 +32,14 @@ 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
|
||||
from ..security_profile import AllowlistPolicy, ReplayGuard
|
||||
from ..transport_contract import RelayResponseClassifier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -264,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
|
||||
@@ -433,6 +445,7 @@ class WeChatWebhookServer:
|
||||
self._user_allowlist = AllowlistPolicy(
|
||||
config.wechat_allowed_users, strict=False
|
||||
)
|
||||
self._session_invalid = False # R93: Track session validity
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
@@ -459,6 +472,7 @@ class WeChatWebhookServer:
|
||||
)
|
||||
|
||||
self.session = aiohttp.ClientSession()
|
||||
self._session_invalid = False # Reset on start
|
||||
|
||||
self.app = web.Application()
|
||||
self.app.router.add_get(self.config.wechat_webhook_path, self.handle_verify)
|
||||
@@ -677,13 +691,22 @@ 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.
|
||||
|
||||
Requires service account with customer service permission.
|
||||
Falls back silently if access_token unavailable.
|
||||
"""
|
||||
if self._session_invalid:
|
||||
logger.warning("R93: Connector session invalid - blocking outbound")
|
||||
return
|
||||
|
||||
if not self.session:
|
||||
return
|
||||
|
||||
@@ -706,6 +729,13 @@ class WeChatWebhookServer:
|
||||
|
||||
try:
|
||||
async with self.session.post(url, json=body) as resp:
|
||||
if RelayResponseClassifier.is_auth_invalid(resp.status):
|
||||
self._session_invalid = True
|
||||
logger.error(
|
||||
f"R93: Auth Invalid (WeChat {resp.status}) - Locking session"
|
||||
)
|
||||
return
|
||||
|
||||
data = await resp.json(content_type=None)
|
||||
errcode = data.get("errcode", 0)
|
||||
if errcode != 0:
|
||||
@@ -722,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.
|
||||
@@ -729,6 +760,10 @@ class WeChatWebhookServer:
|
||||
Text-first: sends caption/notification text. Actual media upload
|
||||
requires media API and is not implemented in phase 1.
|
||||
"""
|
||||
if self._session_invalid:
|
||||
logger.warning("R93: Connector session invalid - blocking outbound image")
|
||||
return
|
||||
|
||||
if caption:
|
||||
await self.send_message(channel_id, caption)
|
||||
else:
|
||||
@@ -754,6 +789,10 @@ class WeChatWebhookServer:
|
||||
if self._cached_token and now < self._token_expires:
|
||||
return self._cached_token
|
||||
|
||||
# R93: Block if session invalid
|
||||
if self._session_invalid:
|
||||
return None
|
||||
|
||||
app_id = self.config.wechat_app_id
|
||||
app_secret = self.config.wechat_app_secret
|
||||
if not app_id or not app_secret:
|
||||
@@ -770,6 +809,13 @@ class WeChatWebhookServer:
|
||||
if not self.session:
|
||||
return None
|
||||
async with self.session.get(url) as resp:
|
||||
if RelayResponseClassifier.is_auth_invalid(resp.status):
|
||||
self._session_invalid = True
|
||||
logger.error(
|
||||
f"R93: Auth Invalid (WeChat Token {resp.status}) - Locking session"
|
||||
)
|
||||
return None
|
||||
|
||||
data = await resp.json(content_type=None)
|
||||
token = data.get("access_token")
|
||||
expires_in = data.get("expires_in", 7200)
|
||||
|
||||
@@ -18,8 +18,10 @@ 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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -62,6 +64,7 @@ class WhatsAppWebhookServer:
|
||||
self.runner = None
|
||||
self.site = None
|
||||
self.session = None
|
||||
self._session_invalid = False # R93: Track session validity
|
||||
|
||||
# S32: shared replay guard (replaces inline F32 nonce cache)
|
||||
self._replay_guard = ReplayGuard(
|
||||
@@ -104,6 +107,7 @@ class WhatsAppWebhookServer:
|
||||
f"{self.config.whatsapp_webhook_path}"
|
||||
)
|
||||
self.session = aiohttp.ClientSession()
|
||||
self._session_invalid = False # Reset on start
|
||||
|
||||
self.app = web.Application()
|
||||
self.app.router.add_get(self.config.whatsapp_webhook_path, self.handle_verify)
|
||||
@@ -216,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
|
||||
@@ -293,8 +297,17 @@ 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")
|
||||
return
|
||||
|
||||
if not self.session:
|
||||
return
|
||||
|
||||
@@ -318,6 +331,13 @@ class WhatsAppWebhookServer:
|
||||
|
||||
try:
|
||||
async with self.session.post(url, headers=headers, json=body) as resp:
|
||||
if RelayResponseClassifier.is_auth_invalid(resp.status):
|
||||
self._session_invalid = True
|
||||
logger.error(
|
||||
f"R93: Auth Invalid (WhatsApp {resp.status}) - Locking session"
|
||||
)
|
||||
return
|
||||
|
||||
if resp.status == 429:
|
||||
logger.warning("WhatsApp API Rate Limit Hit")
|
||||
elif resp.status not in (200, 201):
|
||||
@@ -336,11 +356,16 @@ 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.
|
||||
Reuses F33 media store to host the image, then sends a link message.
|
||||
"""
|
||||
if self._session_invalid:
|
||||
logger.warning("R93: Connector session invalid - blocking outbound image")
|
||||
return
|
||||
|
||||
if not self.config.public_base_url:
|
||||
logger.warning("WhatsApp send_image: No public_base_url configured.")
|
||||
text = (
|
||||
@@ -372,6 +397,9 @@ class WhatsAppWebhookServer:
|
||||
caption: Optional[str] = None,
|
||||
):
|
||||
"""Send image message via Graph API /messages endpoint."""
|
||||
if self._session_invalid:
|
||||
return
|
||||
|
||||
if not self.session:
|
||||
return
|
||||
|
||||
@@ -395,6 +423,13 @@ class WhatsAppWebhookServer:
|
||||
|
||||
try:
|
||||
async with self.session.post(url, headers=headers, json=body) as resp:
|
||||
if RelayResponseClassifier.is_auth_invalid(resp.status):
|
||||
self._session_invalid = True
|
||||
logger.error(
|
||||
f"R93: Auth Invalid (WhatsApp {resp.status}) - Locking session"
|
||||
)
|
||||
return
|
||||
|
||||
if resp.status not in (200, 201):
|
||||
err = await resp.text()
|
||||
logger.error(f"WhatsApp image send failed: {resp.status} {err}")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
+24
-758
@@ -3,26 +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 .llm_client import LLMClient
|
||||
from .prompts import CHAT_STATUS_PROMPT, CHAT_SYSTEM_PROMPT
|
||||
from .command_firewall import CommandFirewall
|
||||
from .rate_limiter import RateLimiter
|
||||
|
||||
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,
|
||||
@@ -33,755 +42,12 @@ 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,
|
||||
channel_rpm=self.config.rate_limit_channel_rpm,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
return CommandResponse(
|
||||
text=f"Trace {args[0]}: {str(res.get('data'))[: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: {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."""
|
||||
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level=trust_level)
|
||||
response = await llm.chat(system_prompt, message)
|
||||
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>"
|
||||
)
|
||||
|
||||
# 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)
|
||||
return CommandResponse(text=response)
|
||||
|
||||
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)
|
||||
# S44/R97: Semantic Guards
|
||||
self.semantic_guard = SemanticGuard()
|
||||
self.command_firewall = CommandFirewall()
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
S44 Semantic Guard Core.
|
||||
|
||||
Implements semantic policy controls for connector chat:
|
||||
- intent classification and gating.
|
||||
- risk scoring for injection/jailbreak patterns.
|
||||
- structured output and SAFE_REPLY sanitization.
|
||||
"""
|
||||
|
||||
import enum
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CODE_BLOCK_RE = re.compile(r"```(?:[a-zA-Z0-9_+-]+)?\s*(.*?)\s*```", re.DOTALL)
|
||||
_COMMAND_LINE_RE = re.compile(r"(?m)^\s*/[a-zA-Z0-9_-]+(?:\s+.*)?$")
|
||||
_DANGEROUS_TOKEN_RE = re.compile(r"[;|`]|\$\(")
|
||||
|
||||
|
||||
class GuardMode(enum.Enum):
|
||||
OFF = "off"
|
||||
AUDIT = "audit"
|
||||
ENFORCE = "enforce"
|
||||
|
||||
|
||||
class GuardAction(enum.Enum):
|
||||
ALLOW = "allow"
|
||||
SAFE_REPLY = "safe_reply_only"
|
||||
FORCE_APPROVAL = "force_approval"
|
||||
DENY = "deny"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GuardDecision:
|
||||
action: GuardAction
|
||||
risk_score: float
|
||||
reason: str
|
||||
code: str = "semantic_allow"
|
||||
severity: str = "info"
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_contract(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"code": self.code,
|
||||
"severity": self.severity,
|
||||
"action": self.action.value,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
class IntentGate:
|
||||
"""Classifies user intent from chat messages."""
|
||||
|
||||
_EXPLICIT_SUBCOMMANDS = {"run", "template", "status"}
|
||||
|
||||
def classify(self, message: str) -> str:
|
||||
msg = (message or "").lower().strip()
|
||||
|
||||
if msg.startswith("/chat "):
|
||||
parts = msg.split(" ", 2)
|
||||
if len(parts) > 1 and parts[1] in self._EXPLICIT_SUBCOMMANDS:
|
||||
return parts[1]
|
||||
|
||||
if any(k in msg for k in ("generate", "create", "make", "draw", "run")):
|
||||
return "run"
|
||||
if any(k in msg for k in ("status", "health", "queue", "jobs")):
|
||||
return "status"
|
||||
if any(k in msg for k in ("template", "json", "workflow")):
|
||||
return "template"
|
||||
return "general"
|
||||
|
||||
|
||||
class RiskScorer:
|
||||
"""Scores message risk against adversarial patterns."""
|
||||
|
||||
_JAILBREAK_PATTERNS = (
|
||||
"ignore previous",
|
||||
"ignore all",
|
||||
"system prompt",
|
||||
"developer message",
|
||||
"override policy",
|
||||
)
|
||||
|
||||
def score(self, message: str) -> Tuple[float, List[str]]:
|
||||
msg = (message or "").lower()
|
||||
score = 0.0
|
||||
reasons: List[str] = []
|
||||
|
||||
if any(p in msg for p in self._JAILBREAK_PATTERNS):
|
||||
score += 0.8
|
||||
reasons.append("jailbreak_pattern")
|
||||
|
||||
if _DANGEROUS_TOKEN_RE.search(msg):
|
||||
score += 0.5
|
||||
reasons.append("shell_injection_char")
|
||||
|
||||
if len(message or "") > 2000:
|
||||
score += 0.3
|
||||
reasons.append("excessive_length")
|
||||
|
||||
return min(score, 1.0), reasons
|
||||
|
||||
|
||||
class SemanticGuard:
|
||||
"""Main entry point for semantic policy enforcement."""
|
||||
|
||||
def __init__(self, mode: str = "enforce", risk_threshold: float = 0.7):
|
||||
self.mode = GuardMode(mode.lower())
|
||||
self.risk_threshold = risk_threshold
|
||||
self.intent_gate = IntentGate()
|
||||
self.risk_scorer = RiskScorer()
|
||||
|
||||
def evaluate_request(self, message: str, context: Dict[str, Any]) -> GuardDecision:
|
||||
if self.mode == GuardMode.OFF:
|
||||
return GuardDecision(
|
||||
action=GuardAction.ALLOW,
|
||||
risk_score=0.0,
|
||||
reason="guard_off",
|
||||
code="semantic_guard_off",
|
||||
severity="info",
|
||||
)
|
||||
|
||||
intent = self.intent_gate.classify(message)
|
||||
risk_score, risk_reasons = self.risk_scorer.score(message)
|
||||
reasons_joined = ", ".join(risk_reasons) if risk_reasons else "none"
|
||||
|
||||
action = GuardAction.ALLOW
|
||||
reason = "safe"
|
||||
code = "semantic_allow"
|
||||
severity = "info"
|
||||
|
||||
if risk_score >= self.risk_threshold:
|
||||
action = GuardAction.DENY
|
||||
reason = f"risk_threshold_exceeded: {reasons_joined}"
|
||||
code = "semantic_risk_high"
|
||||
severity = "high"
|
||||
elif 0.4 <= risk_score < self.risk_threshold:
|
||||
if intent == "run":
|
||||
action = GuardAction.FORCE_APPROVAL
|
||||
reason = f"risk_elevated: {reasons_joined}"
|
||||
code = "semantic_risk_medium_force_approval"
|
||||
severity = "medium"
|
||||
else:
|
||||
action = GuardAction.SAFE_REPLY
|
||||
reason = f"risk_elevated_safety_enforced: {reasons_joined}"
|
||||
code = "semantic_risk_medium_safe_reply"
|
||||
severity = "medium"
|
||||
|
||||
if self.mode == GuardMode.AUDIT:
|
||||
logger.info(
|
||||
"S44 audit decision: action=%s score=%.2f reason=%s",
|
||||
action.value,
|
||||
risk_score,
|
||||
reason,
|
||||
)
|
||||
return GuardDecision(
|
||||
action=GuardAction.ALLOW,
|
||||
risk_score=risk_score,
|
||||
reason=f"audit_mode_({reason})",
|
||||
code="semantic_audit_observe",
|
||||
severity="info",
|
||||
metadata={
|
||||
"intent": intent,
|
||||
"risk_reasons": list(risk_reasons),
|
||||
"would_action": action.value,
|
||||
"trust": context.get("trust"),
|
||||
},
|
||||
)
|
||||
|
||||
return GuardDecision(
|
||||
action=action,
|
||||
risk_score=risk_score,
|
||||
reason=reason,
|
||||
code=code,
|
||||
severity=severity,
|
||||
metadata={
|
||||
"intent": intent,
|
||||
"risk_reasons": list(risk_reasons),
|
||||
"trust": context.get("trust"),
|
||||
},
|
||||
)
|
||||
|
||||
def validate_output(
|
||||
self,
|
||||
response_text: str,
|
||||
intent: str,
|
||||
action: GuardAction = GuardAction.ALLOW,
|
||||
) -> str:
|
||||
if self.mode == GuardMode.OFF:
|
||||
return response_text
|
||||
|
||||
text = response_text or ""
|
||||
|
||||
if text.count("```") % 2 != 0:
|
||||
raise ValueError("unclosed_code_block")
|
||||
|
||||
if intent == "run":
|
||||
cmd = self._extract_command_candidate(text)
|
||||
if not cmd:
|
||||
raise ValueError("run_output_missing_command")
|
||||
|
||||
if action == GuardAction.SAFE_REPLY:
|
||||
return self._sanitize_safe_reply(text)
|
||||
|
||||
return text
|
||||
|
||||
def _extract_command_candidate(self, text: str) -> str:
|
||||
match = _CODE_BLOCK_RE.search(text)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return text.strip()
|
||||
|
||||
def _sanitize_safe_reply(self, text: str) -> str:
|
||||
# CRITICAL: SAFE_REPLY must remove executable hints to preserve no-auto-exec invariants.
|
||||
sanitized = _CODE_BLOCK_RE.sub("[command removed by policy]", text)
|
||||
sanitized = _COMMAND_LINE_RE.sub("[command removed by policy]", sanitized)
|
||||
return sanitized.strip()
|
||||
@@ -685,6 +685,49 @@ class RetryPolicy:
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R93 — Relay Response Classifier (session invalidation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RelayStatus(str, enum.Enum):
|
||||
"""Classification of relay/outbound HTTP response status."""
|
||||
|
||||
OK = "ok"
|
||||
TRANSIENT = "transient" # retriable (408, 429, 5xx)
|
||||
AUTH_INVALID = "auth_invalid" # 401/410 — credential revoked/expired
|
||||
SERVER_ERROR = "server_error" # non-retriable server error
|
||||
|
||||
|
||||
class RelayResponseClassifier:
|
||||
"""
|
||||
Classify HTTP response codes for connector relay/send paths.
|
||||
|
||||
auth_invalid (401, 410) signals permanent credential failure.
|
||||
Connector platforms should stop retrying and mark the session
|
||||
as invalid (require re-pair) when auth_invalid is returned.
|
||||
"""
|
||||
|
||||
AUTH_INVALID_CODES: frozenset = frozenset({401, 410})
|
||||
TRANSIENT_CODES: frozenset = frozenset({408, 429, 500, 502, 503, 504})
|
||||
|
||||
@classmethod
|
||||
def classify(cls, status_code: int) -> RelayStatus:
|
||||
"""Classify an HTTP status code."""
|
||||
if 200 <= status_code < 300:
|
||||
return RelayStatus.OK
|
||||
if status_code in cls.AUTH_INVALID_CODES:
|
||||
return RelayStatus.AUTH_INVALID
|
||||
if status_code in cls.TRANSIENT_CODES:
|
||||
return RelayStatus.TRANSIENT
|
||||
return RelayStatus.SERVER_ERROR
|
||||
|
||||
@classmethod
|
||||
def is_auth_invalid(cls, status_code: int) -> bool:
|
||||
"""Return True if status code indicates credential invalidation."""
|
||||
return status_code in cls.AUTH_INVALID_CODES
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error Envelope (normalized across all transports)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"version": 1,
|
||||
"default_profile": "SDXL-v1",
|
||||
"profiles": [
|
||||
{
|
||||
"id": "SDXL-v1",
|
||||
"version": "1.0",
|
||||
"label": "SDXL 1.0 Base",
|
||||
"description": "Standard SDXL profile",
|
||||
"prompt_guidance": "Width/height should target SDXL-friendly resolutions such as 1024x1024. Keep CFG around 7.0 and steps around 20-30 unless requirements strongly justify a deviation.",
|
||||
"defaults": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"steps": 24,
|
||||
"cfg": 7.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "Flux-Dev",
|
||||
"version": "1.0",
|
||||
"label": "Flux Dev",
|
||||
"description": "Flux Dev profile (high steps, lower cfg)",
|
||||
"prompt_guidance": "Flux variants generally prefer lower CFG than SDXL. Keep CFG around 1.0-4.0 and use moderately higher steps only when needed.",
|
||||
"defaults": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"steps": 28,
|
||||
"cfg": 3.5,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
You are an expert stable diffusion prompt engineer.
|
||||
Your goal is to generate a detailed JSON plan for an image generation job based on the user's requirements.
|
||||
|
||||
Output strict JSON only. No markdown fences.
|
||||
Expected JSON structure:
|
||||
{
|
||||
"positive_prompt": "string",
|
||||
"negative_prompt": "string",
|
||||
"params": {
|
||||
"width": int,
|
||||
"height": int,
|
||||
"steps": int,
|
||||
"cfg": float,
|
||||
"sampler_name": "euler" | "dpmpp_2m" | "...",
|
||||
"scheduler": "normal" | "karras" | "..."
|
||||
}
|
||||
}
|
||||
|
||||
Constraint Guidelines for {{profile_id}} ({{profile_label}}):
|
||||
- {{profile_description}}
|
||||
- {{prompt_guidance}}
|
||||
- Preferred defaults JSON: {{defaults_json}}
|
||||
|
||||
Never return commentary outside the JSON object.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Release Checklist (DoD)
|
||||
|
||||
This document contains the authoritative checklist for releasing **ComfyUI-OpenClaw**.
|
||||
A release candidate must pass **Gate A** to be considered for Public Release v1.
|
||||
If the deployment enables remote control or bridge features, it must also pass **Gate B**.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The validation workflow in `tests/TEST_SOP.md` is **mandatory** for all releases.
|
||||
|
||||
---
|
||||
|
||||
## Gate A: Public Release v1 Baseline (Required)
|
||||
|
||||
**Goal**: Safe-by-default for internet-exposed deployments (assuming they follow the deployment recipes in `docs/deploy/`).
|
||||
|
||||
### 1. Security & configuration
|
||||
|
||||
- [ ] **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/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 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
|
||||
|
||||
- [ ] **Deployment**: `docs/deploy/` contains recipes for:
|
||||
- [ ] Local-only (Default)
|
||||
- [ ] Tailscale (Recommended Remote)
|
||||
- [ ] LAN (Restricted)
|
||||
- [ ] **Security**: [SECURITY.md](SECURITY.md) is up-to-date and linked from README.
|
||||
- [ ] **Feature Flags**: `docs/release/feature_flags.md` accurately reflects the codebase defaults.
|
||||
|
||||
### 3. Validation (Must Pass)
|
||||
|
||||
Run the complete OS-specific regression gate:
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
powershell -File scripts/run_full_tests_windows.ps1
|
||||
```
|
||||
|
||||
```bash
|
||||
# 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)
|
||||
|
||||
**Goal**: Safe operation when `OPENCLAW_BRIDGE_ENABLED=1` or remote commands are active.
|
||||
|
||||
- [ ] **Explicit Enable**: Bridge features are off unless `OPENCLAW_BRIDGE_ENABLED=1` is set.
|
||||
- [ ] **Auth**: Bridge endpoints require `OPENCLAW_BRIDGE_DEVICE_TOKEN` (legacy alias supported) and device pairing/scope checks.
|
||||
- [ ] **CSRF**: State-changing endpoints (admin/bridge) enforce Origin checks or require Token on loopback.
|
||||
- [ ] **Callback Safety**: Delivery targets are validated against DNS/IP allowlists (no internal network access).
|
||||
- [ ] **DoD**: Operator docs include "Red Lines" (never expose Bridge port directly to internet without auth).
|
||||
|
||||
---
|
||||
|
||||
## Gate C: Supply Chain Provenance (R100)
|
||||
|
||||
**Goal**: Ensure integrity and traceability of release artifacts.
|
||||
|
||||
- [ ] **Provenance Generation**: Run `python scripts/generate_provenance.py dist/ dist/provenance.json` to create manifest and SBOM.
|
||||
- [ ] **Verification**: Run `python scripts/verify_provenance.py dist/ dist/provenance.json` on staging environment to verify integrity and completeness.
|
||||
- [ ] **Completeness**: Ensure `provenance.json` contains SHA256 for all distributed wheels/zips and correct git commit hash.
|
||||
|
||||
---
|
||||
|
||||
## Release Metadata
|
||||
|
||||
- [ ] **Version**: `pyproject.toml` version matches git tag.
|
||||
- [ ] **Changelog**: Updated `CHANGELOG.md` (if present) with user-facing changes.
|
||||
- [ ] **Migration**: If config/storage schema changed, explicit migration notes are in `docs/migration/` (Optional).
|
||||
|
||||
---
|
||||
|
||||
## Sign-off
|
||||
|
||||
- [ ] **Gate A Passed**: (Date/Initials)
|
||||
- [ ] **Gate B Passed** (if applicable): (Date/Initials)
|
||||
@@ -0,0 +1,316 @@
|
||||
# Security Policy
|
||||
|
||||
## Quick Links
|
||||
|
||||
- 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`
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the latest version of ComfyUI-OpenClaw is supported for security updates.
|
||||
|
||||
| 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
|
||||
|
||||
Private reporting workflow:
|
||||
1. Submit a private report with repro steps, affected version, and impact.
|
||||
2. Maintainers triage and confirm impact.
|
||||
3. Fix and mitigation guidance are prepared.
|
||||
4. Advisory is published with affected-range + fixed-version metadata.
|
||||
|
||||
Target SLA:
|
||||
- initial acknowledgement: within 72 hours
|
||||
- triage status update: within 7 calendar days
|
||||
- coordinated disclosure target: within 30 days after confirmed impact
|
||||
- timeline may be extended for high-complexity fixes; status updates are still required
|
||||
|
||||
Advisory publication policy:
|
||||
- advisories are tracked in `docs/release/security_advisories.json`
|
||||
- Security Doctor surfaces advisory applicability (`affected`, `mitigation`) for the running version
|
||||
- high-severity affected posture should be treated as priority upgrade work
|
||||
|
||||
### Telemetry Opt-out Contract (S9)
|
||||
|
||||
Security anomaly telemetry is minimal and audit-focused by default. If operators must disable this emission path, use:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_TELEMETRY_OPT_OUT=1
|
||||
# Legacy compatibility:
|
||||
# export MOLTBOT_TELEMETRY_OPT_OUT=1
|
||||
```
|
||||
|
||||
Trade-off:
|
||||
- with opt-out enabled, security anomaly audit events are not emitted
|
||||
- use only when required by policy/privacy constraints and keep compensating controls in place
|
||||
|
||||
---
|
||||
|
||||
# Safe Deployment Guide
|
||||
|
||||
OpenClaw is a powerful extension that interacts with LLMs and the filesystem (via ComfyUI). **By default, it is designed for local (localhost) use.** Exposing it to the public internet requires careful configuration.
|
||||
|
||||
## ⚠️ Warning
|
||||
|
||||
**Do NOT expose your ComfyUI instance directly to the public internet** (for example via direct port-forwarding) without a secure reverse proxy or VPN.
|
||||
|
||||
## Shared Listener Boundary (Critical)
|
||||
|
||||
OpenClaw and ComfyUI share the same HTTP listener/port.
|
||||
|
||||
This means:
|
||||
|
||||
1. Protecting `/openclaw/*` routes does not automatically protect ComfyUI-native routes.
|
||||
2. Public reverse-proxy policy must enforce path-level allow/deny and network ACL boundaries.
|
||||
3. Public posture requires explicit operator acknowledgement that these boundaries are in place.
|
||||
|
||||
High-risk ComfyUI-native routes to deny on public edges unless intentionally required:
|
||||
|
||||
- `/prompt`, `/history*`, `/view*`, `/upload*`, `/ws`
|
||||
- `/api/prompt`, `/api/history*`, `/api/view*`, `/api/upload*`, `/api/ws`
|
||||
|
||||
## Recommended Deployment
|
||||
|
||||
1. **Localhost (Default)**: Use on your own machine. No extra config needed.
|
||||
2. **VPN / Tailscale**: Best for private remote access.
|
||||
3. **SSH Tunnel**: `ssh -L 8188:localhost:8188 user@remote`
|
||||
|
||||
## Reverse Proxy Setup (Advanced)
|
||||
|
||||
If you must expose OpenClaw via a reverse proxy (Nginx, Caddy, Cloudflare Tunnel), you MUST configure the following:
|
||||
|
||||
### 1. Token Boundaries
|
||||
|
||||
Logs (`/openclaw/logs/tail`) and Config (`/openclaw/config`) are restricted to loopback clients by default. (Legacy `/moltbot/*` endpoints are also supported.) To allow remote access via proxy, set a secure token:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_OBSERVABILITY_TOKEN="your-secure-random-token-here"
|
||||
export OPENCLAW_ADMIN_TOKEN="your-secure-random-admin-token-here"
|
||||
# Legacy compatibility (optional):
|
||||
# export MOLTBOT_OBSERVABILITY_TOKEN="your-secure-random-token-here"
|
||||
# export MOLTBOT_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 and Internal Content Redaction Boundary
|
||||
|
||||
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:
|
||||
|
||||
- request explicitly opts in via `X-OpenClaw-Debug-Reveal-Reasoning: 1` or `?debug_reasoning=1`
|
||||
- server-side debug switch is enabled with `OPENCLAW_DEBUG_REASONING_REVEAL=1`
|
||||
- request is admin-authorized
|
||||
- client IP resolves to loopback
|
||||
- deployment profile is `local` or `lan`
|
||||
- runtime profile is not hardened
|
||||
|
||||
Operational rules:
|
||||
|
||||
- do not enable `OPENCLAW_DEBUG_REASONING_REVEAL` on public deployments
|
||||
- treat any successful reveal as privileged debugging activity and review related audit events (`reasoning.debug_reveal`)
|
||||
- the reveal path appends debug reasoning payloads only for the privileged request; default operator outputs remain redacted
|
||||
|
||||
### 2. Trusted Proxy Attribution
|
||||
|
||||
If using a reverse proxy, OpenClaw needs to know the *real* client IP for rate limiting enforcement.
|
||||
|
||||
Configure your proxy to send `X-Forwarded-For`, then configure trusted proxy ranges:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_TRUST_X_FORWARDED_FOR=1
|
||||
export OPENCLAW_TRUSTED_PROXIES="127.0.0.1,10.0.0.0/8"
|
||||
# Legacy compatibility (optional):
|
||||
# export MOLTBOT_TRUST_X_FORWARDED_FOR=1
|
||||
# export MOLTBOT_TRUSTED_PROXIES="127.0.0.1,10.0.0.0/8"
|
||||
```
|
||||
|
||||
### 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:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_DEPLOYMENT_PROFILE=public
|
||||
export OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1
|
||||
# Legacy compatibility (optional):
|
||||
# export MOLTBOT_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1
|
||||
```
|
||||
|
||||
If this acknowledgement is missing in public profile, deployment profile checks fail with `DP-PUBLIC-008`.
|
||||
|
||||
### 4. Connector Allowlist Fail-Closed (Public/Hardened)
|
||||
|
||||
Connector ingress posture is fail-closed in strict profiles:
|
||||
|
||||
- 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)
|
||||
|
||||
Operational requirement:
|
||||
|
||||
- never enable connector platform tokens/enable flags in public or hardened posture without platform allowlist coverage.
|
||||
|
||||
### 4.1 Interactive Callback Contract Baseline (Connector)
|
||||
|
||||
For interactive connector callbacks (actions/modals/workflow style payloads), the shared callback contract is fail-closed by default:
|
||||
|
||||
- signed envelope is required (`signature`, `timestamp`, `request_id`, `workspace_id`, `action_type`, `payload_hash`)
|
||||
- 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:
|
||||
|
||||
- treat callback decision codes/audit trails as security evidence and investigate repeated reject patterns before enabling higher-risk interactive flows.
|
||||
|
||||
### 4.2 Multi-tenant Boundary Model (Fail-Closed)
|
||||
|
||||
When `OPENCLAW_MULTI_TENANT_ENABLED=1`, OpenClaw enforces explicit tenant boundaries across API and service paths.
|
||||
|
||||
Boundary rules:
|
||||
|
||||
- tenant context is resolved from token context and/or tenant header (`X-OpenClaw-Tenant-Id` by default)
|
||||
- token/header mismatch is rejected (`tenant_mismatch`)
|
||||
- connector installation diagnostics/resolution, config read/write, approvals, presets, template visibility, and secret lookup are tenant-scoped
|
||||
- execution budgets add per-tenant concurrency enforcement (`OPENCLAW_MAX_INFLIGHT_SUBMITS_PER_TENANT`)
|
||||
|
||||
Compatibility note:
|
||||
|
||||
- current admin/API handlers default missing tenant context to `default` for backward compatibility; stricter caller paths can enforce explicit tenant presence.
|
||||
|
||||
Compatibility toggles (use only during migration windows):
|
||||
|
||||
- `OPENCLAW_MULTI_TENANT_ALLOW_DEFAULT_FALLBACK=1`
|
||||
- `OPENCLAW_MULTI_TENANT_ALLOW_CONFIG_FALLBACK=1`
|
||||
- `OPENCLAW_MULTI_TENANT_ALLOW_LEGACY_SECRET_FALLBACK=1`
|
||||
|
||||
Security recommendation:
|
||||
|
||||
- keep all fallback toggles disabled for steady-state multi-tenant production.
|
||||
|
||||
### 4.3 Optional Local Secret-manager Path (1Password CLI)
|
||||
|
||||
If `OPENCLAW_1PASSWORD_ENABLED=1`, provider key lookup can use local 1Password CLI as an optional backend source.
|
||||
|
||||
Fail-closed requirements:
|
||||
|
||||
- `OPENCLAW_1PASSWORD_ALLOWED_COMMANDS` must include the command basename in use
|
||||
- `OPENCLAW_1PASSWORD_VAULT` and `OPENCLAW_1PASSWORD_FIELD` must be valid
|
||||
- `OPENCLAW_1PASSWORD_ITEM_TEMPLATE` must include `{provider}`
|
||||
- when multi-tenant mode is enabled, the template must also include `{tenant}`
|
||||
|
||||
Operational note:
|
||||
|
||||
- this path remains backend-only; frontend surfaces stay secret-blind.
|
||||
|
||||
### 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.
|
||||
|
||||
Recommended preflight:
|
||||
|
||||
```bash
|
||||
python scripts/check_deployment_profile.py --profile public --strict-warnings
|
||||
```
|
||||
|
||||
### 6. SSRF Protection
|
||||
|
||||
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+), 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:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_ALLOW_CUSTOM_BASE_URL=1
|
||||
# Legacy compatibility (optional):
|
||||
# export MOLTBOT_ALLOW_CUSTOM_BASE_URL=1
|
||||
```
|
||||
- use strict allowlist:
|
||||
```bash
|
||||
export OPENCLAW_LLM_ALLOWED_HOSTS="api.example.com,llm.example.com"
|
||||
```
|
||||
- `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 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:
|
||||
|
||||
* Webhooks: 30/min
|
||||
* Logs: 60/min
|
||||
* Admin: 20/min
|
||||
|
||||
### 8. Sidecar Bridge
|
||||
|
||||
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`).
|
||||
* **Authentication**: Requires `OPENCLAW_BRIDGE_DEVICE_TOKEN` (legacy `MOLTBOT_BRIDGE_DEVICE_TOKEN`) (shared secret).
|
||||
* **Network**: Bridge endpoints (`/bridge/*`) are sensitive. **Do not expose to public internet.** Use a private network (Tailscale) or restrict access via reverse proxy.
|
||||
* **SSRF**: Callback delivery blocks internal IPs. To allow specific external callback hosts, set `OPENCLAW_BRIDGE_CALLBACK_HOST_ALLOWLIST` (legacy: `MOLTBOT_BRIDGE_CALLBACK_HOST_ALLOWLIST`).
|
||||
|
||||
## Security Checklist
|
||||
|
||||
* [ ] **HTTPS + Edge Auth**: reverse proxy enforces TLS and an additional auth boundary (SSO/Basic/IP ACL).
|
||||
* [ ] **No direct public bind**: never expose raw ComfyUI/OpenClaw listener directly.
|
||||
* [ ] **Token boundaries**: set `OPENCLAW_ADMIN_TOKEN` and `OPENCLAW_OBSERVABILITY_TOKEN` (legacy aliases acceptable).
|
||||
* [ ] **Trusted proxy config**: set `OPENCLAW_TRUST_X_FORWARDED_FOR=1` and exact `OPENCLAW_TRUSTED_PROXIES`.
|
||||
* [ ] **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.
|
||||
* [ ] **Least privilege host posture**: do not run as root/Administrator.
|
||||
@@ -0,0 +1,63 @@
|
||||
# ADR-0001: Configuration Surface Unification
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-03-07
|
||||
- Owners: OpenClaw maintainers
|
||||
- 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.
|
||||
|
||||
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 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)
|
||||
2. `runtime_override` (in-memory only; process-local)
|
||||
3. `persisted` (`OPENCLAW_STATE_DIR/config.json`)
|
||||
4. `default`
|
||||
|
||||
Key points:
|
||||
- Keep env-first semantics for operational safety and backward compatibility.
|
||||
- Preserve legacy key support with explicit warning behavior.
|
||||
- Keep runtime overrides non-persisted and source-attributed.
|
||||
|
||||
## Consequences
|
||||
|
||||
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.
|
||||
- Additional adapter code until follow-up phases complete.
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
Phase 1 (`R139`):
|
||||
- Introduce unified resolver + runtime override registry.
|
||||
- Refactor `services/runtime_config.py` effective-read path to resolver-backed flow.
|
||||
- Migrate core LLM call sites to stop duplicating env precedence.
|
||||
- Add precedence/compatibility regression tests.
|
||||
|
||||
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
|
||||
|
||||
1. Big-bang rewrite of all config readers:
|
||||
- Rejected due to blast radius and rollback difficulty.
|
||||
2. Keep dual systems and patch ad hoc:
|
||||
- Rejected due to ongoing precedence drift and maintenance cost.
|
||||
@@ -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,81 @@
|
||||
# Advanced Registry Sync and Constrained Transforms
|
||||
|
||||
This guide covers optional, high-control features that are disabled by default.
|
||||
|
||||
## Overview
|
||||
|
||||
- Remote registry sync uses quarantine and trust policy controls.
|
||||
- Constrained transforms execute trusted Python modules with strict runtime limits.
|
||||
- Both features are fail-closed when disabled.
|
||||
- Optional operational log hygiene: `OPENCLAW_LOG_TRUNCATE_ON_START=1` clears stale `openclaw.log` at backend startup (once per process).
|
||||
|
||||
## Remote registry sync
|
||||
|
||||
Enable remote registry sync:
|
||||
|
||||
```bash
|
||||
OPENCLAW_ENABLE_REGISTRY_SYNC=1
|
||||
```
|
||||
|
||||
Trust policy:
|
||||
|
||||
- `OPENCLAW_REGISTRY_POLICY=audit` (default): records signature/provenance issues for review
|
||||
- `OPENCLAW_REGISTRY_POLICY=strict`: rejects non-compliant artifacts
|
||||
|
||||
Behavior highlights:
|
||||
|
||||
- Quarantine lifecycle is persisted under the state directory:
|
||||
- `registry/quarantine/index.json`
|
||||
- Entries are tracked with audit trail records.
|
||||
- Anti-abuse controls include bounded dedupe windows and rate limiting.
|
||||
- Integrity and policy checks are enforced before activation paths.
|
||||
|
||||
If registry sync is not enabled, registry operations fail closed.
|
||||
|
||||
## Constrained transforms
|
||||
|
||||
Enable constrained transforms:
|
||||
|
||||
```bash
|
||||
OPENCLAW_ENABLE_TRANSFORMS=1
|
||||
```
|
||||
|
||||
Runtime limits:
|
||||
|
||||
- `OPENCLAW_TRANSFORM_TIMEOUT` (seconds, default `5`)
|
||||
- `OPENCLAW_TRANSFORM_MAX_OUTPUT` (bytes, default `65536`)
|
||||
- `OPENCLAW_TRANSFORM_MAX_PER_REQUEST` (default `5`)
|
||||
|
||||
Trusted module paths:
|
||||
|
||||
- Default trusted directory: `data/transforms`
|
||||
- Add extra trusted directories with `OPENCLAW_TRANSFORM_TRUSTED_DIRS`
|
||||
- Use OS path separator (`;` on Windows, `:` on Linux/macOS)
|
||||
|
||||
Security controls:
|
||||
|
||||
- Only `.py` modules are allowed
|
||||
- Module size is capped
|
||||
- Module hash is pinned at registration time
|
||||
- Integrity is re-checked before execution
|
||||
- Execution is bounded by timeout and output budget
|
||||
|
||||
If transforms are disabled, transform execution is denied and mapping-only behavior continues.
|
||||
|
||||
## Example hardened operator profile
|
||||
|
||||
```bash
|
||||
OPENCLAW_ENABLE_REGISTRY_SYNC=1
|
||||
OPENCLAW_REGISTRY_POLICY=strict
|
||||
OPENCLAW_ENABLE_TRANSFORMS=1
|
||||
OPENCLAW_TRANSFORM_TIMEOUT=3
|
||||
OPENCLAW_TRANSFORM_MAX_OUTPUT=32768
|
||||
OPENCLAW_TRANSFORM_MAX_PER_REQUEST=3
|
||||
```
|
||||
|
||||
## Rollout notes
|
||||
|
||||
1. Enable one feature at a time in a non-production environment.
|
||||
2. Review logs and operator diagnostics after startup.
|
||||
3. Keep strict policies for public or multi-tenant deployments.
|
||||
4. Treat trusted transform directories as code deployment boundaries.
|
||||
@@ -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.
|
||||
+513
-15
@@ -1,30 +1,93 @@
|
||||
# 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**, and **WeChat Official Account**.
|
||||
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 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 require inbound HTTPS webhook endpoints.
|
||||
- **Allowlist**: Only users/chats you explicitly allow can send commands.
|
||||
- **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 ComfyUI instance. See `OPENCLAW_CONNECTOR_ADMIN_TOKEN` below.
|
||||
- **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
|
||||
|
||||
OpenClaw now includes a platform-agnostic baseline for multi-workspace connector lifecycle and interactive callback security:
|
||||
|
||||
- installation registry stores normalized records:
|
||||
- `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:
|
||||
|
||||
- `GET /openclaw/connector/installations`
|
||||
- `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
|
||||
|
||||
When backend multi-tenant mode is enabled (`OPENCLAW_MULTI_TENANT_ENABLED=1`):
|
||||
|
||||
- installation records are tenant-owned (`tenant_id`) and diagnostics are tenant-scoped
|
||||
- resolution rejects cross-tenant matches fail-closed (`tenant_mismatch` path)
|
||||
- admin diagnostics calls can pass tenant context via token context and/or `X-OpenClaw-Tenant-Id` (or your configured `OPENCLAW_TENANT_HEADER`)
|
||||
- missing tenant context currently falls back to `default` tenant for compatibility unless stricter caller paths are used
|
||||
|
||||
## 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
|
||||
|
||||
@@ -41,19 +104,31 @@ 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 (e.g. `/run`, `/stop`, approvals, schedules).
|
||||
- `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.
|
||||
- `OPENCLAW_TENANT_HEADER`: Optional tenant header key (default `X-OpenClaw-Tenant-Id`) used when calling tenant-scoped backend APIs.
|
||||
|
||||
**Admin token behavior:**
|
||||
|
||||
- If the OpenClaw server has `OPENCLAW_ADMIN_TOKEN` configured, you must set `OPENCLAW_CONNECTOR_ADMIN_TOKEN` to the same value or admin calls will return HTTP 403.
|
||||
- If the OpenClaw server is in loopback-only convenience mode (no Admin Token configured), the connector can still call admin endpoints via `localhost` without sending a token.
|
||||
- Connector admin command paths require `OPENCLAW_CONNECTOR_ADMIN_TOKEN` to be set in connector runtime.
|
||||
- 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:**
|
||||
|
||||
@@ -98,17 +173,143 @@ Set the following environment variables (or put them in a `.env` file if you use
|
||||
- `OPENCLAW_CONNECTOR_WECHAT_PORT`: Port (default `8097`).
|
||||
- `OPENCLAW_CONNECTOR_WECHAT_PATH`: Webhook path (default `/wechat/webhook`).
|
||||
|
||||
**Image Delivery (F33):**
|
||||
**KakaoTalk (Kakao i Open Builder):**
|
||||
|
||||
*(Requires Inbound Connectivity - see below)*
|
||||
|
||||
- `OPENCLAW_CONNECTOR_KAKAO_ENABLED`: Set to `true` to enable Kakao webhook adapter.
|
||||
- `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS`: Comma-separated Kakao user IDs (`userRequest.user.id` / botUserKey). Non-allowlisted users are treated as untrusted and sensitive actions require approval.
|
||||
- `OPENCLAW_CONNECTOR_KAKAO_BIND`: Host to bind (default `127.0.0.1`).
|
||||
- `OPENCLAW_CONNECTOR_KAKAO_PORT`: Port (default `8096`).
|
||||
- `OPENCLAW_CONNECTOR_KAKAO_PATH`: Webhook path (default `/kakao/webhook`).
|
||||
|
||||
**Slack (Events API):**
|
||||
|
||||
*(Requires Inbound Connectivity - see below)*
|
||||
|
||||
- `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`).
|
||||
- `OPENCLAW_CONNECTOR_SLACK_PORT`: Port (default `8095`).
|
||||
- `OPENCLAW_CONNECTOR_SLACK_PATH`: Webhook path (default `/slack/events`).
|
||||
- `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`**.
|
||||
> LINE and WhatsApp also **require** `public_base_url` to be HTTPS.
|
||||
> 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
|
||||
|
||||
Connector commands are evaluated through a centralized authorization policy with three command classes:
|
||||
|
||||
- `public`: low-risk status/help style commands
|
||||
- `run`: execution commands such as `/run` (still subject to trust/approval behavior)
|
||||
- `admin`: sensitive commands such as `/trace`, `/approvals`, `/approve`, `/reject`, and schedule controls
|
||||
|
||||
Default behavior:
|
||||
|
||||
- If no explicit allow-from list is configured for a command class, class-level defaults apply.
|
||||
- `admin` commands require the sender to be in `OPENCLAW_CONNECTOR_ADMIN_USERS`.
|
||||
- `public` and `run` commands still pass through each platform adapter's trust/allowlist checks.
|
||||
|
||||
Optional policy controls:
|
||||
|
||||
- `OPENCLAW_COMMAND_OVERRIDES`: JSON object mapping command name to class (`public`, `run`, `admin`).
|
||||
- `OPENCLAW_COMMAND_ALLOW_FROM_PUBLIC`: comma-separated sender IDs.
|
||||
- `OPENCLAW_COMMAND_ALLOW_FROM_RUN`: comma-separated sender IDs.
|
||||
- `OPENCLAW_COMMAND_ALLOW_FROM_ADMIN`: comma-separated sender IDs.
|
||||
|
||||
Normalization rules:
|
||||
|
||||
- Command keys in `OPENCLAW_COMMAND_OVERRIDES` are normalized to lowercase.
|
||||
- Missing leading `/` is added automatically.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
OPENCLAW_COMMAND_OVERRIDES='{"run":"admin","/status":"public"}'
|
||||
OPENCLAW_COMMAND_ALLOW_FROM_ADMIN=alice_id,bob_id
|
||||
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
|
||||
|
||||
@@ -157,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
|
||||
@@ -165,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
|
||||
@@ -192,7 +396,7 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
|
||||
- verify connector receives command and returns text reply
|
||||
|
||||
7. Verify trusted/untrusted behavior:
|
||||
- if sender OpenID is in `OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS`, `/run` can execute directly (subject to admin rules)
|
||||
- if sender OpenID is in `OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS`, `/run` can execute directly (subject to trust/approval policy and command policy)
|
||||
- if not allowlisted, sensitive actions are routed to approval flow
|
||||
|
||||
**WeChat-specific notes:**
|
||||
@@ -200,10 +404,251 @@ 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`.
|
||||
|
||||
#### KakaoTalk (Kakao i Open Builder) Webhook Setup (Detailed)
|
||||
|
||||
Kakao i Open Builder sends webhook requests to your connector Skill endpoint. You must expose the Kakao endpoint publicly over HTTPS.
|
||||
|
||||
1. Prepare the required environment variables:
|
||||
|
||||
```bash
|
||||
OPENCLAW_CONNECTOR_KAKAO_ENABLED=true
|
||||
OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS=kakao_user_id_1,kakao_user_id_2
|
||||
OPENCLAW_CONNECTOR_KAKAO_BIND=127.0.0.1
|
||||
OPENCLAW_CONNECTOR_KAKAO_PORT=8096
|
||||
OPENCLAW_CONNECTOR_KAKAO_PATH=/kakao/webhook
|
||||
```
|
||||
|
||||
2. Start the connector:
|
||||
|
||||
```bash
|
||||
python -m connector
|
||||
```
|
||||
|
||||
3. Expose the local webhook service to HTTPS (Cloudflare Tunnel or reverse proxy):
|
||||
- local upstream: `http://127.0.0.1:8096`
|
||||
- public path: `/kakao/webhook`
|
||||
- expected public URL: `https://<your-public-host>/kakao/webhook`
|
||||
|
||||
4. In Kakao i Open Builder:
|
||||
- create/select your bot
|
||||
- create/select a Skill
|
||||
- set Skill server URL to `https://<your-public-host>/kakao/webhook`
|
||||
- deploy/publish the Skill scenario that calls this Skill endpoint
|
||||
|
||||
5. Functional test:
|
||||
- chat with your Kakao bot
|
||||
- send `/help` or `/status`
|
||||
- verify connector receives command and returns a SkillResponse (`version: 2.0`)
|
||||
|
||||
6. Verify trusted/untrusted behavior:
|
||||
- if sender `userRequest.user.id` is in `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS`, `/run` can execute directly (subject to trust/approval policy and command policy)
|
||||
- if not allowlisted, sensitive actions are routed to approval flow
|
||||
|
||||
7. Optional first-time allowlist bootstrap:
|
||||
- temporarily leave `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS` empty
|
||||
- send a test message and check logs for `Untrusted Kakao message from user=<id>`
|
||||
- add that ID to allowlist and restart connector
|
||||
|
||||
**Kakao-specific notes:**
|
||||
|
||||
- Adapter is disabled unless `OPENCLAW_CONNECTOR_KAKAO_ENABLED=true`.
|
||||
- Kakao webhook ingress is `POST` only on `OPENCLAW_CONNECTOR_KAKAO_PATH`.
|
||||
- Replay protection is enabled: identical payloads within the replay window are acknowledged and not re-executed.
|
||||
- Kakao command requests are normalized from:
|
||||
- `userRequest.user.id` -> `sender_id`
|
||||
- `userRequest.utterance` -> command text
|
||||
- Response format follows Kakao SkillResponse v2.0 with text-first output and optional quick replies.
|
||||
- If `aiohttp` is missing, the adapter is skipped at startup.
|
||||
|
||||
#### Slack Webhook Setup (Detailed)
|
||||
|
||||
Slack uses the Events API webhook mode in OpenClaw. You must expose the endpoint publicly over HTTPS.
|
||||
|
||||
1. **Create the Slack App**
|
||||
- Go to [api.slack.com/apps](https://api.slack.com/apps).
|
||||
- Create a new app (From scratch) and select your workspace.
|
||||
- In **Basic Information**, copy the **Signing Secret**.
|
||||
|
||||
2. **Configure OAuth Scopes and install**
|
||||
- Go to **OAuth & Permissions**.
|
||||
- Add bot scopes:
|
||||
- `chat:write`
|
||||
- `files:write`
|
||||
- `app_mentions:read`
|
||||
- `im:history` (DM support)
|
||||
- `channels:history` (public channel messages)
|
||||
- `groups:history` (private channel messages)
|
||||
- For legacy single-workspace mode, click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||
- For 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_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
|
||||
OPENCLAW_CONNECTOR_SLACK_PORT=8095
|
||||
OPENCLAW_CONNECTOR_SLACK_PATH=/slack/events
|
||||
OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true
|
||||
OPENCLAW_CONNECTOR_SLACK_REPLY_IN_THREAD=true
|
||||
OPENCLAW_CONNECTOR_ADMIN_TOKEN=replace-with-openclaw-admin-token
|
||||
```
|
||||
|
||||
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 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.
|
||||
- Add bot events:
|
||||
- `app_mention`
|
||||
- `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 signing secret and OAuth client secret on incident response.
|
||||
- Do not expose connector without HTTPS termination.
|
||||
|
||||
#### Slack Socket Mode Setup (Optional)
|
||||
|
||||
Use Socket Mode when you cannot expose a public HTTPS webhook endpoint.
|
||||
|
||||
1. **Enable Socket Mode in Slack**
|
||||
- Open your Slack App settings.
|
||||
- Go to **Socket Mode** and enable it.
|
||||
- Create an App-Level Token (`xapp-...`) with `connections:write`.
|
||||
|
||||
2. **Configure connector**
|
||||
|
||||
```bash
|
||||
OPENCLAW_CONNECTOR_SLACK_MODE=socket
|
||||
OPENCLAW_CONNECTOR_SLACK_APP_TOKEN=xapp-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**
|
||||
- `python -m connector`
|
||||
- Expect log: `Slack Socket Mode connected.`
|
||||
|
||||
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
|
||||
|
||||
**General:**
|
||||
@@ -215,7 +660,8 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
|
||||
| `/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`)*
|
||||
@@ -262,6 +708,58 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
|
||||
- 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`.
|
||||
|
||||
- **Kakao requests not arriving**:
|
||||
- `OPENCLAW_CONNECTOR_KAKAO_ENABLED` is not `true`, or Kakao Skill URL/path does not match `OPENCLAW_CONNECTOR_KAKAO_PATH`.
|
||||
- Fix: set `OPENCLAW_CONNECTOR_KAKAO_ENABLED=true`, verify public HTTPS URL, and confirm Skill URL exactly matches `/kakao/webhook` (or your custom path).
|
||||
|
||||
- **Kakao returns empty/fallback response**:
|
||||
- Payload is malformed (missing `userRequest.user.id` / `userRequest.utterance`) or the adapter rejected malformed JSON.
|
||||
- Fix: validate Skill request payload shape in Open Builder test console and check connector logs for `Bad JSON` / payload errors.
|
||||
|
||||
- **Kakao `/run` always goes to approval**:
|
||||
- Sender is not in `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS` (or allowlist is empty).
|
||||
- Fix: capture `userRequest.user.id` from logs, add it to `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS`, restart connector.
|
||||
|
||||
- **Slack Event Subscriptions verification fails**:
|
||||
- Request URL/path mismatch, connector not reachable, or `OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET` is wrong.
|
||||
- Fix: confirm public URL points to `/slack/events`, verify tunnel/proxy routes to `127.0.0.1:8095`, and re-check Signing Secret.
|
||||
|
||||
- **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.
|
||||
|
||||
+17
-5
@@ -33,10 +33,19 @@ Set these Environment Variables:
|
||||
|
||||
```ini
|
||||
# Require a token for admin actions (Stop/Approve)
|
||||
OPENCLAW_CONNECTOR_ADMIN_TOKEN=your-strong-secret-token
|
||||
OPENCLAW_ADMIN_TOKEN=your-strong-secret-token
|
||||
|
||||
# Explicitly allow admin write actions from non-loopback LAN clients
|
||||
OPENCLAW_ALLOW_REMOTE_ADMIN=1
|
||||
|
||||
# Require a token for Logs/Config viewing
|
||||
MOLTBOT_OBSERVABILITY_TOKEN=observability-secret
|
||||
OPENCLAW_OBSERVABILITY_TOKEN=observability-secret
|
||||
|
||||
# Keep strict localhost no-origin behavior (do not relax on LAN)
|
||||
OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0
|
||||
|
||||
# Optional startup log hygiene (avoid stale historical error lines in UI)
|
||||
OPENCLAW_LOG_TRUNCATE_ON_START=1
|
||||
```
|
||||
|
||||
### 3. Firewall Rules (Host)
|
||||
@@ -61,10 +70,13 @@ 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. Use the scoped LLM private-network setting only for reviewed targets.
|
||||
|
||||
## Testing
|
||||
|
||||
1. Find your host IP (e.g., `192.168.1.10`).
|
||||
2. From another device on WiFi, visit `http://192.168.1.10:8188`.
|
||||
3. Open OpenClaw Settings.
|
||||
4. Try to view logs. It should challenge you for the `MOLTBOT_OBSERVABILITY_TOKEN` or deny access.
|
||||
2. From another device on WiFi, open the remote admin page: `http://192.168.1.10:8188/openclaw/admin`.
|
||||
3. Enter `X-OpenClaw-Admin-Token` in the page and click **Save**.
|
||||
4. Verify admin write actions (for example refresh runs, approval actions) are no longer denied by remote policy.
|
||||
5. Open OpenClaw Settings in ComfyUI and try to view logs. It should challenge you for the `OPENCLAW_OBSERVABILITY_TOKEN` or deny access when missing.
|
||||
|
||||
@@ -32,17 +32,32 @@ To see the GUI go to: http://127.0.0.1:8188
|
||||
|
||||
No special configuration is required.
|
||||
|
||||
- **Admin Token**: Not required for loopback-only operations (unless `OPENCLAW_CONNECTOR_ADMIN_TOKEN` is explicitly set).
|
||||
- **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/v1`
|
||||
- LM Studio: `http://localhost:1234/v1`
|
||||
- Keep SSRF relax flags disabled:
|
||||
- `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=0`
|
||||
- `OPENCLAW_ALLOW_INSECURE_BASE_URL=0`
|
||||
- **No-origin convenience override (optional, local-only)**:
|
||||
- default/unset keeps strict no-origin denial
|
||||
- set `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=true` only when local CLI/tooling compatibility requires it
|
||||
- keep it unset/disabled for normal browser-only local use
|
||||
- **Optional log hygiene**:
|
||||
- `OPENCLAW_LOG_TRUNCATE_ON_START=1` clears stale `openclaw.log` content once at startup.
|
||||
|
||||
### 3. "Red Lines" (What NOT to do)
|
||||
|
||||
- ❌ Do not run with `--listen 0.0.0.0` or `--listen`.
|
||||
- ❌ Do not port-forward port 8188 on your router.
|
||||
- ❌ Do not leave `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=true` enabled longer than needed.
|
||||
|
||||
## Testing
|
||||
|
||||
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. All checks should be green.
|
||||
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.
|
||||
|
||||
@@ -3,11 +3,26 @@
|
||||
For power users who want to run ComfyUI behind Caddy, Nginx, or Traefik.
|
||||
This adds limits, TLS, and header management.
|
||||
|
||||
## Shared-Boundary Warning
|
||||
|
||||
OpenClaw and ComfyUI share the same upstream listener.
|
||||
Blocking or authenticating `/openclaw/*` alone is not enough for public posture.
|
||||
If your proxy forwards broad paths to ComfyUI, native ComfyUI routes may still be reachable.
|
||||
|
||||
Treat reverse proxy policy as the primary boundary:
|
||||
|
||||
- allow only the routes you intentionally need
|
||||
- deny ComfyUI-native high-risk paths and their `/api/*` forms
|
||||
|
||||
## Guidelines
|
||||
|
||||
1. **Block Sensitive Paths**: Prevent external access to admin/debug endpoints if not needed.
|
||||
- Block `/openclaw/logs/*`
|
||||
- Block `/openclaw/config`
|
||||
- Block `/openclaw/admin` and legacy `/moltbot/admin` when remote admin UI is not required
|
||||
- Block ComfyUI-native high-risk paths:
|
||||
- `/prompt`, `/history*`, `/view*`, `/upload*`, `/ws`
|
||||
- `/api/prompt`, `/api/history*`, `/api/view*`, `/api/upload*`, `/api/ws`
|
||||
2. **Timeouts**: ComfyUI generation can take time. Increase timeouts.
|
||||
- `proxy_read_timeout 600s;` (Nginx)
|
||||
3. **Websockets**: ComfyUI requires WS support.
|
||||
@@ -25,8 +40,12 @@ comfyui.local {
|
||||
}
|
||||
|
||||
# Security: Block sensitive OpenClaw paths from external access
|
||||
@sensitive path /openclaw/logs* /openclaw/config
|
||||
respond @sensitive 403
|
||||
@openclaw_sensitive path /openclaw/logs* /openclaw/config /openclaw/admin /moltbot/admin
|
||||
respond @openclaw_sensitive 403
|
||||
|
||||
# Security: Block ComfyUI-native high-risk surfaces (direct + /api variants)
|
||||
@comfy_native_sensitive path /prompt /history* /view* /upload* /ws /api/prompt /api/history* /api/view* /api/upload* /api/ws
|
||||
respond @comfy_native_sensitive 403
|
||||
}
|
||||
```
|
||||
|
||||
@@ -56,5 +75,56 @@ server {
|
||||
location /openclaw/logs {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location = /openclaw/admin {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Block ComfyUI native high-risk routes (direct + /api variants)
|
||||
location = /prompt { deny all; }
|
||||
location /history { deny all; }
|
||||
location /view { deny all; }
|
||||
location /upload { deny all; }
|
||||
location = /ws { deny all; }
|
||||
|
||||
location = /api/prompt { deny all; }
|
||||
location /api/history { deny all; }
|
||||
location /api/view { deny all; }
|
||||
location /api/upload { deny all; }
|
||||
location = /api/ws { deny all; }
|
||||
}
|
||||
```
|
||||
|
||||
## Recommended Pattern: Allowlist-First Routing
|
||||
|
||||
If you do not need full ComfyUI UI exposure, prefer explicit allowlist routing:
|
||||
|
||||
- allow only required OpenClaw routes (for example `/openclaw/admin`, selected `/openclaw/*` APIs)
|
||||
- deny everything else by default
|
||||
|
||||
This reduces accidental exposure from ComfyUI route changes or API shim behavior differences.
|
||||
|
||||
## If You Intentionally Expose Remote Admin Console
|
||||
|
||||
Only do this on trusted/private access planes and keep backend protection enabled:
|
||||
|
||||
- `OPENCLAW_ADMIN_TOKEN=<strong-secret>`
|
||||
- `OPENCLAW_ALLOW_REMOTE_ADMIN=1`
|
||||
- `OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1` (when `OPENCLAW_DEPLOYMENT_PROFILE=public`, set only after proxy allowlist + network ACL boundary controls are enforced)
|
||||
- `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0` (keep strict no-origin posture on shared/remote planes)
|
||||
- `OPENCLAW_LOG_TRUNCATE_ON_START=1` (optional, startup log hygiene)
|
||||
|
||||
Use one more auth boundary at proxy layer (IP allowlist, SSO, or basic auth), for example:
|
||||
|
||||
```nginx
|
||||
location = /openclaw/admin {
|
||||
allow 10.0.0.0/8;
|
||||
allow 192.168.0.0/16;
|
||||
deny all;
|
||||
|
||||
auth_basic "Restricted";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
|
||||
proxy_pass http://127.0.0.1:8188/openclaw/admin;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# /etc/default/openclaw.env
|
||||
# Secure environment configuration for OpenClaw
|
||||
|
||||
# Admin Token (Required for remote ops)
|
||||
OPENCLAW_CONNECTOR_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_TOKEN=
|
||||
|
||||
# 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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user