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

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

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

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

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

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

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

Refresh host-surface expectations and compatibility governance tests.

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

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


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

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

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


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

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

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

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

Validation: powershell -File scripts/run_full_tests_windows.ps1 passed.
2026-04-26 20:06:03 +08:00
rookiestar28 da5fb1fcbd docs(readme): refresh current feature notes 2026-04-26 18:54:27 +08:00
rookiestar28 c0bd987ec1 refactor(nodes): align node categories with canonical naming 2026-04-26 18:46:18 +08:00
rookiestar28 062e0f2e11 test(coverage): add hotspot readiness governance 2026-04-26 18:39:47 +08:00
rookiestar28 2ee8245ff1 chore(test): add exception boundary governance 2026-04-26 18:14:48 +08:00
rookiestar28 d49e1d416f feat(api): add legacy compatibility governance 2026-04-26 18:05:31 +08:00
rookiestar28 8660ece6c1 feat(frontend): add shared DOM wiring helpers 2026-04-26 17:52:34 +08:00
rookiestar28 0a959f96aa feat(connector): add Slack interactive callback handling 2026-04-26 17:40:45 +08:00
rookiestar28 1f8e11205f docs: update testing SOP 2026-04-26 13:43:00 +08:00
rookiestar28 4e3a30272e chore: modified 2026-04-26 13:04:03 +08:00
rookiestar28 157ef81505 fix(tests): restore python 3.10 tomllib compatibility 2026-04-24 02:12:13 +08:00
rookiestar28 a6e2669858 docs(api): regenerate openapi spec 2026-04-24 01:55:35 +08:00
rookiestar28 404f19175f style(tests): normalize formatter output 2026-04-24 01:45:03 +08:00
rookiestar28 9285f44766 bump version to v0.9.0 2026-04-24 01:41:11 +08:00
rookiestar28 4dc268b499 docs(readme): streamline public documentation 2026-04-24 01:38:42 +08:00
rookiestar28 a3df558797 feat(connector): publish extraction seam contract 2026-04-24 00:38:19 +08:00
rookiestar28 62eea5fb35 refactor(config): split runtime ownership seams 2026-04-24 00:24:33 +08:00
rookiestar28 ff0ddfd65b docs(architecture): define product boundary contract 2026-04-24 00:04:44 +08:00
rookiestar28 e84aacf295 feat(portability): add workflow fallback contract 2026-04-23 23:51:18 +08:00
rookiestar28 5f948878a8 refactor(imports): consolidate hotspot fallback helpers 2026-04-23 23:37:06 +08:00
rookiestar28 fc00538b43 fix(ci): install coverage toml support 2026-04-21 00:44:23 +08:00
rookiestar28 2195e2d2a5 fix(ci): guard comfy registry publish on version changes 2026-04-20 23:47:48 +08:00
rookiestar28 fdf3a9ec27 chore: apply auto formatted files 2026-04-20 20:07:48 +08:00
rookiestar28 0136543d71 docs(readme): refresh coverage gate guidance 2026-04-20 15:31:02 +08:00
rookiestar28 3d267092b3 test(governance): promote coverage floor to 45 2026-04-20 13:40:40 +08:00
rookiestar28 82c1d15a08 bump version to v0.8.8 2026-04-20 10:48:52 +08:00
rookiestar28 c727814e36 chore: modify gitignore 2026-04-20 10:46:16 +08:00
rookiestar28 9c0de89fc5 docs(readme): align verification and connector guidance 2026-04-20 10:42:11 +08:00
rookiestar28 cf74e0c0c0 fix(connector): bound numeric env parsing 2026-04-20 06:09:56 +08:00
rookiestar28 68b7040ac7 fix(config): defer bootstrap side effects 2026-04-20 05:59:06 +08:00
rookiestar28 75d560719e fix(config): harden pack version fallback 2026-04-20 05:47:29 +08:00
rookiestar28 71199efa07 test(governance): validate test debt metadata 2026-04-20 05:38:56 +08:00
rookiestar28 723215ac48 test(governance): add staged coverage policy 2026-04-20 05:21:03 +08:00
rookiestar28 922299ae8a fix(e2e): recover harness after exhausted import retries 2026-04-18 21:31:10 +08:00
rookiestar28 10c0a60a39 chore(compat): refresh host reference anchors 2026-04-18 18:27:40 +08:00
rookiestar28 10d6c6cee3 fix(e2e): harden harness retry for transient module fetches 2026-04-18 17:34:32 +08:00
rookiestar28 450c470716 chore: apply auto formatted files 2026-04-16 20:18:25 +08:00
rookiestar28 fd3a11c25f docs(readme): sync public docs with current runtime behavior 2026-04-16 16:48:31 +08:00
rookiestar28 59fce18c4d refactor(audit): add retained-chain verifier 2026-04-16 16:06:03 +08:00
rookiestar28 fb7d0c1c2d refactor(security): modularize doctor domains 2026-04-16 15:52:05 +08:00
rookiestar28 8e1cbd089a refactor(egress): consolidate safe outbound executor 2026-04-16 15:29:40 +08:00
rookiestar28 d0acea3952 feat(outputs): preserve explicit asset api fallback contract 2026-04-16 14:56:17 +08:00
rookiestar28 f4ddb22233 fix(tests): widen harness transient import retry window 2026-04-09 19:22:44 +08:00
rookiestar28 b39b7204dd bump version to v0.8.6 2026-04-09 15:53:28 +08:00
rookiestar28 5aff764837 docs: align provider and test harness guidance 2026-04-09 15:31:51 +08:00
rookiestar28 872edafdd6 fix(tests): retry transient harness module fetch 2026-04-09 15:14:16 +08:00
rookiestar28 a0b92e018d test: add provider url contract matrix 2026-04-09 15:01:44 +08:00
rookiestar28 cc64ee4608 test: tighten ollama model list api contract 2026-04-09 14:51:29 +08:00
rookiestar28 fed9fc44c5 fix: normalize ollama openai compat base url 2026-04-09 14:49:20 +08:00
rookiestar28 5c0d168e31 test(ci): align publish workflow contract 2026-04-09 01:39:01 +08:00
rookiestar28 a1748856ce fix(ci): use official comfy registry publish action 2026-04-09 01:33:21 +08:00
rookiestar28 c58dda9032 docs: prepare v0.8.5 release notes and assets 2026-04-09 01:24:22 +08:00
rookiestar28 a558825ffb fix: reduce comfyui pnginfo prompt noise 2026-04-09 00:53:09 +08:00
rookiestar28 100e2ac7bf fix: reduce queue monitor disconnect noise 2026-04-09 00:33:59 +08:00
rookiestar28 8209d7d9b2 fix: improve png info error and comfyui rendering 2026-04-09 00:14:26 +08:00
rookiestar28 11fec30e8e feat: extract comfyui png info metadata 2026-04-09 00:10:51 +08:00
rookiestar28 a44001fcab fix: make png info header scroll with content 2026-04-08 23:45:06 +08:00
rookiestar28 dd20639802 feat: add png info sidebar tab 2026-04-08 22:42:31 +08:00
rookiestar28 62fe3430e7 feat: add pnginfo metadata api baseline 2026-04-08 22:30:29 +08:00
rookiestar28 b64c4c118d docs: refresh codeql rollout wording 2026-04-08 18:42:22 +08:00
rookiestar28 c21ce49a87 docs: refresh security scanning references 2026-04-08 18:41:25 +08:00
rookiestar28 f57d46061f docs: fold security closeout into latest update 2026-04-08 18:40:13 +08:00
rookiestar28 b99b9a9865 docs: close residual security chain 2026-04-08 14:28:04 +08:00
rookiestar28 0abdafab73 docs: refresh residual security chain status 2026-04-08 03:42:58 +08:00
rookiestar28 3cf28d7a4c docs: mirror residual security planning references 2026-04-08 03:36:21 +08:00
rookiestar28 016657c37d style: apply model-manager autofixes 2026-04-08 03:32:33 +08:00
rookiestar28 bf06ef539c fix(security): make model import path proof explicit 2026-04-08 03:31:41 +08:00
rookiestar28 09e318b415 fix(security): detach audit traces from request taint 2026-04-08 03:25:31 +08:00
rookiestar28 2cc3edf959 fix(security): remove residual audit hash and log sinks 2026-04-08 03:11:42 +08:00
rookiestar28 43a44495a1 fix(security): remove audit identifier persistence 2026-04-08 02:53:35 +08:00
rookiestar28 f70b197ef4 style: apply codeql workflow autofixes 2026-04-08 02:44:10 +08:00
rookiestar28 4a84b9858d ci(security): add codeql workflow baseline 2026-04-08 02:43:09 +08:00
rookiestar28 95c9c655de style: apply pre-push autofixes 2026-04-08 02:35:38 +08:00
rookiestar28 e7c0566efe fix(ci): restore defusedxml dependency parity 2026-04-08 02:34:34 +08:00
rookiestar28 0f133ec6e0 style: apply pre-push black autofixes 2026-04-08 02:24:48 +08:00
rookiestar28 e5d37e80c7 docs(security): replace wechat app id sample 2026-04-08 02:23:32 +08:00
rookiestar28 2da760de02 fix(security): remove residual token hash indexing 2026-04-08 02:22:17 +08:00
rookiestar28 728aeefb4a test(security): harden notification regex sanitization 2026-04-08 02:19:21 +08:00
rookiestar28 92ebb2b113 fix(security): harden connector ingress parser paths 2026-04-08 02:18:00 +08:00
rookiestar28 28975b1f22 fix(security): bound model import temp lifecycle 2026-04-08 02:15:47 +08:00
rookiestar28 d6f26d38ce fix(security): harden audit identifier redaction 2026-04-08 02:13:44 +08:00
rookiestar28 b8470e73cc style: apply black autofixes 2026-04-08 01:36:18 +08:00
rookiestar28 4f32f98f7d docs: record security hardening wave 2026-04-08 01:32:50 +08:00
rookiestar28 522771bee4 test(security): prove notification content stays escaped 2026-04-08 01:18:43 +08:00
rookiestar28 af9c9a4c2d fix(security): harden connector ingress failures 2026-04-08 01:12:56 +08:00
rookiestar28 01f9828f99 fix(security): strengthen sensitive hash derivation 2026-04-08 01:06:41 +08:00
rookiestar28 d429c11018 fix(security): redact sensitive bridge and audit identifiers 2026-04-08 01:02:39 +08:00
rookiestar28 95279609d2 fix(security): harden path boundary handling 2026-04-08 00:46:47 +08:00
rookiestar28 153f158a6a fix(security): harden workflow token permissions 2026-04-08 00:41:03 +08:00
rookiestar28 2fa9fe24d9 chore(security): accept S74 code scanning inventory baseline 2026-04-07 18:57:03 +08:00
rookiestar28 1ad2e0fcc8 chore(security): accept S73 vite alert verification 2026-04-07 18:56:05 +08:00
rookiestar28 75245d7748 Merge pull request #11 from rookiestar28/dependabot/npm_and_yarn/npm_and_yarn-c4bc6a0a9e
chore(deps-dev): bump vite from 7.3.1 to 7.3.2 in the npm_and_yarn group across 1 directory
2026-04-07 16:35:16 +08:00
dependabot[bot] 0e9829ce39 chore(deps-dev): bump vite in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 7.3.1 to 7.3.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 23:49:27 +00:00
rookiestar28 a8030cf8dc docs: refresh recent updates and host parity notes 2026-04-01 22:44:37 +08:00
rookiestar28 2d98e2ead4 test(e2e): add desktop host parity regression lane 2026-04-01 22:31:02 +08:00
rookiestar28 5876f12c19 docs(compat): refresh reference host anchors 2026-04-01 22:00:56 +08:00
rookiestar28 73b920a925 test(e2e): fulfill mocked live backend image outputs 2026-03-30 02:25:27 +08:00
rookiestar28 86dfbb6566 fix(ci): upgrade deprecated GitHub action runtimes 2026-03-30 02:25:18 +08:00
rookiestar28 cc0853d61c fix(e2e): harden harness bootstrap mocks 2026-03-30 02:25:10 +08:00
rookiestar28 bc6484bf7d docs: document feishu connector support and bump to v0.8.1 2026-03-28 00:53:52 +08:00
rookiestar28 c607a86228 feat: add feishu interactive callback adapter 2026-03-27 23:02:40 +08:00
rookiestar28 d0f7c35620 feat: add feishu installation bindings 2026-03-27 22:38:00 +08:00
rookiestar28 0de9cdec15 feat: add feishu connector baseline 2026-03-27 22:10:28 +08:00
rookiestar28 4366ee32cd Merge pull request #10 from rookiestar28/dependabot/npm_and_yarn/npm_and_yarn-3f9ee708be
chore(deps-dev): bump picomatch from 4.0.3 to 4.0.4 in the npm_and_yarn group across 1 directory
2026-03-27 19:14:36 +08:00
dependabot[bot] 1137112caf chore(deps-dev): bump picomatch
Bumps the npm_and_yarn group with 1 update in the / directory: [picomatch](https://github.com/micromatch/picomatch).


Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 11:48:33 +00:00
403 changed files with 66175 additions and 10306 deletions
+69 -33
View File
@@ -10,6 +10,9 @@ on:
schedule:
- cron: '0 3 * * *' # nightly 03:00 UTC for adversarial-extended
permissions:
contents: read
jobs:
import-smoke:
@@ -20,13 +23,16 @@ jobs:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install import deps
run: |
python -m pip install --upgrade pip
@@ -50,23 +56,26 @@ jobs:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install preflight deps
run: |
python -m pip install --upgrade pip
python -m pip install cryptography
python -m pip install -r requirements.txt
- name: R120 preflight
run: |
python scripts/preflight_check.py --strict
- name: Install Node deps
run: |
npm install
npm ci
- name: Install Playwright browsers
run: |
npx playwright install chromium
@@ -79,23 +88,32 @@ jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install test deps
run: |
python -m pip install --upgrade pip
# Keep aligned with local pre-push/full-test scripts.
# aiohttp is required by multiple unit-test import paths.
# CRITICAL: Python 3.10 coverage reads pyproject.toml only when the
# TOML extra is present; do not downgrade this back to plain coverage.
python -m pip install -r requirements.txt
python -m pip install numpy pillow aiohttp
python -m pip install -r requirements-quality.txt
python -m pip install numpy pillow aiohttp "coverage[toml]"
- name: R120 preflight
run: |
python scripts/preflight_check.py --strict
- name: Static-analysis policy
run: |
python scripts/verify_static_analysis_policy.py
- name: Run MAE hard-guarantee suites
env:
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_mae
@@ -110,19 +128,25 @@ jobs:
env:
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_unit
run: |
python scripts/run_unittests.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json
python scripts/run_backend_coverage.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json --coverage-json .tmp/coverage/backend_unit_coverage.json
- name: Coverage hotspot report
run: |
python scripts/report_coverage_governance.py --coverage-json .tmp/coverage/backend_unit_coverage.json
backend-e2e-real:
name: Backend E2E (real-backend lane, low-mock)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install test deps
run: |
python -m pip install --upgrade pip
@@ -148,13 +172,16 @@ jobs:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install test deps
run: |
python -m pip install --upgrade pip
@@ -171,18 +198,21 @@ jobs:
name: Security Audit (S23)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Frontend Audit (npm)
run: |
# Audit only production dependencies, ignore dev
npm audit --production
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Frontend Audit (npm)
run: |
# Development tooling is part of the build/test trust boundary.
npm ci
npm audit --audit-level=high
- name: Install backend deps
run: |
python -m pip install --upgrade pip
@@ -200,12 +230,15 @@ jobs:
name: Adversarial Gate (adaptive)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install test deps
run: |
python -m pip install --upgrade pip
@@ -223,7 +256,7 @@ jobs:
python scripts/run_adversarial_gate.py --profile auto --seed 42 --artifact-dir .tmp/adversarial
- name: Upload adversarial artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: adversarial-adaptive-manifest
path: .tmp/adversarial/
@@ -234,10 +267,13 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install test deps
run: |
python -m pip install --upgrade pip
@@ -253,7 +289,7 @@ jobs:
python scripts/run_adversarial_gate.py --profile extended --artifact-dir .tmp/adversarial-extended
- name: Upload adversarial artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: adversarial-extended-manifest
path: .tmp/adversarial-extended/
+50
View File
@@ -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 }}"
+26
View File
@@ -0,0 +1,26 @@
name: Dependency Review
on:
pull_request:
paths:
- "package.json"
- "package-lock.json"
- "requirements.txt"
- "pyproject.toml"
- ".github/workflows/dependency-review.yml"
permissions:
contents: read
pull-requests: read
jobs:
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Dependency Review
uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
comment-summary-in-pr: always
+9 -2
View File
@@ -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
+13 -3
View File
@@ -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 }}
+5 -2
View File
@@ -9,6 +9,9 @@ on:
push:
branches: [main, master]
permissions:
contents: read
jobs:
secret-scan:
name: Detect Secrets
@@ -16,10 +19,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.11"
+56 -20
View File
@@ -1,36 +1,72 @@
__pycache__/
*.py[cod]
*.pyd
.planning/
.env
.venv/
.venv-wsl/
venv/
env/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.tox/
htmlcov/
.coverage
.coverage.*
coverage.xml
*.cover
reference/
AGEN*.md
scripts/sync_split.py
tests_output.txt
node_modules/
playwright-report/
test-results/
playwright/.cache/
openclaw_state/
.tmp/
*.log
moltbot_state/
test_output.txt
test_auth_out.txt
connector_state.json*
*.swp
# Agent/local project exclusions
.pla*/
reference/
REFERENCE/
.reference/
ROA*.md
roa*.md
AG*.md
# Secrets and local environment
.env
.env.*
*.env
!.env.example
!.env.sample
# Python
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
.se*/
.mypy_cache/
.ruff_cache/
.coverage
coverage.xml
htmlcov/
.venv/
.venv-*/
venv/
ENV/
# Node / frontend tests
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
playwright-report/
test-results/
coverage/
# Build, cache, temp, and local tool output
dist/
build/
.cache/
.tmp/
tmp/
temp/
*.log
# OS and editor files
.DS_Store
Thumbs.db
.vscode/
.idea/
*.swp
+16
View File
@@ -41,6 +41,22 @@ repos:
language: python
pass_filenames: false
always_run: true
- id: static-analysis-policy
name: incremental Ruff/Mypy static-analysis policy
# Keep isolated pins aligned with requirements-quality.txt and policy JSON.
entry: python -B scripts/verify_static_analysis_policy.py
language: python
additional_dependencies:
- ruff==0.15.20
- mypy==2.2.0
pass_filenames: false
always_run: true
- id: production-dependency-boundary
name: production dependency boundary contract
entry: python -B scripts/verify_production_dependencies.py
language: python
pass_filenames: false
always_run: true
# Secret detection
- repo: https://github.com/Yelp/detect-secrets
+332 -609
View File
File diff suppressed because it is too large Load Diff
+20 -3
View File
@@ -9,6 +9,7 @@ if _OPENCLAW_ROOT not in sys.path:
if __package__:
from .nodes.batch_variants import OpenClawBatchVariants
from .nodes.image_to_prompt import OpenClawImageToPrompt
from .nodes.portability_contract import NODE_PORTABILITY_MAPPINGS
from .nodes.prompt_planner import OpenClawPromptPlanner
from .nodes.prompt_refiner import OpenClawPromptRefiner
@@ -27,12 +28,18 @@ if __package__:
"MoltbotPromptRefiner": "openclaw: Prompt Refiner",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
__all__ = [
"NODE_CLASS_MAPPINGS",
"NODE_DISPLAY_NAME_MAPPINGS",
"NODE_PORTABILITY_MAPPINGS",
"WEB_DIRECTORY",
]
else:
# Allow test collection to proceed without crashing on relative imports
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
__all__ = ["WEB_DIRECTORY"]
NODE_PORTABILITY_MAPPINGS = {}
__all__ = ["NODE_PORTABILITY_MAPPINGS", "WEB_DIRECTORY"]
WEB_DIRECTORY = "./web"
@@ -46,7 +53,17 @@ def _bootstrap_openclaw_routes() -> None:
from .services.route_bootstrap import register_routes_once
else:
from services.route_bootstrap import register_routes_once
except Exception:
except Exception as exc:
try:
if __package__:
from .services.startup_lifecycle import mark_bootstrap_import_failed
else:
from services.startup_lifecycle import mark_bootstrap_import_failed
mark_bootstrap_import_failed(exc)
except Exception:
# IMPORTANT: diagnostics must not mask the original compatibility fallback.
pass
return
register_routes_once()
+16 -4
View File
@@ -26,6 +26,7 @@ try:
from ..services.execution_budgets import BudgetExceededError
from ..services.idempotency_store import IdempotencyStore
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.redaction import stable_redaction_tag
from ..services.sidecar.auth import is_bridge_enabled, require_bridge_auth
from ..services.sidecar.bridge_contract import (
BRIDGE_ENDPOINTS,
@@ -44,6 +45,7 @@ except ImportError:
from services.execution_budgets import BudgetExceededError
from services.idempotency_store import IdempotencyStore
from services.rate_limit import build_rate_limit_response, check_rate_limit
from services.redaction import stable_redaction_tag
from services.sidecar.auth import is_bridge_enabled, require_bridge_auth
from services.sidecar.bridge_contract import (
BRIDGE_ENDPOINTS,
@@ -82,6 +84,10 @@ MAX_FILES_COUNT = 10
_startup_time = time.time()
def _bridge_sensitive_tag(value: Optional[str], *, label: str) -> str:
return stable_redaction_tag(value, label=label)
class BridgeHandlers:
"""Handlers for bridge API endpoints."""
@@ -107,7 +113,7 @@ class BridgeHandlers:
def _bridge_token(self, device_id: Optional[str], scope: Optional[str] = None):
scopes = {scope} if scope else set()
return SimpleNamespace(
token_id=f"bridge:{device_id or 'unknown'}",
token_id=f"bridge:{_bridge_sensitive_tag(device_id, label='device')}",
role="bridge",
scopes=scopes,
)
@@ -716,7 +722,10 @@ class BridgeHandlers:
store_key = f"wr:{idempotency_key}"
is_dup, _ = self._idempotency_store.check_and_record(store_key, ttl=86400)
if is_dup:
logger.info(f"Duplicate worker result suppressed: {idempotency_key}")
logger.info(
"Duplicate worker result suppressed for %s",
_bridge_sensitive_tag(idempotency_key, label="idem"),
)
return web.json_response(
{
"ok": True,
@@ -745,7 +754,8 @@ class BridgeHandlers:
self._worker_results[job_id] = {
"status": data.get("status", "completed"),
"outputs": data.get("outputs", {}),
"worker_id": device_id,
# IMPORTANT: keep worker identity redacted in cached bridge state.
"worker_id": _bridge_sensitive_tag(device_id, label="device"),
"timestamp": time.time(),
}
@@ -761,7 +771,9 @@ class BridgeHandlers:
scope=BridgeScope.JOB_SUBMIT.value,
details={"status": data.get("status", "completed")},
)
logger.info(f"F46: Worker result accepted for job={job_id} from={device_id}")
# IMPORTANT: keep this success log constant. Request-auth CodeQL still treats
# the surrounding handler scope as credential-tainted even when job_id is benign.
logger.info("F46: Worker result accepted.")
return web.json_response(response_data, status=201)
@endpoint_metadata(
+75 -804
View File
@@ -166,6 +166,29 @@ except Exception:
logger = logging.getLogger("ComfyUI-OpenClaw.api.config")
(
ConfigHandlerDependencies,
config_get_response,
config_put_response,
) = import_attrs_dual(
__package__,
"..api.config_projection_handlers",
"api.config_projection_handlers",
("ConfigHandlerDependencies", "config_get_response", "config_put_response"),
)
(llm_models_response,) = import_attrs_dual(
__package__,
"..api.config_model_handlers",
"api.config_model_handlers",
("llm_models_response",),
)
(llm_chat_response, llm_test_response) = import_attrs_dual(
__package__,
"..api.config_llm_handlers",
"api.config_llm_handlers",
("llm_chat_response", "llm_test_response"),
)
(
_MODEL_LIST_CACHE,
_MODEL_LIST_MAX_ENTRIES,
@@ -258,6 +281,46 @@ except ImportError:
]
def _handler_dependencies():
"""Capture established facade patch seams for owned config handlers."""
return ConfigHandlerDependencies(
web=web,
logger=logger,
provider_catalog=PROVIDER_CATALOG,
pack_version=PACK_VERSION,
require_observability_access=require_observability_access,
require_admin_token=require_admin_token,
require_same_origin_if_no_token=require_same_origin_if_no_token,
resolve_token_info=resolve_token_info,
emit_audit_event=emit_audit_event,
check_rate_limit=check_rate_limit,
build_rate_limit_response=build_rate_limit_response,
get_client_ip=get_client_ip,
is_loopback=is_loopback,
get_admin_token=get_admin_token,
get_apply_semantics=get_apply_semantics,
get_effective_config=get_effective_config,
get_llm_egress_controls=get_llm_egress_controls,
get_runtime_guardrails=get_runtime_guardrails,
get_settings_schema=get_settings_schema,
is_loopback_client=is_loopback_client,
update_config=update_config,
tenant_boundary_error=TenantBoundaryError,
request_tenant_scope=request_tenant_scope,
runtime_only_code=CODE_RUNTIME_ONLY_PERSIST_FORBIDDEN,
payload_contains_runtime_guardrails=payload_contains_runtime_guardrails,
model_cache_get=_cache_get,
format_llm_ssrf_error=_format_llm_ssrf_error,
llm_insecure_override_enabled=_llm_insecure_override_enabled,
fetch_remote_model_list=fetch_remote_model_list,
get_stale_cached_models=get_stale_cached_models,
resolve_model_list_target=resolve_model_list_target,
validate_model_list_target=validate_model_list_target,
llm_client=LLMClient,
)
@endpoint_metadata(
auth=AuthTier.OBSERVABILITY,
risk=RiskTier.LOW,
@@ -272,73 +335,8 @@ async def config_get_handler(request: web.Request) -> web.Response:
Returns effective config, sources, and provider catalog.
Enforced by S14 Access Control.
"""
if web is None:
raise RuntimeError("aiohttp not available")
# S14: Access Control
allowed, error = require_observability_access(request)
if not allowed:
return web.json_response({"ok": False, "error": error}, status=403)
# S17: Rate Limit
if not check_rate_limit(request, "admin"):
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
effective, sources = get_effective_config(tenant_id=tenant.tenant_id)
guardrails = get_runtime_guardrails()
if guardrails.get("status") != "ok":
emit_audit_event(
action="runtime.guardrails",
target="runtime_guardrails",
outcome="warn",
token_info=token_info,
status_code=200,
details={
"tenant_id": tenant.tenant_id,
"code": guardrails.get("code"),
"violations": guardrails.get("violations", []),
},
request=request,
)
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"config": effective,
"sources": sources,
"runtime_guardrails": guardrails,
"providers": PROVIDER_CATALOG,
# R70: Settings schema for frontend type coercion / validation
"schema": get_settings_schema(),
# Simplified UX: writes are controlled by admin access policy, not a separate env "enable" flag.
"write_enabled": True,
}
)
except TenantBoundaryError as e:
return web.json_response(
{"ok": False, "error": e.code, "message": str(e)},
status=403,
)
except Exception as e:
logger.exception("Error getting config")
return web.json_response(
{
"ok": False,
"error": str(e),
},
status=500,
)
# CRITICAL: owned implementation performs require_observability_access before reads.
return await config_get_response(request, _handler_dependencies())
@endpoint_metadata(
@@ -357,210 +355,11 @@ async def llm_models_handler(request: web.Request) -> web.Response:
Security:
- admin boundary
- loopback-only unless OPENCLAW_ALLOW_REMOTE_ADMIN=1
- SSRF policy enforced via OPENCLAW_LLM_ALLOWED_HOSTS / OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST
- SSRF policy enforced via LLM egress controls, including scoped private-network allowance
"""
if web is None:
raise RuntimeError("aiohttp not available")
# S17: Rate Limit
if not check_rate_limit(request, "admin"):
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
# Admin boundary
allowed, err = require_admin_token(request)
if not allowed:
emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=403,
details={
"tenant_id": tenant.tenant_id,
"reason": err or "unauthorized",
},
request=request,
)
return web.json_response(
{
"ok": False,
"error": err or "Unauthorized",
},
status=403,
)
# Optional loopback check (match config_put behavior)
import os
allow_remote = (
os.environ.get("OPENCLAW_ALLOW_REMOTE_ADMIN")
or os.environ.get("MOLTBOT_ALLOW_REMOTE_ADMIN")
or ""
).lower()
if allow_remote not in ("1", "true", "yes", "on"):
remote = request.remote or ""
if not is_loopback_client(remote):
return web.json_response(
{
"ok": False,
"error": "Remote admin access denied. Set OPENCLAW_ALLOW_REMOTE_ADMIN=1 (or legacy MOLTBOT_ALLOW_REMOTE_ADMIN=1) to allow.",
},
status=403,
)
provider_override = (request.query.get("provider") or "").strip().lower()
effective, _sources = get_effective_config(tenant_id=tenant.tenant_id)
try:
target = resolve_model_list_target(
provider_override,
effective,
tenant.tenant_id,
)
except ValueError as e:
return web.json_response(
{"ok": False, "error": str(e)},
status=400,
)
except TypeError as e:
return web.json_response(
{"ok": False, "error": str(e)},
status=400,
)
# R60: Check bounded TTL+LRU cache
cached_entry = _cache_get(target.cache_key)
if cached_entry:
_ts, models = cached_entry
if isinstance(models, list):
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": target.provider,
"models": models,
"cached": True,
}
)
# CRITICAL:
# Local providers (e.g. ollama/lmstudio) intentionally work without API keys.
# Do not change this gate back to `if not api_key`, or local model-list loading
# will regress with false 400 errors.
if target.requires_api_key and not target.api_key:
return web.json_response(
{
"ok": False,
"error": f"No API key configured for provider '{target.provider}'.",
},
status=400,
)
# SSRF policy
try:
controls = get_llm_egress_controls(target.provider, target.base_url)
validate_model_list_target(
target,
controls,
allow_insecure_base_url=_llm_insecure_override_enabled(),
)
except Exception as e:
return web.json_response(
{"ok": False, "error": _format_llm_ssrf_error(e)},
status=403,
)
# Fetch /models
try:
try:
from ..services.safe_io import SSRFError
except ImportError:
from services.safe_io import SSRFError # type: ignore
models = fetch_remote_model_list(
target,
controls,
pack_version=PACK_VERSION,
allow_insecure_base_url=_llm_insecure_override_enabled(),
)
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": target.provider,
"models": models,
"cached": False,
}
)
except SSRFError as e:
return web.json_response(
{"ok": False, "error": _format_llm_ssrf_error(e)},
status=403,
)
except RuntimeError as e:
# safe_request_json raises RuntimeError for HTTP errors (non-200) contextually
# check if it looks like an HTTP error
str_e = str(e)
if "HTTP" in str_e:
# Fallback: serve stale cache entry (if any) on fetch failure
stale = get_stale_cached_models(target.cache_key)
if stale:
_ts, models = stale
warning = f"Using cached list (refresh failed: {str_e})"
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": target.provider,
"models": models,
"cached": True,
"warning": warning,
}
)
return web.json_response(
{"ok": False, "error": f"Upstream error: {str_e}"}, status=502
)
raise
except Exception as e:
stale = get_stale_cached_models(target.cache_key)
if stale:
# IMPORTANT:
# Test path intentionally injects network failures to verify cache fallback.
# Keep this as warning (no traceback) to avoid noisy false-alarm logs.
logger.warning(
"Model list refresh failed, serving cached list: %s", e
)
_ts, models = stale
warning = f"Using cached list (refresh failed: {str(e)})"
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": target.provider,
"models": models,
"cached": True,
"warning": warning,
}
)
logger.exception("Failed to fetch model list")
return web.json_response({"ok": False, "error": str(e)}, status=500)
except TenantBoundaryError as e:
return web.json_response(
{"ok": False, "error": e.code, "message": str(e)},
status=403,
)
# CRITICAL: owned implementation performs require_admin_token( before network access.
# CRITICAL S65: fetch_remote_model_list remains the safe_request_json egress owner.
return await llm_models_response(request, _handler_dependencies())
@endpoint_metadata(
@@ -576,182 +375,8 @@ async def config_put_handler(request: web.Request) -> web.Response:
PUT /moltbot/config
Updates non-secret LLM config. Protected by admin boundary (S13) + CSRF (S26+).
"""
if web is None:
raise RuntimeError("aiohttp not available")
# S26+: CSRF protection for convenience mode
admin_token_configured = bool(get_admin_token())
resp = require_same_origin_if_no_token(request, admin_token_configured)
if resp:
return resp
# S17: Rate Limit
if not check_rate_limit(request, "admin"):
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
# R99/S46: resolve identity context for non-repudiation audits.
token_info = resolve_token_info(request)
# Still enforce admin requirement (which checks hierarchy)
allowed, err = require_admin_token(request)
if not allowed:
emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": err or "admin_token_required"},
request=request,
)
return web.json_response(
{
"ok": False,
"error": err or "Unauthorized",
},
status=403,
)
# S13: Optional loopback check
import os
allow_remote = (
os.environ.get("OPENCLAW_ALLOW_REMOTE_ADMIN")
or os.environ.get("MOLTBOT_ALLOW_REMOTE_ADMIN")
or ""
).lower()
if allow_remote not in ("1", "true", "yes", "on"):
# Use S14 is_loopback which handles ipv6/mapped
remote = get_client_ip(request)
if not is_loopback(remote):
emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": "remote_admin_denied", "remote": remote},
request=request,
)
return web.json_response(
{
"ok": False,
"error": "Remote admin access denied. Set OPENCLAW_ALLOW_REMOTE_ADMIN=1 (or legacy MOLTBOT_ALLOW_REMOTE_ADMIN=1) to allow.",
},
status=403,
)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response(
{
"ok": False,
"error": "Invalid JSON body",
},
status=400,
)
# S66: Runtime guardrails are ENV-driven + runtime-only and must never be
# persisted via config writes (prevents config drift / silent downgrade paths).
if payload_contains_runtime_guardrails(body):
emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=400,
details={
"tenant_id": tenant.tenant_id,
"reason": "runtime_guardrails_runtime_only",
"code": CODE_RUNTIME_ONLY_PERSIST_FORBIDDEN,
},
request=request,
)
return web.json_response(
{
"ok": False,
"error": "runtime_guardrails are runtime-only (ENV-driven) and cannot be persisted via /config",
"code": CODE_RUNTIME_ONLY_PERSIST_FORBIDDEN,
},
status=400,
)
# Extract LLM config updates
updates = body.get("llm", body) # Support both { llm: {...} } and {...}
if not isinstance(updates, dict):
return web.json_response(
{
"ok": False,
"error": "Expected object with config fields",
},
status=400,
)
success, errors = update_config(updates, tenant_id=tenant.tenant_id)
# R99: Standardized Audit Emission
emit_audit_event(
action="config.update",
target="config.json",
outcome="allow" if success else "error",
token_info=token_info,
status_code=200 if success else 400,
details=(
{"tenant_id": tenant.tenant_id, "errors": errors}
if errors
else {"tenant_id": tenant.tenant_id}
),
request=request,
)
if not success:
return web.json_response(
{
"ok": False,
"errors": errors,
},
status=400,
)
# Return updated config
effective, sources = get_effective_config(tenant_id=tenant.tenant_id)
# R53: Calculate apply semantics
apply_info = get_apply_semantics(list(updates.keys()))
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"config": effective,
"sources": sources,
"apply": apply_info,
}
)
except TenantBoundaryError as e:
emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": e.code},
request=request,
)
return web.json_response(
{"ok": False, "error": e.code, "message": str(e)},
status=403,
)
# CRITICAL: owned implementation performs require_admin_token( before mutation.
return await config_put_response(request, _handler_dependencies())
@endpoint_metadata(
@@ -767,215 +392,8 @@ async def llm_test_handler(request: web.Request) -> web.Response:
POST /moltbot/llm/test
Tests LLM connection. Protected by admin boundary (S13) + CSRF (S26+).
"""
if web is None:
raise RuntimeError("aiohttp not available")
try:
from ..services.async_utils import run_in_thread
except ImportError:
from services.async_utils import run_in_thread
try:
# IMPORTANT: use package-relative import in ComfyUI runtime.
# CRITICAL: Missing this import causes NameError in provider error handling.
from ..services.provider_errors import ProviderHTTPError
except ImportError:
from services.provider_errors import ProviderHTTPError # type: ignore
# S26+: CSRF protection for convenience mode
admin_token_configured = bool(get_admin_token())
resp = require_same_origin_if_no_token(request, admin_token_configured)
if resp:
return resp
# S17: Rate Limit
if not check_rate_limit(request, "admin"):
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
token_info = resolve_token_info(request)
# S13: Validate admin boundary
allowed, err = require_admin_token(request)
if not allowed:
emit_audit_event(
action="llm.test_connection",
target="llm",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": err or "unauthorized"},
request=request,
)
return web.json_response(
{
"ok": False,
"error": err or "Unauthorized",
},
status=403,
)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
# IMPORTANT (Settings UX / provider mismatch):
# - The Settings UI allows selecting provider/model/base_url without persisting config immediately.
# - If this endpoint only uses effective config, "Test Connection" can misleadingly test the
# previous provider (often "openai") and report: "API key not configured for provider 'openai'"
# even when the UI is set to Gemini and a Gemini key is stored.
# Therefore, accept optional overrides in the JSON body.
#
# Contract:
# - Empty body -> test effective config
# - Body may include: provider, model, base_url, timeout_sec, max_retries
try:
body = await request.json()
if body is None:
body = {}
except Exception:
body = {}
if body and not isinstance(body, dict):
return web.json_response(
{"ok": False, "error": "Expected JSON object body (or empty body)"},
status=400,
)
provider = (
body.get("provider") if isinstance(body.get("provider"), str) else None
)
model = body.get("model") if isinstance(body.get("model"), str) else None
base_url = (
body.get("base_url") if isinstance(body.get("base_url"), str) else None
)
timeout_val = body.get("timeout_sec")
timeout_sec = None
if (
isinstance(timeout_val, (int, float, str))
and str(timeout_val).strip() != ""
):
try:
timeout_sec = int(timeout_val)
except Exception:
return web.json_response(
{"ok": False, "error": "timeout_sec must be an integer"},
status=400,
)
retries_val = body.get("max_retries")
max_retries = None
if (
isinstance(retries_val, (int, float, str))
and str(retries_val).strip() != ""
):
try:
max_retries = int(retries_val)
except Exception:
return web.json_response(
{"ok": False, "error": "max_retries must be an integer"},
status=400,
)
# Initialize client (uses effective config by default; overrides if provided)
client = LLMClient(
provider=provider,
base_url=base_url,
model=model,
timeout=timeout_sec,
max_retries=max_retries,
)
# Run test in a worker thread since LLMClient is sync
result = await run_in_thread(
client.complete,
system="You are a test assistant.",
user_message="Respond with exactly: OK",
max_tokens=10,
)
# Check result
if result and "text" in result:
emit_audit_event(
action="llm.test_connection",
target=f"{client.provider}:{client.model}",
outcome="allow",
token_info=token_info,
status_code=200,
details={
"tenant_id": tenant.tenant_id,
"provider": client.provider,
"model": client.model,
},
request=request,
)
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"message": "Connection successful",
"response": result["text"].strip(),
"provider": client.provider,
"model": client.model,
}
)
emit_audit_event(
action="llm.test_connection",
target=f"{client.provider}:{client.model}",
outcome="error",
token_info=token_info,
status_code=500,
details={
"tenant_id": tenant.tenant_id,
"provider": client.provider,
"model": client.model,
"error": "Empty response",
},
request=request,
)
return web.json_response(
{
"ok": False,
"error": "Empty or invalid response from LLM",
}
)
except TenantBoundaryError as e:
emit_audit_event(
action="llm.test_connection",
target="llm",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": e.code},
request=request,
)
return web.json_response(
{"ok": False, "error": e.code, "message": str(e)},
status=403,
)
except Exception as e:
logger.exception("LLM test failed")
emit_audit_event(
action="llm.test_connection",
target="llm",
outcome="error",
token_info=token_info,
status_code=500,
details={"error": str(e)},
request=request,
)
return web.json_response(
{
"ok": False,
"error": str(e),
},
status=500,
)
# CRITICAL: owned implementation performs require_admin_token( before provider access.
return await llm_test_response(request, _handler_dependencies())
@endpoint_metadata(
@@ -992,152 +410,5 @@ async def llm_chat_handler(request: web.Request) -> web.Response:
Run a simple chat completion using server-side LLM config + keys.
This endpoint is intended for the connector; no prompt content is logged.
"""
if web is None:
raise RuntimeError("aiohttp not available")
try:
from ..services.async_utils import run_in_thread
except ImportError:
from services.async_utils import run_in_thread
try:
# IMPORTANT: use package-relative import in ComfyUI runtime.
# CRITICAL: Missing this import causes NameError in provider error handling.
from ..services.provider_errors import ProviderHTTPError
except ImportError:
from services.provider_errors import ProviderHTTPError # type: ignore
# S28: CSRF protection for convenience mode (no admin token configured)
admin_token_configured = bool(get_admin_token())
resp = require_same_origin_if_no_token(request, admin_token_configured)
if resp:
return resp
# S17: Rate Limit
if not check_rate_limit(request, "admin"):
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
# NOTE: Keep this server-side. Connector cannot access UI-stored secrets directly.
# This endpoint ensures keys are resolved via backend config + secret store.
# S13: Validate admin boundary (or loopback if no admin token configured)
token_info = resolve_token_info(request)
allowed, err = require_admin_token(request)
if not allowed:
return web.json_response(
{
"ok": False,
"error": err or "Unauthorized",
},
status=403,
)
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
return web.json_response(
{"ok": False, "error": "Expected JSON object body"},
status=400,
)
system = body.get("system") if isinstance(body.get("system"), str) else ""
user_message = (
body.get("user_message")
if isinstance(body.get("user_message"), str)
else body.get("message") if isinstance(body.get("message"), str) else ""
)
temperature = (
body.get("temperature")
if isinstance(body.get("temperature"), (int, float))
else 0.7
)
max_tokens = (
body.get("max_tokens") if isinstance(body.get("max_tokens"), int) else 1024
)
if not user_message:
return web.json_response(
{"ok": False, "error": "missing_user_message"},
status=400,
)
# S29: Debug-level structured log — metadata only, never raw prompt content.
logger.debug(
"llm_chat: has_system=%s msg_len=%d temperature=%.2f max_tokens=%d",
bool(system),
len(user_message),
temperature,
max_tokens,
)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
client = LLMClient()
def _run():
return client.complete(
system=system,
user_message=user_message,
temperature=temperature,
max_tokens=max_tokens,
)
result = await run_in_thread(_run)
text = ""
if isinstance(result, dict):
text = result.get("text") or ""
return web.json_response(
{"ok": True, "tenant_id": tenant.tenant_id, "text": text}
)
except TenantBoundaryError as e:
return web.json_response(
{"ok": False, "error": e.code, "message": str(e)},
status=403,
)
except ValueError as e:
# Common: missing API key for selected provider
return web.json_response(
{"ok": False, "error": str(e)},
status=400,
)
except ProviderHTTPError as e:
# IMPORTANT (recurring support issue):
# Do not swallow provider errors into a generic "llm_request_failed" without context.
# The connector can safely surface *redacted* provider messages (no prompt content)
# so users can fix misconfiguration (401/403/429, SSRF allowlist, etc.) quickly.
payload = {
"ok": False,
"error": f"{e.provider} HTTP {e.status_code}: {e.message}",
"provider": e.provider,
"status_code": e.status_code,
}
if getattr(e, "retry_after", None):
payload["retry_after"] = e.retry_after
return web.json_response(payload, status=e.status_code)
except Exception as e:
# S29: Redact exception message to prevent accidental prompt content leakage.
# Downgraded from error → warning (non-actionable for operators when provider-specific).
try:
from services.redaction import redact_text # type: ignore
except ImportError:
try:
from ..services.redaction import redact_text
except ImportError:
redact_text = str # type: ignore
logger.warning(
"LLM chat request failed: %s: %s",
type(e).__name__,
redact_text(str(e)),
)
return web.json_response(
{"ok": False, "error": "llm_request_failed"},
status=500,
)
# CRITICAL: owned implementation performs require_admin_token( before provider access.
return await llm_chat_response(request, _handler_dependencies())
+283
View File
@@ -0,0 +1,283 @@
"""Owned LLM connection-test and chat handler implementations."""
from __future__ import annotations
from typing import Any
from .config_projection_handlers import ConfigHandlerDependencies
async def llm_test_response(request: Any, deps: ConfigHandlerDependencies) -> Any:
"""Run the existing tenant-scoped, audited LLM connection test."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
try:
from ..services.async_utils import run_in_thread
except ImportError:
from services.async_utils import run_in_thread
admin_token_configured = bool(deps.get_admin_token())
response = deps.require_same_origin_if_no_token(request, admin_token_configured)
if response:
return response
if not deps.check_rate_limit(request, "admin"):
return deps.build_rate_limit_response(
request,
"admin",
web_module=deps.web,
error="Rate limit exceeded",
include_ok=True,
)
token_info = deps.resolve_token_info(request)
allowed, error = deps.require_admin_token(request)
if not allowed:
deps.emit_audit_event(
action="llm.test_connection",
target="llm",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": error or "unauthorized"},
request=request,
)
return deps.web.json_response(
{"ok": False, "error": error or "Unauthorized"}, status=403
)
try:
with deps.request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
try:
body = await request.json()
if body is None:
body = {}
except Exception:
body = {}
if body and not isinstance(body, dict):
return deps.web.json_response(
{"ok": False, "error": "Expected JSON object body (or empty body)"},
status=400,
)
provider = (
body.get("provider") if isinstance(body.get("provider"), str) else None
)
model = body.get("model") if isinstance(body.get("model"), str) else None
base_url = (
body.get("base_url") if isinstance(body.get("base_url"), str) else None
)
timeout_val = body.get("timeout_sec")
timeout_sec = None
if (
isinstance(timeout_val, (int, float, str))
and str(timeout_val).strip() != ""
):
try:
timeout_sec = int(timeout_val)
except (TypeError, ValueError, OverflowError):
return deps.web.json_response(
{"ok": False, "error": "timeout_sec must be an integer"},
status=400,
)
retries_val = body.get("max_retries")
max_retries = None
if (
isinstance(retries_val, (int, float, str))
and str(retries_val).strip() != ""
):
try:
max_retries = int(retries_val)
except (TypeError, ValueError, OverflowError):
return deps.web.json_response(
{"ok": False, "error": "max_retries must be an integer"},
status=400,
)
client = deps.llm_client(
provider=provider,
base_url=base_url,
model=model,
timeout=timeout_sec,
max_retries=max_retries,
)
result = await run_in_thread(
client.complete,
system="You are a test assistant.",
user_message="Respond with exactly: OK",
max_tokens=10,
)
if result and "text" in result:
deps.emit_audit_event(
action="llm.test_connection",
target=f"{client.provider}:{client.model}",
outcome="allow",
token_info=token_info,
status_code=200,
details={
"tenant_id": tenant.tenant_id,
"provider": client.provider,
"model": client.model,
},
request=request,
)
return deps.web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"message": "Connection successful",
"response": result["text"].strip(),
"provider": client.provider,
"model": client.model,
}
)
deps.emit_audit_event(
action="llm.test_connection",
target=f"{client.provider}:{client.model}",
outcome="error",
token_info=token_info,
status_code=500,
details={
"tenant_id": tenant.tenant_id,
"provider": client.provider,
"model": client.model,
"error": "Empty response",
},
request=request,
)
return deps.web.json_response(
{"ok": False, "error": "Empty or invalid response from LLM"}
)
except deps.tenant_boundary_error as exc:
deps.emit_audit_event(
action="llm.test_connection",
target="llm",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": exc.code},
request=request,
)
return deps.web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)}, status=403
)
except Exception as exc:
deps.logger.error("LLM test failed (error_type=%s)", type(exc).__name__)
deps.emit_audit_event(
action="llm.test_connection",
target="llm",
outcome="error",
token_info=token_info,
status_code=500,
details={"error": "llm_test_failed"},
request=request,
)
return deps.web.json_response(
{"ok": False, "error": "llm_test_failed"}, status=500
)
async def llm_chat_response(request: Any, deps: ConfigHandlerDependencies) -> Any:
"""Run server-side tenant-scoped chat without logging prompt content."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
try:
from ..services.async_utils import run_in_thread
except ImportError:
from services.async_utils import run_in_thread
try:
from ..services.provider_errors import ProviderHTTPError
except ImportError:
from services.provider_errors import ProviderHTTPError
admin_token_configured = bool(deps.get_admin_token())
response = deps.require_same_origin_if_no_token(request, admin_token_configured)
if response:
return response
if not deps.check_rate_limit(request, "admin"):
return deps.build_rate_limit_response(
request,
"admin",
web_module=deps.web,
error="Rate limit exceeded",
include_ok=True,
)
token_info = deps.resolve_token_info(request)
allowed, error = deps.require_admin_token(request)
if not allowed:
return deps.web.json_response(
{"ok": False, "error": error or "Unauthorized"}, status=403
)
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
return deps.web.json_response(
{"ok": False, "error": "Expected JSON object body"}, status=400
)
system = body.get("system") if isinstance(body.get("system"), str) else ""
user_message = (
body.get("user_message")
if isinstance(body.get("user_message"), str)
else body.get("message") if isinstance(body.get("message"), str) else ""
)
temperature = (
body.get("temperature")
if isinstance(body.get("temperature"), (int, float))
else 0.7
)
max_tokens = (
body.get("max_tokens") if isinstance(body.get("max_tokens"), int) else 1024
)
if not user_message:
return deps.web.json_response(
{"ok": False, "error": "missing_user_message"}, status=400
)
deps.logger.debug(
"llm_chat: has_system=%s msg_len=%d temperature=%.2f max_tokens=%d",
bool(system),
len(user_message),
temperature,
max_tokens,
)
try:
with deps.request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
client = deps.llm_client()
def _run():
return client.complete(
system=system,
user_message=user_message,
temperature=temperature,
max_tokens=max_tokens,
)
result = await run_in_thread(_run)
text = result.get("text") or "" if isinstance(result, dict) else ""
return deps.web.json_response(
{"ok": True, "tenant_id": tenant.tenant_id, "text": text}
)
except deps.tenant_boundary_error as exc:
return deps.web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)}, status=403
)
except ValueError as exc:
return deps.web.json_response({"ok": False, "error": str(exc)}, status=400)
except ProviderHTTPError as exc:
payload = {
"ok": False,
"error": f"{exc.provider} HTTP {exc.status_code}: {exc.message}",
"provider": exc.provider,
"status_code": exc.status_code,
}
if getattr(exc, "retry_after", None):
payload["retry_after"] = exc.retry_after
return deps.web.json_response(payload, status=exc.status_code)
except Exception as exc:
deps.logger.warning(
"LLM chat request failed: ***REDACTED*** (error_type=%s)",
type(exc).__name__,
)
return deps.web.json_response(
{"ok": False, "error": "llm_request_failed"}, status=500
)
+182
View File
@@ -0,0 +1,182 @@
"""Owned remote model-discovery handler implementation."""
from __future__ import annotations
import os
from typing import Any
from .config_projection_handlers import ConfigHandlerDependencies
async def llm_models_response(request: Any, deps: ConfigHandlerDependencies) -> Any:
"""Serve tenant-isolated bounded provider model discovery."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
if not deps.check_rate_limit(request, "admin"):
return deps.build_rate_limit_response(
request,
"admin",
web_module=deps.web,
error="Rate limit exceeded",
include_ok=True,
)
token_info = deps.resolve_token_info(request)
try:
with deps.request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
allowed, error = deps.require_admin_token(request)
if not allowed:
deps.emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=403,
details={
"tenant_id": tenant.tenant_id,
"reason": error or "unauthorized",
},
request=request,
)
return deps.web.json_response(
{"ok": False, "error": error or "Unauthorized"}, status=403
)
allow_remote = (
os.environ.get("OPENCLAW_ALLOW_REMOTE_ADMIN")
or os.environ.get("MOLTBOT_ALLOW_REMOTE_ADMIN")
or ""
).lower()
if allow_remote not in ("1", "true", "yes", "on"):
remote = request.remote or ""
if not deps.is_loopback_client(remote):
return deps.web.json_response(
{
"ok": False,
"error": "Remote admin access denied. Set OPENCLAW_ALLOW_REMOTE_ADMIN=1 (or legacy MOLTBOT_ALLOW_REMOTE_ADMIN=1) to allow.",
},
status=403,
)
provider_override = (request.query.get("provider") or "").strip().lower()
effective, _sources = deps.get_effective_config(tenant_id=tenant.tenant_id)
try:
target = deps.resolve_model_list_target(
provider_override, effective, tenant.tenant_id
)
except (TypeError, ValueError) as exc:
return deps.web.json_response(
{"ok": False, "error": str(exc)}, status=400
)
cached_entry = deps.model_cache_get(target.cache_key)
if cached_entry:
_timestamp, models = cached_entry
if isinstance(models, list):
return deps.web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": target.provider,
"models": models,
"cached": True,
}
)
# CRITICAL: local providers intentionally work without API keys.
if target.requires_api_key and not target.api_key:
return deps.web.json_response(
{
"ok": False,
"error": f"No API key configured for provider '{target.provider}'.",
},
status=400,
)
try:
controls = deps.get_llm_egress_controls(
target.provider,
target.base_url,
allow_private_network=target.allow_private_network,
)
deps.validate_model_list_target(
target,
controls,
allow_insecure_base_url=deps.llm_insecure_override_enabled(),
)
except Exception as exc:
return deps.web.json_response(
{"ok": False, "error": deps.format_llm_ssrf_error(exc)},
status=403,
)
try:
try:
from ..services.safe_io import SSRFError
except ImportError:
from services.safe_io import SSRFError
models = deps.fetch_remote_model_list(
target,
controls,
pack_version=deps.pack_version,
allow_insecure_base_url=deps.llm_insecure_override_enabled(),
)
return deps.web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": target.provider,
"models": models,
"cached": False,
}
)
except SSRFError as exc:
return deps.web.json_response(
{"ok": False, "error": deps.format_llm_ssrf_error(exc)},
status=403,
)
except RuntimeError as exc:
error_text = str(exc)
if "HTTP" in error_text:
stale = deps.get_stale_cached_models(target.cache_key)
if stale:
_timestamp, models = stale
warning = f"Using cached list (refresh failed: {error_text})"
return deps.web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": target.provider,
"models": models,
"cached": True,
"warning": warning,
}
)
return deps.web.json_response(
{"ok": False, "error": f"Upstream error: {error_text}"},
status=502,
)
raise
except Exception as exc:
stale = deps.get_stale_cached_models(target.cache_key)
if stale:
deps.logger.warning(
"Model list refresh failed, serving cached list: %s", exc
)
_timestamp, models = stale
warning = f"Using cached list (refresh failed: {exc!s})"
return deps.web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"provider": target.provider,
"models": models,
"cached": True,
"warning": warning,
}
)
deps.logger.exception("Failed to fetch model list")
return deps.web.json_response(
{"ok": False, "error": str(exc)}, status=500
)
except deps.tenant_boundary_error as exc:
return deps.web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)}, status=403
)
+244
View File
@@ -0,0 +1,244 @@
"""Owned config projection and mutation handler implementations."""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class ConfigHandlerDependencies:
web: Any
logger: Any
provider_catalog: Any
pack_version: Any
require_observability_access: Any
require_admin_token: Any
require_same_origin_if_no_token: Any
resolve_token_info: Any
emit_audit_event: Any
check_rate_limit: Any
build_rate_limit_response: Any
get_client_ip: Any
is_loopback: Any
get_admin_token: Any
get_apply_semantics: Any
get_effective_config: Any
get_llm_egress_controls: Any
get_runtime_guardrails: Any
get_settings_schema: Any
is_loopback_client: Any
update_config: Any
tenant_boundary_error: Any
request_tenant_scope: Any
runtime_only_code: Any
payload_contains_runtime_guardrails: Any
model_cache_get: Any
format_llm_ssrf_error: Any
llm_insecure_override_enabled: Any
fetch_remote_model_list: Any
get_stale_cached_models: Any
resolve_model_list_target: Any
validate_model_list_target: Any
llm_client: Any
async def config_get_response(request: Any, deps: ConfigHandlerDependencies) -> Any:
"""Return the tenant-scoped effective configuration projection."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
allowed, error = deps.require_observability_access(request)
if not allowed:
return deps.web.json_response({"ok": False, "error": error}, status=403)
if not deps.check_rate_limit(request, "admin"):
return deps.build_rate_limit_response(
request,
"admin",
web_module=deps.web,
error="Rate limit exceeded",
include_ok=True,
)
token_info = deps.resolve_token_info(request)
try:
with deps.request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
effective, sources = deps.get_effective_config(tenant_id=tenant.tenant_id)
guardrails = deps.get_runtime_guardrails()
if guardrails.get("status") != "ok":
deps.emit_audit_event(
action="runtime.guardrails",
target="runtime_guardrails",
outcome="warn",
token_info=token_info,
status_code=200,
details={
"tenant_id": tenant.tenant_id,
"code": guardrails.get("code"),
"violations": guardrails.get("violations", []),
},
request=request,
)
return deps.web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"config": effective,
"sources": sources,
"runtime_guardrails": guardrails,
"providers": deps.provider_catalog,
"schema": deps.get_settings_schema(),
"write_enabled": True,
}
)
except deps.tenant_boundary_error as exc:
return deps.web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)}, status=403
)
except Exception as exc:
deps.logger.error("Error getting config (error_type=%s)", type(exc).__name__)
return deps.web.json_response(
{"ok": False, "error": "config_read_failed"}, status=500
)
async def config_put_response(request: Any, deps: ConfigHandlerDependencies) -> Any:
"""Validate and atomically apply tenant-scoped non-secret config updates."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
admin_token_configured = bool(deps.get_admin_token())
response = deps.require_same_origin_if_no_token(request, admin_token_configured)
if response:
return response
if not deps.check_rate_limit(request, "admin"):
return deps.build_rate_limit_response(
request,
"admin",
web_module=deps.web,
error="Rate limit exceeded",
include_ok=True,
)
token_info = deps.resolve_token_info(request)
allowed, error = deps.require_admin_token(request)
if not allowed:
deps.emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": error or "admin_token_required"},
request=request,
)
return deps.web.json_response(
{"ok": False, "error": error or "Unauthorized"}, status=403
)
allow_remote = (
os.environ.get("OPENCLAW_ALLOW_REMOTE_ADMIN")
or os.environ.get("MOLTBOT_ALLOW_REMOTE_ADMIN")
or ""
).lower()
if allow_remote not in ("1", "true", "yes", "on"):
remote = deps.get_client_ip(request)
if not deps.is_loopback(remote):
deps.emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": "remote_admin_denied", "remote": remote},
request=request,
)
return deps.web.json_response(
{
"ok": False,
"error": "Remote admin access denied. Set OPENCLAW_ALLOW_REMOTE_ADMIN=1 (or legacy MOLTBOT_ALLOW_REMOTE_ADMIN=1) to allow.",
},
status=403,
)
try:
with deps.request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
try:
body = await request.json()
except json.JSONDecodeError:
return deps.web.json_response(
{"ok": False, "error": "Invalid JSON body"}, status=400
)
if deps.payload_contains_runtime_guardrails(body):
deps.emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=400,
details={
"tenant_id": tenant.tenant_id,
"reason": "runtime_guardrails_runtime_only",
"code": deps.runtime_only_code,
},
request=request,
)
return deps.web.json_response(
{
"ok": False,
"error": "runtime_guardrails are runtime-only (ENV-driven) and cannot be persisted via /config",
"code": deps.runtime_only_code,
},
status=400,
)
updates = body.get("llm", body)
if not isinstance(updates, dict):
return deps.web.json_response(
{"ok": False, "error": "Expected object with config fields"},
status=400,
)
success, errors = deps.update_config(updates, tenant_id=tenant.tenant_id)
deps.emit_audit_event(
action="config.update",
target="config.json",
outcome="allow" if success else "error",
token_info=token_info,
status_code=200 if success else 400,
details=(
{"tenant_id": tenant.tenant_id, "errors": errors}
if errors
else {"tenant_id": tenant.tenant_id}
),
request=request,
)
if not success:
return deps.web.json_response(
{"ok": False, "errors": errors}, status=400
)
effective, sources = deps.get_effective_config(tenant_id=tenant.tenant_id)
apply_info = deps.get_apply_semantics(list(updates.keys()))
return deps.web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"config": effective,
"sources": sources,
"apply": apply_info,
}
)
except deps.tenant_boundary_error as exc:
deps.emit_audit_event(
action="config.update",
target="config.json",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": exc.code},
request=request,
)
return deps.web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)}, status=403
)
+25
View File
@@ -25,6 +25,9 @@ except ImportError: # pragma: no cover
if __package__ and "." in __package__:
from ..services.access_control import require_admin_token, resolve_token_info
from ..services.connector_extraction_contract import (
get_connector_extraction_contract,
)
from ..services.connector_installation_registry import (
get_connector_installation_registry,
)
@@ -33,6 +36,9 @@ if __package__ and "." in __package__:
else: # pragma: no cover
from services.access_control import require_admin_token # type: ignore
from services.access_control import resolve_token_info # type: ignore
from services.connector_extraction_contract import ( # type: ignore
get_connector_extraction_contract,
)
from services.connector_installation_registry import ( # type: ignore
get_connector_installation_registry,
)
@@ -241,3 +247,22 @@ async def connector_installation_audit_handler(request):
{"ok": False, "error": exc.code, "message": str(exc)},
status=403,
)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="Connector extraction contract",
description="Returns the machine-readable connector extraction feasibility contract.",
audit="connector.extraction_contract.get",
plane=RoutePlane.ADMIN,
)
async def connector_extraction_contract_handler(request):
if (guard := _require_admin(request)) is not None:
return guard
return web.json_response(
{
"ok": True,
"contract": get_connector_extraction_contract(),
}
)
+84
View File
@@ -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)
+5
View File
@@ -26,6 +26,7 @@ if __package__ and "." in __package__:
)
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.request_ip import get_client_ip
from ..services.workflow_portability import get_workflow_portability_contract
else: # pragma: no cover (test-only import mode)
from models.schemas import MAX_BODY_SIZE # type: ignore
from services.access_control import is_loopback, require_admin_token # type: ignore
@@ -39,6 +40,9 @@ else: # pragma: no cover (test-only import mode)
check_rate_limit,
)
from services.request_ip import get_client_ip # type: ignore
from services.workflow_portability import ( # type: ignore
get_workflow_portability_contract,
)
# R98: Endpoint Metadata
if __package__ and "." in __package__:
@@ -213,6 +217,7 @@ async def inventory_handler(request: web.Request) -> web.Response:
"ok": True,
"nodes": node_classes,
"models": inventory_snapshot["models"],
"portability_contract": get_workflow_portability_contract(),
"snapshot_ts": inventory_snapshot["snapshot_ts"],
"scan_state": inventory_snapshot["scan_state"],
"stale": inventory_snapshot["stale"],
+35 -23
View File
@@ -8,29 +8,41 @@ import os
import time
from typing import Optional
try:
from ..services.access_control import require_admin_token, resolve_token_info
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
from ..services.presets import Preset, preset_store
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
except ImportError:
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
from services.access_control import require_admin_token, resolve_token_info
from services.aiohttp_compat import import_aiohttp_web
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
from services.presets import Preset, preset_store
from services.tenant_context import TenantBoundaryError, request_tenant_scope
if __package__ and "." in __package__:
from ..services.import_fallback import import_attrs_dual
else:
from services.import_fallback import import_attrs_dual # type: ignore
(require_admin_token, resolve_token_info) = import_attrs_dual(
__package__,
"..services.access_control",
"services.access_control",
("require_admin_token", "resolve_token_info"),
)
(import_aiohttp_web,) = import_attrs_dual(
__package__,
"..services.aiohttp_compat",
"services.aiohttp_compat",
("import_aiohttp_web",),
)
(AuthTier, RiskTier, RoutePlane, endpoint_metadata) = import_attrs_dual(
__package__,
"..services.endpoint_manifest",
"services.endpoint_manifest",
("AuthTier", "RiskTier", "RoutePlane", "endpoint_metadata"),
)
(Preset, preset_store) = import_attrs_dual(
__package__,
"..services.presets",
"services.presets",
("Preset", "preset_store"),
)
(TenantBoundaryError, request_tenant_scope) = import_attrs_dual(
__package__,
"..services.tenant_context",
"services.tenant_context",
("TenantBoundaryError", "request_tenant_scope"),
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.presets")
web = import_aiohttp_web()
+445
View File
@@ -0,0 +1,445 @@
"""Owned observability and jobs handler implementations for the API facade."""
from __future__ import annotations
import os
import time
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class RouteHandlerDependencies:
web: Any
pack_name: Any
pack_version: Any
pack_start_time: Any
log_file: Any
metrics: Any
tail_log: Any
require_observability_access: Any
require_admin_token: Any
check_rate_limit: Any
build_rate_limit_response: Any
trace_store: Any
get_executor_diagnostics: Any
redact_text: Any
check_dependency: Callable[[str], bool]
resolve_token_info: Any
emit_audit_event: Any
jobs_request_tenant_scope: Any
normalize_jobs_query: Any
build_jobs_audit_details: Any
safe_job_audit_outcomes: Any
jobs_security_error: Any
tenant_boundary_error: Any
jobs_host_contract_unsupported: Any
jobs_backend_unavailable: Any
read_jobs: Any
ensure_observability_deps_ready: Any
def ensure_observability_deps_ready(
deps: RouteHandlerDependencies,
) -> tuple[bool, str | None]:
"""Reject partially initialized observability handlers deterministically."""
missing: list[str] = []
if not callable(deps.require_observability_access):
missing.append("require_observability_access")
if not callable(deps.check_rate_limit):
missing.append("check_rate_limit")
if not callable(deps.tail_log):
missing.append("tail_log")
if missing:
return (
False,
"Backend not fully initialized (missing route dependencies: "
+ ", ".join(missing)
+ ").",
)
return True, None
async def health_response(request: Any, deps: RouteHandlerDependencies) -> Any:
"""Build the existing partial-failure-tolerant health response."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
try:
from ..services.llm_client import LLMClient
from ..services.providers.keys import requires_api_key
except ImportError:
from services.llm_client import LLMClient
from services.providers.keys import requires_api_key
uptime = time.time() - deps.pack_start_time
provider_info = {
"provider": "unknown",
"key_configured": False,
"model": "unknown",
"base_url": None,
"api_type": None,
}
key_required = True
try:
client = LLMClient()
provider_info = client.get_provider_summary()
key_required = requires_api_key(provider_info.get("provider", "unknown"))
except Exception:
provider_info = {
"provider": "unknown",
"key_configured": False,
"model": "unknown",
"base_url": None,
"api_type": None,
}
key_required = True
try:
from ..services.access_control import is_loopback
_ = is_loopback
token_val = (
os.environ.get("OPENCLAW_OBSERVABILITY_TOKEN")
or os.environ.get("MOLTBOT_OBSERVABILITY_TOKEN")
or ""
).strip()
token_configured = bool(token_val)
except ImportError:
from services.access_control import is_loopback
_ = is_loopback
token_val = (
os.environ.get("OPENCLAW_OBSERVABILITY_TOKEN")
or os.environ.get("MOLTBOT_OBSERVABILITY_TOKEN")
or ""
).strip()
token_configured = bool(token_val)
policy_mode = "token" if token_configured else "loopback_only"
try:
metrics_snapshot = deps.metrics.get_snapshot()
except Exception:
metrics_snapshot = {"errors_captured": 0, "logs_processed": 0}
try:
executor_snapshot = deps.get_executor_diagnostics() or {}
except Exception:
executor_snapshot = {}
try:
if __package__ and "." in __package__:
from ..services.startup_lifecycle import get_startup_diagnostics
else:
from services.startup_lifecycle import get_startup_diagnostics
startup_diagnostics = get_startup_diagnostics()
except Exception:
# SECURITY: keep the public fallback deterministic and content-free even when
# startup diagnostics cannot be imported.
startup_diagnostics = {
"schema_version": 1,
"phase": "package_import",
"state": "fatal",
"reason_code": "bootstrap_import_failed",
"ready": False,
"degraded": False,
"fatal": True,
"attempt": 0,
"max_attempts": 0,
"elapsed_ms": 0,
"phase_elapsed_ms": 0,
"ready_elapsed_ms": None,
"warmups": [],
}
job_stats = {}
try:
from ..services.job_events import get_job_event_store
job_stats = get_job_event_store().stats()
except Exception:
pass
control_plane_info = {}
runtime_profile = "minimal"
try:
try:
from ..services.capabilities import _get_control_plane_info
from ..services.runtime_profile import get_runtime_profile
except ImportError:
from services.capabilities import _get_control_plane_info
from services.runtime_profile import get_runtime_profile
control_plane_info = _get_control_plane_info()
runtime_profile = get_runtime_profile().value
except Exception:
pass
return deps.web.json_response(
{
"ok": True,
"pack": {
"name": deps.pack_name,
"version": deps.pack_version,
"dependencies": {
"aiohttp": deps.check_dependency("aiohttp"),
"watchdog": deps.check_dependency("watchdog"),
},
},
"uptime_sec": uptime,
"config": {
"provider": provider_info.get("provider"),
"model": provider_info.get("model"),
"base_url": provider_info.get("base_url"),
"api_type": provider_info.get("api_type"),
"llm_key_configured": provider_info.get("key_configured", False),
"llm_key_required": key_required,
},
"stats": {
"errors_captured": metrics_snapshot["errors_captured"],
"logs_processed": metrics_snapshot["logs_processed"],
"executors": executor_snapshot,
"observability": job_stats,
},
"startup": startup_diagnostics,
"access_policy": {
"observability": policy_mode,
"token_configured": token_configured,
},
"control_plane": control_plane_info,
"runtime_profile": runtime_profile,
}
)
async def logs_tail_response(request: Any, deps: RouteHandlerDependencies) -> Any:
"""Authorize, bound, filter, and redact the log-tail response."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
ok, init_error = deps.ensure_observability_deps_ready()
if not ok:
return deps.web.json_response({"ok": False, "error": init_error}, status=500)
allowed, error = deps.require_admin_token(request)
if not allowed:
return deps.web.json_response({"ok": False, "error": error}, status=403)
if not deps.check_rate_limit(request, "logs"):
return deps.build_rate_limit_response(
request,
"logs",
web_module=deps.web,
error="Rate limit exceeded",
include_ok=True,
)
try:
line_count = 50
val_n = request.query.get("n")
val_lines = request.query.get("lines")
target_val = val_n if val_n is not None else val_lines
if target_val:
with suppress(ValueError):
line_count = int(target_val)
line_count = min(max(line_count, 1), 500)
trace_id_filter = request.query.get("trace_id")
prompt_id_filter = request.query.get("prompt_id")
content = deps.tail_log(deps.log_file, line_count)
if trace_id_filter or prompt_id_filter:
content = [
line
for line in content
if (trace_id_filter and trace_id_filter in line)
or (prompt_id_filter and prompt_id_filter in line)
]
if deps.redact_text:
content = [deps.redact_text(line) for line in content]
max_bytes = 100_000
if sum(len(line.encode("utf-8")) for line in content) > max_bytes:
truncated: list[str] = []
current_bytes = 0
for line in reversed(content):
line_bytes = len(line.encode("utf-8"))
if current_bytes + line_bytes > max_bytes:
break
truncated.insert(0, line)
current_bytes += line_bytes
content = truncated
return deps.web.json_response(
{
"ok": True,
"content": content,
"filtered": bool(trace_id_filter or prompt_id_filter),
}
)
except Exception as exc:
return deps.web.json_response({"ok": False, "error": str(exc)}, status=500)
def emit_jobs_list_audit(
deps: RouteHandlerDependencies,
*,
request: Any,
token_info: Any,
outcome: str,
status_code: int,
reason: str,
**counts: Any,
) -> None:
safe_outcome = outcome if outcome in deps.safe_job_audit_outcomes else "error"
deps.emit_audit_event(
action="jobs.list",
target="jobs",
outcome=safe_outcome,
token_info=token_info,
status_code=status_code,
details=deps.build_jobs_audit_details(reason, **counts),
request=request,
)
async def jobs_response(request: Any, deps: RouteHandlerDependencies) -> Any:
"""Serve the R213 bounded jobs read model behind its security transaction."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
token_info = deps.resolve_token_info(request)
if not deps.check_rate_limit(request, "admin"):
emit_jobs_list_audit(
deps,
request=request,
token_info=token_info,
outcome="rate_limit",
status_code=429,
reason="jobs_rate_limited",
)
return deps.build_rate_limit_response(
request,
"admin",
web_module=deps.web,
error="jobs_rate_limited",
include_ok=True,
)
# CRITICAL: metadata is descriptive; this guard must precede queue/history access.
allowed, _error = deps.require_admin_token(request)
if not allowed:
emit_jobs_list_audit(
deps,
request=request,
token_info=token_info,
outcome="deny",
status_code=403,
reason="jobs_admin_required",
)
return deps.web.json_response(
{"ok": False, "error": "jobs_admin_required"}, status=403
)
try:
with deps.jobs_request_tenant_scope(request, token_info) as tenant_context:
query = deps.normalize_jobs_query(request.query)
body = deps.read_jobs(query, tenant_id=tenant_context.tenant_id)
scan = body["scan"]
emit_jobs_list_audit(
deps,
request=request,
token_info=token_info,
outcome="allow",
status_code=200,
reason="jobs_listed",
returned_count=len(body["jobs"]),
excluded_count=scan["excluded"],
malformed_count=scan["malformed"],
)
except deps.tenant_boundary_error as exc:
emit_jobs_list_audit(
deps,
request=request,
token_info=token_info,
outcome="deny",
status_code=403,
reason=exc.code,
)
return deps.web.json_response({"ok": False, "error": exc.code}, status=403)
except deps.jobs_security_error:
emit_jobs_list_audit(
deps,
request=request,
token_info=token_info,
outcome="error",
status_code=400,
reason="jobs_query_invalid",
)
return deps.web.json_response(
{"ok": False, "error": "jobs_query_invalid"}, status=400
)
except deps.jobs_host_contract_unsupported:
emit_jobs_list_audit(
deps,
request=request,
token_info=token_info,
outcome="unsupported",
status_code=501,
reason="jobs_host_contract_unsupported",
)
return deps.web.json_response(
{"ok": False, "error": "jobs_host_contract_unsupported"}, status=501
)
except deps.jobs_backend_unavailable:
emit_jobs_list_audit(
deps,
request=request,
token_info=token_info,
outcome="error",
status_code=503,
reason="jobs_backend_unavailable",
)
return deps.web.json_response(
{"ok": False, "error": "jobs_backend_unavailable"}, status=503
)
return deps.web.json_response(body)
async def trace_response(request: Any, deps: RouteHandlerDependencies) -> Any:
"""Authorize and return the redacted operator trace projection."""
if deps.web is None:
raise RuntimeError("aiohttp not available")
ok, init_error = deps.ensure_observability_deps_ready()
if not ok:
return deps.web.json_response({"ok": False, "error": init_error}, status=500)
allowed, error = deps.require_admin_token(request)
if not allowed:
return deps.web.json_response({"ok": False, "error": error}, status=403)
prompt_id = request.match_info.get("prompt_id")
if not prompt_id:
return deps.web.json_response(
{"ok": False, "error": "missing_prompt_id"}, status=400
)
record = deps.trace_store.get(prompt_id)
if not record:
return deps.web.json_response({"ok": False, "error": "not_found"}, status=404)
trace_data = record.to_dict()
try:
from ..services.reasoning_redaction import (
audit_reasoning_reveal,
resolve_reasoning_reveal,
sanitize_operator_payload,
)
from ..services.redaction import redact_json
except ImportError:
from services.reasoning_redaction import (
audit_reasoning_reveal,
resolve_reasoning_reveal,
sanitize_operator_payload,
)
from services.redaction import redact_json
if redact_json:
trace_data = redact_json(trace_data)
reveal = resolve_reasoning_reveal(request, admin_authorized=allowed)
audit_reasoning_reveal(request, target="trace.get", decision=reveal)
trace_data = sanitize_operator_payload(
trace_data, include_reasoning=reveal["allowed"]
)
return deps.web.json_response({"ok": True, "trace": trace_data})
+206
View File
@@ -0,0 +1,206 @@
"""Owned PromptServer route registration and startup orchestration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from functools import wraps
from typing import Any
@dataclass(frozen=True)
class RouteRegistrationDependencies:
build_core_route_specs: Callable[..., Any]
build_assist_route_specs: Callable[..., Any]
build_connector_installation_route_specs: Callable[..., Any]
build_pack_route_specs: Callable[..., Any]
register_route_family: Callable[..., None]
register_dual_route: Callable[..., None]
core_handlers: dict[str, Any]
assist: Any
connector_installation_handlers: dict[str, Any] | None
run_mae_startup_gate: Callable[[Any], None]
def register_dual_route(
server: Any,
method: str,
path: str,
handler: Any,
*,
metrics: Any = None,
legacy_headers_builder: Any = None,
) -> None:
"""Register PromptServer and direct aliases with one legacy wrapper."""
if not callable(handler):
print(
f"[OpenClaw] Warning: Skipping route {method} {path} because handler is missing (None)."
)
return
actual_handler = handler
if path.startswith("/moltbot"):
@wraps(handler)
async def _deprecated_handler(request: Any) -> Any:
try:
if metrics:
metrics.inc("legacy_api_hits")
except Exception:
pass
print(
f"[OpenClaw] DEPRECATION WARNING: Legacy route accessed: {request.path}. Please migrate to /openclaw/* equivalents."
)
response = await handler(request)
if legacy_headers_builder:
headers = legacy_headers_builder(getattr(request, "path", path))
response_headers = getattr(response, "headers", None)
if (
headers
and response_headers is not None
and hasattr(response_headers, "update")
):
response_headers.update(headers)
return response
actual_handler = _deprecated_handler
registrar = (
getattr(server.routes, method.lower(), None)
if method in {"GET", "POST", "PUT", "DELETE"}
else None
)
if registrar is not None:
registrar(path)(actual_handler)
if hasattr(server, "app") and hasattr(server.app, "router"):
for target in (path, "/api" + path):
try:
# IMPORTANT: direct aliases must retain the same legacy wrapper.
server.app.router.add_route(method, target, actual_handler)
except RuntimeError:
pass
except Exception as exc:
print(
f"[OpenClaw] Warning: Failed to register fallback route {target}: {exc}"
)
def run_mae_startup_gate(server: Any, resolve_profile: Callable[[], str]) -> None:
"""Validate the registered OpenClaw route posture for the active profile."""
if not hasattr(server, "app"):
return
try:
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
generate_manifest,
validate_mae_posture,
)
else:
from services.endpoint_manifest import (
generate_manifest,
validate_mae_posture,
)
except Exception as exc:
print(f"[OpenClaw] Warning: S60 MAE gate unavailable: {exc}")
return
profile = resolve_profile()
manifest = generate_manifest(server.app)
scoped_manifest = [
entry for entry in manifest if _is_openclaw_managed_path(entry.get("path", ""))
]
ok, violations = validate_mae_posture(scoped_manifest, profile=profile)
if ok:
return
message = "S60 MAE posture validation failed:\n" + "\n".join(
f"- {item}" for item in violations
)
if profile in {"public", "hardened"}:
raise RuntimeError(message)
print(f"[OpenClaw] Warning: {message}")
def _is_openclaw_managed_path(path: str) -> bool:
if not isinstance(path, str):
return False
return path.startswith(
(
"/openclaw",
"/moltbot",
"/api/openclaw",
"/api/moltbot",
"/bridge",
"/api/bridge",
)
)
def _register_bridge(server: Any) -> None:
try:
try:
from ..api.bridge import register_bridge_routes
from ..services.modules import ModuleCapability, is_module_enabled
except (ImportError, ValueError):
from api.bridge import register_bridge_routes
from services.modules import ModuleCapability, is_module_enabled
if hasattr(server, "app") and is_module_enabled(ModuleCapability.BRIDGE):
register_bridge_routes(server.app)
print("[OpenClaw] Bridge routes registered")
elif not is_module_enabled(ModuleCapability.BRIDGE):
print("[OpenClaw] Bridge module disabled; skipping route registration")
except ImportError:
pass
def _register_packs(
server: Any, prefixes: tuple[str, ...], deps: RouteRegistrationDependencies
) -> None:
try:
try:
from ..api.packs import PacksHandlers
except (ImportError, ValueError):
from api.packs import PacksHandlers
try:
from ..config import DATA_DIR
except (ImportError, ValueError):
from config import DATA_DIR
packs = PacksHandlers(DATA_DIR)
for prefix in prefixes:
deps.register_route_family(
server,
deps.register_dual_route,
deps.build_pack_route_specs(prefix, packs),
)
except ImportError:
pass
def register_route_families(server: Any, deps: RouteRegistrationDependencies) -> None:
"""Register all route families in the frozen R220 exposure order."""
prefixes = ("/openclaw", "/moltbot")
for prefix in prefixes:
deps.register_route_family(
server,
deps.register_dual_route,
deps.build_core_route_specs(prefix, deps.core_handlers),
)
if deps.assist:
for prefix in prefixes:
deps.register_route_family(
server,
deps.register_dual_route,
deps.build_assist_route_specs(prefix, deps.assist),
)
if deps.connector_installation_handlers is not None:
for prefix in prefixes:
deps.register_route_family(
server,
deps.register_dual_route,
deps.build_connector_installation_route_specs(
prefix, deps.connector_installation_handlers
),
)
_register_bridge(server)
deps.run_mae_startup_gate(server)
_register_packs(server, prefixes, deps)
+6
View File
@@ -58,6 +58,7 @@ def build_core_route_specs(
f"{prefix}/preflight/inventory",
handlers["inventory_handler"],
),
RouteSpec("POST", f"{prefix}/pnginfo", handlers["pnginfo_handler"]),
RouteSpec("GET", f"{prefix}/checkpoints", handlers["list_checkpoints_handler"]),
RouteSpec(
"POST",
@@ -225,6 +226,11 @@ def build_connector_installation_route_specs(
f"{prefix}/connector/installations",
handlers["connector_installations_list_handler"],
),
RouteSpec(
"GET",
f"{prefix}/connector/extraction-contract",
handlers["connector_extraction_contract_handler"],
),
RouteSpec(
"GET",
f"{prefix}/connector/installations/resolve",
+244 -481
View File
@@ -7,10 +7,8 @@ Registers /openclaw/* endpoints (and legacy /moltbot/*) against ComfyUI PromptSe
# Do not move this import or insert code above it, or ComfyUI route registration will fail.
from __future__ import annotations
import json
import os
import sys
import time
from typing import cast
if __package__ and "." in __package__:
from ..services.import_fallback import import_attrs_dual
@@ -39,6 +37,46 @@ else:
),
)
(
RouteHandlerDependencies,
emit_jobs_list_audit,
health_response,
jobs_response,
logs_tail_response,
owned_ensure_observability_deps_ready,
trace_response,
) = import_attrs_dual(
__package__,
"..api.route_handlers",
"api.route_handlers",
(
"RouteHandlerDependencies",
"emit_jobs_list_audit",
"health_response",
"jobs_response",
"logs_tail_response",
"ensure_observability_deps_ready",
"trace_response",
),
)
(
RouteRegistrationDependencies,
orchestrate_dual_route,
register_route_families,
run_mae_startup_gate,
) = import_attrs_dual(
__package__,
"..api.route_orchestration",
"api.route_orchestration",
(
"RouteRegistrationDependencies",
"register_dual_route",
"register_route_families",
"run_mae_startup_gate",
),
)
# R98 / R64: Endpoint Metadata import via shared helper
(
AuthTier,
@@ -52,6 +90,13 @@ else:
("AuthTier", "RiskTier", "RoutePlane", "endpoint_metadata"),
)
(build_legacy_route_deprecation_headers,) = import_attrs_dual(
__package__,
"..services.legacy_compat",
"services.legacy_compat",
("build_legacy_route_deprecation_headers",),
)
try:
from aiohttp import web # type: ignore
except ModuleNotFoundError: # pragma: no cover (optional for unit tests)
@@ -59,13 +104,20 @@ except ModuleNotFoundError: # pragma: no cover (optional for unit tests)
PACK_NAME = PACK_VERSION = PACK_START_TIME = LOG_FILE = get_api_key = None # type: ignore
metrics = tail_log = require_observability_access = check_rate_limit = trace_store = None # type: ignore
require_admin_token = resolve_token_info = emit_audit_event = None # type: ignore
jobs_request_tenant_scope = normalize_jobs_query = build_jobs_audit_details = None # type: ignore
SAFE_JOB_AUDIT_OUTCOMES = None # type: ignore
JobsSecurityError = TenantBoundaryError = None # type: ignore
JobsHostContractUnsupported = JobsBackendUnavailable = read_jobs = None # type: ignore
get_executor_diagnostics = None # type: ignore
webhook_handler = webhook_submit_handler = webhook_validate_handler = capabilities_handler = preflight_handler = None # type: ignore
pnginfo_handler = None # type: ignore # R168
config_get_handler = config_put_handler = llm_test_handler = llm_models_handler = llm_chat_handler = None # type: ignore
remote_admin_page_handler = None # type: ignore # F61
security_doctor_handler = None # type: ignore # S30
connector_installations_list_handler = connector_installation_get_handler = None # type: ignore
connector_installation_resolve_handler = connector_installation_audit_handler = None # type: ignore
connector_extraction_contract_handler = None # type: ignore
templates_list_handler = None # type: ignore
rewrite_recipes_list_handler = rewrite_recipe_get_handler = None # type: ignore
rewrite_recipe_create_handler = rewrite_recipe_update_handler = None # type: ignore
@@ -95,6 +147,7 @@ if web is not None:
connector_installation_get_handler,
connector_installation_resolve_handler,
connector_installation_audit_handler,
connector_extraction_contract_handler,
) = import_attrs_dual(
__package__,
"..api.connector_contracts",
@@ -104,6 +157,7 @@ if web is not None:
"connector_installation_get_handler",
"connector_installation_resolve_handler",
"connector_installation_audit_handler",
"connector_extraction_contract_handler",
),
)
(
@@ -158,6 +212,12 @@ if web is not None:
"api.preflight_handler",
("inventory_handler", "preflight_handler"),
)
(pnginfo_handler,) = import_attrs_dual(
__package__,
"..api.pnginfo",
"api.pnginfo",
("pnginfo_handler",),
)
(secrets_delete_handler, secrets_put_handler, secrets_status_handler) = (
import_attrs_dual(
__package__,
@@ -259,11 +319,57 @@ if web is not None:
# CRITICAL: These imports MUST remain present.
# If edited out, module-level placeholders stay as None and handlers raise at runtime
# (e.g., TypeError: 'NoneType' object is not callable), producing noisy aiohttp tracebacks.
(require_admin_token, require_observability_access) = import_attrs_dual(
(require_admin_token, require_observability_access, resolve_token_info) = (
import_attrs_dual(
__package__,
"..services.access_control",
"services.access_control",
(
"require_admin_token",
"require_observability_access",
"resolve_token_info",
),
)
)
(emit_audit_event,) = import_attrs_dual(
__package__,
"..services.access_control",
"services.access_control",
("require_admin_token", "require_observability_access"),
"..services.audit",
"services.audit",
("emit_audit_event",),
)
(
JobsSecurityError,
SAFE_JOB_AUDIT_OUTCOMES,
TenantBoundaryError,
build_jobs_audit_details,
jobs_request_tenant_scope,
normalize_jobs_query,
) = import_attrs_dual(
__package__,
"..services.jobs_security",
"services.jobs_security",
(
"JobsSecurityError",
"SAFE_JOB_AUDIT_OUTCOMES",
"TenantBoundaryError",
"build_jobs_audit_details",
"jobs_request_tenant_scope",
"normalize_jobs_query",
),
)
(
JobsBackendUnavailable,
JobsHostContractUnsupported,
read_jobs,
) = import_attrs_dual(
__package__,
"..services.jobs_read_model",
"services.jobs_read_model",
(
"JobsBackendUnavailable",
"JobsHostContractUnsupported",
"read_jobs",
),
)
(tail_log,) = import_attrs_dual(
__package__,
@@ -335,27 +441,47 @@ def check_dependency(module_name: str) -> bool:
return False
def _handler_dependencies():
"""Capture facade patch seams for the owned route implementations."""
return RouteHandlerDependencies(
web=web,
pack_name=PACK_NAME,
pack_version=PACK_VERSION,
pack_start_time=PACK_START_TIME,
log_file=LOG_FILE,
metrics=metrics,
tail_log=tail_log,
require_observability_access=require_observability_access,
require_admin_token=require_admin_token,
check_rate_limit=check_rate_limit,
build_rate_limit_response=build_rate_limit_response,
trace_store=trace_store,
get_executor_diagnostics=get_executor_diagnostics,
redact_text=redact_text,
check_dependency=check_dependency,
resolve_token_info=resolve_token_info,
emit_audit_event=emit_audit_event,
jobs_request_tenant_scope=jobs_request_tenant_scope,
normalize_jobs_query=normalize_jobs_query,
build_jobs_audit_details=build_jobs_audit_details,
safe_job_audit_outcomes=SAFE_JOB_AUDIT_OUTCOMES,
jobs_security_error=JobsSecurityError,
tenant_boundary_error=TenantBoundaryError,
jobs_host_contract_unsupported=JobsHostContractUnsupported,
jobs_backend_unavailable=JobsBackendUnavailable,
read_jobs=read_jobs,
ensure_observability_deps_ready=_ensure_observability_deps_ready,
)
def _ensure_observability_deps_ready() -> tuple[bool, str | None]:
"""
Defensive guard against a recurring class of regressions:
if the import block above is edited incorrectly, the module-level
placeholders stay as None and handlers raise TypeError at runtime.
"""
missing: list[str] = []
if not callable(require_observability_access):
missing.append("require_observability_access")
if not callable(check_rate_limit):
missing.append("check_rate_limit")
if not callable(tail_log):
missing.append("tail_log")
if missing:
return (
False,
"Backend not fully initialized (missing route dependencies: "
+ ", ".join(missing)
+ ").",
)
return True, None
"""Preserve the established initialization-check patch seam."""
return cast(
tuple[bool, str | None],
owned_ensure_observability_deps_ready(_handler_dependencies()),
)
@endpoint_metadata(
@@ -371,135 +497,7 @@ async def health_handler(request: web.Request) -> web.Response:
GET /openclaw/health (legacy: /moltbot/health)
Returns pack status, uptime, dependencies, config presence, and stats.
"""
if web is None:
raise RuntimeError("aiohttp not available")
try:
from ..services.llm_client import LLMClient
from ..services.providers.keys import requires_api_key
except ImportError:
from services.llm_client import LLMClient
from services.providers.keys import requires_api_key
uptime = time.time() - PACK_START_TIME
# Get provider info from LLMClient
provider_info = {
"provider": "unknown",
"key_configured": False,
"model": "unknown",
"base_url": None,
"api_type": None,
}
key_required = True
try:
client = LLMClient()
provider_info = client.get_provider_summary()
key_required = requires_api_key(provider_info.get("provider", "unknown"))
except Exception:
provider_info = {
"provider": "unknown",
"key_configured": False,
"model": "unknown",
"base_url": None,
"api_type": None,
}
key_required = True
# S15: Access Policy Info
try:
from ..services.access_control import is_loopback
token_val = (
os.environ.get("OPENCLAW_OBSERVABILITY_TOKEN")
or os.environ.get("MOLTBOT_OBSERVABILITY_TOKEN")
or ""
).strip()
token_configured = bool(token_val)
except ImportError:
from services.access_control import is_loopback
token_val = (
os.environ.get("OPENCLAW_OBSERVABILITY_TOKEN")
or os.environ.get("MOLTBOT_OBSERVABILITY_TOKEN")
or ""
).strip()
token_configured = bool(token_val)
# Determine basic policy state
policy_mode = "token" if token_configured else "loopback_only"
# Metrics snapshot
# Metrics snapshot (robust even if metrics implementation changes)
try:
m_snapshot = metrics.get_snapshot()
except Exception:
m_snapshot = {"errors_captured": 0, "logs_processed": 0}
try:
executor_snapshot = get_executor_diagnostics() or {}
except Exception:
executor_snapshot = {}
# Job Event Store Stats (Backpressure)
job_stats = {}
try:
from ..services.job_events import get_job_event_store
store = get_job_event_store()
job_stats = store.stats()
except Exception:
pass
# H3 (F55): Include control_plane info for frontend mode badge
cp_info = {}
runtime_prof = "minimal"
try:
try:
from ..services.capabilities import _get_control_plane_info
from ..services.runtime_profile import get_runtime_profile
except ImportError:
from services.capabilities import _get_control_plane_info
from services.runtime_profile import get_runtime_profile
cp_info = _get_control_plane_info()
runtime_prof = get_runtime_profile().value
except Exception:
pass
return web.json_response(
{
"ok": True,
"pack": {
"name": PACK_NAME,
"version": PACK_VERSION,
"dependencies": {
"aiohttp": check_dependency("aiohttp"),
"watchdog": check_dependency("watchdog"),
},
},
"uptime_sec": uptime,
"config": {
"provider": provider_info.get("provider"),
"model": provider_info.get("model"),
"base_url": provider_info.get("base_url"),
"api_type": provider_info.get("api_type"),
"llm_key_configured": provider_info.get("key_configured", False),
"llm_key_required": key_required,
},
"stats": {
"errors_captured": m_snapshot["errors_captured"],
"logs_processed": m_snapshot["logs_processed"],
"executors": executor_snapshot, # R129
"observability": job_stats, # R87
},
# S15: Exposure Detection
"access_policy": {
"observability": policy_mode,
"token_configured": token_configured,
},
# H3 (F55): Control plane mode for frontend badge
"control_plane": cp_info,
"runtime_profile": runtime_prof,
}
)
return await health_response(request, _handler_dependencies())
@endpoint_metadata(
@@ -512,114 +510,46 @@ async def health_handler(request: web.Request) -> web.Response:
)
async def logs_tail_handler(request: web.Request) -> web.Response:
"""GET /moltbot/logs/tail - Returns the last N lines of the log file."""
if web is None:
raise RuntimeError("aiohttp not available")
ok, init_error = _ensure_observability_deps_ready()
if not ok:
return web.json_response({"ok": False, "error": init_error}, status=500)
# S34: Trace/Log data is high sensitivity -> Require Admin Token
allowed, error = require_admin_token(request)
if not allowed:
return web.json_response({"ok": False, "error": error}, status=403)
# S17: Rate Limit
if not check_rate_limit(request, "logs"):
return build_rate_limit_response(
request,
"logs",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
try:
# Default 50 lines, max 500
# Support both 'n' (internal preference) and 'lines' (legacy frontend)
line_count = 50
val_n = request.query.get("n")
val_lines = request.query.get("lines")
target_val = val_n if val_n is not None else val_lines
if target_val:
try:
line_count = int(target_val)
except ValueError:
pass
# Cap at 500
line_count = min(max(line_count, 1), 500)
# R31: Filter parameters
trace_id_filter = request.query.get("trace_id")
prompt_id_filter = request.query.get("prompt_id")
content = tail_log(LOG_FILE, line_count)
# R31: Apply filtering if requested
if trace_id_filter or prompt_id_filter:
filtered_content = []
for line in content:
# Simple substring match (case-sensitive for IDs)
if trace_id_filter and trace_id_filter in line:
filtered_content.append(line)
elif prompt_id_filter and prompt_id_filter in line:
filtered_content.append(line)
content = filtered_content
# S24: Apply redaction to each line
if redact_text:
content = [redact_text(line) for line in content]
# R31: Enforce max bytes limit (100KB total)
MAX_BYTES = 100_000
total_bytes = sum(len(line.encode("utf-8")) for line in content)
if total_bytes > MAX_BYTES:
# Truncate from end to stay under limit
truncated = []
current_bytes = 0
for line in reversed(content):
line_bytes = len(line.encode("utf-8"))
if current_bytes + line_bytes > MAX_BYTES:
break
truncated.insert(0, line)
current_bytes += line_bytes
content = truncated
return web.json_response(
{
"ok": True,
"content": content,
"filtered": bool(trace_id_filter or prompt_id_filter),
}
)
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
# CRITICAL: logs_tail_response performs require_admin_token( before log access.
return await logs_tail_response(request, _handler_dependencies())
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="List jobs",
description="Stub endpoint for job listing.",
description="Admin-authorized versioned bounded in-process jobs read model.",
audit="jobs.list",
plane=RoutePlane.ADMIN,
)
async def jobs_handler(request: web.Request) -> web.Response:
"""
GET /moltbot/jobs
Stub endpoint for job listing (not implemented yet).
GET /openclaw/jobs (legacy: /moltbot/jobs).
This handler preserves the authorization and tenant boundary around the read model.
"""
if web is None:
raise RuntimeError("aiohttp not available")
return web.json_response(
{
"ok": True,
"jobs": [],
"not_implemented": True,
"message": "Job persistence is not yet implemented. This is a stub endpoint.",
}
# CRITICAL: jobs_response performs require_admin_token( before queue/history access.
return await jobs_response(request, _handler_dependencies())
def _emit_jobs_list_audit(
*,
request,
token_info,
outcome: str,
status_code: int,
reason: str,
**counts,
) -> None:
"""Preserve the established facade seam with content-free dependency capture."""
emit_jobs_list_audit(
_handler_dependencies(),
request=request,
token_info=token_info,
outcome=outcome,
status_code=status_code,
reason=reason,
**counts,
)
@@ -633,52 +563,8 @@ async def jobs_handler(request: web.Request) -> web.Response:
)
async def trace_handler(request: web.Request) -> web.Response:
"""GET /moltbot/trace/{prompt_id} - Returns trace_id and redacted timeline."""
if web is None:
raise RuntimeError("aiohttp not available")
ok, init_error = _ensure_observability_deps_ready()
if not ok:
return web.json_response({"ok": False, "error": init_error}, status=500)
# S34: Trace/Log data is high sensitivity -> Require Admin Token
allowed, error = require_admin_token(request)
if not allowed:
return web.json_response({"ok": False, "error": error}, status=403)
prompt_id = request.match_info.get("prompt_id")
if not prompt_id:
return web.json_response(
{"ok": False, "error": "missing_prompt_id"}, status=400
)
rec = trace_store.get(prompt_id)
if not rec:
return web.json_response({"ok": False, "error": "not_found"}, status=404)
# S24: Apply redaction to trace data
trace_data = rec.to_dict()
try:
from ..services.reasoning_redaction import (
audit_reasoning_reveal,
resolve_reasoning_reveal,
sanitize_operator_payload,
)
from ..services.redaction import redact_json
except ImportError:
from services.reasoning_redaction import ( # type: ignore
audit_reasoning_reveal,
resolve_reasoning_reveal,
sanitize_operator_payload,
)
from services.redaction import redact_json
if redact_json:
trace_data = redact_json(trace_data)
reveal = resolve_reasoning_reveal(request, admin_authorized=allowed)
audit_reasoning_reveal(request, target="trace.get", decision=reveal)
trace_data = sanitize_operator_payload(
trace_data, include_reasoning=reveal["allowed"]
)
return web.json_response({"ok": True, "trace": trace_data})
# CRITICAL: trace_response performs require_admin_token( before trace access.
return await trace_response(request, _handler_dependencies())
assist = None
@@ -697,72 +583,33 @@ def register_dual_route(server, method: str, path: str, handler) -> None:
and directly to the aiohttp router with and without /api prefix
to ensure robustness against loading order (R26/F24).
"""
# IMPORTANT: handler MUST be callable. If imports fail, handlers remain None.
# Registering a None handler crashes ComfyUI at startup (aiohttp assertion).
if not callable(handler):
print(
f"[OpenClaw] Warning: Skipping route {method} {path} because handler is missing (None)."
)
return
# Phase 3 Deprecation wrapper for legacy paths
actual_handler = handler
if path.startswith("/moltbot"):
from functools import wraps
@wraps(handler)
async def _deprecated_handler(request: web.Request) -> web.Response:
try:
# Assuming `metrics` is available in scope (from module level imports)
if metrics:
metrics.inc("legacy_api_hits")
except Exception:
pass
print(
f"[OpenClaw] DEPRECATION WARNING: Legacy route accessed: {request.path}. Please migrate to /openclaw/* equivalents."
)
return await handler(request)
actual_handler = _deprecated_handler
# 1. Standard ComfyUI registration
if method == "GET":
server.routes.get(path)(actual_handler)
elif method == "POST":
server.routes.post(path)(actual_handler)
elif method == "PUT":
server.routes.put(path)(actual_handler)
elif method == "DELETE":
server.routes.delete(path)(actual_handler)
# 2. Hardened direct registration
if hasattr(server, "app") and hasattr(server.app, "router"):
# We try to register /api/... and legacy /... explicitly
# This fixes 404s if the extension loads after ComfyUI has compiled routes
targets = [path, "/api" + path]
for t in targets:
try:
server.app.router.add_route(method, t, handler)
except RuntimeError:
# Route likely exists (e.g. added by step 1 or duplicate)
pass
except Exception as e:
print(f"[OpenClaw] Warning: Failed to register fallback route {t}: {e}")
def _is_openclaw_managed_path(path: str) -> bool:
if not isinstance(path, str):
return False
return (
path.startswith("/openclaw")
or path.startswith("/moltbot")
or path.startswith("/api/openclaw")
or path.startswith("/api/moltbot")
or path.startswith("/bridge")
or path.startswith("/api/bridge")
orchestrate_dual_route(
server,
method,
path,
handler,
metrics=metrics,
legacy_headers_builder=build_legacy_route_deprecation_headers,
)
def _resolve_mae_profile() -> str:
try:
if __package__ and "." in __package__:
from ..services.effective_security_posture import (
get_effective_security_posture,
)
else:
from services.effective_security_posture import (
get_effective_security_posture,
)
posture = get_effective_security_posture(required=False)
if posture is not None:
return str(posture.mae_profile)
except ImportError:
# IMPORTANT: dependency-light import mode retains the accepted resolver below.
pass
profile = os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "local").strip().lower()
if profile in {"public", "hardened"}:
return profile
@@ -775,45 +622,16 @@ def _resolve_mae_profile() -> str:
runtime_profile = get_runtime_profile().value
if runtime_profile == "hardened":
return "hardened"
except Exception:
except ImportError:
# IMPORTANT: optional standalone import absence may fall back to the deployment
# profile, but unexpected resolver failures must propagate instead of downgrading
# hardened posture silently.
pass
return profile or "local"
def _run_mae_startup_gate(server) -> None:
if not hasattr(server, "app"):
return
try:
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
generate_manifest,
validate_mae_posture,
)
else:
from services.endpoint_manifest import (
generate_manifest,
validate_mae_posture,
)
except Exception as e:
print(f"[OpenClaw] Warning: S60 MAE gate unavailable: {e}")
return
mae_profile = _resolve_mae_profile()
manifest = generate_manifest(server.app)
scoped_manifest = [
entry for entry in manifest if _is_openclaw_managed_path(entry.get("path", ""))
]
ok, violations = validate_mae_posture(scoped_manifest, profile=mae_profile)
if ok:
return
message = "S60 MAE posture validation failed:\n" + "\n".join(
f"- {item}" for item in violations
)
if mae_profile in {"public", "hardened"}:
raise RuntimeError(message)
print(f"[OpenClaw] Warning: {message}")
run_mae_startup_gate(server, _resolve_mae_profile)
def register_routes(server) -> None:
@@ -825,18 +643,29 @@ def register_routes(server) -> None:
# Must run BEFORE any route or worker registration.
try:
try:
from ..services.effective_security_posture import (
get_effective_security_posture,
resolve_effective_security_posture,
)
from ..services.startup_profile_gate import enforce_startup_gate
except (ImportError, ValueError):
from services.effective_security_posture import (
get_effective_security_posture,
resolve_effective_security_posture,
)
from services.startup_profile_gate import enforce_startup_gate
enforce_startup_gate()
posture = get_effective_security_posture(required=False)
if posture is None:
# Compatibility/direct-test invocation is not the process owner.
posture = resolve_effective_security_posture()
enforce_startup_gate(posture=posture)
except RuntimeError:
# CRITICAL: fail-closed. Never continue route registration after S56
# startup gate failure.
raise
print("[OpenClaw] Registering routes (Shim Alignment R26)...")
prefixes = ["/openclaw", "/moltbot"] # new, legacy
core_handlers = {
"remote_admin_page_handler": remote_admin_page_handler,
"health_handler": health_handler,
@@ -855,6 +684,7 @@ def register_routes(server) -> None:
"templates_list_handler": templates_list_handler,
"preflight_handler": preflight_handler,
"inventory_handler": inventory_handler,
"pnginfo_handler": pnginfo_handler,
"list_checkpoints_handler": list_checkpoints_handler,
"create_checkpoint_handler": create_checkpoint_handler,
"get_checkpoint_handler": get_checkpoint_handler,
@@ -889,94 +719,27 @@ def register_routes(server) -> None:
"select_apply_winner_handler": select_apply_winner_handler,
}
# Core Observability & Config
for prefix in prefixes:
register_route_family(
server,
register_dual_route,
build_core_route_specs(prefix, core_handlers),
)
# F8/F21 Assist Routes
# R84 Boot Boundary: CORE (Planner/Refiner part of core/assist)
if assist:
for prefix in prefixes:
register_route_family(
server,
register_dual_route,
build_assist_route_specs(prefix, assist),
)
# R126: Connector installation diagnostics/read APIs
connector_handlers = None
if connector_installations_list_handler:
connector_installation_handlers = {
connector_handlers = {
"connector_installations_list_handler": connector_installations_list_handler,
"connector_extraction_contract_handler": connector_extraction_contract_handler,
"connector_installation_resolve_handler": connector_installation_resolve_handler,
"connector_installation_audit_handler": connector_installation_audit_handler,
"connector_installation_get_handler": connector_installation_get_handler,
}
for prefix in prefixes:
register_route_family(
server,
register_dual_route,
build_connector_installation_route_specs(
prefix, connector_installation_handlers
),
)
# F10 Bridge Routes (Sidecar)
# R84 Boot Boundary: BRIDGE
# F10 Bridge Routes (Sidecar)
# R84 Boot Boundary: BRIDGE
try:
try:
from ..api.bridge import register_bridge_routes
from ..services.modules import ModuleCapability, is_module_enabled
except (ImportError, ValueError):
from api.bridge import register_bridge_routes
from services.modules import ModuleCapability, is_module_enabled
if hasattr(server, "app") and is_module_enabled(ModuleCapability.BRIDGE):
register_bridge_routes(server.app)
print("[OpenClaw] Bridge routes registered")
elif not is_module_enabled(ModuleCapability.BRIDGE):
print("[OpenClaw] Bridge module disabled; skipping route registration")
except ImportError:
pass
_run_mae_startup_gate(server)
# S8/S23/F11 Asset Packs
# R84 Boot Boundary: REGISTRY_SYNC (Packs management)
try:
try:
from ..api.packs import PacksHandlers
from ..services.modules import ModuleCapability, is_module_enabled
except (ImportError, ValueError):
from api.packs import PacksHandlers
from services.modules import ModuleCapability, is_module_enabled
# Packs are currently treated as part of CORE or REGISTRY_SYNC depending on strictness.
# For now, we bind them to REGISTRY_SYNC if we want to segment them,
# but realistically they are often core local features.
# Let's check REGISTRY_SYNC for import/export features specifically if we wanted to split,
# but keeping them enabled by default for now unless R84 explicitly segments them.
# DESIGN DECISION: Packs are local core features. Registry sync is remote.
# We will keep basic pack routes, but R84 might control remote interactions later.
try:
from ..config import DATA_DIR
except (ImportError, ValueError):
from config import DATA_DIR
packs = PacksHandlers(DATA_DIR)
for prefix in prefixes:
register_route_family(
server,
register_dual_route,
build_pack_route_specs(prefix, packs),
)
except ImportError:
pass
register_route_families(
server,
RouteRegistrationDependencies(
build_core_route_specs=build_core_route_specs,
build_assist_route_specs=build_assist_route_specs,
build_connector_installation_route_specs=build_connector_installation_route_specs,
build_pack_route_specs=build_pack_route_specs,
register_route_family=register_route_family,
register_dual_route=register_dual_route,
core_handlers=core_handlers,
assist=assist,
connector_installation_handlers=connector_handlers,
run_mae_startup_gate=_run_mae_startup_gate,
),
)
+20 -1
View File
@@ -16,12 +16,20 @@ from typing import Optional
# can silently import another pack's module and break auth/approval semantics.
if __package__ and "." in __package__:
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.scheduler.delivery_contract import (
DeliveryContractError,
normalize_schedule_delivery,
)
from ..services.scheduler.models import Schedule, TriggerType
from ..services.scheduler.storage import get_schedule_store
from ..services.templates import is_template_allowed
from ..services.webhook_auth import AuthError
else: # pragma: no cover (test-only import mode)
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.scheduler.delivery_contract import ( # type: ignore
DeliveryContractError,
normalize_schedule_delivery,
)
from services.scheduler.models import Schedule, TriggerType # type: ignore
from services.scheduler.storage import get_schedule_store # type: ignore
from services.templates import is_template_allowed # type: ignore
@@ -31,6 +39,10 @@ logger = logging.getLogger("ComfyUI-OpenClaw.api.schedules")
web = import_aiohttp_web()
def _delivery_error_response(exc: DeliveryContractError) -> web.Response:
return web.json_response({"error": str(exc), "code": exc.code}, status=400)
def _get_scheduler_runner():
if __package__ and "." in __package__:
from ..services.scheduler.runner import get_scheduler_runner
@@ -157,6 +169,8 @@ class ScheduleHandlers:
timezone=data.get("timezone", "local"),
enabled=data.get("enabled", True),
)
except DeliveryContractError as e:
return _delivery_error_response(e)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
@@ -209,7 +223,10 @@ class ScheduleHandlers:
if "inputs" in data:
existing.inputs = data["inputs"]
if "delivery" in data:
existing.delivery = data["delivery"]
try:
existing.delivery = normalize_schedule_delivery(data["delivery"])
except DeliveryContractError as e:
return _delivery_error_response(e)
if "timezone" in data:
existing.timezone = data["timezone"]
if "enabled" in data:
@@ -218,6 +235,8 @@ class ScheduleHandlers:
# Re-validate
try:
existing.validate()
except DeliveryContractError as e:
return _delivery_error_response(e)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
+39 -30
View File
@@ -8,33 +8,47 @@ from __future__ import annotations
import json
import logging
try:
from ..services.access_control import require_admin_token, resolve_token_info
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.audit import emit_audit_event
from ..services.tool_runner import get_tool_runner, is_tools_enabled
except ImportError:
from services.access_control import require_admin_token # type: ignore
from services.access_control import resolve_token_info # type: ignore
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.audit import emit_audit_event # type: ignore
from services.tool_runner import get_tool_runner, is_tools_enabled
# R98: Endpoint Metadata
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
from ..services.import_fallback import import_attrs_dual
else:
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
from services.import_fallback import import_attrs_dual # type: ignore
(require_admin_token, resolve_token_info) = import_attrs_dual(
__package__,
"..services.access_control",
"services.access_control",
("require_admin_token", "resolve_token_info"),
)
(import_aiohttp_web,) = import_attrs_dual(
__package__,
"..services.aiohttp_compat",
"services.aiohttp_compat",
("import_aiohttp_web",),
)
(emit_audit_event,) = import_attrs_dual(
__package__,
"..services.audit",
"services.audit",
("emit_audit_event",),
)
(get_tool_runner, is_tools_enabled) = import_attrs_dual(
__package__,
"..services.tool_runner",
"services.tool_runner",
("get_tool_runner", "is_tools_enabled"),
)
(AuthTier, RiskTier, RoutePlane, endpoint_metadata) = import_attrs_dual(
__package__,
"..services.endpoint_manifest",
"services.endpoint_manifest",
("AuthTier", "RiskTier", "RoutePlane", "endpoint_metadata"),
)
(check_surface,) = import_attrs_dual(
__package__,
"..services.surface_guard",
"services.surface_guard",
("check_surface",),
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.tools")
web = import_aiohttp_web()
@@ -87,11 +101,6 @@ async def tools_run_handler(request: web.Request) -> web.Response:
Requires: Admin Token.
"""
# S62: Block tool execution in public+split mode
try:
# CRITICAL: package-relative import must stay first in ComfyUI runtime.
from ..services.surface_guard import check_surface
except ImportError:
from services.surface_guard import check_surface # type: ignore
blocked = check_surface("tool_execution", request)
if blocked:
return blocked
+61 -36
View File
@@ -14,43 +14,68 @@ from __future__ import annotations
import json
import logging
try:
from .errors import APIError, ErrorCode, create_error_response
except ImportError:
# Build-time / Test fallback
from api.errors import APIError, ErrorCode, create_error_response
if __package__ and "." in __package__:
from ..services.import_fallback import import_attrs_dual
else:
from services.import_fallback import import_attrs_dual # type: ignore
try:
from ..models.schemas import MAX_BODY_SIZE, WebhookJobRequest
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.metrics import metrics
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.trace import get_effective_trace_id
from ..services.webhook_auth import get_auth_summary, require_auth
except ImportError:
from models.schemas import MAX_BODY_SIZE, WebhookJobRequest
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.metrics import metrics
from services.rate_limit import build_rate_limit_response, check_rate_limit
from services.trace import get_effective_trace_id
from services.webhook_auth import get_auth_summary, require_auth
try:
from ..services.diagnostics_flags import diagnostics
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
except ImportError:
from services.diagnostics_flags import diagnostics
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
(APIError, ErrorCode, create_error_response) = import_attrs_dual(
__package__,
".errors",
"api.errors",
("APIError", "ErrorCode", "create_error_response"),
)
(
MAX_BODY_SIZE,
WebhookJobRequest,
) = import_attrs_dual(
__package__,
"..models.schemas",
"models.schemas",
("MAX_BODY_SIZE", "WebhookJobRequest"),
)
(import_aiohttp_web,) = import_attrs_dual(
__package__,
"..services.aiohttp_compat",
"services.aiohttp_compat",
("import_aiohttp_web",),
)
(metrics,) = import_attrs_dual(
__package__,
"..services.metrics",
"services.metrics",
("metrics",),
)
(build_rate_limit_response, check_rate_limit) = import_attrs_dual(
__package__,
"..services.rate_limit",
"services.rate_limit",
("build_rate_limit_response", "check_rate_limit"),
)
(get_effective_trace_id,) = import_attrs_dual(
__package__,
"..services.trace",
"services.trace",
("get_effective_trace_id",),
)
(get_auth_summary, require_auth) = import_attrs_dual(
__package__,
"..services.webhook_auth",
"services.webhook_auth",
("get_auth_summary", "require_auth"),
)
(diagnostics,) = import_attrs_dual(
__package__,
"..services.diagnostics_flags",
"services.diagnostics_flags",
("diagnostics",),
)
(AuthTier, RiskTier, RoutePlane, endpoint_metadata) = import_attrs_dual(
__package__,
"..services.endpoint_manifest",
"services.endpoint_manifest",
("AuthTier", "RiskTier", "RoutePlane", "endpoint_metadata"),
)
# R46: Scoped logger for safe-by-default redaction
logger = diagnostics.get_logger("ComfyUI-OpenClaw.api.webhook", "webhook")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+88 -33
View File
@@ -42,22 +42,31 @@ PACK_NAME = "ComfyUI-OpenClaw"
PACK_START_TIME = time.time()
def _read_pyproject_version() -> Optional[str]:
"""
Read version from pyproject.toml ([project].version) as the single source of truth.
def _extract_toml_section(text: str, header: str) -> Optional[str]:
pattern = re.compile(
rf"(?ms)^\ufeff?\[{re.escape(header)}\]\s*$\n(?P<body>.*?)(?=^\[|\Z)"
)
match = pattern.search(text)
if not match:
return None
return match.group("body")
Uses a lightweight regex parse to avoid non-stdlib TOML dependencies.
"""
try:
pack_dir = os.path.dirname(os.path.abspath(__file__))
pyproject_path = os.path.join(pack_dir, "pyproject.toml")
if not os.path.exists(pyproject_path):
return None
text = ""
with open(pyproject_path, "r", encoding="utf-8") as f:
text = f.read()
# Prefer stdlib TOML parser if available (Python 3.11+), then fallback to regex.
def _extract_toml_string_assignment(section_text: str, key: str) -> Optional[str]:
match = re.search(
rf"(?m)^\s*{re.escape(key)}\s*=\s*['\"]([^'\"]+)['\"]\s*$",
section_text,
)
if not match:
return None
value = match.group(1).strip()
return value or None
def _parse_pyproject_version_text(
text: str, *, prefer_tomllib: bool = True
) -> Optional[str]:
if prefer_tomllib:
try:
from tomllib import loads as _toml_loads # type: ignore
except Exception:
@@ -72,21 +81,41 @@ def _read_pyproject_version() -> Optional[str]:
except Exception:
pass
# Find the [project] section and parse `version = "..."` within it.
# IMPORTANT: tolerate BOM/CRLF so the UI version does not silently fall back to 0.1.0.
# This is intentionally conservative to avoid false matches in other sections.
m = re.search(
r"(?ms)^\ufeff?\\[project\\]\\s*(?:[^\\[]*?)^version\\s*=\\s*[\"']([^\"']+)[\"']\\s*$",
text,
)
if not m:
# IMPORTANT: keep this fallback section-bounded.
# Matching any `version = ...` outside `[project]` silently reports the wrong build.
project_section = _extract_toml_section(text, "project")
if project_section is None:
return None
return _extract_toml_string_assignment(project_section, "version")
def _read_pyproject_version_from_path(
pyproject_path: os.PathLike[str] | str, *, prefer_tomllib: bool = True
) -> Optional[str]:
"""
Read version from pyproject.toml ([project].version) as the single source of truth.
Uses a lightweight regex parse to avoid non-stdlib TOML dependencies.
"""
try:
pyproject_path = os.fspath(pyproject_path)
if not os.path.exists(pyproject_path):
return None
ver = (m.group(1) or "").strip()
return ver or None
with open(pyproject_path, "r", encoding="utf-8") as f:
text = f.read()
return _parse_pyproject_version_text(
text,
prefer_tomllib=prefer_tomllib,
)
except Exception:
return None
def _read_pyproject_version() -> Optional[str]:
pack_dir = os.path.dirname(os.path.abspath(__file__))
return _read_pyproject_version_from_path(os.path.join(pack_dir, "pyproject.toml"))
# Version: single source of truth is pyproject.toml (line 4 in this repo).
PACK_VERSION = _read_pyproject_version() or "0.1.0"
@@ -98,24 +127,39 @@ LEGACY2_ENV_API_KEY = GENERIC_LLM_API_KEY_ENV_KEYS[2]
# Data directory (R11: use portable state directory)
try:
# Prefer package-relative import (ComfyUI loads custom nodes by file loader)
from .services.state_dir import get_log_path, get_state_dir # type: ignore
from .services.state_dir import ( # type: ignore
get_log_path,
get_state_dir,
peek_log_path,
peek_state_dir,
)
except Exception:
try:
# Fallback for unit tests / direct sys.path imports
from services.state_dir import get_log_path, get_state_dir
from services.state_dir import (
get_log_path,
get_state_dir,
peek_log_path,
peek_state_dir,
)
except Exception:
get_state_dir = None
get_log_path = None
peek_state_dir = None
peek_log_path = None
if get_state_dir and get_log_path:
DATA_DIR = get_state_dir()
LOG_FILE = get_log_path()
if peek_state_dir and peek_log_path:
DATA_DIR = peek_state_dir()
LOG_FILE = peek_log_path()
else:
# Last-resort fallback during early import or if state_dir is unavailable
PACK_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(PACK_DIR, "data")
LOG_FILE = os.path.join(DATA_DIR, "openclaw.log")
_IMPORT_DATA_DIR = DATA_DIR
_IMPORT_LOG_FILE = LOG_FILE
# IMPORTANT: startup log truncation must run once per process.
# Multiple module-level loggers call setup_logger(); repeated truncation would
# erase fresh logs emitted after the first logger initialization.
@@ -151,6 +195,16 @@ def _maybe_truncate_log_on_start(logger: logging.Logger) -> None:
logger.warning(f"Failed to truncate startup log file {LOG_FILE}: {e}")
def _ensure_log_targets() -> tuple[str, str]:
global DATA_DIR, LOG_FILE
if DATA_DIR != _IMPORT_DATA_DIR or LOG_FILE != _IMPORT_LOG_FILE:
return DATA_DIR, LOG_FILE
if get_state_dir and get_log_path:
DATA_DIR = get_state_dir()
LOG_FILE = get_log_path()
return DATA_DIR, LOG_FILE
class RedactedFormatter(logging.Formatter):
"""
Custom formatter to redact sensitive information (like API keys) from logs.
@@ -194,6 +248,7 @@ def setup_logger(name: str = "ComfyUI-OpenClaw") -> logging.Logger:
# Only add handler if not already added to avoid duplicates on reload
if not logger.handlers:
data_dir, log_file = _ensure_log_targets()
_maybe_truncate_log_on_start(logger)
api_key = get_api_key()
sensitive = [api_key] if api_key else []
@@ -208,9 +263,9 @@ def setup_logger(name: str = "ComfyUI-OpenClaw") -> logging.Logger:
# File handler with rotation (5MB, 3 backups)
try:
os.makedirs(DATA_DIR, exist_ok=True)
os.makedirs(data_dir, exist_ok=True)
file_handler = RotatingFileHandler(
LOG_FILE,
log_file,
maxBytes=5 * 1024 * 1024, # 5MB
backupCount=3,
encoding="utf-8",
@@ -226,5 +281,5 @@ def setup_logger(name: str = "ComfyUI-OpenClaw") -> logging.Logger:
return logger
# Global config accessor if needed
logger = setup_logger()
# Global logger handle for compatibility; actual handler bootstrap is lazy.
logger = logging.getLogger("ComfyUI-OpenClaw")
+74 -1
View File
@@ -10,6 +10,9 @@ import sys
from .config import load_config
from .openclaw_client import OpenClawClient
from .platforms.discord_gateway import DiscordGateway
from .platforms.feishu_installation_manager import FeishuInstallationManager
from .platforms.feishu_long_connection import FeishuLongConnectionClient
from .platforms.feishu_webhook import FeishuWebhookServer
from .platforms.kakao_webhook import KakaoWebhookServer
from .platforms.line_webhook import LINEWebhookServer
from .platforms.slack_webhook import SlackWebhookServer
@@ -44,6 +47,7 @@ def _print_security_banner(config):
or config.wechat_allowed_users
or config.kakao_allowed_users
or config.slack_allowed_users
or config.feishu_allowed_users
)
has_admins = bool(config.admin_users)
@@ -107,6 +111,8 @@ async def main():
wechat_server = None
kakao_server = None
slack_server = None
feishu_server = None
feishu_long_clients = []
# 3. Platforms
if config.telegram_bot_token:
@@ -206,6 +212,67 @@ async def main():
else:
logger.info("Slack not configured (OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN missing)")
if config.feishu_bindings_json or (
config.feishu_app_id and config.feishu_app_secret
):
try:
feishu_installation_manager = FeishuInstallationManager(config)
except Exception as exc:
logger.error("Feishu adapter startup aborted (fail-closed): %s", exc)
feishu_installation_manager = None
if config.feishu_mode == "webhook":
if feishu_installation_manager is not None:
feishu_server = FeishuWebhookServer(
config,
router,
installation_manager=feishu_installation_manager,
)
elif config.feishu_mode == "websocket":
# CRITICAL: Feishu long-connection remains explicit opt-in; do not
# auto-fallback across transports or ingress verification can drift.
if feishu_installation_manager is not None:
feishu_server = FeishuWebhookServer(
config,
router,
installation_manager=feishu_installation_manager,
)
binding_configs = feishu_installation_manager.binding_configs()
if not binding_configs:
binding_configs = [config]
for binding_config in binding_configs:
feishu_long_clients.append(
FeishuLongConnectionClient(
binding_config,
router,
installation_manager=feishu_installation_manager,
bound_account_id=str(
binding_config.feishu_account_id or ""
).strip(),
)
)
else:
logger.error(
"Invalid OPENCLAW_CONNECTOR_FEISHU_MODE=%r. Expected 'websocket' or 'webhook'.",
config.feishu_mode,
)
feishu_server = None
logger.error("Feishu adapter startup aborted (fail-closed).")
if feishu_server is None and not feishu_long_clients:
logger.warning("Feishu adapter disabled due to invalid mode config.")
else:
platforms["feishu"] = feishu_server or feishu_long_clients[0]
if feishu_server is not None:
await feishu_server.start()
for feishu_long_client in feishu_long_clients:
await feishu_long_client.start()
if not tasks:
tasks.append(asyncio.create_task(asyncio.sleep(3600 * 24 * 365)))
elif config.feishu_app_id:
logger.warning("Feishu configured but App Secret missing. Skipping.")
else:
logger.info("Feishu not configured (OPENCLAW_CONNECTOR_FEISHU_APP_ID missing)")
if (
not tasks
and not line_server
@@ -213,11 +280,13 @@ async def main():
and not wechat_server
and not kakao_server
and not slack_server
and not feishu_server
and not feishu_long_clients
):
logger.error(
"No platforms configured! Set TELEGRAM_TOKEN, DISCORD_TOKEN, "
"LINE_SECRET, WHATSAPP_ACCESS_TOKEN, WECHAT_TOKEN, "
"KAKAO_ENABLED or SLACK_BOT_TOKEN."
"KAKAO_ENABLED, SLACK_BOT_TOKEN or FEISHU_APP_ID."
)
await client.close()
return
@@ -246,6 +315,10 @@ async def main():
await kakao_server.stop()
if slack_server:
await slack_server.stop()
for feishu_long_client in feishu_long_clients:
await feishu_long_client.stop()
if feishu_server:
await feishu_server.stop()
if poller:
await poller.stop()
await client.close()
+324 -54
View File
@@ -3,12 +3,155 @@ Connector Configuration (F29).
Loads environment variables and validates allowlists.
"""
import logging
import os
import sys
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional, Set
logger = logging.getLogger("ComfyUI-OpenClaw.connector.config")
DEFAULT_DELIVERY_MAX_IMAGES = 4
MIN_DELIVERY_MAX_IMAGES = 1
MAX_DELIVERY_MAX_IMAGES = 16
DEFAULT_DELIVERY_MAX_BYTES = 10 * 1024 * 1024
MIN_DELIVERY_MAX_BYTES = 64 * 1024
MAX_DELIVERY_MAX_BYTES = 50 * 1024 * 1024
DEFAULT_DELIVERY_TIMEOUT_SEC = 600
MIN_DELIVERY_TIMEOUT_SEC = 30
MAX_DELIVERY_TIMEOUT_SEC = 3600
DEFAULT_LINE_BIND_PORT = 8099
DEFAULT_WHATSAPP_BIND_PORT = 8098
DEFAULT_WECHAT_BIND_PORT = 8097
DEFAULT_KAKAO_BIND_PORT = 8096
DEFAULT_SLACK_BIND_PORT = 8095
DEFAULT_FEISHU_BIND_PORT = 8094
MIN_BIND_PORT = 1
MAX_BIND_PORT = 65535
DEFAULT_SLACK_OAUTH_STATE_TTL_SEC = 600
MIN_SLACK_OAUTH_STATE_TTL_SEC = 60
MAX_SLACK_OAUTH_STATE_TTL_SEC = 3600
DEFAULT_RATE_LIMIT_USER_RPM = 10
DEFAULT_RATE_LIMIT_CHANNEL_RPM = 30
MIN_RATE_LIMIT_RPM = 1
MAX_RATE_LIMIT_RPM = 600
DEFAULT_MAX_COMMAND_LENGTH = 4096
MIN_MAX_COMMAND_LENGTH = 128
MAX_MAX_COMMAND_LENGTH = 32768
DEFAULT_MEDIA_TTL_SEC = 300
MIN_MEDIA_TTL_SEC = 60
MAX_MEDIA_TTL_SEC = 86400
DEFAULT_MEDIA_MAX_MB = 8
MIN_MEDIA_MAX_MB = 1
MAX_MEDIA_MAX_MB = 64
def _warn_default_env(
env_key: str, raw_value: str, *, default: int, reason: str
) -> None:
logger.warning(
"Connector env %s=%r %s; using default %s.",
env_key,
raw_value,
reason,
default,
)
def _warn_clamped_env(
env_key: str,
raw_value: str,
*,
bound_name: str,
bound_value: int,
resolved: int,
) -> None:
logger.warning(
(
"Connector env %s=%r is below %s %s; clamped to %s."
if bound_name == "minimum"
else "Connector env %s=%r is above %s %s; clamped to %s."
),
env_key,
raw_value,
bound_name,
bound_value,
resolved,
)
def _load_bounded_int_env(
env_key: str,
*,
default: int,
minimum: Optional[int] = None,
maximum: Optional[int] = None,
clamp: bool = True,
) -> int:
raw_value = os.environ.get(env_key)
if raw_value is None:
return default
raw_value = raw_value.strip()
if not raw_value:
return default
try:
value = int(raw_value)
except (TypeError, ValueError):
_warn_default_env(
env_key,
raw_value,
default=default,
reason="is not a valid integer",
)
return default
if minimum is not None and value < minimum:
if clamp:
_warn_clamped_env(
env_key,
raw_value,
bound_name="minimum",
bound_value=minimum,
resolved=minimum,
)
return minimum
_warn_default_env(
env_key,
raw_value,
default=default,
reason=f"is outside supported range {minimum}..{maximum or 'inf'}",
)
return default
if maximum is not None and value > maximum:
if clamp:
_warn_clamped_env(
env_key,
raw_value,
bound_name="maximum",
bound_value=maximum,
resolved=maximum,
)
return maximum
_warn_default_env(
env_key,
raw_value,
default=default,
reason=f"is outside supported range {minimum or '-inf'}..{maximum}",
)
return default
return value
class CommandClass(str, Enum):
PUBLIC = "public" # status, help, tools
@@ -40,9 +183,9 @@ class ConnectorConfig:
# Results Delivery
delivery_enabled: bool = True
delivery_max_images: int = 4
delivery_max_bytes: int = 10 * 1024 * 1024 # 10MB
delivery_timeout_sec: int = 600
delivery_max_images: int = DEFAULT_DELIVERY_MAX_IMAGES
delivery_max_bytes: int = DEFAULT_DELIVERY_MAX_BYTES
delivery_timeout_sec: int = DEFAULT_DELIVERY_TIMEOUT_SEC
# Telegram
telegram_bot_token: Optional[str] = None
@@ -60,7 +203,7 @@ class ConnectorConfig:
line_allowed_users: List[str] = field(default_factory=list)
line_allowed_groups: List[str] = field(default_factory=list)
line_bind_host: str = "127.0.0.1"
line_bind_port: int = 8099
line_bind_port: int = DEFAULT_LINE_BIND_PORT
line_webhook_path: str = "/line/webhook"
# WhatsApp
@@ -70,7 +213,7 @@ class ConnectorConfig:
whatsapp_phone_number_id: Optional[str] = None
whatsapp_allowed_users: List[str] = field(default_factory=list)
whatsapp_bind_host: str = "127.0.0.1"
whatsapp_bind_port: int = 8098
whatsapp_bind_port: int = DEFAULT_WHATSAPP_BIND_PORT
whatsapp_webhook_path: str = "/whatsapp/webhook"
# WeChat Official Account (R74/S31/F43)
@@ -80,13 +223,13 @@ class ConnectorConfig:
wechat_encoding_aes_key: Optional[str] = None # R82: AES encrypted mode
wechat_allowed_users: List[str] = field(default_factory=list)
wechat_bind_host: str = "127.0.0.1"
wechat_bind_port: int = 8097
wechat_bind_port: int = DEFAULT_WECHAT_BIND_PORT
wechat_webhook_path: str = "/wechat/webhook"
# KakaoTalk (F44 Phase A)
kakao_enabled: bool = False
kakao_bind_host: str = "127.0.0.1"
kakao_bind_port: int = 8096
kakao_bind_port: int = DEFAULT_KAKAO_BIND_PORT
kakao_webhook_path: str = "/kakao/webhook"
kakao_allowed_users: List[str] = field(default_factory=list)
@@ -96,8 +239,9 @@ class ConnectorConfig:
slack_allowed_users: List[str] = field(default_factory=list)
slack_allowed_channels: List[str] = field(default_factory=list)
slack_bind_host: str = "127.0.0.1"
slack_bind_port: int = 8095
slack_bind_port: int = DEFAULT_SLACK_BIND_PORT
slack_webhook_path: str = "/slack/events"
slack_interactions_path: str = "/slack/interactions"
slack_require_mention: bool = True
slack_reply_in_thread: bool = True
slack_mode: str = "events" # F57: events | socket
@@ -118,7 +262,28 @@ class ConnectorConfig:
"mpim:history",
]
)
slack_oauth_state_ttl_sec: int = 600
slack_oauth_state_ttl_sec: int = DEFAULT_SLACK_OAUTH_STATE_TTL_SEC
# Feishu / Lark (F67)
feishu_app_id: Optional[str] = None
feishu_app_secret: Optional[str] = None
feishu_verification_token: Optional[str] = None
feishu_encrypt_key: Optional[str] = None
feishu_account_id: Optional[str] = None
feishu_default_account_id: Optional[str] = None
feishu_workspace_id: Optional[str] = None
feishu_workspace_name: Optional[str] = None
feishu_bindings_json: Optional[str] = None
feishu_allowed_users: List[str] = field(default_factory=list)
feishu_allowed_chats: List[str] = field(default_factory=list)
feishu_bind_host: str = "127.0.0.1"
feishu_bind_port: int = DEFAULT_FEISHU_BIND_PORT
feishu_webhook_path: str = "/feishu/events"
feishu_callback_path: str = "/feishu/callback"
feishu_domain: str = "feishu" # feishu | lark
feishu_mode: str = "websocket" # websocket | webhook
feishu_require_mention: bool = True
feishu_reply_in_thread: bool = True
# Privileged Access (ID match across platforms; Telegram Int vs Discord Str handled by router)
admin_users: List[str] = field(default_factory=list)
@@ -126,13 +291,19 @@ class ConnectorConfig:
# Media Host (F33)
public_base_url: Optional[str] = None
media_path: str = "/media"
media_ttl_sec: int = 300
media_max_mb: int = 8
media_ttl_sec: int = DEFAULT_MEDIA_TTL_SEC
media_max_mb: int = DEFAULT_MEDIA_MAX_MB
# Security (F32)
rate_limit_user_rpm: int = 10 # Requests per minute per user
rate_limit_channel_rpm: int = 30 # Requests per minute per channel
max_command_length: int = 4096 # Max characters in a single command
rate_limit_user_rpm: int = (
DEFAULT_RATE_LIMIT_USER_RPM # Requests per minute per user
)
rate_limit_channel_rpm: int = (
DEFAULT_RATE_LIMIT_CHANNEL_RPM # Requests per minute per channel
)
max_command_length: int = (
DEFAULT_MAX_COMMAND_LENGTH # Max characters in a single command
)
llm_max_tokens_per_request: int = 1024 # LLM token budget
# R80: Command Auth Policy
@@ -165,14 +336,23 @@ def load_config() -> ConnectorConfig:
cfg.state_path = os.environ.get("OPENCLAW_CONNECTOR_STATE_PATH")
# Delivery
cfg.delivery_max_images = int(
os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES", "4")
cfg.delivery_max_images = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES",
default=DEFAULT_DELIVERY_MAX_IMAGES,
minimum=MIN_DELIVERY_MAX_IMAGES,
maximum=MAX_DELIVERY_MAX_IMAGES,
)
cfg.delivery_max_bytes = int(
os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES", str(10 * 1024 * 1024))
cfg.delivery_max_bytes = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES",
default=DEFAULT_DELIVERY_MAX_BYTES,
minimum=MIN_DELIVERY_MAX_BYTES,
maximum=MAX_DELIVERY_MAX_BYTES,
)
cfg.delivery_timeout_sec = int(
os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC", "600")
cfg.delivery_timeout_sec = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC",
default=DEFAULT_DELIVERY_TIMEOUT_SEC,
minimum=MIN_DELIVERY_TIMEOUT_SEC,
maximum=MAX_DELIVERY_TIMEOUT_SEC,
)
# Telegram
@@ -208,9 +388,13 @@ def load_config() -> ConnectorConfig:
cfg.line_allowed_groups = [u.strip() for u in l_groups.split(",") if u.strip()]
cfg.line_bind_host = os.environ.get("OPENCLAW_CONNECTOR_LINE_BIND", "127.0.0.1")
if l_port := os.environ.get("OPENCLAW_CONNECTOR_LINE_PORT"):
if l_port.isdigit():
cfg.line_bind_port = int(l_port)
cfg.line_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_LINE_PORT",
default=DEFAULT_LINE_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.line_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_LINE_PATH", "/line/webhook"
)
@@ -233,9 +417,13 @@ def load_config() -> ConnectorConfig:
cfg.whatsapp_bind_host = os.environ.get(
"OPENCLAW_CONNECTOR_WHATSAPP_BIND", "127.0.0.1"
)
if wa_port := os.environ.get("OPENCLAW_CONNECTOR_WHATSAPP_PORT"):
if wa_port.isdigit():
cfg.whatsapp_bind_port = int(wa_port)
cfg.whatsapp_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_WHATSAPP_PORT",
default=DEFAULT_WHATSAPP_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.whatsapp_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_WHATSAPP_PATH", "/whatsapp/webhook"
)
@@ -250,9 +438,13 @@ def load_config() -> ConnectorConfig:
if wc_users := os.environ.get("OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS"):
cfg.wechat_allowed_users = [u.strip() for u in wc_users.split(",") if u.strip()]
cfg.wechat_bind_host = os.environ.get("OPENCLAW_CONNECTOR_WECHAT_BIND", "127.0.0.1")
if wc_port := os.environ.get("OPENCLAW_CONNECTOR_WECHAT_PORT"):
if wc_port.isdigit():
cfg.wechat_bind_port = int(wc_port)
cfg.wechat_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_WECHAT_PORT",
default=DEFAULT_WECHAT_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.wechat_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_WECHAT_PATH", "/wechat/webhook"
)
@@ -262,9 +454,13 @@ def load_config() -> ConnectorConfig:
cfg.kakao_enabled = True
cfg.kakao_bind_host = os.environ.get("OPENCLAW_CONNECTOR_KAKAO_BIND", "127.0.0.1")
if kp := os.environ.get("OPENCLAW_CONNECTOR_KAKAO_PORT"):
if kp.isdigit():
cfg.kakao_bind_port = int(kp)
cfg.kakao_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_KAKAO_PORT",
default=DEFAULT_KAKAO_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.kakao_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_KAKAO_PATH", "/kakao/webhook"
)
@@ -279,12 +475,19 @@ def load_config() -> ConnectorConfig:
if sc := os.environ.get("OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS"):
cfg.slack_allowed_channels = [u.strip() for u in sc.split(",") if u.strip()]
cfg.slack_bind_host = os.environ.get("OPENCLAW_CONNECTOR_SLACK_BIND", "127.0.0.1")
if sp := os.environ.get("OPENCLAW_CONNECTOR_SLACK_PORT"):
if sp.isdigit():
cfg.slack_bind_port = int(sp)
cfg.slack_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_SLACK_PORT",
default=DEFAULT_SLACK_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.slack_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_SLACK_PATH", "/slack/events"
)
cfg.slack_interactions_path = os.environ.get(
"OPENCLAW_CONNECTOR_SLACK_INTERACTIONS_PATH", "/slack/interactions"
)
if (
os.environ.get("OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION", "").lower()
== "false"
@@ -314,36 +517,103 @@ def load_config() -> ConnectorConfig:
]
if parsed_scopes:
cfg.slack_oauth_scopes = parsed_scopes
if slack_oauth_ttl := os.environ.get(
"OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC"
cfg.slack_oauth_state_ttl_sec = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC",
default=DEFAULT_SLACK_OAUTH_STATE_TTL_SEC,
minimum=MIN_SLACK_OAUTH_STATE_TTL_SEC,
maximum=MAX_SLACK_OAUTH_STATE_TTL_SEC,
)
# Feishu / Lark (F67)
cfg.feishu_app_id = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_APP_ID")
cfg.feishu_app_secret = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_APP_SECRET")
cfg.feishu_verification_token = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_VERIFICATION_TOKEN"
)
cfg.feishu_encrypt_key = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_ENCRYPT_KEY")
cfg.feishu_account_id = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_ACCOUNT_ID")
cfg.feishu_default_account_id = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_DEFAULT_ACCOUNT_ID"
)
cfg.feishu_workspace_id = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_ID")
cfg.feishu_workspace_name = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_NAME"
)
cfg.feishu_bindings_json = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON")
if fu := os.environ.get("OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS"):
cfg.feishu_allowed_users = [u.strip() for u in fu.split(",") if u.strip()]
if fc := os.environ.get("OPENCLAW_CONNECTOR_FEISHU_ALLOWED_CHATS"):
cfg.feishu_allowed_chats = [u.strip() for u in fc.split(",") if u.strip()]
cfg.feishu_bind_host = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_BIND", "127.0.0.1")
cfg.feishu_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_FEISHU_PORT",
default=DEFAULT_FEISHU_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.feishu_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_PATH", "/feishu/events"
)
cfg.feishu_callback_path = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH", "/feishu/callback"
)
cfg.feishu_domain = (
os.environ.get("OPENCLAW_CONNECTOR_FEISHU_DOMAIN", "feishu").strip() or "feishu"
)
cfg.feishu_mode = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_MODE", "websocket"
).lower()
if (
os.environ.get("OPENCLAW_CONNECTOR_FEISHU_REQUIRE_MENTION", "").lower()
== "false"
):
if slack_oauth_ttl.isdigit():
cfg.slack_oauth_state_ttl_sec = max(60, int(slack_oauth_ttl))
cfg.feishu_require_mention = False
if (
os.environ.get("OPENCLAW_CONNECTOR_FEISHU_REPLY_IN_THREAD", "").lower()
== "false"
):
cfg.feishu_reply_in_thread = False
# Admin
if admins := os.environ.get("OPENCLAW_CONNECTOR_ADMIN_USERS"):
cfg.admin_users = [u.strip() for u in admins.split(",") if u.strip()]
# Security (F32)
if rpm := os.environ.get("OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM"):
if rpm.isdigit():
cfg.rate_limit_user_rpm = int(rpm)
if rpm := os.environ.get("OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM"):
if rpm.isdigit():
cfg.rate_limit_channel_rpm = int(rpm)
if max_len := os.environ.get("OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH"):
if max_len.isdigit():
cfg.max_command_length = int(max_len)
cfg.rate_limit_user_rpm = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM",
default=DEFAULT_RATE_LIMIT_USER_RPM,
minimum=MIN_RATE_LIMIT_RPM,
maximum=MAX_RATE_LIMIT_RPM,
)
cfg.rate_limit_channel_rpm = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM",
default=DEFAULT_RATE_LIMIT_CHANNEL_RPM,
minimum=MIN_RATE_LIMIT_RPM,
maximum=MAX_RATE_LIMIT_RPM,
)
cfg.max_command_length = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH",
default=DEFAULT_MAX_COMMAND_LENGTH,
minimum=MIN_MAX_COMMAND_LENGTH,
maximum=MAX_MAX_COMMAND_LENGTH,
)
# Media Host (F33)
cfg.public_base_url = os.environ.get("OPENCLAW_CONNECTOR_PUBLIC_BASE_URL")
cfg.media_path = os.environ.get("OPENCLAW_CONNECTOR_MEDIA_PATH", "/media")
if ttl := os.environ.get("OPENCLAW_CONNECTOR_MEDIA_TTL_SEC"):
if ttl.isdigit():
cfg.media_ttl_sec = int(ttl)
if mb := os.environ.get("OPENCLAW_CONNECTOR_MEDIA_MAX_MB"):
if mb.isdigit():
cfg.media_max_mb = int(mb)
cfg.media_ttl_sec = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_MEDIA_TTL_SEC",
default=DEFAULT_MEDIA_TTL_SEC,
minimum=MIN_MEDIA_TTL_SEC,
maximum=MAX_MEDIA_TTL_SEC,
)
cfg.media_max_mb = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_MEDIA_MAX_MB",
default=DEFAULT_MEDIA_MAX_MB,
minimum=MIN_MEDIA_MAX_MB,
maximum=MAX_MEDIA_MAX_MB,
)
# R80: Command Auth Policy
import json
+183
View File
@@ -0,0 +1,183 @@
"""Strict, bounded formatter for the connector's authoritative jobs view."""
from __future__ import annotations
import re
from collections import Counter
from collections.abc import Mapping
from typing import Any
JOBS_CONTRACT_VERSION = 1
MAX_RETURNED_JOBS = 200
MAX_SNAPSHOT_TOTAL = 10_000
MAX_JOB_ID_LENGTH = 128
MAX_DISPLAY_JOB_ID_LENGTH = 24
MAX_JOBS_SUMMARY_LENGTH = 1_000
MAX_QUEUE_REMAINING = 1_000_000
MAX_NORMALIZATION_WARNINGS = 2
JOB_STATUSES = (
"pending",
"in_progress",
"completed",
"failed",
"cancelled",
)
STATUS_LABELS = {
"pending": "pending",
"in_progress": "in progress",
"completed": "completed",
"failed": "failed",
"cancelled": "cancelled",
}
_SAFE_JOB_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
class JobsContractError(ValueError):
"""Raised when a connector jobs payload is not safe to render."""
def format_jobs_summary(payload: Any) -> str:
"""Validate contract version 1 and return a deterministic operator summary."""
jobs, pagination = _parse_jobs_payload(payload)
total = pagination["total"]
if total == 0:
return "[Jobs] No jobs in the authoritative snapshot."
counts = Counter(job["status"] for job in jobs)
active = counts["pending"] + counts["in_progress"]
terminal = counts["completed"] + counts["failed"] + counts["cancelled"]
lines = [
"[Jobs] Authoritative snapshot",
f"Snapshot total: {total}; returned page: {len(jobs)}",
(
f"Page states: Active {active} (pending {counts['pending']}, "
f"in progress {counts['in_progress']}); "
f"Terminal {terminal} (completed {counts['completed']}, "
f"failed {counts['failed']}, cancelled {counts['cancelled']})"
),
]
if jobs:
for job in jobs[:5]:
lines.append(
f"- {_short_job_id(job['id'])}{STATUS_LABELS[job['status']]}"
)
if len(jobs) > 5:
lines.append(f"Showing 5 of {len(jobs)} returned jobs.")
else:
lines.append("No jobs are present on this page.")
summary = "\n".join(lines)
if len(summary) > MAX_JOBS_SUMMARY_LENGTH:
raise JobsContractError("jobs summary exceeds the safe display bound")
return summary
def format_queue_fallback(response: Any) -> str:
"""Render only a bounded coarse queue count from the legacy fallback seam."""
remaining = _queue_remaining(response)
if remaining is None:
return "[Jobs fallback] Coarse queue count is unavailable."
return (
f"[Jobs fallback] Queue remaining: {remaining} "
"(coarse count; not an authoritative jobs snapshot)."
)
def _parse_jobs_payload(payload: Any) -> tuple[list[dict[str, str]], dict[str, Any]]:
if not isinstance(payload, Mapping) or payload.get("ok") is not True:
raise JobsContractError("jobs response must be a successful mapping")
version = payload.get("contract_version")
if isinstance(version, bool) or version != JOBS_CONTRACT_VERSION:
raise JobsContractError("unsupported jobs contract version")
raw_jobs = payload.get("jobs")
pagination = payload.get("pagination")
if not isinstance(raw_jobs, list) or len(raw_jobs) > MAX_RETURNED_JOBS:
raise JobsContractError("jobs list is malformed or oversized")
if not isinstance(pagination, Mapping):
raise JobsContractError("jobs pagination is missing")
if not isinstance(payload.get("source"), Mapping) or not isinstance(
payload.get("scan"), Mapping
):
raise JobsContractError("jobs source diagnostics are missing")
parsed_jobs = [_parse_job(item) for item in raw_jobs]
parsed_pagination = _parse_pagination(pagination, returned=len(parsed_jobs))
return parsed_jobs, parsed_pagination
def _parse_job(item: Any) -> dict[str, str]:
if not isinstance(item, Mapping):
raise JobsContractError("job summary must be a mapping")
job_id = item.get("id")
status = item.get("status")
if (
not isinstance(job_id, str)
or not job_id
or len(job_id) > MAX_JOB_ID_LENGTH
or _SAFE_JOB_ID.fullmatch(job_id) is None
):
raise JobsContractError("job id is outside the safe display contract")
if not isinstance(status, str) or status not in JOB_STATUSES:
raise JobsContractError("job status is unsupported")
return {"id": job_id, "status": status}
def _parse_pagination(
pagination: Mapping[str, Any], *, returned: int
) -> dict[str, Any]:
offset = _bounded_int(pagination.get("offset"), minimum=0, maximum=10_000)
limit = _bounded_int(pagination.get("limit"), minimum=1, maximum=MAX_RETURNED_JOBS)
total = _bounded_int(pagination.get("total"), minimum=0, maximum=MAX_SNAPSHOT_TOTAL)
has_more = pagination.get("has_more")
warnings = pagination.get("warnings")
if not isinstance(has_more, bool):
raise JobsContractError("jobs has_more must be boolean")
if not isinstance(warnings, list) or len(warnings) > MAX_NORMALIZATION_WARNINGS:
raise JobsContractError("jobs warnings are malformed")
if returned > limit or offset + returned > total:
raise JobsContractError("jobs pagination counts are inconsistent")
if has_more != (offset + returned < total):
raise JobsContractError("jobs has_more is inconsistent")
return {
"offset": offset,
"limit": limit,
"total": total,
"has_more": has_more,
}
def _bounded_int(value: Any, *, minimum: int, maximum: int) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise JobsContractError("jobs count must be an integer")
if value < minimum or value > maximum:
raise JobsContractError("jobs count is outside the safe bound")
return value
def _short_job_id(job_id: str) -> str:
if len(job_id) <= MAX_DISPLAY_JOB_ID_LENGTH:
return job_id
return job_id[: MAX_DISPLAY_JOB_ID_LENGTH - 3] + "..."
def _queue_remaining(response: Any) -> int | None:
if not isinstance(response, Mapping) or response.get("ok") is not True:
return None
data = response.get("data")
if not isinstance(data, Mapping):
return None
exec_info = data.get("exec_info")
if not isinstance(exec_info, Mapping):
return None
remaining = exec_info.get("queue_remaining")
if (
isinstance(remaining, bool)
or not isinstance(remaining, int)
or remaining < 0
or remaining > MAX_QUEUE_REMAINING
):
return None
return remaining
+66
View File
@@ -0,0 +1,66 @@
"""Safe response helpers for connector-served local media."""
from __future__ import annotations
import mimetypes
from pathlib import Path
from typing import Any
DANGEROUS_CONTENT_TYPES = {
"text/html",
"text/html-sandboxed",
"application/xhtml+xml",
"text/javascript",
"application/javascript",
"application/x-javascript",
"application/ecmascript",
"text/css",
"image/svg+xml",
"application/xml",
"text/xml",
"message/rfc822",
}
def is_dangerous_content_type(content_type: str | None) -> bool:
"""Return True for browser-renderable active content types."""
if not content_type:
return False
normalized = content_type.split(";", 1)[0].strip().lower()
if normalized in DANGEROUS_CONTENT_TYPES:
return True
return normalized.endswith("+xml") or normalized.endswith("/xml")
def _content_disposition_filename(name: str) -> str:
safe_name = name.replace("\r", "").replace("\n", "")
safe_name = safe_name.replace("\\", "\\\\").replace('"', '\\"')
return f'filename="{safe_name}"'
def build_connector_media_response(web: Any, path: Path):
"""Build a hardened FileResponse for signed connector media files."""
content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
disposition = _content_disposition_filename(path.name)
# IMPORTANT: connector media is user-controlled. Dangerous active content
# must download instead of rendering inline in the local media origin.
if is_dangerous_content_type(content_type):
content_type = "application/octet-stream"
disposition = f"attachment; {_content_disposition_filename(path.name)}"
return web.FileResponse(
path,
headers={
"Content-Disposition": disposition,
"Content-Type": content_type,
"X-Content-Type-Options": "nosniff",
},
)
__all__ = [
"DANGEROUS_CONTENT_TYPES",
"build_connector_media_response",
"is_dangerous_content_type",
]
+13 -4
View File
@@ -7,6 +7,7 @@ import json
import logging
import uuid
from typing import Optional
from urllib.parse import quote
from .config import ConnectorConfig
@@ -68,7 +69,7 @@ class OpenClawClient:
async with session.request(
method, url, headers=self.headers, json=json_data, timeout=timeout
) as resp:
result = {"ok": resp.status in (200, 201, 202)}
result = {"ok": resp.status in (200, 201, 202), "status": resp.status}
try:
data = await resp.json()
@@ -166,9 +167,17 @@ class OpenClawClient:
}
return await self._request("POST", "/openclaw/triggers/fire", data)
async def interrupt_output(self) -> dict:
# Remediation: Cancel -> Interrupt (Global)
return await self._request("POST", "/api/interrupt", {})
async def cancel_job(self, job_id: str) -> dict:
encoded_job_id = quote(str(job_id), safe="")
return await self._request("POST", f"/api/jobs/{encoded_job_id}/cancel", {})
async def cancel_jobs(self, job_ids: list[str]) -> dict:
return await self._request("POST", "/api/jobs/cancel", {"job_ids": job_ids})
async def interrupt_output(self, prompt_id: Optional[str] = None) -> dict:
# No prompt_id means explicit global interrupt. A prompt_id is targeted.
payload = {"prompt_id": str(prompt_id)} if prompt_id else {}
return await self._request("POST", "/api/interrupt", payload)
async def get_view(
self, filename: str, subfolder: str = "", type: str = "output"
@@ -0,0 +1,421 @@
"""Owned Feishu card, response, and media-delivery mixin."""
# ruff: noqa: UP006, UP035, UP045 -- preserve frozen facade annotations.
from __future__ import annotations
import json
import secrets
from dataclasses import dataclass
from typing import Any, Dict, Optional
from services.safe_io import STANDARD_OUTBOUND_POLICY, SafeIOHTTPError
from ..reply_visibility import decide_reply_visibility
from .feishu_installation_manager import FeishuBinding
# mypy: disable-error-code="attr-defined,no-any-return"
@dataclass
class FeishuDeliveryTarget:
channel_id: str
reply_to_message_id: str = ""
workspace_id: str = ""
account_id: str = ""
class FeishuDeliveryMixin:
def _build_card_button_value(
self,
button: Dict[str, Any],
*,
target: FeishuDeliveryTarget,
binding: FeishuBinding,
signing_secret: str,
) -> Dict[str, Any]:
contract = self._callback_contract_for_binding(
binding=binding,
signing_secret=signing_secret,
)
command_text = str(button.get("value", "") or "").strip()
callback_payload = {
"label": str(button.get("label", "") or "").strip(),
"command": command_text,
"approval_id": str(button.get("approval_id", "") or "").strip(),
"workspace_id": target.workspace_id or binding.workspace_id,
"account_id": target.account_id or binding.account_id,
"channel_id": target.channel_id,
"message_id": target.reply_to_message_id,
}
envelope = contract.build_envelope(
request_id=secrets.token_hex(12),
workspace_id=callback_payload["workspace_id"],
action_type=self._adapter_infer_callback_action_type(command_text, button),
payload=callback_payload,
)
return {
"callback_envelope": dict(envelope.__dict__),
"payload": callback_payload,
}
def _build_interactive_card(
self,
target: FeishuDeliveryTarget,
text: str,
buttons: list[dict],
*,
binding: FeishuBinding,
secrets: Dict[str, str],
) -> Dict[str, Any]:
signing_secret = str(
secrets.get("app_secret", "") or binding.app_secret or ""
).strip()
if not signing_secret:
raise RuntimeError("feishu_callback_signing_secret_missing")
actions = []
for button in buttons[:6]:
command_text = str(button.get("value", "") or "").strip()
if not command_text:
continue
actions.append(
{
"tag": "button",
"type": str(button.get("style", "") or "default"),
"text": {
"tag": "plain_text",
"content": str(button.get("label", "") or "OpenClaw"),
},
"value": self._build_card_button_value(
button,
target=target,
binding=binding,
signing_secret=signing_secret,
),
}
)
return {
"config": {"wide_screen_mode": True},
"header": {
"template": "blue",
"title": {"tag": "plain_text", "content": "OpenClaw"},
},
"elements": [
{"tag": "markdown", "content": text or "OpenClaw"},
{"tag": "action", "actions": actions},
],
}
async def _send_interactive_reply(
self,
target: FeishuDeliveryTarget,
text: str,
buttons: list[dict],
) -> None:
resolution, binding, secrets = self._resolve_delivery_binding(
workspace_id=target.workspace_id,
account_id=target.account_id,
)
if binding is None or not resolution.ok:
self._adapter_logger().warning(
"Feishu interactive reply dropped: no workspace binding available (%s / %s)",
target.workspace_id or "no-workspace",
target.account_id or "no-account",
)
return
token = await self._get_tenant_access_token(
binding=binding,
workspace_id=target.workspace_id,
account_id=target.account_id,
)
api_base = self._adapter_resolve_domain_base(binding.domain)
card = self._build_interactive_card(
target,
text,
buttons,
binding=binding,
secrets=secrets,
)
payload = {
"content": json.dumps(card, ensure_ascii=False),
"msg_type": "interactive",
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json; charset=utf-8",
}
if target.reply_to_message_id:
url = (
f"{api_base}/open-apis/im/v1/messages/"
f"{target.reply_to_message_id}/reply"
)
else:
url = f"{api_base}/open-apis/im/v1/messages?receive_id_type=chat_id"
payload["receive_id"] = target.channel_id
try:
data = self._adapter_safe_request_json(
method="POST",
url=url,
json_body=payload,
headers=headers,
content_type="application/json; charset=utf-8",
timeout_sec=15,
allow_hosts=self._adapter_allowed_api_hosts(binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "interactive_reply"},
)
self._adapter_logger().warning(
"Feishu interactive reply failed: status=%s", exc.status_code
)
return
if data.get("code", 0) != 0:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=str(data.get("msg", "unknown") or "unknown"),
status_code=200,
details={"phase": "interactive_reply"},
)
self._adapter_logger().warning(
"Feishu interactive reply failed: %s", data.get("msg", "unknown")
)
async def _send_reply(
self,
target: FeishuDeliveryTarget,
text: str,
*,
delivery_context: Optional[Dict[str, Any]] = None,
) -> None:
ctx = dict(delivery_context or {})
if target.workspace_id:
ctx.setdefault("workspace_id", target.workspace_id)
if target.account_id:
ctx.setdefault("account_id", target.account_id)
if target.reply_to_message_id:
ctx.setdefault("thread_id", target.reply_to_message_id)
decision = decide_reply_visibility(
delivery_context=ctx,
platform="feishu",
channel_kind=str(ctx.get("chat_type", "") or ""),
in_thread=bool(target.reply_to_message_id),
text=text,
)
if decision.suppressed:
self._adapter_logger().info(
"Suppressed Feishu reply channel=%s reason=%s",
target.channel_id,
decision.reason,
)
return
resolution, binding, _ = self._resolve_delivery_binding(
workspace_id=target.workspace_id,
account_id=target.account_id,
)
if binding is None or not resolution.ok:
self._adapter_logger().warning(
"Feishu reply dropped: no workspace binding available (%s / %s)",
target.workspace_id or "no-workspace",
target.account_id or "no-account",
)
return
token = await self._get_tenant_access_token(
binding=binding,
workspace_id=target.workspace_id,
account_id=target.account_id,
)
api_base = self._adapter_resolve_domain_base(binding.domain)
payload = {
"content": json.dumps({"text": text}, ensure_ascii=False),
"msg_type": "text",
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json; charset=utf-8",
}
if target.reply_to_message_id:
url = (
f"{api_base}/open-apis/im/v1/messages/"
f"{target.reply_to_message_id}/reply"
)
else:
url = f"{api_base}/open-apis/im/v1/messages?receive_id_type=chat_id"
payload["receive_id"] = target.channel_id
try:
data = self._adapter_safe_request_json(
method="POST",
url=url,
json_body=payload,
headers=headers,
content_type="application/json; charset=utf-8",
timeout_sec=15,
allow_hosts=self._adapter_allowed_api_hosts(binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "reply"},
)
self._adapter_logger().warning(
"Feishu reply failed: status=%s", exc.status_code
)
return
if data.get("code", 0) != 0:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=str(data.get("msg", "unknown") or "unknown"),
status_code=200,
details={"phase": "reply"},
)
self._adapter_logger().warning(
"Feishu reply failed: %s",
data.get("msg", "unknown"),
)
async def send_message(
self,
channel_id: str,
text: str,
delivery_context: Optional[Dict[str, Any]] = None,
):
ctx = dict(delivery_context or {})
await self._send_reply(
self._adapter_delivery_target(
channel_id=channel_id,
reply_to_message_id=str(ctx.get("thread_id", "") or "").strip(),
workspace_id=str(ctx.get("workspace_id", "") or "").strip(),
account_id=str(ctx.get("account_id", "") or "").strip(),
),
text,
delivery_context=ctx,
)
async def send_image(
self,
channel_id: str,
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[Dict[str, Any]] = None,
):
ctx = dict(delivery_context or {})
resolution, binding, _ = self._resolve_delivery_binding(
workspace_id=str(ctx.get("workspace_id", "") or "").strip(),
account_id=str(ctx.get("account_id", "") or "").strip(),
)
if binding is None or not resolution.ok:
self._adapter_logger().warning(
"Feishu image dropped: no workspace binding available (%s / %s)",
str(ctx.get("workspace_id", "") or "").strip() or "no-workspace",
str(ctx.get("account_id", "") or "").strip() or "no-account",
)
return
token = await self._get_tenant_access_token(
binding=binding,
workspace_id=str(ctx.get("workspace_id", "") or "").strip(),
account_id=str(ctx.get("account_id", "") or "").strip(),
)
api_base = self._adapter_resolve_domain_base(binding.domain)
upload_headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
upload_body, upload_content_type = self._adapter_build_multipart_form(
fields={"image_type": "message"},
file_field="image",
filename=filename,
file_bytes=image_data,
file_content_type="image/png",
)
try:
upload_payload = self._adapter_safe_request_json(
method="POST",
url=f"{api_base}/open-apis/im/v1/images",
raw_body=upload_body,
headers=upload_headers,
content_type=upload_content_type,
timeout_sec=30,
allow_hosts=self._adapter_allowed_api_hosts(binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "image_upload"},
)
self._adapter_logger().warning(
"Feishu image upload failed: status=%s", exc.status_code
)
return
image_key = str(
(upload_payload.get("data") or {}).get("image_key", "") or ""
).strip()
if upload_payload.get("code", 0) != 0 or not image_key:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=str(upload_payload.get("msg", "unknown") or "unknown"),
status_code=200,
details={"phase": "image_upload"},
)
self._adapter_logger().warning(
"Feishu image upload failed: %s",
upload_payload.get("msg", "unknown"),
)
return
message_payload = {
"content": json.dumps({"image_key": image_key}, ensure_ascii=False),
"msg_type": "image",
}
thread_id = str(ctx.get("thread_id", "") or "").strip()
if thread_id:
send_url = f"{api_base}/open-apis/im/v1/messages/{thread_id}/reply"
else:
send_url = f"{api_base}/open-apis/im/v1/messages?receive_id_type=chat_id"
message_payload["receive_id"] = channel_id
try:
self._adapter_safe_request_json(
method="POST",
url=send_url,
json_body=message_payload,
headers={
"Accept": "application/json",
"Authorization": f"Bearer {token}",
},
content_type="application/json; charset=utf-8",
timeout_sec=30,
allow_hosts=self._adapter_allowed_api_hosts(binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "image_send"},
)
self._adapter_logger().warning(
"Feishu image send failed: status=%s", exc.status_code
)
if caption:
await self.send_message(
channel_id,
caption,
delivery_context=ctx,
)
@@ -0,0 +1,462 @@
"""Owned Feishu webhook ingress and callback transaction mixin."""
# ruff: noqa: UP006, UP035, UP045 -- preserve frozen facade annotations.
from __future__ import annotations
import json
import secrets
import time
from typing import Any, Dict, Optional, Tuple
from services.connector_callback_contract import (
CallbackActorContext,
CallbackDecisionCode,
ConnectorCallbackContract,
)
from ..contract import CommandRequest
from .feishu_installation_manager import FeishuBinding
# mypy: disable-error-code="attr-defined,index,no-any-return"
class FeishuIngressMixin:
async def handle_event(self, request):
_, web = self._adapter_import_aiohttp_web()
try:
body = await request.read()
except Exception:
return self._adapter_make_response(web, status=400, text="Bad request")
if len(body) > self._adapter_max_body_bytes():
return self._adapter_make_response(
web, status=413, text="Payload too large"
)
try:
payload = json.loads(body or b"{}")
except json.JSONDecodeError:
return self._adapter_make_response(web, status=400, text="Bad JSON")
if self._is_challenge(payload):
if not self._verify_request_token(payload):
return self._adapter_make_response(
web, status=401, text="Invalid verification token"
)
return self._adapter_make_json_response(
web, {"challenge": str(payload.get("challenge", "") or "")}
)
if not self._verify_request_token(payload):
return self._adapter_make_response(
web, status=401, text="Invalid verification token"
)
try:
await self.process_event_payload(payload)
except ValueError as exc:
safe_code = self._adapter_safe_external_error_code("event_rejected", exc)
self._adapter_logger().warning("Feishu event rejected: %s", safe_code)
return self._adapter_make_response(
web,
status=400,
text=safe_code,
)
return self._adapter_make_response(web, status=200, text="OK")
async def handle_callback(self, request):
_, web = self._adapter_import_aiohttp_web()
try:
body = await request.read()
except Exception:
return self._adapter_make_response(web, status=400, text="Bad request")
if len(body) > self._adapter_max_body_bytes():
return self._adapter_make_response(
web, status=413, text="Payload too large"
)
try:
payload = json.loads(body or b"{}")
except json.JSONDecodeError:
return self._adapter_make_response(web, status=400, text="Bad JSON")
try:
response = await self.process_callback_payload(payload)
except ValueError as exc:
safe_code = self._adapter_safe_external_error_code("callback_rejected", exc)
self._adapter_logger().warning("Feishu callback rejected: %s", safe_code)
return self._adapter_make_json_response(
web,
{
"ok": False,
"error": safe_code,
},
status=403,
)
return self._adapter_make_json_response(web, response)
def _is_challenge(self, payload: Dict[str, Any]) -> bool:
return bool(
payload.get("challenge")
and str(payload.get("type", "") or "").strip().lower() == "url_verification"
)
def _verify_request_token(self, payload: Dict[str, Any]) -> bool:
try:
self._resolve_inbound_binding(payload)
return True
except ValueError:
return False
def _extract_callback_action(self, payload: Dict[str, Any]) -> Tuple[
Dict[str, Any],
Dict[str, Any],
Dict[str, Any],
Dict[str, Any],
str,
str,
]:
header = payload.get("header") or {}
event = payload.get("event") or {}
action = payload.get("action") or event.get("action") or {}
if not action and isinstance(event.get("actions"), list):
first_action = event.get("actions")[0] if event.get("actions") else {}
if isinstance(first_action, dict):
action = first_action
if not isinstance(action, dict):
raise ValueError("invalid_callback_action")
raw_value = action.get("value") or {}
if isinstance(raw_value, str):
raw_value = self._adapter_json_loads_safe(raw_value)
if not isinstance(raw_value, dict):
raise ValueError("invalid_callback_value")
envelope = raw_value.get("callback_envelope") or {}
callback_payload = raw_value.get("payload") or {}
if not isinstance(envelope, dict) or not isinstance(callback_payload, dict):
raise ValueError("invalid_callback_envelope")
workspace_id = str(
header.get("tenant_key")
or event.get("tenant_key")
or callback_payload.get("workspace_id")
or ""
).strip()
account_id = str(callback_payload.get("account_id", "") or "").strip()
return header, event, envelope, callback_payload, workspace_id, account_id
def _callback_contract_for_binding(
self,
*,
binding: FeishuBinding,
signing_secret: str,
) -> ConnectorCallbackContract:
cache_key = self._cache_key_for_binding(binding)
if (
self._callback_contracts.get(cache_key) is not None
and self._callback_contract_secrets.get(cache_key) == signing_secret
):
return self._callback_contracts[cache_key]
contract = ConnectorCallbackContract(
signing_secret=signing_secret,
installation_registry=self._installation_manager.registry,
action_policy_map=self._adapter_callback_policy_map(),
)
self._callback_contracts[cache_key] = contract
self._callback_contract_secrets[cache_key] = signing_secret
return contract
def _actor_context_for_callback(
self,
*,
actor_id: str,
actor_open_id: str,
channel_id: str,
message_id: str,
workspace_id: str,
account_id: str,
command_text: str,
) -> Tuple[CallbackActorContext, CommandRequest]:
request = CommandRequest(
platform="feishu",
sender_id=actor_id or actor_open_id,
channel_id=channel_id or actor_id or actor_open_id,
username=actor_id or actor_open_id,
message_id=message_id or f"cb-{secrets.token_hex(4)}",
text=command_text,
timestamp=time.time(),
workspace_id=workspace_id,
thread_id=message_id,
metadata={
"account_id": account_id,
"sender_open_id": actor_open_id,
"interactive_callback": True,
},
)
actor = CallbackActorContext(
is_admin=self.router._is_admin(request.sender_id),
is_trusted=self.router._is_trusted(request),
user_id=request.sender_id,
tenant_id=workspace_id or request.workspace_id or "",
)
return actor, request
def _build_callback_response(
self,
*,
ok: bool,
text: str,
response_type: str = "info",
card: Optional[Dict[str, Any]] = None,
duplicate: bool = False,
decision_code: str = "",
) -> Dict[str, Any]:
response = {
"ok": ok,
"duplicate": duplicate,
"decision_code": decision_code,
"toast": {
"type": response_type,
"content": text[:500] if text else "",
},
}
if card is not None:
response["card"] = card
return response
def _build_request(
self,
payload: Dict[str, Any],
*,
binding: FeishuBinding,
bot_open_id: str,
) -> Optional[CommandRequest]:
header = payload.get("header") or {}
if (
str(header.get("event_type", "") or "").strip()
not in self._adapter_supported_event_types()
):
return None
event = payload.get("event") or {}
message = event.get("message") or {}
sender = event.get("sender") or {}
sender_id = sender.get("sender_id") or {}
mentions = self._adapter_normalize_mentions(message)
sender_user_id = str(sender_id.get("user_id", "") or "").strip()
sender_open_id = str(sender_id.get("open_id", "") or "").strip()
chat_id = str(message.get("chat_id", "") or "").strip()
chat_type = str(message.get("chat_type", "") or "").strip().lower()
message_id = str(message.get("message_id", "") or "").strip()
workspace_id = (
str(header.get("tenant_key", "") or "").strip() or binding.workspace_id
)
if not sender_user_id and not sender_open_id:
return None
if not chat_id or not message_id:
return None
if sender_open_id and bot_open_id and sender_open_id == bot_open_id:
return None
raw_text = self._adapter_parse_message_text(message)
if not raw_text:
return None
mentioned_bot = False
if bot_open_id:
for mention in mentions:
open_id = str(((mention.get("id") or {}).get("open_id")) or "").strip()
if open_id and open_id == bot_open_id:
mentioned_bot = True
break
text = self._adapter_strip_bot_mention(raw_text, mentions, bot_open_id)
if (
chat_type == "group"
and self.config.feishu_require_mention
and not mentioned_bot
):
return None
effective_sender = sender_user_id or sender_open_id
return CommandRequest(
platform="feishu",
sender_id=effective_sender,
channel_id=chat_id,
username=effective_sender,
message_id=message_id,
text=text,
timestamp=time.time(),
workspace_id=workspace_id,
thread_id=(
str(message.get("root_id", "") or "").strip()
or (message_id if self.config.feishu_reply_in_thread else "")
),
metadata={
"account_id": binding.account_id,
"chat_type": chat_type,
"mentioned_bot": mentioned_bot,
"message_type": str(message.get("message_type", "") or "").strip(),
"sender_open_id": sender_open_id,
},
)
async def process_event_payload(
self,
payload: Dict[str, Any],
*,
binding: Optional[FeishuBinding] = None,
) -> None:
header = payload.get("header") or {}
event_id = str(header.get("event_id", "") or "").strip()
if not event_id:
raise ValueError("Missing event_id")
if not self._replay_guard.check_and_record(event_id):
return
effective_binding = binding or self._resolve_inbound_binding(payload)
bot_open_id = self._cached_bot_open_id(effective_binding)
message = (payload.get("event") or {}).get("message") or {}
chat_type = str(message.get("chat_type", "") or "").strip().lower()
if not bot_open_id and chat_type == "group":
bot_open_id = await self._fetch_bot_open_id(
binding=effective_binding, allow_degrade=True
)
request = self._build_request(
payload,
binding=effective_binding,
bot_open_id=bot_open_id,
)
if request is None:
return
if self._user_allowlist.entries:
user_result = self._user_allowlist.evaluate(str(request.sender_id))
if user_result.decision == "deny":
return
if self._chat_allowlist.entries:
chat_result = self._chat_allowlist.evaluate(str(request.channel_id))
if chat_result.decision == "deny":
return
response = await self.router.handle(request)
resp_text = str(getattr(response, "text", "") or "").strip()
buttons = getattr(response, "buttons", []) or []
target = self._adapter_delivery_target(
channel_id=request.channel_id,
reply_to_message_id=request.thread_id,
workspace_id=request.workspace_id,
account_id=str(request.metadata.get("account_id", "") or ""),
)
if buttons:
await self._send_interactive_reply(target, resp_text, buttons)
elif resp_text:
await self._send_reply(
target,
resp_text,
delivery_context={
"workspace_id": request.workspace_id,
"thread_id": request.thread_id,
"account_id": str(request.metadata.get("account_id", "") or ""),
"chat_type": str(request.metadata.get("chat_type", "") or ""),
"mentioned_bot": bool(request.metadata.get("mentioned_bot")),
},
)
async def process_callback_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
_, _, envelope_dict, callback_payload, workspace_id, account_id = (
self._extract_callback_action(payload)
)
resolution, binding, secrets = self._resolve_delivery_binding(
workspace_id=workspace_id,
account_id=account_id,
)
if binding is None or not resolution.ok:
raise ValueError(resolution.reject_reason or "missing_binding")
signing_secret = str(
secrets.get("app_secret", "") or binding.app_secret or ""
).strip()
if not signing_secret:
raise ValueError("missing_callback_signing_secret")
contract = self._callback_contract_for_binding(
binding=binding,
signing_secret=signing_secret,
)
event = payload.get("event") or {}
operator = payload.get("operator") or event.get("operator") or {}
operator_id = operator.get("operator_id") or operator.get("sender_id") or {}
actor_id = str(
operator.get("user_id")
or operator_id.get("user_id")
or callback_payload.get("actor_user_id")
or ""
).strip()
actor_open_id = str(
operator.get("open_id")
or operator_id.get("open_id")
or callback_payload.get("actor_open_id")
or ""
).strip()
command_text = str(callback_payload.get("command", "") or "").strip()
actor, request = self._actor_context_for_callback(
actor_id=actor_id,
actor_open_id=actor_open_id,
channel_id=str(
payload.get("open_chat_id")
or event.get("open_chat_id")
or callback_payload.get("channel_id")
or ""
).strip(),
message_id=str(
payload.get("open_message_id")
or event.get("open_message_id")
or callback_payload.get("message_id")
or ""
).strip(),
workspace_id=workspace_id or binding.workspace_id,
account_id=binding.account_id,
command_text=command_text,
)
decision = contract.evaluate(
platform="feishu",
envelope_dict=envelope_dict,
payload=callback_payload,
actor=actor,
)
if decision.decision_code == CallbackDecisionCode.REJECT_REPLAY.value:
return self._build_callback_response(
ok=True,
text="Action already processed.",
response_type="info",
duplicate=True,
decision_code=decision.decision_code,
)
if not decision.ok and not decision.requires_approval:
raise ValueError(decision.message or decision.decision_code)
request.text = (
self._adapter_force_approval_command(request.text)
if decision.requires_approval
else request.text
)
request_id = str(envelope_dict.get("request_id", "") or "")
contract.acknowledge_request(request_id)
try:
response = await self.router.handle(request)
except Exception:
# IMPORTANT: failures before route completion remain retryable.
# After router.handle returns, the action may already have side effects,
# so completion failures must not release the claim for rerouting.
contract.release_request_retryable(
request_id, reason="feishu_callback_failed_before_commit"
)
raise
contract.complete_request(request_id)
response_text = str(getattr(response, "text", "") or "").strip() or (
"Action processed."
)
response_buttons = getattr(response, "buttons", []) or []
card = None
if response_buttons:
card = self._build_interactive_card(
self._adapter_delivery_target(
channel_id=request.channel_id,
reply_to_message_id=request.thread_id,
workspace_id=request.workspace_id,
account_id=binding.account_id,
),
response_text,
response_buttons,
binding=binding,
secrets=secrets,
)
return self._build_callback_response(
ok=True,
text=response_text,
response_type="success",
card=card,
decision_code=decision.decision_code,
)
@@ -0,0 +1,198 @@
"""Owned Feishu installation, tenant-token, and bot-identity mixin."""
# ruff: noqa: UP006, UP035, UP045 -- preserve frozen facade annotations.
from __future__ import annotations
import time
from typing import Any, Dict, Optional, Tuple
from services.connector_installation_registry import InstallationResolution
from services.safe_io import STANDARD_OUTBOUND_POLICY, SafeIOHTTPError
from .feishu_installation_manager import FeishuBinding
# mypy: disable-error-code="attr-defined,no-any-return"
class FeishuInstallationMixin:
def _resolve_inbound_binding(self, payload: Dict[str, Any]) -> FeishuBinding:
header = payload.get("header") or {}
verification_token = (
str(payload.get("token", "") or "").strip()
or str(header.get("token", "") or "").strip()
or str(((payload.get("event") or {}).get("token")) or "").strip()
)
workspace_id = str(header.get("tenant_key", "") or "").strip()
return self._installation_manager.resolve_inbound_binding(
verification_token=verification_token,
workspace_id=workspace_id,
account_id=self._bound_account_id,
)
def _cache_key_for_binding(self, binding: FeishuBinding) -> str:
return binding.installation_id or binding.account_id
def _cached_bot_open_id(self, binding: FeishuBinding) -> str:
return (
self._bot_open_ids.get(self._cache_key_for_binding(binding), "")
or self._bot_open_id
)
def _resolve_delivery_binding(
self, *, workspace_id: str = "", account_id: str = ""
) -> Tuple[InstallationResolution, Optional[FeishuBinding], Dict[str, str]]:
return self._installation_manager.resolve_binding(
workspace_id=workspace_id,
account_id=account_id or self._bound_account_id,
)
async def _get_tenant_access_token(
self,
*,
binding: Optional[FeishuBinding] = None,
workspace_id: str = "",
account_id: str = "",
) -> str:
resolution, effective_binding, secrets = self._resolve_delivery_binding(
workspace_id=workspace_id,
account_id=account_id or (binding.account_id if binding else ""),
)
if effective_binding is None or not resolution.ok:
raise RuntimeError(
f"feishu_binding_resolution_failed:{resolution.reject_reason or 'missing_binding'}"
)
cache_key = self._cache_key_for_binding(effective_binding)
if self._tenant_access_tokens.get(
cache_key
) and self._tenant_access_token_expires_at.get(cache_key, 0.0) > (
time.time() + 30
):
return self._tenant_access_tokens[cache_key]
app_secret = str(
secrets.get("app_secret", "") or effective_binding.app_secret
).strip()
payload = {
"app_id": effective_binding.app_id,
"app_secret": app_secret,
}
url = f"{self._adapter_resolve_domain_base(effective_binding.domain)}/open-apis/auth/v3/tenant_access_token/internal"
try:
data = self._adapter_safe_request_json(
method="POST",
url=url,
json_body=payload,
headers={"Accept": "application/json"},
content_type="application/json; charset=utf-8",
timeout_sec=15,
allow_hosts=self._adapter_allowed_api_hosts(effective_binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "tenant_access_token"},
)
raise RuntimeError(
f"feishu_token_fetch_failed:{exc.status_code}:{exc.reason}"
) from exc
if data.get("code", 0) != 0:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=str(data.get("msg", "unknown") or "unknown"),
status_code=200,
details={"phase": "tenant_access_token"},
)
raise RuntimeError(
f"feishu_token_fetch_failed:200:{data.get('msg', 'unknown')}"
)
token = str(data.get("tenant_access_token", "") or "").strip()
if not token:
raise RuntimeError("feishu_token_fetch_failed:missing_token")
expire = int(
data.get("expire", self._adapter_token_ttl_sec())
or self._adapter_token_ttl_sec()
)
self._tenant_access_tokens[cache_key] = token
self._tenant_access_token_expires_at[cache_key] = time.time() + max(60, expire)
if resolution.installation is not None:
self._installation_manager.mark_resolution_success(
resolution.installation.installation_id,
effective_binding.workspace_id,
)
return token
async def _fetch_bot_open_id(
self,
*,
binding: Optional[FeishuBinding] = None,
workspace_id: str = "",
account_id: str = "",
allow_degrade: bool = False,
) -> str:
resolution, effective_binding, _ = self._resolve_delivery_binding(
workspace_id=workspace_id,
account_id=account_id or (binding.account_id if binding else ""),
)
if effective_binding is None or not resolution.ok:
return ""
cache_key = self._cache_key_for_binding(effective_binding)
if self._bot_open_ids.get(cache_key):
return self._bot_open_ids[cache_key]
token = await self._get_tenant_access_token(binding=effective_binding)
url = f"{self._adapter_resolve_domain_base(effective_binding.domain)}/open-apis/bot/v3/info"
try:
data = self._adapter_safe_request_json(
method="GET",
url=url,
headers={
"Accept": "application/json",
"Authorization": f"Bearer {token}",
},
timeout_sec=15,
allow_hosts=self._adapter_allowed_api_hosts(effective_binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "bot_info"},
)
if allow_degrade:
return ""
return ""
if data.get("code", 0) != 0:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=str(data.get("msg", "unknown") or "unknown"),
status_code=200,
details={"phase": "bot_info"},
)
return ""
bot_open_id = str(
(((data.get("data") or {}).get("bot") or {}).get("open_id")) or ""
).strip()
if bot_open_id:
self._bot_open_ids[cache_key] = bot_open_id
self._bot_open_id = bot_open_id
return bot_open_id
async def prime_bot_identity(self) -> None:
try:
await self._fetch_bot_open_id(
account_id=self._bound_account_id
or str(self.config.feishu_account_id or "").strip()
or str(self.config.feishu_default_account_id or "").strip(),
workspace_id=str(self.config.feishu_workspace_id or "").strip(),
allow_degrade=True,
)
except Exception as exc:
self._adapter_logger().debug("Feishu bot identity fetch failed: %s", exc)
@@ -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)
+424
View File
@@ -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()
+54 -6
View File
@@ -28,6 +28,11 @@ from ..contract import CommandRequest, CommandResponse
from ..router import CommandRouter
from ..security_profile import AllowlistPolicy, ReplayGuard
try:
from services.connector_replay_lifecycle import ConnectorReplayLifecycle
except ImportError: # pragma: no cover
ConnectorReplayLifecycle = None # type: ignore
logger = logging.getLogger(__name__)
@@ -99,6 +104,13 @@ class KakaoWebhookServer:
window_sec=self.REPLAY_WINDOW_SEC,
max_entries=self.NONCE_CACHE_SIZE,
)
if ConnectorReplayLifecycle is None: # pragma: no cover
self._replay_lifecycle = None
else:
self._replay_lifecycle = ConnectorReplayLifecycle(
ttl_sec=self.REPLAY_WINDOW_SEC,
max_entries=self.NONCE_CACHE_SIZE,
)
# S32: Allowlist (soft-deny via AllowlistPolicy primitive)
self._user_allowlist = AllowlistPolicy(config.kakao_allowed_users, strict=False)
@@ -165,10 +177,24 @@ class KakaoWebhookServer:
# We use a hash of the body bytes as the "nonce" for deduplication.
# This prevents re-transmitting the exact same request.
content_hash = hashlib.sha256(body_bytes).hexdigest()
if not self._replay_guard.check_and_record(content_hash):
logger.warning(f"Replay rejected for Kakao hash: {content_hash}")
# Return 200 to stop Kakao retries
return _make_response(web, status=200, text="OK")
lifecycle_key = f"kakao:webhook:{content_hash}"
if self._replay_lifecycle is None: # pragma: no cover
if not self._replay_guard.check_and_record(content_hash):
logger.warning(f"Replay rejected for Kakao hash: {content_hash}")
return _make_response(web, status=200, text="OK")
else:
claim = self._replay_lifecycle.claim(
lifecycle_key,
metadata={"platform": "kakao"},
)
if not claim.accepted:
logger.warning(
"Replay rejected for Kakao hash: %s code=%s state=%s",
content_hash,
claim.code,
claim.record.state,
)
return _make_response(web, status=200, text="OK")
# Normalization
# userRequest.user.id is the opaque user ID (botUserKey)
@@ -180,6 +206,10 @@ class KakaoWebhookServer:
if not sender_id:
# Not a valid user request (maybe a ping?)
if self._replay_lifecycle is not None:
self._replay_lifecycle.fail_terminal(
lifecycle_key, reason="invalid_payload_no_user_id"
)
return self._build_error_response("Invalid Payload: No User ID")
# S32: Allowlist
@@ -208,6 +238,17 @@ class KakaoWebhookServer:
try:
resp = await self.router.handle(req)
except Exception as e:
# IMPORTANT: router failures happen before Kakao response delivery and
# must remain retryable; successful router returns are never rerouted.
if self._replay_lifecycle is not None:
self._replay_lifecycle.release_retryable(
lifecycle_key, reason="kakao_router_failed_before_commit"
)
logger.exception(f"Error handling Kakao command: {e}")
return self._build_error_response("Internal Error")
try:
# IMPORTANT:
# Router mocks in unit tests may return non-string `.text` values.
# Normalize defensively to avoid turning a valid routing flow into
@@ -230,13 +271,20 @@ class KakaoWebhookServer:
# Skipping complex media upload for F44 scope unless specifically required.
if resp_text or buttons:
return self._build_response(resp_text, quick_replies=buttons)
response = self._build_response(resp_text, quick_replies=buttons)
else:
# No response content (e.g. valid command but no output intended?)
# Kakao requires *some* response payload or it treats as error.
# We'll return a simple valid JSON to ack.
return self._build_response("Command processed.")
response = self._build_response("Command processed.")
if self._replay_lifecycle is not None:
self._replay_lifecycle.commit_success(lifecycle_key, reason="routed")
return response
except Exception as e:
if self._replay_lifecycle is not None:
self._replay_lifecycle.fail_terminal(
lifecycle_key, reason="kakao_response_build_failed"
)
logger.exception(f"Error handling Kakao command: {e}")
return self._build_error_response("Internal Error")
+2 -1
View File
@@ -13,6 +13,7 @@ from typing import Optional
from ..config import ConnectorConfig
from ..contract import CommandRequest, CommandResponse
from ..media_response import build_connector_media_response
from ..router import CommandRouter
from ..security_profile import AllowlistPolicy, ReplayGuard, verify_hmac_signature
from ..transport_contract import RelayResponseClassifier
@@ -175,7 +176,7 @@ class LINEWebhookServer:
if not path:
return web.Response(status=404, text="Media Not Found or Expired")
return web.FileResponse(path)
return build_connector_media_response(web, path)
async def _process_event(self, event: dict):
"""Convert LINE event to CommandRequest and route."""
@@ -0,0 +1,345 @@
"""Owned Slack response and media-delivery mixin."""
# ruff: noqa: SIM117, UP006, UP035, UP045 -- preserve frozen behavior/signatures.
from typing import Any, Dict, Optional
from ..reply_visibility import decide_reply_visibility
# mypy: disable-error-code="attr-defined,no-any-return"
class SlackDeliveryMixin:
async def _send_interactive_reply(
self,
*,
channel_id: str,
text: str,
buttons: list[dict],
thread_ts: str = "",
delivery_context: Optional[Dict[str, Any]] = None,
) -> None:
"""Send a Slack Block Kit message with bounded button actions."""
try:
import aiohttp as _aiohttp
except ImportError:
self._adapter_logger().warning(
"aiohttp not available; cannot send Slack interactive reply"
)
return
ctx = dict(delivery_context or {})
if not thread_ts:
thread_ts = str(ctx.get("thread_id", "") or "").strip()
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
str(ctx.get("workspace_id", "") or "").strip()
)
if not bot_token:
self._adapter_logger().warning(
"Slack interactive reply dropped: no workspace token available (workspace=%s)",
workspace_id or "legacy",
)
return
elements: list[dict] = []
for idx, button in enumerate(buttons[:5]):
value = str(button.get("value", "") or "").strip()
if not value:
continue
label = str(button.get("label", "") or "OpenClaw").strip()[:75]
action_id = str(
button.get("action_type")
or button.get("action_id")
or f"openclaw.{idx}"
).strip()[:255]
element: Dict[str, Any] = {
"type": "button",
"text": {"type": "plain_text", "text": label or "OpenClaw"},
"value": value[:2000],
"action_id": action_id or f"openclaw.{idx}",
}
style = self._adapter_style_to_slack(str(button.get("style", "") or ""))
if style:
element["style"] = style
elements.append(element)
if not elements:
if text:
await self._send_reply(
channel_id=channel_id,
text=text,
thread_ts=thread_ts,
delivery_context=ctx,
)
return
payload: Dict[str, Any] = {
"channel": channel_id,
"text": text or "OpenClaw",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (text or "OpenClaw")[:3000],
},
},
{"type": "actions", "elements": elements},
],
}
if thread_ts:
payload["thread_ts"] = thread_ts
headers = {
"Authorization": f"Bearer {bot_token}",
"Content-Type": "application/json; charset=utf-8",
}
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
"https://slack.com/api/chat.postMessage",
json=payload,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status != 200:
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=f"http_{resp.status}",
status_code=resp.status,
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
"interactive": True,
},
)
return
data = await resp.json()
if not data.get("ok"):
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=str(data.get("error", "unknown")),
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
"interactive": True,
},
)
elif installation_id:
self._installation_manager.mark_installation_health(
installation_id,
health_code="ok",
reason="chat_post_message_interactive_ok",
details={"workspace_id": workspace_id},
)
except Exception as e:
self._adapter_logger().warning("Slack interactive reply failed: %s", e)
async def _send_reply(
self,
channel_id: str,
text: str,
thread_ts: str = "",
delivery_context: Optional[Dict[str, Any]] = None,
) -> None:
"""Send a message via Slack Web API (chat.postMessage)."""
ctx = dict(delivery_context or {})
if not thread_ts:
thread_ts = str(ctx.get("thread_id", "") or "").strip()
decision = decide_reply_visibility(
delivery_context=ctx,
platform="slack",
channel_kind=self._adapter_channel_kind(channel_id),
in_thread=bool(thread_ts),
text=text,
)
if decision.suppressed:
self._adapter_logger().info(
"Suppressed Slack reply channel=%s reason=%s",
channel_id,
decision.reason,
)
return
try:
import aiohttp as _aiohttp
except ImportError:
self._adapter_logger().warning(
"aiohttp not available; cannot send Slack reply"
)
return
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
str(ctx.get("workspace_id", "") or "").strip()
)
if not bot_token:
self._adapter_logger().warning(
"Slack reply dropped: no workspace token available (workspace=%s)",
workspace_id or "legacy",
)
return
url = "https://slack.com/api/chat.postMessage"
headers = {
"Authorization": f"Bearer {bot_token}",
"Content-Type": "application/json; charset=utf-8",
}
payload: Dict[str, Any] = {
"channel": channel_id,
"text": text,
}
if thread_ts:
payload["thread_ts"] = thread_ts
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
url,
json=payload,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status != 200:
body = await resp.text()
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=f"http_{resp.status}",
status_code=resp.status,
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
},
)
self._adapter_logger().warning(
f"Slack API error: status={resp.status} body={body[:200]}"
)
else:
data = await resp.json()
if not data.get("ok"):
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=str(data.get("error", "unknown")),
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
},
)
self._adapter_logger().warning(
f"Slack API error: {data.get('error', 'unknown')}"
)
elif installation_id:
self._installation_manager.mark_installation_health(
installation_id,
health_code="ok",
reason="chat_post_message_ok",
details={"workspace_id": workspace_id},
)
except Exception as e:
self._adapter_logger().warning(f"Slack reply failed: {e}")
# ------------------------------------------------------------------
# Platform contract: send_message / send_image
# ------------------------------------------------------------------
async def send_message(
self,
channel_id: str,
text: str,
delivery_context: Optional[Dict[str, Any]] = None,
):
"""Platform contract: send text message."""
await self._send_reply(
channel_id=channel_id,
text=text,
delivery_context=delivery_context,
)
async def send_image(
self,
channel_id: str,
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[Dict[str, Any]] = None,
):
"""Platform contract: send image (Slack files.upload)."""
try:
import aiohttp as _aiohttp
except ImportError:
self._adapter_logger().warning(
"aiohttp not available; cannot upload Slack image"
)
return
ctx = dict(delivery_context or {})
thread_ts = str(ctx.get("thread_id", "") or "").strip()
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
str(ctx.get("workspace_id", "") or "").strip()
)
if not bot_token:
self._adapter_logger().warning(
"Slack image dropped: no workspace token available (workspace=%s)",
workspace_id or "legacy",
)
return
url = "https://slack.com/api/files.upload"
headers = {
"Authorization": f"Bearer {bot_token}",
}
data = _aiohttp.FormData()
data.add_field("file", image_data, filename=filename, content_type="image/png")
data.add_field("channels", channel_id)
if caption:
data.add_field("initial_comment", caption)
if thread_ts:
data.add_field("thread_ts", thread_ts)
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
url,
data=data,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status != 200:
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=f"http_{resp.status}",
status_code=resp.status,
details={
"workspace_id": workspace_id,
"path": "files.upload",
},
)
self._adapter_logger().warning(
f"Slack file upload error: status={resp.status}"
)
else:
resp_data = await resp.json()
if not resp_data.get("ok"):
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=str(resp_data.get("error", "unknown")),
details={
"workspace_id": workspace_id,
"path": "files.upload",
},
)
self._adapter_logger().warning(
f"Slack file upload error: {resp_data.get('error')}"
)
elif installation_id:
self._installation_manager.mark_installation_health(
installation_id,
health_code="ok",
reason="files_upload_ok",
details={"workspace_id": workspace_id},
)
except Exception as e:
self._adapter_logger().warning(f"Slack image upload failed: {e}")
@@ -0,0 +1,497 @@
"""Owned Slack signed-ingress and interaction transaction mixin."""
import json
import time
from typing import Any, Dict, Optional
from urllib.parse import parse_qs
from ..contract import CommandRequest
# ruff: noqa: SIM102, UP006, UP035, UP045 -- preserve frozen behavior/signatures.
# mypy: disable-error-code="attr-defined,no-any-return"
class SlackIngressMixin:
async def handle_event(self, request):
"""POST handler for Slack Events API."""
_, web = self._adapter_import_aiohttp_web()
try:
body_bytes = await request.read()
except Exception:
return self._adapter_make_response(web, status=400, text="Bad request")
# -- Step 1: Signature verification (fail-closed) --
timestamp = ""
signature = ""
if hasattr(request, "headers"):
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
signature = request.headers.get("X-Slack-Signature", "")
if not self._adapter_verify_slack_signature(
signing_secret=self.config.slack_signing_secret or "",
timestamp=timestamp,
body=body_bytes,
signature=signature,
):
self._adapter_logger().warning(
"Slack signature verification failed (rejected)"
)
return self._adapter_make_response(
web, status=401, text="Invalid signature"
)
# -- Step 2: Parse payload --
try:
payload = json.loads(body_bytes)
except json.JSONDecodeError:
return self._adapter_make_response(web, status=400, text="Bad JSON")
# -- Step 3: url_verification challenge (Webhook only) --
if payload.get("type") == "url_verification":
challenge = payload.get("challenge", "")
return self._adapter_make_json_response(web, {"challenge": challenge})
# -- Step 4: Process event --
try:
await self.process_event_payload(payload)
except ValueError:
return self._adapter_make_response(web, status=400, text="Bad Request")
return self._adapter_make_response(web, status=200, text="OK")
async def handle_interaction(self, request):
"""POST handler for Slack Block Kit interactivity callbacks."""
_, web = self._adapter_import_aiohttp_web()
try:
body_bytes = await request.read()
except Exception:
return self._adapter_make_response(web, status=400, text="Bad request")
timestamp = ""
signature = ""
if hasattr(request, "headers"):
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
signature = request.headers.get("X-Slack-Signature", "")
if not self._adapter_verify_slack_signature(
signing_secret=self.config.slack_signing_secret or "",
timestamp=timestamp,
body=body_bytes,
signature=signature,
):
self._adapter_logger().warning(
"Slack interaction signature verification failed (rejected)"
)
return self._adapter_make_response(
web, status=401, text="Invalid signature"
)
parsed = parse_qs(body_bytes.decode("utf-8"), keep_blank_values=True)
raw_payload = (parsed.get("payload") or [""])[0]
if not raw_payload:
return self._adapter_make_response(web, status=400, text="Missing payload")
try:
payload = json.loads(raw_payload)
except json.JSONDecodeError:
return self._adapter_make_response(web, status=400, text="Bad payload")
if not isinstance(payload, dict):
return self._adapter_make_response(web, status=400, text="Bad payload")
try:
routed = await self.process_interaction_payload(payload)
except ValueError:
return self._adapter_make_response(web, status=400, text="Bad Request")
except Exception as exc:
safe_text = self._adapter_safe_external_error_text(
"Slack interaction failed", exc
)
self._adapter_logger().warning("Slack interaction failed: %s", safe_text)
return self._adapter_make_response(web, status=500, text=safe_text)
# Slack requires a fast acknowledgement for interactivity requests.
# Keep the external response bounded; detailed action results are routed
# through the existing reply/deferred-response surfaces.
return self._adapter_make_json_response(
web, {"ok": True, "routed": bool(routed)}
)
async def process_event_payload(self, payload: Dict[str, Any]) -> None:
"""
Shared event processing path for both webhook and socket mode transports.
"""
if payload.get("type") != "event_callback":
return
event = payload.get("event", {})
event_id = payload.get("event_id", "")
event_type = event.get("type", "")
workspace_id = self._installation_manager.extract_workspace_id(payload)
if event_type in ("app_uninstalled", "tokens_revoked", "app_rate_limited"):
if workspace_id:
self._handle_lifecycle_event(workspace_id, event_type)
return
# -- Step 5: Replay / dedupe guard --
if not event_id:
self._adapter_logger().warning("Slack event missing event_id (rejected)")
raise ValueError("Missing event_id")
if not self._replay_guard.check_and_record(event_id):
self._adapter_logger().debug(
f"Slack duplicate event_id={event_id} (accepted, no-op)"
)
return
# -- Step 6: Bot-loop prevention --
# Resolve bot user ID from authorizations or cache.
bot_user_id = self._get_bot_user_id(payload, workspace_id)
sender_id = event.get("user", "")
if sender_id and bot_user_id and sender_id == bot_user_id:
return
if event.get("bot_id"):
return
subtype = event.get("subtype", "")
if subtype and subtype not in ("", "file_share"):
return
# -- Step 7: Event normalization --
text = event.get("text", "").strip()
channel_id = event.get("channel", "")
thread_ts = event.get("thread_ts", "")
message_ts = event.get("ts", "")
if event_type not in ("message", "app_mention"):
return
if not text or not sender_id:
return
# S67: Require mention in group channels.
is_dm = channel_id.startswith("D")
mentioned_bot = event_type == "app_mention" or (
bool(bot_user_id) and f"<@{bot_user_id}>" in text
)
if not is_dm and self.config.slack_require_mention:
if event_type != "app_mention":
if bot_user_id and f"<@{bot_user_id}>" not in text:
return
if bot_user_id:
text = text.replace(f"<@{bot_user_id}>", "").strip()
# -- Step 8: Allowlist checks (S67) --
if self._user_allowlist.entries:
user_result = self._user_allowlist.evaluate(sender_id)
if user_result.decision == "deny":
self._adapter_logger().warning(
f"Slack user {sender_id} denied by allowlist"
)
return
if self._channel_allowlist.entries and channel_id:
chan_result = self._channel_allowlist.evaluate(channel_id)
if chan_result.decision == "deny":
self._adapter_logger().warning(
f"Slack channel {channel_id} denied by allowlist"
)
return
# -- Step 9: Build CommandRequest and route --
req = CommandRequest(
platform="slack",
sender_id=sender_id,
channel_id=channel_id,
username=sender_id,
message_id=event_id,
text=text,
timestamp=float(message_ts) if message_ts else time.time(),
workspace_id=workspace_id,
thread_id=thread_ts
or (message_ts if self.config.slack_reply_in_thread else ""),
)
try:
resp = await self.router.handle(req)
resp_text = getattr(resp, "text", "")
if not isinstance(resp_text, str):
resp_text = str(resp_text) if resp_text is not None else ""
buttons = getattr(resp, "buttons", []) or []
if resp_text or buttons:
if buttons:
await self._send_interactive_reply(
channel_id=channel_id,
text=resp_text or "OpenClaw",
buttons=buttons,
thread_ts=req.thread_id,
delivery_context={
"workspace_id": workspace_id,
"thread_id": req.thread_id,
"channel_kind": self._adapter_channel_kind(channel_id),
"mentioned": mentioned_bot,
},
)
else:
await self._send_reply(
channel_id=channel_id,
text=resp_text,
thread_ts=req.thread_id,
delivery_context={
"workspace_id": workspace_id,
"thread_id": req.thread_id,
"channel_kind": self._adapter_channel_kind(channel_id),
"mentioned": mentioned_bot,
},
)
except Exception as e:
self._adapter_logger().error(
"Slack event handling failed (error_type=%s)", type(e).__name__
)
async def process_interaction_payload(self, payload: Dict[str, Any]) -> bool:
interaction_type = str(payload.get("type", "") or "").strip()
if interaction_type not in self._adapter_interaction_types():
return False
request = self._build_interaction_request(payload)
if request is None:
return False
replay_key = self._interaction_replay_key(payload, request)
if self._interaction_lifecycle is None: # pragma: no cover
if not self._replay_guard.check_and_record(replay_key):
self._adapter_logger().debug(
"Slack duplicate interaction %s (accepted, no-op)", replay_key
)
return False
claim = None
else:
claim = self._interaction_lifecycle.claim(
replay_key,
metadata={
"platform": "slack",
"workspace_id": request.workspace_id,
"interaction_type": str(payload.get("type", "") or ""),
},
)
if claim is not None and not claim.accepted:
self._adapter_logger().debug(
"Slack duplicate interaction %s state=%s code=%s (accepted, no-op)",
replay_key,
claim.record.state,
claim.code,
)
return False
# IMPORTANT: interactive run-like payloads must be routed through the same
# approval semantics as text commands. Untrusted users get approval forced
# before CommandRouter sees the request, avoiding a parallel bypass path.
if request.text.startswith("/run") and not (
self.router._is_admin(request) or self.router._is_trusted(request)
):
request.text = self._adapter_force_approval_command(request.text)
try:
response = await self.router.handle(request)
except Exception:
# IMPORTANT: only failures before router completion are retryable.
# Once router.handle returns, duplicate user actions must not reroute.
if self._interaction_lifecycle is not None:
self._interaction_lifecycle.release_retryable(
replay_key, reason="slack_interaction_failed_before_commit"
)
raise
if self._interaction_lifecycle is not None:
self._interaction_lifecycle.commit_success(replay_key, reason="routed")
response_text = str(getattr(response, "text", "") or "").strip()
response_buttons = getattr(response, "buttons", []) or []
if response_text or response_buttons:
if response_buttons:
await self._send_interactive_reply(
channel_id=request.channel_id,
text=response_text or "Action processed.",
buttons=response_buttons,
thread_ts=request.thread_id,
delivery_context={
"workspace_id": request.workspace_id,
"thread_id": request.thread_id,
},
)
elif response_text:
await self._send_reply(
channel_id=request.channel_id,
text=response_text,
thread_ts=request.thread_id,
delivery_context={
"workspace_id": request.workspace_id,
"thread_id": request.thread_id,
},
)
return True
def _build_interaction_request(
self, payload: Dict[str, Any]
) -> Optional[CommandRequest]:
interaction_type = str(payload.get("type", "") or "").strip()
command_text = self._extract_interaction_command(payload)
if not command_text:
return None
team = payload.get("team") or {}
user = payload.get("user") or {}
container = payload.get("container") or {}
channel = payload.get("channel") or {}
view = payload.get("view") or {}
message = payload.get("message") or {}
action = self._first_action(payload)
workspace_id = self._adapter_first_non_empty(
team.get("id"),
payload.get("team_id"),
(
payload.get("enterprise", {}).get("id")
if isinstance(payload.get("enterprise"), dict)
else ""
),
)
sender_id = self._adapter_first_non_empty(
user.get("id"), payload.get("user_id")
)
channel_id = self._adapter_first_non_empty(
channel.get("id"),
container.get("channel_id"),
payload.get("channel_id"),
)
message_id = self._adapter_first_non_empty(
view.get("id"),
action.get("action_ts"),
container.get("message_ts"),
payload.get("trigger_id"),
f"slack-interaction-{int(time.time())}",
)
thread_id = self._adapter_first_non_empty(
container.get("thread_ts"),
message.get("thread_ts") if isinstance(message, dict) else "",
container.get("message_ts"),
)
if not thread_id and self.config.slack_reply_in_thread:
thread_id = self._adapter_first_non_empty(
container.get("message_ts"), message.get("ts")
)
return CommandRequest(
platform="slack",
sender_id=sender_id,
channel_id=channel_id or sender_id,
username=self._adapter_first_non_empty(
user.get("username"), user.get("name"), sender_id
),
message_id=message_id,
text=command_text,
timestamp=time.time(),
workspace_id=workspace_id,
thread_id=thread_id,
metadata={
"interactive_callback": True,
"interaction_type": interaction_type,
"action_id": self._adapter_first_non_empty(
action.get("action_id"), view.get("callback_id")
),
"response_url": str(payload.get("response_url", "") or ""),
},
)
def _extract_interaction_command(self, payload: Dict[str, Any]) -> str:
interaction_type = str(payload.get("type", "") or "").strip()
if interaction_type == "block_actions":
action = self._first_action(payload)
selected = action.get("selected_option") or {}
value = self._adapter_first_non_empty(
action.get("value"),
selected.get("value") if isinstance(selected, dict) else "",
action.get("action_id"),
)
parsed = self._adapter_json_loads_safe(value)
return self._adapter_first_non_empty(
parsed.get("command"), parsed.get("value"), value
)
if interaction_type == "view_submission":
view = payload.get("view") or {}
private_meta = self._adapter_first_non_empty(view.get("private_metadata"))
parsed = self._adapter_json_loads_safe(private_meta)
if parsed:
return self._adapter_first_non_empty(
parsed.get("command"), parsed.get("value")
)
if private_meta:
return private_meta
state = (view.get("state") or {}).get("values") or {}
return self._extract_command_from_view_state(state)
if interaction_type == "workflow_step_execute":
workflow_step = payload.get("workflow_step") or {}
inputs = workflow_step.get("inputs") or {}
command = inputs.get("command") or {}
if isinstance(command, dict):
return self._adapter_first_non_empty(command.get("value"))
return self._adapter_first_non_empty(workflow_step.get("callback_id"))
return ""
def _extract_command_from_view_state(self, state: Dict[str, Any]) -> str:
if not isinstance(state, dict):
return ""
for block_value in state.values():
if not isinstance(block_value, dict):
continue
for action_value in block_value.values():
if not isinstance(action_value, dict):
continue
candidate = self._adapter_first_non_empty(
action_value.get("value"),
(
(action_value.get("selected_option") or {}).get("value")
if isinstance(action_value.get("selected_option"), dict)
else ""
),
)
parsed = self._adapter_json_loads_safe(candidate)
command = self._adapter_first_non_empty(
parsed.get("command"), parsed.get("value"), candidate
)
if command:
return command
return ""
def _first_action(self, payload: Dict[str, Any]) -> Dict[str, Any]:
actions = payload.get("actions") or []
if isinstance(actions, list) and actions and isinstance(actions[0], dict):
return actions[0]
return {}
def _interaction_replay_key(
self, payload: Dict[str, Any], request: CommandRequest
) -> str:
action = self._first_action(payload)
key_parts = [
"interaction",
str(payload.get("type", "") or ""),
request.workspace_id,
request.sender_id,
request.channel_id,
request.message_id,
str(payload.get("trigger_id", "") or ""),
str(action.get("action_id", "") or ""),
str(action.get("action_ts", "") or ""),
request.text,
]
return ":".join(key_parts)
# ------------------------------------------------------------------
# Slack Web API reply
# ------------------------------------------------------------------
@@ -0,0 +1,171 @@
"""Owned Slack installation, OAuth, and workspace-identity mixin."""
# ruff: noqa: UP006, UP035, UP045 -- preserve frozen facade annotations.
from typing import Any, Dict, Optional, Tuple
# mypy: disable-error-code="attr-defined,has-type,no-any-return"
class SlackInstallationMixin:
async def handle_oauth_install(self, request):
_, web = self._adapter_import_aiohttp_web()
if not self._installation_manager.can_handle_oauth():
return self._adapter_make_response(
web, status=503, text="Slack OAuth not configured"
)
state = self._installation_manager.issue_install_state()
return self._adapter_make_redirect_response(
web, self._installation_manager.build_install_url(state)
)
async def handle_oauth_callback(self, request):
_, web = self._adapter_import_aiohttp_web()
if not self._installation_manager.can_handle_oauth():
return self._adapter_make_response(
web, status=503, text="Slack OAuth not configured"
)
query = getattr(request, "query", {}) or {}
if query.get("error"):
return self._adapter_make_response(
web,
status=400,
text=f"Slack OAuth rejected: {query.get('error')}",
)
state = str(query.get("state", "") or "").strip()
code = str(query.get("code", "") or "").strip()
if not state or not code:
return self._adapter_make_response(
web, status=400, text="Missing OAuth callback fields"
)
if not self._installation_manager.consume_install_state(state):
return self._adapter_make_response(
web, status=400, text="Invalid or replayed OAuth state"
)
try:
payload = await self._installation_manager.exchange_code(code)
installation = self._installation_manager.upsert_from_oauth_payload(payload)
return self._adapter_make_response(
web,
status=200,
text=(
"Slack installation complete for "
f"{installation.workspace_id} ({installation.installation_id})."
),
)
except Exception as exc:
safe_text = self._adapter_safe_external_error_text(
"Slack OAuth processing failed", exc
)
self._adapter_logger().warning("Slack OAuth callback failed: %s", safe_text)
return self._adapter_make_response(
web,
status=502,
text=safe_text,
)
def _get_bot_user_id(self, payload: Dict[str, Any], workspace_id: str) -> str:
candidate = ""
if workspace_id and workspace_id in self._bot_user_ids:
return self._bot_user_ids[workspace_id]
if self._bot_user_id:
return self._bot_user_id
authorizations = payload.get("authorizations", [])
if authorizations and isinstance(authorizations, list):
candidate = str((authorizations[0] or {}).get("user_id", "") or "").strip()
if candidate:
self._bot_user_id = candidate
if workspace_id:
self._bot_user_ids[workspace_id] = candidate
return candidate
if workspace_id:
workspace_resolution, _ = (
self._installation_manager.resolve_workspace_tokens(workspace_id)
)
candidate = self._installation_manager.bot_user_id_for_installation(
workspace_resolution.installation if workspace_resolution.ok else None
)
if candidate:
self._bot_user_ids[workspace_id] = candidate
if self._bot_user_id is None:
self._bot_user_id = candidate
return candidate
def _resolve_workspace_credentials(
self, workspace_id: str
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
workspace_id = str(workspace_id or "").strip()
if workspace_id:
resolution, tokens = self._installation_manager.resolve_workspace_tokens(
workspace_id
)
if resolution.ok and resolution.installation is not None:
bot_token = tokens.get("bot_token")
if bot_token:
self._installation_manager.mark_resolution_success(
resolution.installation.installation_id, workspace_id
)
return (
resolution.installation.installation_id,
bot_token,
workspace_id,
)
self._adapter_logger().warning(
"Slack workspace %s resolved without bot token secret", workspace_id
)
return (
resolution.installation.installation_id,
None,
workspace_id,
)
if (
not self._installation_manager.oauth_enabled
and self.config.slack_bot_token
):
return (None, self.config.slack_bot_token, workspace_id)
self._adapter_logger().warning(
"Slack workspace resolution failed for %s: %s (%s)",
workspace_id,
resolution.reject_reason,
resolution.health_code,
)
return (None, None, workspace_id)
if self.config.slack_bot_token:
return (None, self.config.slack_bot_token, "")
return (None, None, workspace_id)
def _handle_lifecycle_event(self, workspace_id: str, event_type: str) -> None:
installation_id = self._installation_manager.installation_id_for_workspace(
workspace_id
)
try:
if event_type == "app_uninstalled":
self._installation_manager.mark_installation_health(
installation_id,
health_code="revoked",
reason="slack_app_uninstalled",
details={"workspace_id": workspace_id},
)
self._installation_manager.uninstall_installation(
installation_id, reason="slack_app_uninstalled"
)
elif event_type == "tokens_revoked":
self._installation_manager.mark_installation_health(
installation_id,
health_code="invalid_token",
reason="slack_tokens_revoked",
details={"workspace_id": workspace_id},
)
elif event_type == "app_rate_limited":
self._installation_manager.mark_installation_health(
installation_id,
health_code="degraded",
reason="slack_app_rate_limited",
details={"workspace_id": workspace_id},
)
except ValueError:
self._adapter_logger().warning(
"Slack lifecycle event for unbound workspace %s (%s)",
workspace_id,
event_type,
)
+129 -497
View File
@@ -33,16 +33,33 @@ import hmac
import json
import logging
import time
from typing import Any, Dict, Optional, Tuple
from typing import Any, Dict, Optional
from ..config import ConnectorConfig
from ..contract import CommandRequest, CommandResponse
from ..router import CommandRouter
from ..security_profile import AllowlistPolicy, ReplayGuard
from .slack_delivery_handlers import SlackDeliveryMixin
from .slack_ingress_handlers import SlackIngressMixin
from .slack_installation_handlers import SlackInstallationMixin
from .slack_installation_manager import SlackInstallationManager
try:
from services.connector_replay_lifecycle import ConnectorReplayLifecycle
except ImportError: # pragma: no cover
ConnectorReplayLifecycle = None # type: ignore
logger = logging.getLogger(__name__)
_SLACK_INTERACTION_TYPES = frozenset(
{"block_actions", "view_submission", "workflow_step_execute"}
)
def _slack_channel_kind(channel_id: str) -> str:
if str(channel_id or "").startswith("D"):
return "dm"
return "group"
# -- aiohttp compat layer (same pattern as kakao/whatsapp/wechat) -----------
@@ -97,6 +114,46 @@ def _make_redirect_response(web_mod, url: str):
return _CompatResponse(status=302, text=url)
def _safe_external_error_text(default: str, _exc: Exception) -> str:
# IMPORTANT: keep Slack external failures constant. Even "short safe-looking"
# exception text remains scanner-tainted and can re-expose internal detail.
return default
def _json_loads_safe(raw: Any) -> Dict[str, Any]:
if isinstance(raw, dict):
return raw
if not isinstance(raw, str):
return {}
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except (TypeError, ValueError):
return {}
def _first_non_empty(*values: Any) -> str:
for value in values:
text = str(value or "").strip()
if text:
return text
return ""
def _force_approval_command(command_text: str) -> str:
normalized = str(command_text or "").strip()
if normalized.startswith("/run") and "--approval" not in normalized:
return f"{normalized} --approval"
return normalized
def _style_to_slack(style: str) -> str:
normalized = str(style or "").strip().lower()
if normalized in {"primary", "danger"}:
return normalized
return "primary" if normalized in {"approve", "success"} else ""
# -- Slack signature verification -------------------------------------------
# Maximum acceptable clock skew for timestamp validation (5 minutes).
@@ -146,7 +203,11 @@ def verify_slack_signature(
# -- Slack adapter ----------------------------------------------------------
class SlackWebhookServer:
class SlackWebhookServer(
SlackInstallationMixin,
SlackIngressMixin,
SlackDeliveryMixin,
):
"""
F56 -- Slack Events API adapter.
@@ -173,6 +234,13 @@ class SlackWebhookServer:
window_sec=self.REPLAY_WINDOW_SEC,
max_entries=self.NONCE_CACHE_SIZE,
)
if ConnectorReplayLifecycle is None: # pragma: no cover
self._interaction_lifecycle = None
else:
self._interaction_lifecycle = ConnectorReplayLifecycle(
ttl_sec=self.REPLAY_WINDOW_SEC,
max_entries=self.NONCE_CACHE_SIZE,
)
# S67: Allowlists (fail-closed when configured)
self._user_allowlist = AllowlistPolicy(config.slack_allowed_users, strict=False)
@@ -182,9 +250,63 @@ class SlackWebhookServer:
self._installation_manager = SlackInstallationManager(config)
# Bot user ID (resolved on first event or set from config)
self._bot_user_id: Optional[str] = None
self._bot_user_id: Optional[str] = None # type: ignore[assignment]
self._bot_user_ids: Dict[str, str] = {}
# IMPORTANT: resolve facade globals at call time; integration suites and
# minimal-host shims patch these security/protocol seams directly.
@staticmethod
def _adapter_import_aiohttp_web():
return _import_aiohttp_web()
@staticmethod
def _adapter_make_response(*args, **kwargs):
return _make_response(*args, **kwargs)
@staticmethod
def _adapter_make_json_response(*args, **kwargs):
return _make_json_response(*args, **kwargs)
@staticmethod
def _adapter_make_redirect_response(*args, **kwargs):
return _make_redirect_response(*args, **kwargs)
@staticmethod
def _adapter_safe_external_error_text(*args, **kwargs):
return _safe_external_error_text(*args, **kwargs)
@staticmethod
def _adapter_verify_slack_signature(*args, **kwargs):
return verify_slack_signature(*args, **kwargs)
@staticmethod
def _adapter_json_loads_safe(*args, **kwargs):
return _json_loads_safe(*args, **kwargs)
@staticmethod
def _adapter_first_non_empty(*args, **kwargs):
return _first_non_empty(*args, **kwargs)
@staticmethod
def _adapter_force_approval_command(*args, **kwargs):
return _force_approval_command(*args, **kwargs)
@staticmethod
def _adapter_style_to_slack(*args, **kwargs):
return _style_to_slack(*args, **kwargs)
@staticmethod
def _adapter_channel_kind(*args, **kwargs):
return _slack_channel_kind(*args, **kwargs)
@staticmethod
def _adapter_interaction_types():
return _SLACK_INTERACTION_TYPES
@staticmethod
def _adapter_logger():
return logger
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
@@ -219,6 +341,9 @@ class SlackWebhookServer:
self.app = web.Application()
self.app.router.add_post(self.config.slack_webhook_path, self.handle_event)
self.app.router.add_post(
self.config.slack_interactions_path, self.handle_interaction
)
if self._installation_manager.can_handle_oauth():
self.app.router.add_get(
self.config.slack_oauth_install_path, self.handle_oauth_install
@@ -243,496 +368,3 @@ class SlackWebhookServer:
# ------------------------------------------------------------------
# Event handler
# ------------------------------------------------------------------
async def handle_oauth_install(self, request):
_, web = _import_aiohttp_web()
if not self._installation_manager.can_handle_oauth():
return _make_response(web, status=503, text="Slack OAuth not configured")
state = self._installation_manager.issue_install_state()
return _make_redirect_response(
web, self._installation_manager.build_install_url(state)
)
async def handle_oauth_callback(self, request):
_, web = _import_aiohttp_web()
if not self._installation_manager.can_handle_oauth():
return _make_response(web, status=503, text="Slack OAuth not configured")
query = getattr(request, "query", {}) or {}
if query.get("error"):
return _make_response(
web,
status=400,
text=f"Slack OAuth rejected: {query.get('error')}",
)
state = str(query.get("state", "") or "").strip()
code = str(query.get("code", "") or "").strip()
if not state or not code:
return _make_response(web, status=400, text="Missing OAuth callback fields")
if not self._installation_manager.consume_install_state(state):
return _make_response(
web, status=400, text="Invalid or replayed OAuth state"
)
try:
payload = await self._installation_manager.exchange_code(code)
installation = self._installation_manager.upsert_from_oauth_payload(payload)
return _make_response(
web,
status=200,
text=(
"Slack installation complete for "
f"{installation.workspace_id} ({installation.installation_id})."
),
)
except Exception as exc:
logger.warning("Slack OAuth callback failed: %s", exc)
return _make_response(web, status=502, text=str(exc))
def _get_bot_user_id(self, payload: Dict[str, Any], workspace_id: str) -> str:
candidate = ""
if workspace_id and workspace_id in self._bot_user_ids:
return self._bot_user_ids[workspace_id]
if self._bot_user_id:
return self._bot_user_id
authorizations = payload.get("authorizations", [])
if authorizations and isinstance(authorizations, list):
candidate = str((authorizations[0] or {}).get("user_id", "") or "").strip()
if candidate:
self._bot_user_id = candidate
if workspace_id:
self._bot_user_ids[workspace_id] = candidate
return candidate
if workspace_id:
workspace_resolution, _ = (
self._installation_manager.resolve_workspace_tokens(workspace_id)
)
candidate = self._installation_manager.bot_user_id_for_installation(
workspace_resolution.installation if workspace_resolution.ok else None
)
if candidate:
self._bot_user_ids[workspace_id] = candidate
if self._bot_user_id is None:
self._bot_user_id = candidate
return candidate
def _resolve_workspace_credentials(
self, workspace_id: str
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
workspace_id = str(workspace_id or "").strip()
if workspace_id:
resolution, tokens = self._installation_manager.resolve_workspace_tokens(
workspace_id
)
if resolution.ok and resolution.installation is not None:
bot_token = tokens.get("bot_token")
if bot_token:
self._installation_manager.mark_resolution_success(
resolution.installation.installation_id, workspace_id
)
return (
resolution.installation.installation_id,
bot_token,
workspace_id,
)
logger.warning(
"Slack workspace %s resolved without bot token secret", workspace_id
)
return (
resolution.installation.installation_id,
None,
workspace_id,
)
if (
not self._installation_manager.oauth_enabled
and self.config.slack_bot_token
):
return (None, self.config.slack_bot_token, workspace_id)
logger.warning(
"Slack workspace resolution failed for %s: %s (%s)",
workspace_id,
resolution.reject_reason,
resolution.health_code,
)
return (None, None, workspace_id)
if self.config.slack_bot_token:
return (None, self.config.slack_bot_token, "")
return (None, None, workspace_id)
def _handle_lifecycle_event(self, workspace_id: str, event_type: str) -> None:
installation_id = self._installation_manager.installation_id_for_workspace(
workspace_id
)
try:
if event_type == "app_uninstalled":
self._installation_manager.mark_installation_health(
installation_id,
health_code="revoked",
reason="slack_app_uninstalled",
details={"workspace_id": workspace_id},
)
self._installation_manager.uninstall_installation(
installation_id, reason="slack_app_uninstalled"
)
elif event_type == "tokens_revoked":
self._installation_manager.mark_installation_health(
installation_id,
health_code="invalid_token",
reason="slack_tokens_revoked",
details={"workspace_id": workspace_id},
)
elif event_type == "app_rate_limited":
self._installation_manager.mark_installation_health(
installation_id,
health_code="degraded",
reason="slack_app_rate_limited",
details={"workspace_id": workspace_id},
)
except ValueError:
logger.warning(
"Slack lifecycle event for unbound workspace %s (%s)",
workspace_id,
event_type,
)
async def handle_event(self, request):
"""POST handler for Slack Events API."""
_, web = _import_aiohttp_web()
try:
body_bytes = await request.read()
except Exception:
return _make_response(web, status=400, text="Bad request")
# -- Step 1: Signature verification (fail-closed) --
timestamp = ""
signature = ""
if hasattr(request, "headers"):
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
signature = request.headers.get("X-Slack-Signature", "")
if not verify_slack_signature(
signing_secret=self.config.slack_signing_secret or "",
timestamp=timestamp,
body=body_bytes,
signature=signature,
):
logger.warning("Slack signature verification failed (rejected)")
return _make_response(web, status=401, text="Invalid signature")
# -- Step 2: Parse payload --
try:
payload = json.loads(body_bytes)
except json.JSONDecodeError:
return _make_response(web, status=400, text="Bad JSON")
# -- Step 3: url_verification challenge (Webhook only) --
if payload.get("type") == "url_verification":
challenge = payload.get("challenge", "")
return _make_json_response(web, {"challenge": challenge})
# -- Step 4: Process event --
try:
await self.process_event_payload(payload)
except ValueError:
return _make_response(web, status=400, text="Bad Request")
return _make_response(web, status=200, text="OK")
async def process_event_payload(self, payload: Dict[str, Any]) -> None:
"""
Shared event processing path for both webhook and socket mode transports.
"""
if payload.get("type") != "event_callback":
return
event = payload.get("event", {})
event_id = payload.get("event_id", "")
event_type = event.get("type", "")
workspace_id = self._installation_manager.extract_workspace_id(payload)
if event_type in ("app_uninstalled", "tokens_revoked", "app_rate_limited"):
if workspace_id:
self._handle_lifecycle_event(workspace_id, event_type)
return
# -- Step 5: Replay / dedupe guard --
if not event_id:
logger.warning("Slack event missing event_id (rejected)")
raise ValueError("Missing event_id")
if not self._replay_guard.check_and_record(event_id):
logger.debug(f"Slack duplicate event_id={event_id} (accepted, no-op)")
return
# -- Step 6: Bot-loop prevention --
# Resolve bot user ID from authorizations or cache.
bot_user_id = self._get_bot_user_id(payload, workspace_id)
sender_id = event.get("user", "")
if sender_id and bot_user_id and sender_id == bot_user_id:
return
if event.get("bot_id"):
return
subtype = event.get("subtype", "")
if subtype and subtype not in ("", "file_share"):
return
# -- Step 7: Event normalization --
text = event.get("text", "").strip()
channel_id = event.get("channel", "")
thread_ts = event.get("thread_ts", "")
message_ts = event.get("ts", "")
if event_type not in ("message", "app_mention"):
return
if not text or not sender_id:
return
# S67: Require mention in group channels.
is_dm = channel_id.startswith("D")
if not is_dm and self.config.slack_require_mention:
if event_type != "app_mention":
if bot_user_id and f"<@{bot_user_id}>" not in text:
return
if bot_user_id:
text = text.replace(f"<@{bot_user_id}>", "").strip()
# -- Step 8: Allowlist checks (S67) --
if self._user_allowlist.entries:
user_result = self._user_allowlist.evaluate(sender_id)
if user_result.decision == "deny":
logger.warning(f"Slack user {sender_id} denied by allowlist")
return
if self._channel_allowlist.entries and channel_id:
chan_result = self._channel_allowlist.evaluate(channel_id)
if chan_result.decision == "deny":
logger.warning(f"Slack channel {channel_id} denied by allowlist")
return
# -- Step 9: Build CommandRequest and route --
req = CommandRequest(
platform="slack",
sender_id=sender_id,
channel_id=channel_id,
username=sender_id,
message_id=event_id,
text=text,
timestamp=float(message_ts) if message_ts else time.time(),
workspace_id=workspace_id,
thread_id=thread_ts
or (message_ts if self.config.slack_reply_in_thread else ""),
)
try:
resp = await self.router.handle(req)
resp_text = getattr(resp, "text", "")
if not isinstance(resp_text, str):
resp_text = str(resp_text) if resp_text is not None else ""
if resp_text:
await self._send_reply(
channel_id=channel_id,
text=resp_text,
thread_ts=req.thread_id,
delivery_context={
"workspace_id": workspace_id,
"thread_id": req.thread_id,
},
)
except Exception as e:
logger.exception(f"Error handling Slack event: {e}")
# ------------------------------------------------------------------
# Slack Web API reply
# ------------------------------------------------------------------
async def _send_reply(
self,
channel_id: str,
text: str,
thread_ts: str = "",
delivery_context: Optional[Dict[str, Any]] = None,
) -> None:
"""Send a message via Slack Web API (chat.postMessage)."""
try:
import aiohttp as _aiohttp
except ImportError:
logger.warning("aiohttp not available; cannot send Slack reply")
return
ctx = dict(delivery_context or {})
if not thread_ts:
thread_ts = str(ctx.get("thread_id", "") or "").strip()
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
str(ctx.get("workspace_id", "") or "").strip()
)
if not bot_token:
logger.warning(
"Slack reply dropped: no workspace token available (workspace=%s)",
workspace_id or "legacy",
)
return
url = "https://slack.com/api/chat.postMessage"
headers = {
"Authorization": f"Bearer {bot_token}",
"Content-Type": "application/json; charset=utf-8",
}
payload: Dict[str, Any] = {
"channel": channel_id,
"text": text,
}
if thread_ts:
payload["thread_ts"] = thread_ts
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
url,
json=payload,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status != 200:
body = await resp.text()
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=f"http_{resp.status}",
status_code=resp.status,
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
},
)
logger.warning(
f"Slack API error: status={resp.status} body={body[:200]}"
)
else:
data = await resp.json()
if not data.get("ok"):
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=str(data.get("error", "unknown")),
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
},
)
logger.warning(
f"Slack API error: {data.get('error', 'unknown')}"
)
elif installation_id:
self._installation_manager.mark_installation_health(
installation_id,
health_code="ok",
reason="chat_post_message_ok",
details={"workspace_id": workspace_id},
)
except Exception as e:
logger.warning(f"Slack reply failed: {e}")
# ------------------------------------------------------------------
# Platform contract: send_message / send_image
# ------------------------------------------------------------------
async def send_message(
self,
channel_id: str,
text: str,
delivery_context: Optional[Dict[str, Any]] = None,
):
"""Platform contract: send text message."""
await self._send_reply(
channel_id=channel_id,
text=text,
delivery_context=delivery_context,
)
async def send_image(
self,
channel_id: str,
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[Dict[str, Any]] = None,
):
"""Platform contract: send image (Slack files.upload)."""
try:
import aiohttp as _aiohttp
except ImportError:
logger.warning("aiohttp not available; cannot upload Slack image")
return
ctx = dict(delivery_context or {})
thread_ts = str(ctx.get("thread_id", "") or "").strip()
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
str(ctx.get("workspace_id", "") or "").strip()
)
if not bot_token:
logger.warning(
"Slack image dropped: no workspace token available (workspace=%s)",
workspace_id or "legacy",
)
return
url = "https://slack.com/api/files.upload"
headers = {
"Authorization": f"Bearer {bot_token}",
}
data = _aiohttp.FormData()
data.add_field("file", image_data, filename=filename, content_type="image/png")
data.add_field("channels", channel_id)
if caption:
data.add_field("initial_comment", caption)
if thread_ts:
data.add_field("thread_ts", thread_ts)
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
url,
data=data,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status != 200:
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=f"http_{resp.status}",
status_code=resp.status,
details={
"workspace_id": workspace_id,
"path": "files.upload",
},
)
logger.warning(f"Slack file upload error: status={resp.status}")
else:
resp_data = await resp.json()
if not resp_data.get("ok"):
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=str(resp_data.get("error", "unknown")),
details={
"workspace_id": workspace_id,
"path": "files.upload",
},
)
logger.warning(
f"Slack file upload error: {resp_data.get('error')}"
)
elif installation_id:
self._installation_manager.mark_installation_health(
installation_id,
health_code="ok",
reason="files_upload_ok",
details={"workspace_id": workspace_id},
)
except Exception as e:
logger.warning(f"Slack image upload failed: {e}")
+168 -12
View File
@@ -5,15 +5,20 @@ Long-polling implementation for Telegram Bot API.
import asyncio
import logging
import re
import time
from typing import Optional
from services.connector_replay_lifecycle import ConnectorReplayLifecycle
from ..config import ConnectorConfig
from ..contract import CommandRequest, CommandResponse
from ..reply_visibility import decide_reply_visibility
from ..router import CommandRouter
from ..state import ConnectorState
logger = logging.getLogger(__name__)
_THREAD_ID_RE = re.compile(r"^\d{1,10}$")
def _import_aiohttp():
@@ -24,6 +29,30 @@ def _import_aiohttp():
return aiohttp
def _normalize_message_thread_id(value) -> Optional[int]:
if value is None or isinstance(value, bool):
return None
text = str(value).strip()
if not _THREAD_ID_RE.fullmatch(text):
return None
try:
thread_id = int(text)
except ValueError:
return None
if thread_id <= 0:
return None
return thread_id
def _telegram_channel_kind(chat_id) -> str:
text = str(chat_id or "").strip()
if text.startswith("-100"):
return "supergroup"
if text.startswith("-"):
return "group"
return "dm"
class TelegramPolling:
def __init__(self, config: ConnectorConfig, router: CommandRouter):
self.config = config
@@ -35,6 +64,10 @@ class TelegramPolling:
# Remediation: Load offset from persistent state
self.offset = self.state_store.get_offset("telegram")
self.session = None
self._update_lifecycle = ConnectorReplayLifecycle(
ttl_sec=300,
max_entries=5000,
)
async def start(self):
aiohttp = _import_aiohttp()
@@ -91,15 +124,42 @@ class TelegramPolling:
if self.config.debug and not updates:
logger.debug("Telegram poll OK (no updates). offset=%s", self.offset)
for update in updates:
next_offset = update["update_id"] + 1
if next_offset > self.offset:
self.offset = next_offset
# Remediation: Persist offset
self.state_store.set_offset("telegram", self.offset)
update_id = update["update_id"]
lifecycle_key = f"telegram:update:{update_id}"
claim = self._update_lifecycle.claim(
lifecycle_key,
metadata={"platform": "telegram"},
)
if not claim.accepted:
logger.debug(
"Telegram duplicate update_id=%s code=%s state=%s",
update_id,
claim.code,
claim.record.state,
)
if claim.code == "duplicate_after_success":
self._commit_offset(update_id + 1)
continue
await self._process_update(update)
processed = await self._process_update(update)
if processed:
self._update_lifecycle.commit_success(
lifecycle_key, reason="processed"
)
self._commit_offset(update_id + 1)
else:
# IMPORTANT: keep failed-before-delivery updates retryable.
# Advancing the Telegram offset here would drop the update.
self._update_lifecycle.release_retryable(
lifecycle_key, reason="telegram_update_failed_before_commit"
)
async def _process_update(self, update: dict):
def _commit_offset(self, next_offset: int) -> None:
if next_offset > self.offset:
self.offset = next_offset
self.state_store.set_offset("telegram", self.offset)
async def _process_update(self, update: dict) -> bool:
# Telegram update shapes vary by chat type and sender mode.
# - Normal groups/DMs: `message`
# - Edited messages: `edited_message`
@@ -116,7 +176,7 @@ class TelegramPolling:
or update.get("edited_channel_post")
)
if not message or "text" not in message:
return
return True
chat_id = message["chat"]["id"]
# `from` may be missing for channel posts; `sender_chat` is used for anonymous admins.
@@ -125,6 +185,9 @@ class TelegramPolling:
user_id = from_obj.get("id")
username = from_obj.get("username") or sender_chat.get("username") or "unknown"
text = message["text"]
message_thread_id = _normalize_message_thread_id(
message.get("message_thread_id")
)
# Security Check
is_allowed = False
@@ -149,32 +212,98 @@ class TelegramPolling:
message_id=str(message["message_id"]),
text=text,
timestamp=time.time(),
thread_id=str(message_thread_id or ""),
)
try:
resp = await self.router.handle(req)
await self._send_response(chat_id, resp)
return await self._send_response(
chat_id,
resp,
delivery_context=(
{"thread_id": req.thread_id} if req.thread_id else None
),
)
except Exception as e:
logger.exception(f"Error handling command: {e}")
await self._send_response(
chat_id, CommandResponse(text="[Error] Internal processing error.")
return await self._send_response(
chat_id,
CommandResponse(text="[Error] Internal processing error."),
delivery_context=(
{"thread_id": req.thread_id} if req.thread_id else None
),
)
async def _send_response(self, chat_id: int, resp: CommandResponse):
def _thread_id_from_context(
self, delivery_context: Optional[dict]
) -> Optional[int]:
context = delivery_context or {}
return _normalize_message_thread_id(context.get("thread_id"))
async def _send_thread_diagnostic(self, chat_id, raw_thread_id) -> None:
preview = str(raw_thread_id or "")[:32]
logger.warning("Invalid Telegram message_thread_id ignored: %r", preview)
if not self.session:
return
url = f"{self.base_url}/sendMessage"
payload = {
"chat_id": chat_id,
"text": "[OpenClaw] Invalid Telegram thread/topic id; delivery used the parent chat.",
}
try:
async with self.session.post(url, json=payload) as r:
if r.status != 200:
logger.error(
f"Failed to send Telegram thread diagnostic: {r.status} {await r.text()}"
)
except Exception as e:
logger.error(f"Telegram thread diagnostic exception: {e}")
async def _send_response(
self,
chat_id: int,
resp: CommandResponse,
delivery_context: Optional[dict] = None,
) -> bool:
decision = decide_reply_visibility(
delivery_context=dict(delivery_context or {}),
platform="telegram",
channel_kind=_telegram_channel_kind(chat_id),
text=getattr(resp, "text", ""),
has_buttons=bool(getattr(resp, "buttons", None)),
has_files=bool(getattr(resp, "files", None)),
)
if decision.suppressed:
logger.info(
"Suppressed Telegram reply chat=%s reason=%s",
chat_id,
decision.reason,
)
return True
url = f"{self.base_url}/sendMessage"
payload = {
"chat_id": chat_id,
# Remediation: Plain text only, no parse_mode
"text": resp.text,
}
thread_id = self._thread_id_from_context(delivery_context)
if thread_id is not None:
payload["message_thread_id"] = thread_id
elif delivery_context and delivery_context.get("thread_id"):
await self._send_thread_diagnostic(
chat_id, delivery_context.get("thread_id")
)
try:
async with self.session.post(url, json=payload) as r:
if r.status != 200:
logger.error(
f"Failed to send Telegram response: {r.status} {await r.text()}"
)
return False
return True
except Exception as e:
logger.error(f"Telegram send exception: {e}")
return False
async def send_image(
self,
@@ -193,6 +322,13 @@ class TelegramPolling:
url = f"{self.base_url}/sendPhoto"
data = aiohttp.FormData()
data.add_field("chat_id", channel_id)
thread_id = self._thread_id_from_context(delivery_context)
if thread_id is not None:
data.add_field("message_thread_id", str(thread_id))
elif delivery_context and delivery_context.get("thread_id"):
await self._send_thread_diagnostic(
channel_id, delivery_context.get("thread_id")
)
if caption:
data.add_field("caption", caption)
@@ -215,11 +351,31 @@ class TelegramPolling:
"""Send text message."""
if not self.session:
return
decision = decide_reply_visibility(
delivery_context=dict(delivery_context or {}),
platform="telegram",
channel_kind=_telegram_channel_kind(channel_id),
text=text,
)
if decision.suppressed:
logger.info(
"Suppressed Telegram send_message chat=%s reason=%s",
channel_id,
decision.reason,
)
return
# Reuse internal logic logic but public
# Using simplified direct call
url = f"{self.base_url}/sendMessage"
payload = {"chat_id": channel_id, "text": text}
thread_id = self._thread_id_from_context(delivery_context)
if thread_id is not None:
payload["message_thread_id"] = thread_id
elif delivery_context and delivery_context.get("thread_id"):
await self._send_thread_diagnostic(
channel_id, delivery_context.get("thread_id")
)
try:
async with self.session.post(url, json=payload) as r:
if r.status != 200:
+13 -2
View File
@@ -32,6 +32,9 @@ import time
from typing import Optional
from xml.etree import ElementTree as ET
from defusedxml import ElementTree as DefusedET
from defusedxml.common import DefusedXmlException
from ..config import ConnectorConfig
from ..contract import CommandRequest, CommandResponse
from ..router import CommandRouter
@@ -265,9 +268,17 @@ def parse_wechat_xml(raw: bytes) -> dict:
f"Payload size {len(raw)} exceeds limit {XML_MAX_PAYLOAD_BYTES}"
)
lowered = raw.lower()
if b"<!doctype" in lowered or b"<!entity" in lowered:
# IMPORTANT: reject DTD / ENTITY declarations before parser entry to
# keep entity-expansion bombs fail-closed.
raise XMLBudgetExceeded("DTD/entity declarations are not allowed")
try:
root = ET.fromstring(raw)
except ET.ParseError as e:
# IMPORTANT: keep defusedxml here. Reverting to the stdlib parser path
# reopens the residual CodeQL xml-bomb finding on this ingress seam.
root = DefusedET.fromstring(raw.decode("utf-8"))
except (ET.ParseError, DefusedXmlException, UnicodeDecodeError) as e:
raise XMLBudgetExceeded(f"XML parse error: {e}") from e
# Depth check — WeChat envelopes are <xml><Tag>val</Tag></xml>, depth=2
+2 -1
View File
@@ -18,6 +18,7 @@ from typing import Optional
from ..config import ConnectorConfig
from ..contract import CommandRequest, CommandResponse
from ..media_response import build_connector_media_response
from ..router import CommandRouter
from ..security_profile import AllowlistPolicy, ReplayGuard, verify_hmac_signature
from ..transport_contract import RelayResponseClassifier
@@ -219,7 +220,7 @@ class WhatsAppWebhookServer:
if not path:
return web.Response(status=404, text="Media Not Found or Expired")
return web.FileResponse(path)
return build_connector_media_response(web, path)
# ------------------------------------------------------------------
# Message Processing
+1 -1
View File
@@ -15,7 +15,7 @@ CHAT_SYSTEM_PROMPT = """You are OpenClaw Assistant, an AI helper for controlling
**Available Commands (for reference):**
- `/run <template_id> [--input key=value ...]` - Execute a workflow template
- `/status` - Check system status
- `/jobs` - View queue
- `/jobs` - View the authoritative jobs summary (admin)
- `/approvals` - List pending approvals (admin)
- `/approve <id>` - Approve a request (admin)
+129
View File
@@ -0,0 +1,129 @@
"""Shared connector reply visibility decisions."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
VISIBLE = "visible"
SUPPRESS_TEXT = "suppress_text"
TOOL_ONLY = "tool_only"
INTERNAL = "internal"
AUTO = "auto"
_VISIBLE_VALUES = {"", AUTO, VISIBLE, "public", "reply", "send"}
_SUPPRESS_VALUES = {SUPPRESS_TEXT, "suppress", "silent", "no_text", "none"}
_TOOL_ONLY_VALUES = {TOOL_ONLY, "tool-only", "tool", "action_only", "action-only"}
_INTERNAL_VALUES = {INTERNAL, "internal_only", "internal-only", "private"}
_TRUTHY_VALUES = {"1", "true", "yes", "y", "on"}
@dataclass(frozen=True)
class ReplyVisibilityDecision:
visible: bool
mode: str
reason: str
diagnostics: Dict[str, Any] = field(default_factory=dict)
@property
def suppressed(self) -> bool:
return not self.visible
def normalize_reply_visibility_mode(value: Any) -> str:
text = str(value or "").strip().lower()
if text in _VISIBLE_VALUES:
return VISIBLE
if text in _SUPPRESS_VALUES:
return SUPPRESS_TEXT
if text in _TOOL_ONLY_VALUES:
return TOOL_ONLY
if text in _INTERNAL_VALUES:
return INTERNAL
return VISIBLE
def decide_reply_visibility(
*,
delivery_context: Optional[Dict[str, Any]] = None,
platform: str = "",
channel_kind: str = "",
mentioned: Optional[bool] = None,
in_thread: bool = False,
text: str = "",
has_buttons: bool = False,
has_files: bool = False,
) -> ReplyVisibilityDecision:
ctx = dict(delivery_context or {})
explicit_mode = _extract_mode(ctx)
mode = normalize_reply_visibility_mode(explicit_mode)
normalized_channel_kind = (
str(ctx.get("channel_kind") or ctx.get("chat_type") or channel_kind or "")
.strip()
.lower()
)
threaded = bool(in_thread or str(ctx.get("thread_id", "") or "").strip())
diagnostics = {
"platform": str(platform or ctx.get("platform", "") or "").strip(),
"mode": mode,
"channel_kind": normalized_channel_kind,
"in_thread": threaded,
"has_text": bool(str(text or "").strip()),
"has_buttons": bool(has_buttons),
"has_files": bool(has_files),
}
# Approval/action replies must stay visible; hiding them can strand operators.
if has_buttons:
return ReplyVisibilityDecision(
True, VISIBLE, "interactive_action_required", diagnostics
)
if mode == INTERNAL or _truthy(ctx.get("internal_delivery")):
return ReplyVisibilityDecision(
False, INTERNAL, "internal_delivery", diagnostics
)
if (
mode in {SUPPRESS_TEXT, TOOL_ONLY}
or _truthy(ctx.get("tool_only"))
or _truthy(ctx.get("silent"))
):
if has_files:
return ReplyVisibilityDecision(
True, VISIBLE, "file_delivery_preserved", diagnostics
)
return ReplyVisibilityDecision(
False, mode, "text_reply_suppressed", diagnostics
)
if normalized_channel_kind in {"group", "supergroup", "channel"}:
if mentioned is None and "mentioned" in ctx:
mentioned = _truthy(ctx.get("mentioned"))
if mentioned is None and "mentioned_bot" in ctx:
mentioned = _truthy(ctx.get("mentioned_bot"))
if mentioned is False and not threaded:
return ReplyVisibilityDecision(
False, SUPPRESS_TEXT, "group_no_mention", diagnostics
)
return ReplyVisibilityDecision(True, VISIBLE, "visible", diagnostics)
def _extract_mode(ctx: Dict[str, Any]) -> Any:
for key in ("reply_visibility", "visibility", "reply_visibility_mode"):
if key in ctx:
return ctx.get(key)
policy = ctx.get("delivery_policy")
if isinstance(policy, dict):
for key in ("reply_visibility", "visibility", "reply_visibility_mode"):
if key in policy:
return policy.get(key)
return AUTO
def _truthy(value: Any) -> bool:
if isinstance(value, bool):
return value
return str(value or "").strip().lower() in _TRUTHY_VALUES
+17 -1
View File
@@ -6,6 +6,7 @@ from typing import Any, Dict, Optional
from .config import ConnectorConfig
from .contract import Platform
from .openclaw_client import OpenClawClient
from .reply_visibility import decide_reply_visibility
logger = logging.getLogger(__name__)
@@ -363,13 +364,28 @@ class ResultsPoller:
*,
delivery_context: Optional[Dict[str, Any]] = None,
):
ctx = dict(delivery_context or {})
decision = decide_reply_visibility(
delivery_context=ctx,
platform=platform_name,
text=text,
)
if decision.suppressed:
# IMPORTANT: a suppressed visible reply is a successful delivery no-op.
logger.info(
"Suppressed connector text reply platform=%s channel=%s reason=%s",
platform_name,
channel_id,
decision.reason,
)
return
platform = self.platforms.get(platform_name)
if platform:
try:
await platform.send_message(
channel_id,
text,
delivery_context=dict(delivery_context or {}),
delivery_context=ctx,
)
except Exception as e:
logger.error(f"Failed to send text to {platform_name}: {e}")
+20 -916
View File
@@ -3,33 +3,35 @@ Connector Router (F29 Remediation).
Dispatches parsed commands to handlers with AST argument parsing.
"""
import logging
import shlex
from typing import Any, Dict, List, Optional
from .config import CommandClass, ConnectorConfig
from .contract import CommandRequest, CommandResponse
from .config import ConnectorConfig
from .contract import CommandRequest as CommandRequest
from .contract import CommandResponse as CommandResponse
from .llm_client import LLMClient
from .openclaw_client import OpenClawClient
from .router_admin_handlers import RouterAdminMixin
from .router_chat_handlers import RouterChatMixin
from .router_dispatch import RouterDispatchMixin
from .router_execution_handlers import RouterExecutionMixin
from .state import ConnectorState
if False: # Type hinting only
from .results_poller import ResultsPoller
from .command_firewall import CommandFirewall
from .llm_client import LLMClient
from .prompts import CHAT_STATUS_PROMPT, CHAT_SYSTEM_PROMPT
from .rate_limiter import RateLimiter
from .semantic_guard import GuardAction, SemanticGuard
try:
from services.reasoning_redaction import sanitize_operator_payload
except Exception: # pragma: no cover - connector tests may stub import graph
sanitize_operator_payload = lambda value, **_: value # type: ignore
logger = logging.getLogger(__name__)
from .semantic_guard import SemanticGuard
class CommandRouter:
class CommandRouter(
RouterDispatchMixin,
RouterExecutionMixin,
RouterAdminMixin,
RouterChatMixin,
):
def _build_llm_client(self) -> LLMClient:
"""Resolve the facade dependency at call time to preserve patch seams."""
return LLMClient(self.client)
def __init__(
self,
config: ConnectorConfig,
@@ -40,7 +42,7 @@ class CommandRouter:
self.client = client
self.poller = poller
self.state = ConnectorState(path=self.config.state_path)
self._template_meta_cache: Dict[str, Dict[str, Any]] = {}
self._template_meta_cache: dict[str, dict[str, object]] = {}
# F32 WP2: Rate limiter
self._rate_limiter = RateLimiter(
user_rpm=self.config.rate_limit_user_rpm,
@@ -49,901 +51,3 @@ class CommandRouter:
# S44/R97: Semantic Guards
self.semantic_guard = SemanticGuard()
self.command_firewall = CommandFirewall()
async def handle(self, req: CommandRequest) -> CommandResponse:
"""Main dispatch loop."""
text = req.text.strip()
# NOTE: Debug-only raw message logging for troubleshooting parsing issues.
# Enable with OPENCLAW_CONNECTOR_DEBUG=1. May include sensitive user content.
if self.config.debug:
logger.info(
"DEBUG raw message: platform=%s user=%s chat=%s text=%r",
req.platform,
req.sender_id,
req.channel_id,
text,
)
# F32 WP2: Rate limiting
if not self._rate_limiter.is_allowed(str(req.sender_id), str(req.channel_id)):
return CommandResponse(
text="[Rate Limited] Too many requests. Please wait a moment."
)
# F32 WP5: Command length limit
if len(text) > self.config.max_command_length:
return CommandResponse(
text=f"[Error] Command too long ({len(text)} chars). Max: {self.config.max_command_length}."
)
try:
# IMPORTANT (recurring usability bug):
# Do not use `shlex.split()` directly for ChatOps commands that may include natural
# language. In POSIX mode, `shlex` treats apostrophes (`'`) as quote delimiters, so
# common contractions like "She's" trigger "unbalanced quotes" failures.
#
# We therefore only treat *double quotes* (`"`) as quoting characters, so users can
# still do: positive_prompt="a prompt with spaces" while apostrophes remain safe.
lexer = shlex.shlex(text, posix=True)
lexer.whitespace_split = True
lexer.commenters = ""
lexer.quotes = '"'
parts = list(lexer)
except ValueError:
return CommandResponse(
text="[Error] Parsing command arguments failed (unbalanced quotes?)."
)
if not parts:
return CommandResponse(text="Empty command.")
cmd = parts[0].lower()
args = parts[1:]
# Telegram group commands often include the bot username suffix, e.g. `/help@mybot`.
# If we don't strip it, the command won't match our dispatch table and appears "dead"
# even though polling is working.
if (
(req.platform or "").lower() == "telegram"
and cmd.startswith("/")
and "@" in cmd
):
cmd = cmd.split("@", 1)[0]
# Some users type `@bot /help` in group chats. Treat that as a command too.
if cmd.startswith("@") and args and args[0].startswith("/"):
cmd = args[0].lower()
args = args[1:]
# Dispatch Table
handlers = {
("/status", "status"): (self._handle_status, CommandClass.PUBLIC),
("/help", "help", "/start"): (self._handle_help, CommandClass.PUBLIC),
("/run", "run"): (self._handle_run, CommandClass.RUN),
("/interrupt", "interrupt", "/cancel", "cancel", "/stop"): (
self._handle_interrupt,
CommandClass.ADMIN,
), # Global interrupt => admin-only.
("/approvals", "approvals"): (
self._handle_approvals_list,
CommandClass.ADMIN,
),
("/approve", "approve"): (self._handle_approve, CommandClass.ADMIN),
("/reject", "reject"): (self._handle_reject, CommandClass.ADMIN),
("/schedules", "schedules"): (
self._handle_schedules_list,
CommandClass.ADMIN,
),
("/schedule", "schedule"): (
self._handle_schedule_subcommand,
CommandClass.ADMIN,
),
# Phase 3 Introspection
("/history", "history"): (self._handle_history, CommandClass.PUBLIC),
("/trace", "trace"): (self._handle_trace, CommandClass.ADMIN), # Admin only
("/jobs", "jobs", "queue"): (self._handle_jobs, CommandClass.PUBLIC),
# F30: Chat Assistant
("/chat", "chat"): (self._handle_chat, CommandClass.PUBLIC),
}
# Find Handler
handler = None
requires_admin = False
canonical_cmd = cmd # Fallback
for aliases, (func, cmd_class) in handlers.items():
if cmd in aliases:
handler = func
default_class = cmd_class
# R80 Remediation: Use canonical command (first alias) for policy checks
# This prevents "run" vs "/run" bypass issues.
if isinstance(aliases, tuple):
# Convention: first alias is canonical (e.g. "/run")
canonical_cmd = aliases[0]
else:
canonical_cmd = aliases
break
if not handler:
return CommandResponse(
text=f"Unknown command: {cmd}. Type /help for options."
)
# R80: Centralized Authorization Gate
# Pass canonical_cmd to ensure policy matches aliases correctly
if auth_err := self._check_command_authz(canonical_cmd, req, default_class):
return auth_err
# Execute
try:
return await handler(req, args)
except Exception as e:
logger.exception(f"Command execution error {cmd}: {e}")
return CommandResponse(text=f"[Internal Error] {str(e)}")
def _is_admin(self, user_id: str) -> bool:
return str(user_id) in self.config.admin_users
def _delivery_context(self, req: CommandRequest) -> Dict[str, Any]:
context: Dict[str, Any] = {}
if getattr(req, "workspace_id", ""):
context["workspace_id"] = str(req.workspace_id)
if getattr(req, "thread_id", ""):
context["thread_id"] = str(req.thread_id)
return context
def _check_command_authz(
self, cmd: str, req: CommandRequest, default_class: CommandClass
) -> Optional[CommandResponse]:
"""
R80: Verify command authorization policy.
Returns None if allowed, or CommandResponse(text=error) if denied.
"""
policy = self.config.command_policy
# 1. Resolve Effective Class (Handle per-command overrides)
# Note: 'cmd' here is the canonical parsed command string (lowercase), e.g., "/run" or "run"
# The overrides dict might use "/run" or "run", we should check both or normalize.
# Currently, the router logic normalized `cmd` from input (lines 90-101).
# We'll check exact match against the override key.
eff_class = policy.command_overrides.get(cmd, default_class)
# 2. Check AllowFrom List (Explicit User Allow)
# If an explicit AllowFrom list exists for this class, the user MUST be in it.
# This takes precedence over role logic.
allowed_users = policy.allow_from.get(eff_class)
if allowed_users is not None and len(allowed_users) > 0:
if str(req.sender_id) not in allowed_users:
# If explicit allow-list is active, even admins must be in it?
# Decision: YES, for strict compliance. If you want admins, add them to the list.
# However, for usability, usually admins are implied.
# Let's stick to "Explicit List Wins" for R80 strict mode.
return CommandResponse(
text="[Access Denied] You are not in the allow-list for this command."
)
# If in list, proceed (bypass default role checks? No, usually allows)
return None
# 3. Default Role Logic
if eff_class == CommandClass.ADMIN:
if not self._is_admin(req.sender_id):
return CommandResponse(
text="[Access Denied] This command requires Admin privileges."
)
# PUBLIC and RUN are allowed by default (RUN checks trust internally)
return None
def _is_trusted(self, req: CommandRequest) -> bool:
"""
Trusted users can execute /run immediately.
Untrusted users are routed to approval flow.
"""
if self._is_admin(req.sender_id):
return True
platform = (req.platform or "").lower()
sender_id = str(req.sender_id)
channel_id = str(req.channel_id)
if platform == "telegram":
try:
uid = int(sender_id)
except Exception:
uid = None
try:
cid = int(channel_id)
except Exception:
cid = None
if uid is not None and uid in self.config.telegram_allowed_users:
return True
if cid is not None and cid in self.config.telegram_allowed_chats:
return True
return False
if platform == "discord":
if sender_id in self.config.discord_allowed_users:
return True
if channel_id in self.config.discord_allowed_channels:
return True
return False
if platform == "line":
if sender_id in self.config.line_allowed_users:
return True
if channel_id in self.config.line_allowed_groups:
return True
return False
if platform == "whatsapp":
if sender_id in self.config.whatsapp_allowed_users:
return True
return False
if platform == "wechat":
if sender_id in self.config.wechat_allowed_users:
return True
return False
if platform == "kakao":
if sender_id in self.config.kakao_allowed_users:
return True
return False
if platform == "slack":
if sender_id in self.config.slack_allowed_users:
return True
if channel_id in self.config.slack_allowed_channels:
return True
return False
# Unknown platform: trust only admins
return False
# --- Handlers ---
async def _handle_status(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
health = await self.client.get_health()
queue = await self.client.get_prompt_queue()
# New standardized response handling
health_ok = health.get("ok")
status_icon = "Online" if health_ok else "Offline"
details = []
if health_ok:
data = health.get("data", {})
stats = data.get("stats", {})
details.append(f"Logs: {stats.get('logs_processed', 0)}")
details.append(f"Errors: {stats.get('errors_captured', 0)}")
q_res = queue.get("data", {})
q_rem = q_res.get("exec_info", {}).get("queue_remaining", 0)
details.append(f"Queue: {q_rem}")
else:
details.append(f"Error: {health.get('error')}")
return CommandResponse(
text=f"[{status_icon}] System Status\n"
+ "\n".join(f"- {d}" for d in details)
)
def _require_admin_token_configured(self) -> Optional[CommandResponse]:
"""
F32 WP3: Check if admin token is configured before running admin commands.
Fail-fast with clear error message instead of 403/500 later.
IMPORTANT (recurring CI failure mode):
- Admin-only commands are gated by BOTH:
(1) sender is an admin user, AND
(2) the connector admin token is configured (OPENCLAW_CONNECTOR_ADMIN_TOKEN).
- Unit tests that exercise admin command handlers MUST set `config.admin_token`,
otherwise they will correctly receive the config error response.
"""
if not self.config.admin_token:
return CommandResponse(
text="[Error] Admin token not configured. Set OPENCLAW_CONNECTOR_ADMIN_TOKEN and restart connector."
)
return None
async def _handle_run(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(
text="Usage: /run <template_id> [prompt text] [key=value ...] [--approval]"
)
# Parse flags
explicit_approval = False
clean_args = []
for arg in args:
if arg in ("--require-approval", "--approval", "-a"):
explicit_approval = True
else:
clean_args.append(arg)
if not clean_args:
return CommandResponse(text="Usage: /run <template_id> ...")
template_id = clean_args[0]
inputs: Dict[str, str] = {}
free_text_parts: List[str] = []
for arg in clean_args[1:]:
if "=" in arg:
k, v = arg.split("=", 1)
inputs[k.strip()] = v.strip()
else:
free_text_parts.append(arg)
# If user provided free text without key=value, treat it as the prompt.
# We map it to a best-effort prompt key (prefers template metadata if available).
if free_text_parts:
prompt_key = await self._resolve_prompt_key(template_id)
if prompt_key not in inputs:
inputs[prompt_key] = " ".join(free_text_parts).strip()
elif self.config.debug:
logger.info(
"DEBUG /run free-text ignored (prompt key already set): %s",
prompt_key,
)
# NOTE: Debug-only payload logging for troubleshooting prompt mismatches.
# Enable with OPENCLAW_CONNECTOR_DEBUG=1 to log template_id + inputs.
if self.config.debug:
logger.info(
"DEBUG /run payload: template=%s inputs=%s approval_flag=%s trusted=%s",
template_id,
inputs,
explicit_approval,
self._is_trusted(req),
)
trusted = self._is_trusted(req)
require_approval = explicit_approval or (not trusted)
res = await self.client.submit_job(
template_id, inputs, require_approval=require_approval
)
if res.get("ok"):
data = res.get("data", {})
trace_id = data.get("trace_id", "unknown")
if data.get("pending"):
approval_id = data.get("approval_id", "unknown")
msg = f"[Approval Requested]\nID: {approval_id}\nTrace: {trace_id}"
if "expires_at" in data:
msg += f"\nExpires: {data['expires_at']}"
if self.poller:
# IMPORTANT:
# For untrusted users, approvals are done in the OpenClaw UI.
# We must start tracking the approval_id so we can map
# approval_id -> executed_prompt_id later and auto-deliver images.
self.poller.track_approval(
approval_id,
req.platform,
req.channel_id,
req.sender_id,
delivery_context=self._delivery_context(req),
)
return CommandResponse(text=msg)
else:
prompt_id = data.get("prompt_id", "unknown")
if self.poller:
self.poller.track_job(
prompt_id,
req.platform,
req.channel_id,
req.sender_id,
delivery_context=self._delivery_context(req),
)
return CommandResponse(
text=f"[Job Submitted]\nID: {prompt_id}\nTemplate: {template_id}\nTrace: {trace_id}"
)
else:
err = res.get("error", "Unknown error")
return CommandResponse(text=f"[Submission Failed] Reason: {err}")
async def _resolve_prompt_key(self, template_id: str) -> str:
"""
Best-effort prompt key resolution.
Prefer template metadata (allowed_inputs), then fall back to common names.
"""
meta = await self._get_template_meta(template_id)
allowed = meta.get("allowed_inputs") or []
# If template explicitly declares a single input, use it.
if isinstance(allowed, list) and len(allowed) == 1:
return str(allowed[0])
preferred = ("positive_prompt", "prompt", "text", "positive", "caption")
if isinstance(allowed, list):
for key in preferred:
if key in allowed:
return key
# Default fallback
return "positive_prompt"
async def _get_template_meta(self, template_id: str) -> Dict[str, Any]:
if template_id in self._template_meta_cache:
return self._template_meta_cache[template_id]
try:
res = await self.client.get_templates()
if res.get("ok"):
for item in res.get("templates", []) or []:
if item.get("id") == template_id:
self._template_meta_cache[template_id] = item
return item
except Exception as e:
if self.config.debug:
logger.info(f"DEBUG template meta fetch failed: {e}")
return {}
async def _handle_interrupt(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
# Remediation: Global Interrupt
res = await self.client.interrupt_output()
if res.get("ok"):
return CommandResponse(text="[Stop] Global Interrupt sent to ComfyUI.")
else:
return CommandResponse(text=f"[Stop Failed] {res.get('error')}")
async def _handle_approvals_list(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_approvals()
if not res.get("ok"):
return CommandResponse(
text=f"[Error] Failed to list approvals: {res.get('error')}"
)
items = res.get("items", [])
if not items:
return CommandResponse(text="No pending approvals.")
pending_count = res.get("pending_count")
lines = []
for i in items:
# IMPORTANT (stability): the backend approval schema uses:
# `approval_id`, `template_id`, `status`, `requested_by`, `source`.
# Do not “simplify” these keys to `id/description/requester` unless you also
# update the backend API + all tests. This mismatch previously caused silent
# bad output and brittle regressions.
approval_id = i.get("approval_id") or i.get("id") or "unknown"
template_id = i.get("template_id") or "unknown"
status = i.get("status") or "unknown"
requested_by = i.get("requested_by") or "unknown"
source = i.get("source") or "unknown"
lines.append(
f"- {approval_id} [{status}] template={template_id} by={requested_by} source={source}"
)
header = "Pending Approvals"
if isinstance(pending_count, int):
header += f" ({pending_count})"
return CommandResponse(text=header + ":\n" + "\n".join(lines))
async def _handle_approve(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /approve <id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
# Assuming auto_execute=True by default for chat logic
res = await self.client.approve_request(args[0], auto_execute=True)
if not res.get("ok"):
return CommandResponse(text=f"[Failed] {res.get('error')}")
data = res.get("data", {})
msg = f"[Approved] {args[0]}"
# Phase 4: Show execution result
if "prompt_id" in data:
pid = data["prompt_id"]
msg += f"\nExecuted: {pid}"
if self.poller:
# Approval request might have come from different flow, but usually user invoking /approve
# wants the result. Using current req context is safest assumption for "ChatOps".
self.poller.track_job(
pid,
req.platform,
req.channel_id,
req.sender_id,
delivery_context=self._delivery_context(req),
)
elif data.get("executed") is False:
msg += "\n(Not Executed)"
if err := data.get("execution_error"):
msg += f"\nError: {err}"
return CommandResponse(text=msg)
async def _handle_reject(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /reject <id> [reason]")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
reason = " ".join(args[1:]) if len(args) > 1 else "Rejected via chat"
res = await self.client.reject_request(args[0], reason)
if not res.get("ok"):
return CommandResponse(text=f"[Failed] {res.get('error')}")
return CommandResponse(text=f"[Rejected] {args[0]}")
async def _handle_schedules_list(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_schedules()
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
scheds = res.get("schedules", [])
if not scheds:
return CommandResponse(text="No schedules found.")
lines = []
for s in scheds:
status = "+" if s.get("enabled") else "-"
lines.append(
f"[{status}] {s.get('id')}: {s.get('cron')} - {s.get('template_id')}"
)
return CommandResponse(text="Schedules:\n" + "\n".join(lines))
async def _handle_schedule_subcommand(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if len(args) < 2:
return CommandResponse(text="Usage: /schedule <run|toggle> <id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
sub = args[0].lower()
sid = args[1]
if sub == "run":
res = await self.client.run_schedule(sid)
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
return CommandResponse(text=f"[Success] Schedule {sid} triggered manually.")
else:
return CommandResponse(text="Not implemented yet.")
async def _handle_help(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
return CommandResponse(
text=(
"OpenClaw Connector\n"
"/status - Check system health and queue\n"
"/run <template> [prompt] [k=v] - Run a generation (trusted users auto-exec; others require approval)\n"
"/stop - Global Interrupt (Admin)\n"
"/history <id> - Job details\n"
"/jobs - Queue summary\n"
"Admin Only:\n"
"/approvals - List pending approvals\n"
"/approve <id>, /reject <id>\n"
"/schedules, /schedule run <id>\n"
"/trace <id> - Execution trace"
)
)
async def _handle_history(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /history <prompt_id>")
res = await self.client.get_history(args[0])
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
# Simple format
data = res.get("data", {})
status = data.get("status", {}).get("status_str", "unknown")
# Assuming backend returns a structure we can summarise
return CommandResponse(
text=f"Job {args[0]}: {status}\nFull details: not implemented in connector view yet."
)
async def _handle_trace(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /trace <prompt_id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_trace(args[0])
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
# Dump trace
sanitized = sanitize_operator_payload(res.get("data"))
return CommandResponse(text=f"Trace {args[0]}: {str(sanitized)[:1000]}...")
async def _handle_jobs(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# Try native /openclaw/jobs first
res = await self.client.get_jobs()
if res.get("ok"):
# Format nice summary
return CommandResponse(
text=f"Default Jobs View: {sanitize_operator_payload(res.get('data'))}"
)
# Fallback: Queue
q = await self.client.get_prompt_queue()
if q.get("ok"):
rem = q.get("data", {}).get("exec_info", {}).get("queue_remaining", "?")
return CommandResponse(text=f"[Fallback] Queue Remaining: {rem}")
return CommandResponse(text="[Error] Could not fetch jobs or queue.")
# -------------------------------------------------------------------------
# F30: Chat LLM Assistant
# -------------------------------------------------------------------------
async def _handle_chat(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
"""
/chat [subcommand] <message>
Subcommands: run, template, status
Default: general chat
Security: Never auto-executes commands. Only suggests command text.
"""
llm = LLMClient(self.client)
if not await llm.is_configured():
return CommandResponse(
text="[Chat Error] LLM not configured. Configure in OpenClaw Settings."
)
# Parse subcommand
if not args:
return CommandResponse(
text="Usage: /chat <message> or /chat run|template|status <request>"
)
subcommand = args[0].lower()
message = " ".join(args[1:]) if len(args) > 1 else ""
trust_level = "TRUSTED" if self._is_trusted(req) else "UNTRUSTED"
if subcommand == "run":
return await self._chat_run(llm, message, trust_level)
elif subcommand == "template":
return await self._chat_template(llm, message)
elif subcommand == "status":
return await self._chat_status(llm)
else:
# General chat: first word is part of message
full_message = " ".join(args)
return await self._chat_general(llm, full_message, trust_level)
async def _chat_general(
self, llm: LLMClient, message: str, trust_level: str
) -> CommandResponse:
"""General chat with assistant."""
# S44: Semantic Guard Evaluation
decision = self.semantic_guard.evaluate_request(message, {"trust": trust_level})
if decision.action == GuardAction.DENY:
return CommandResponse(
text=(
"[Blocked] Request denied by semantic policy "
f"({decision.reason}). {self._policy_kv(decision.to_contract())}"
)
)
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level=trust_level)
response = await llm.chat(system_prompt, message)
# S44: Output Validation + SAFE_REPLY sanitization.
try:
response = self.semantic_guard.validate_output(
response, "general", decision.action
)
except ValueError as e:
return CommandResponse(
text=(
"[Validation Error] Assistant output invalid: "
f"{e}. {self._policy_kv({'code': 'semantic_output_invalid', 'severity': 'medium', 'action': 'deny', 'reason': str(e)})}"
)
)
if decision.action == GuardAction.SAFE_REPLY:
safe_response = (
response
or "I can help with general guidance, but commands are restricted for this request."
)
return CommandResponse(
text=(
f"[Safe Mode] {safe_response}\n\n"
f"(Policy: {self._policy_kv(decision.to_contract())})"
)
)
return CommandResponse(text=response)
async def _chat_run(
self, llm: LLMClient, request: str, trust_level: str
) -> CommandResponse:
"""Suggest a /run command based on user request."""
if not request:
return CommandResponse(
text="Usage: /chat run <description of what you want>"
)
# S44: Semantic Guard Evaluation
decision = self.semantic_guard.evaluate_request(request, {"trust": trust_level})
if decision.action == GuardAction.DENY:
return CommandResponse(
text=(
"[Blocked] Request denied by semantic policy "
f"({decision.reason}). {self._policy_kv(decision.to_contract())}"
)
)
# Force Approval Override based on Risk
force_approval_policy = decision.action == GuardAction.FORCE_APPROVAL
# Get available templates (simplified - could fetch from API)
templates = "txt2img, img2img, upscale (examples)"
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level=trust_level)
user_prompt = f"""User wants to run a generation. Suggest a `/run` command.
Request: {request}
Available templates: {templates}
Trust level: {trust_level}
Remember: {"add --approval flag" if trust_level == "UNTRUSTED" else "no --approval needed"}.
Output only the command in a code block."""
response = await llm.chat(system_prompt, user_prompt)
# S44: Output Structure Validation
try:
response = self.semantic_guard.validate_output(
response, "run", decision.action
)
except ValueError as e:
return CommandResponse(
text=(
"[Validation Error] Assistant output invalid: "
f"{e}. {self._policy_kv({'code': 'semantic_output_invalid', 'severity': 'high', 'action': 'deny', 'reason': str(e)})}"
)
)
# R97: Command Firewall - Extract and Validate
import re
cmd_match = re.search(r"```(?:bash)?\s*(.*?)\s*```", response, re.DOTALL)
raw_cmd = cmd_match.group(1).strip() if cmd_match else response.strip()
# Validate through Firewall
normalized = self.command_firewall.validate_suggestion(raw_cmd)
if not normalized.is_safe:
return CommandResponse(
text=(
"[Safety Block] Assistant suggested unsafe command: "
f"{normalized.safety_reason}. {self._policy_kv(normalized.to_contract())}"
)
)
# R97: Strict /run enforcement (Remediation for Medium Severity)
# CRITICAL: keep this check. /chat run must never emit non-/run commands.
if normalized.command != "/run":
return CommandResponse(
text=(
"[Policy Block] Only /run commands are allowed in this mode. "
f"Got: {normalized.command}. "
f"{self._policy_kv({'code': 'firewall_non_run_command', 'severity': 'high', 'action': 'deny', 'reason': 'non_run_command_in_run_mode'})}"
)
)
# R97/S44: Apply Policy Overrides
# If risk was elevated, ensure --approval is present
if (
force_approval_policy
and "--approval" not in normalized.args
and "approval" not in normalized.flags
):
normalized.args.append("--approval")
final_cmd = normalized.to_string()
# Return as code block for easy copy-paste (or auto-execution UI cues)
if force_approval_policy:
return CommandResponse(
text=(
f"```\n{final_cmd}\n```\n"
f"(Policy: {self._policy_kv(decision.to_contract())})"
)
)
return CommandResponse(text=f"```\n{final_cmd}\n```")
@staticmethod
def _policy_kv(contract: Dict[str, Any]) -> str:
ordered = ("code", "severity", "action", "reason")
parts = []
for key in ordered:
value = contract.get(key)
if value is not None:
parts.append(f"{key}={value}")
return "[" + ", ".join(parts) + "]"
async def _chat_template(self, llm: LLMClient, request: str) -> CommandResponse:
"""Generate a template JSON suggestion."""
if not request:
return CommandResponse(text="Usage: /chat template <description>")
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level="N/A")
user_prompt = f"""Generate a workflow template JSON for this request:
Request: {request}
Output:
1. Suggested filename
2. Template JSON in a code block
Keep it minimal."""
response = await llm.chat(system_prompt, user_prompt)
return CommandResponse(text=response)
async def _chat_status(self, llm: LLMClient) -> CommandResponse:
"""Summarize system status using LLM."""
# Fetch status data
health = await self.client.get_health()
jobs = await self.client.get_jobs()
queue = await self.client.get_prompt_queue()
status_data = {
"health": health.get("data", {}) if health.get("ok") else "unavailable",
"jobs": jobs.get("data", {}) if jobs.get("ok") else "unavailable",
"queue": queue.get("data", {}) if queue.get("ok") else "unavailable",
}
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level="N/A")
user_prompt = CHAT_STATUS_PROMPT.format(status_data=status_data)
response = await llm.chat(system_prompt, user_prompt)
return CommandResponse(text=response)
+332
View File
@@ -0,0 +1,332 @@
"""Owned status, approval, schedule, and introspection command mixin."""
# ruff: noqa: UP006, UP035, UP045 -- preserve the frozen public annotations.
# mypy: disable-error-code="attr-defined"
from collections.abc import Mapping
from typing import List, Optional
from .contract import CommandRequest, CommandResponse
from .jobs_summary import JobsContractError, format_jobs_summary, format_queue_fallback
try:
from services.reasoning_redaction import sanitize_operator_payload
except Exception: # pragma: no cover - connector tests may stub import graph
def sanitize_operator_payload(value, **_): # type: ignore
return value
class RouterAdminMixin:
async def _handle_status(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
health = await self.client.get_health()
queue = await self.client.get_prompt_queue()
# New standardized response handling
health_ok = health.get("ok")
status_icon = "Online" if health_ok else "Offline"
details = []
if health_ok:
data = health.get("data", {})
stats = data.get("stats", {})
details.append(f"Logs: {stats.get('logs_processed', 0)}")
details.append(f"Errors: {stats.get('errors_captured', 0)}")
q_res = queue.get("data", {})
q_rem = q_res.get("exec_info", {}).get("queue_remaining", 0)
details.append(f"Queue: {q_rem}")
else:
details.append(f"Error: {health.get('error')}")
return CommandResponse(
text=f"[{status_icon}] System Status\n"
+ "\n".join(f"- {d}" for d in details)
)
def _require_admin_token_configured(self) -> Optional[CommandResponse]:
"""
F32 WP3: Check if admin token is configured before running admin commands.
Fail-fast with clear error message instead of 403/500 later.
IMPORTANT (recurring CI failure mode):
- Admin-only commands are gated by BOTH:
(1) sender is an admin user, AND
(2) the connector admin token is configured (OPENCLAW_CONNECTOR_ADMIN_TOKEN).
- Unit tests that exercise admin command handlers MUST set `config.admin_token`,
otherwise they will correctly receive the config error response.
"""
if not self.config.admin_token:
return CommandResponse(
text="[Error] Admin token not configured. Set OPENCLAW_CONNECTOR_ADMIN_TOKEN and restart connector."
)
return None
async def _handle_approvals_list(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_approvals()
if not res.get("ok"):
return CommandResponse(
text=f"[Error] Failed to list approvals: {res.get('error')}"
)
items = res.get("items", [])
if not items:
return CommandResponse(text="No pending approvals.")
pending_count = res.get("pending_count")
lines = []
buttons = []
for i in items:
# IMPORTANT (stability): the backend approval schema uses:
# `approval_id`, `template_id`, `status`, `requested_by`, `source`.
# Do not “simplify” these keys to `id/description/requester` unless you also
# update the backend API + all tests. This mismatch previously caused silent
# bad output and brittle regressions.
approval_id = i.get("approval_id") or i.get("id") or "unknown"
template_id = i.get("template_id") or "unknown"
status = i.get("status") or "unknown"
requested_by = i.get("requested_by") or "unknown"
source = i.get("source") or "unknown"
lines.append(
f"- {approval_id} [{status}] template={template_id} by={requested_by} source={source}"
)
for i in items[:3]:
approval_id = i.get("approval_id") or i.get("id") or "unknown"
short_id = str(approval_id)[:8]
buttons.append(
{
"label": f"Approve {short_id}",
"value": f"/approve {approval_id}",
"action_type": "approval.approve",
"approval_id": approval_id,
"style": "primary",
}
)
buttons.append(
{
"label": f"Reject {short_id}",
"value": f"/reject {approval_id}",
"action_type": "approval.reject",
"approval_id": approval_id,
"style": "danger",
}
)
header = "Pending Approvals"
if isinstance(pending_count, int):
header += f" ({pending_count})"
return CommandResponse(
text=header + ":\n" + "\n".join(lines),
buttons=buttons,
)
async def _handle_approve(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /approve <id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
# Assuming auto_execute=True by default for chat logic
res = await self.client.approve_request(args[0], auto_execute=True)
if not res.get("ok"):
return CommandResponse(text=f"[Failed] {res.get('error')}")
data = res.get("data", {})
msg = f"[Approved] {args[0]}"
# Phase 4: Show execution result
if "prompt_id" in data:
pid = data["prompt_id"]
msg += f"\nExecuted: {pid}"
if self.poller:
# Approval request might have come from different flow, but usually user invoking /approve
# wants the result. Using current req context is safest assumption for "ChatOps".
self.poller.track_job(
pid,
req.platform,
req.channel_id,
req.sender_id,
delivery_context=self._delivery_context(req),
)
elif data.get("executed") is False:
msg += "\n(Not Executed)"
if err := data.get("execution_error"):
msg += f"\nError: {err}"
return CommandResponse(text=msg)
async def _handle_reject(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /reject <id> [reason]")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
reason = " ".join(args[1:]) if len(args) > 1 else "Rejected via chat"
res = await self.client.reject_request(args[0], reason)
if not res.get("ok"):
return CommandResponse(text=f"[Failed] {res.get('error')}")
return CommandResponse(text=f"[Rejected] {args[0]}")
async def _handle_schedules_list(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_schedules()
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
scheds = res.get("schedules", [])
if not scheds:
return CommandResponse(text="No schedules found.")
lines = []
for s in scheds:
status = "+" if s.get("enabled") else "-"
lines.append(
f"[{status}] {s.get('id')}: {s.get('cron')} - {s.get('template_id')}"
)
return CommandResponse(text="Schedules:\n" + "\n".join(lines))
async def _handle_schedule_subcommand(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if len(args) < 2:
return CommandResponse(text="Usage: /schedule <run|toggle> <id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
sub = args[0].lower()
sid = args[1]
if sub == "run":
res = await self.client.run_schedule(sid)
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
return CommandResponse(text=f"[Success] Schedule {sid} triggered manually.")
else:
return CommandResponse(text="Not implemented yet.")
async def _handle_help(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
return CommandResponse(
text=(
"OpenClaw Connector\n"
"/status - Check system health and queue\n"
"/run <template> [prompt] [k=v] - Run a generation (trusted users auto-exec; others require approval)\n"
"/stop [job_id ...] - Cancel jobs by id; no args sends Global Interrupt (Admin)\n"
"/history <id> - Job details\n"
"Admin Only:\n"
"/jobs - Authoritative jobs summary\n"
"/approvals - List pending approvals\n"
"/approve <id>, /reject <id>\n"
"/schedules, /schedule run <id>\n"
"/trace <id> - Execution trace"
)
)
async def _handle_history(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /history <prompt_id>")
res = await self.client.get_history(args[0])
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
# Simple format
data = res.get("data", {})
status = data.get("status", {}).get("status_str", "unknown")
# Assuming backend returns a structure we can summarise
return CommandResponse(
text=f"Job {args[0]}: {status}\nFull details: not implemented in connector view yet."
)
async def _handle_trace(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /trace <prompt_id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_trace(args[0])
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
# Dump trace
sanitized = sanitize_operator_payload(res.get("data"))
return CommandResponse(text=f"Trace {args[0]}: {str(sanitized)[:1000]}...")
async def _handle_jobs(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if err := self._require_admin_token_configured():
return err
res = await self.client.get_jobs()
if not isinstance(res, Mapping):
return CommandResponse(
text="[Jobs] Could not fetch the authoritative jobs snapshot."
)
if res.get("ok") is True:
try:
return CommandResponse(text=format_jobs_summary(res.get("data")))
except JobsContractError:
return CommandResponse(
text="[Jobs] Malformed or unsupported jobs response."
)
status = res.get("status")
error = res.get("error")
access_denied = (
isinstance(status, int)
and not isinstance(status, bool)
and status in {401, 403}
)
if access_denied:
return CommandResponse(
text="[Jobs] Access denied. Check connector Admin authorization and token posture."
)
fallback_allowed = isinstance(error, str) and (
(status == 501 and error == "jobs_host_contract_unsupported")
or (status == 503 and error == "jobs_backend_unavailable")
)
if fallback_allowed:
return CommandResponse(
text=format_queue_fallback(await self.client.get_prompt_queue())
)
return CommandResponse(
text="[Jobs] Could not fetch the authoritative jobs snapshot."
)
# -------------------------------------------------------------------------
# F30: Chat LLM Assistant
# -------------------------------------------------------------------------
+244
View File
@@ -0,0 +1,244 @@
"""Owned chat and semantic-guard command-family mixin."""
# ruff: noqa: UP006, UP035 -- preserve the frozen public annotations.
# mypy: disable-error-code="attr-defined"
from typing import Any, Dict, List
from .contract import CommandRequest, CommandResponse
from .llm_client import LLMClient
from .prompts import CHAT_STATUS_PROMPT, CHAT_SYSTEM_PROMPT
from .semantic_guard import GuardAction
class RouterChatMixin:
async def _handle_chat(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
"""
/chat [subcommand] <message>
Subcommands: run, template, status
Default: general chat
Security: Never auto-executes commands. Only suggests command text.
"""
llm = self._build_llm_client()
if not await llm.is_configured():
return CommandResponse(
text="[Chat Error] LLM not configured. Configure in OpenClaw Settings."
)
# Parse subcommand
if not args:
return CommandResponse(
text="Usage: /chat <message> or /chat run|template|status <request>"
)
subcommand = args[0].lower()
message = " ".join(args[1:]) if len(args) > 1 else ""
trust_level = "TRUSTED" if self._is_trusted(req) else "UNTRUSTED"
if subcommand == "run":
return await self._chat_run(llm, message, trust_level)
elif subcommand == "template":
return await self._chat_template(llm, message)
elif subcommand == "status":
return await self._chat_status(llm)
else:
# General chat: first word is part of message
full_message = " ".join(args)
return await self._chat_general(llm, full_message, trust_level)
async def _chat_general(
self, llm: LLMClient, message: str, trust_level: str
) -> CommandResponse:
"""General chat with assistant."""
# S44: Semantic Guard Evaluation
decision = self.semantic_guard.evaluate_request(message, {"trust": trust_level})
if decision.action == GuardAction.DENY:
return CommandResponse(
text=(
"[Blocked] Request denied by semantic policy "
f"({decision.reason}). {self._policy_kv(decision.to_contract())}"
)
)
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level=trust_level)
response = await llm.chat(system_prompt, message)
# S44: Output Validation + SAFE_REPLY sanitization.
try:
response = self.semantic_guard.validate_output(
response, "general", decision.action
)
except ValueError as e:
return CommandResponse(
text=(
"[Validation Error] Assistant output invalid: "
f"{e}. {self._policy_kv({'code': 'semantic_output_invalid', 'severity': 'medium', 'action': 'deny', 'reason': str(e)})}"
)
)
if decision.action == GuardAction.SAFE_REPLY:
safe_response = (
response
or "I can help with general guidance, but commands are restricted for this request."
)
return CommandResponse(
text=(
f"[Safe Mode] {safe_response}\n\n"
f"(Policy: {self._policy_kv(decision.to_contract())})"
)
)
return CommandResponse(text=response)
async def _chat_run(
self, llm: LLMClient, request: str, trust_level: str
) -> CommandResponse:
"""Suggest a /run command based on user request."""
if not request:
return CommandResponse(
text="Usage: /chat run <description of what you want>"
)
# S44: Semantic Guard Evaluation
decision = self.semantic_guard.evaluate_request(request, {"trust": trust_level})
if decision.action == GuardAction.DENY:
return CommandResponse(
text=(
"[Blocked] Request denied by semantic policy "
f"({decision.reason}). {self._policy_kv(decision.to_contract())}"
)
)
# Force Approval Override based on Risk
force_approval_policy = decision.action == GuardAction.FORCE_APPROVAL
# Get available templates (simplified - could fetch from API)
templates = "txt2img, img2img, upscale (examples)"
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level=trust_level)
user_prompt = f"""User wants to run a generation. Suggest a `/run` command.
Request: {request}
Available templates: {templates}
Trust level: {trust_level}
Remember: {"add --approval flag" if trust_level == "UNTRUSTED" else "no --approval needed"}.
Output only the command in a code block."""
response = await llm.chat(system_prompt, user_prompt)
# S44: Output Structure Validation
try:
response = self.semantic_guard.validate_output(
response, "run", decision.action
)
except ValueError as e:
return CommandResponse(
text=(
"[Validation Error] Assistant output invalid: "
f"{e}. {self._policy_kv({'code': 'semantic_output_invalid', 'severity': 'high', 'action': 'deny', 'reason': str(e)})}"
)
)
# R97: Command Firewall - Extract and Validate
import re
cmd_match = re.search(r"```(?:bash)?\s*(.*?)\s*```", response, re.DOTALL)
raw_cmd = cmd_match.group(1).strip() if cmd_match else response.strip()
# Validate through Firewall
normalized = self.command_firewall.validate_suggestion(raw_cmd)
if not normalized.is_safe:
return CommandResponse(
text=(
"[Safety Block] Assistant suggested unsafe command: "
f"{normalized.safety_reason}. {self._policy_kv(normalized.to_contract())}"
)
)
# R97: Strict /run enforcement (Remediation for Medium Severity)
# CRITICAL: keep this check. /chat run must never emit non-/run commands.
if normalized.command != "/run":
return CommandResponse(
text=(
"[Policy Block] Only /run commands are allowed in this mode. "
f"Got: {normalized.command}. "
f"{self._policy_kv({'code': 'firewall_non_run_command', 'severity': 'high', 'action': 'deny', 'reason': 'non_run_command_in_run_mode'})}"
)
)
# R97/S44: Apply Policy Overrides
# If risk was elevated, ensure --approval is present
if (
force_approval_policy
and "--approval" not in normalized.args
and "approval" not in normalized.flags
):
normalized.args.append("--approval")
final_cmd = normalized.to_string()
# Return as code block for easy copy-paste (or auto-execution UI cues)
if force_approval_policy:
return CommandResponse(
text=(
f"```\n{final_cmd}\n```\n"
f"(Policy: {self._policy_kv(decision.to_contract())})"
)
)
return CommandResponse(text=f"```\n{final_cmd}\n```")
@staticmethod
def _policy_kv(contract: Dict[str, Any]) -> str:
ordered = ("code", "severity", "action", "reason")
parts = []
for key in ordered:
value = contract.get(key)
if value is not None:
parts.append(f"{key}={value}")
return "[" + ", ".join(parts) + "]"
async def _chat_template(self, llm: LLMClient, request: str) -> CommandResponse:
"""Generate a template JSON suggestion."""
if not request:
return CommandResponse(text="Usage: /chat template <description>")
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level="N/A")
user_prompt = f"""Generate a workflow template JSON for this request:
Request: {request}
Output:
1. Suggested filename
2. Template JSON in a code block
Keep it minimal."""
response = await llm.chat(system_prompt, user_prompt)
return CommandResponse(text=response)
async def _chat_status(self, llm: LLMClient) -> CommandResponse:
"""Summarize system status using LLM."""
# Fetch status data
health = await self.client.get_health()
queue = await self.client.get_prompt_queue()
status_data = {
"health": health.get("data", {}) if health.get("ok") else "unavailable",
"jobs": "admin-only; use /jobs as an authorized operator",
"queue": queue.get("data", {}) if queue.get("ok") else "unavailable",
}
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level="N/A")
user_prompt = CHAT_STATUS_PROMPT.format(status_data=status_data)
response = await llm.chat(system_prompt, user_prompt)
return CommandResponse(text=response)
+275
View File
@@ -0,0 +1,275 @@
"""Owned command parsing, dispatch, and authorization mixin."""
# ruff: noqa: UP006, UP035, UP045 -- preserve the frozen public annotations.
# mypy: disable-error-code="attr-defined,no-any-return"
import logging
import shlex
from dataclasses import dataclass
from typing import Any, Dict, Optional
from .config import CommandClass
from .contract import CommandRequest, CommandResponse
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class RouterRequestContext:
"""Immutable dispatch values for one authorized command attempt."""
request: CommandRequest
parsed_command: str
canonical_command: str
args: tuple[str, ...]
command_class: CommandClass
class RouterDispatchMixin:
async def handle(self, req: CommandRequest) -> CommandResponse:
"""Main dispatch loop."""
text = req.text.strip()
# NOTE: Debug-only raw message logging for troubleshooting parsing issues.
# Enable with OPENCLAW_CONNECTOR_DEBUG=1. May include sensitive user content.
if self.config.debug:
logger.info(
"DEBUG raw message: platform=%s user=%s chat=%s text=%r",
req.platform,
req.sender_id,
req.channel_id,
text,
)
# F32 WP2: Rate limiting
if not self._rate_limiter.is_allowed(str(req.sender_id), str(req.channel_id)):
return CommandResponse(
text="[Rate Limited] Too many requests. Please wait a moment."
)
# F32 WP5: Command length limit
if len(text) > self.config.max_command_length:
return CommandResponse(
text=f"[Error] Command too long ({len(text)} chars). Max: {self.config.max_command_length}."
)
try:
# IMPORTANT (recurring usability bug):
# Do not use `shlex.split()` directly for ChatOps commands that may include natural
# language. In POSIX mode, `shlex` treats apostrophes (`'`) as quote delimiters, so
# common contractions like "She's" trigger "unbalanced quotes" failures.
#
# We therefore only treat *double quotes* (`"`) as quoting characters, so users can
# still do: positive_prompt="a prompt with spaces" while apostrophes remain safe.
lexer = shlex.shlex(text, posix=True)
lexer.whitespace_split = True
lexer.commenters = ""
lexer.quotes = '"'
parts = list(lexer)
except ValueError:
return CommandResponse(
text="[Error] Parsing command arguments failed (unbalanced quotes?)."
)
if not parts:
return CommandResponse(text="Empty command.")
cmd = parts[0].lower()
args = parts[1:]
# Telegram group commands often include the bot username suffix, e.g. `/help@mybot`.
# If we don't strip it, the command won't match our dispatch table and appears "dead"
# even though polling is working.
if (
(req.platform or "").lower() == "telegram"
and cmd.startswith("/")
and "@" in cmd
):
cmd = cmd.split("@", 1)[0]
# Some users type `@bot /help` in group chats. Treat that as a command too.
if cmd.startswith("@") and args and args[0].startswith("/"):
cmd = args[0].lower()
args = args[1:]
# Dispatch Table
handlers = {
("/status", "status"): (self._handle_status, CommandClass.PUBLIC),
("/help", "help", "/start"): (self._handle_help, CommandClass.PUBLIC),
("/run", "run"): (self._handle_run, CommandClass.RUN),
("/interrupt", "interrupt", "/cancel", "cancel", "/stop"): (
self._handle_interrupt,
CommandClass.ADMIN,
), # Global interrupt => admin-only.
("/approvals", "approvals"): (
self._handle_approvals_list,
CommandClass.ADMIN,
),
("/approve", "approve"): (self._handle_approve, CommandClass.ADMIN),
("/reject", "reject"): (self._handle_reject, CommandClass.ADMIN),
("/schedules", "schedules"): (
self._handle_schedules_list,
CommandClass.ADMIN,
),
("/schedule", "schedule"): (
self._handle_schedule_subcommand,
CommandClass.ADMIN,
),
# Phase 3 Introspection
("/history", "history"): (self._handle_history, CommandClass.PUBLIC),
("/trace", "trace"): (self._handle_trace, CommandClass.ADMIN), # Admin only
("/jobs", "jobs", "queue"): (self._handle_jobs, CommandClass.ADMIN),
# F30: Chat Assistant
("/chat", "chat"): (self._handle_chat, CommandClass.PUBLIC),
}
# Find Handler
handler = None
canonical_cmd = cmd # Fallback
for aliases, (func, cmd_class) in handlers.items():
if cmd in aliases:
handler = func
default_class = cmd_class
# R80 Remediation: Use canonical command (first alias) for policy checks
# This prevents "run" vs "/run" bypass issues.
# Convention: first alias is canonical (e.g. "/run").
canonical_cmd = aliases[0] if isinstance(aliases, tuple) else aliases
break
if not handler:
return CommandResponse(
text=f"Unknown command: {cmd}. Type /help for options."
)
context = RouterRequestContext(
request=req,
parsed_command=cmd,
canonical_command=canonical_cmd,
args=tuple(args),
command_class=default_class,
)
# R80: Centralized Authorization Gate
# Pass canonical_cmd to ensure policy matches aliases correctly
if auth_err := self._check_command_authz(
context.canonical_command, context.request, context.command_class
):
return auth_err
# Execute
try:
return await handler(context.request, list(context.args))
except Exception as e:
logger.exception(f"Command execution error {cmd}: {e}")
return CommandResponse(text=f"[Internal Error] {e!s}")
def _is_admin(self, user_id: str) -> bool:
return str(user_id) in self.config.admin_users
def _delivery_context(self, req: CommandRequest) -> Dict[str, Any]:
context: Dict[str, Any] = {}
if getattr(req, "workspace_id", ""):
context["workspace_id"] = str(req.workspace_id)
if getattr(req, "thread_id", ""):
context["thread_id"] = str(req.thread_id)
return context
def _check_command_authz(
self, cmd: str, req: CommandRequest, default_class: CommandClass
) -> Optional[CommandResponse]:
"""
R80: Verify command authorization policy.
Returns None if allowed, or CommandResponse(text=error) if denied.
"""
policy = self.config.command_policy
# 1. Resolve Effective Class (Handle per-command overrides)
# Note: 'cmd' here is the canonical parsed command string (lowercase), e.g., "/run" or "run"
# The overrides dict might use "/run" or "run", we should check both or normalize.
# Currently, the router logic normalized `cmd` from input (lines 90-101).
# We'll check exact match against the override key.
eff_class = policy.command_overrides.get(cmd, default_class)
# 2. Check AllowFrom List (Explicit User Allow)
# If an explicit AllowFrom list exists for this class, the user MUST be in it.
# This takes precedence over role logic.
allowed_users = policy.allow_from.get(eff_class)
if allowed_users is not None and len(allowed_users) > 0:
if str(req.sender_id) not in allowed_users:
# If explicit allow-list is active, even admins must be in it?
# Decision: YES, for strict compliance. If you want admins, add them to the list.
# However, for usability, usually admins are implied.
# Let's stick to "Explicit List Wins" for R80 strict mode.
return CommandResponse(
text="[Access Denied] You are not in the allow-list for this command."
)
# If in list, proceed (bypass default role checks? No, usually allows)
return None
# 3. Default Role Logic
if eff_class == CommandClass.ADMIN and not self._is_admin(req.sender_id):
return CommandResponse(
text="[Access Denied] This command requires Admin privileges."
)
# PUBLIC and RUN are allowed by default (RUN checks trust internally)
return None
def _is_trusted(self, req: CommandRequest) -> bool:
"""
Trusted users can execute /run immediately.
Untrusted users are routed to approval flow.
"""
if self._is_admin(req.sender_id):
return True
platform = (req.platform or "").lower()
sender_id = str(req.sender_id)
channel_id = str(req.channel_id)
if platform == "telegram":
try:
uid = int(sender_id)
except ValueError:
uid = None
try:
cid = int(channel_id)
except ValueError:
cid = None
if uid is not None and uid in self.config.telegram_allowed_users:
return True
return cid is not None and cid in self.config.telegram_allowed_chats
if platform == "discord":
if sender_id in self.config.discord_allowed_users:
return True
return channel_id in self.config.discord_allowed_channels
if platform == "line":
if sender_id in self.config.line_allowed_users:
return True
return channel_id in self.config.line_allowed_groups
if platform == "whatsapp":
return sender_id in self.config.whatsapp_allowed_users
if platform == "wechat":
return sender_id in self.config.wechat_allowed_users
if platform == "kakao":
return sender_id in self.config.kakao_allowed_users
if platform == "slack":
if sender_id in self.config.slack_allowed_users:
return True
return channel_id in self.config.slack_allowed_channels
if platform == "feishu":
if sender_id in self.config.feishu_allowed_users:
return True
return channel_id in self.config.feishu_allowed_chats
# Unknown platform: trust only admins
return False
# --- Handlers ---
+217
View File
@@ -0,0 +1,217 @@
"""Owned run and interrupt command-family mixin."""
# ruff: noqa: UP006, UP035 -- preserve the frozen public annotations.
# mypy: disable-error-code="attr-defined,no-any-return"
import logging
from typing import Any, Dict, List
from .contract import CommandRequest, CommandResponse
logger = logging.getLogger(__name__)
class RouterExecutionMixin:
async def _handle_run(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(
text="Usage: /run <template_id> [prompt text] [key=value ...] [--approval]"
)
# Parse flags
explicit_approval = False
clean_args = []
for arg in args:
if arg in ("--require-approval", "--approval", "-a"):
explicit_approval = True
else:
clean_args.append(arg)
if not clean_args:
return CommandResponse(text="Usage: /run <template_id> ...")
template_id = clean_args[0]
inputs: Dict[str, str] = {}
free_text_parts: List[str] = []
for arg in clean_args[1:]:
if "=" in arg:
k, v = arg.split("=", 1)
inputs[k.strip()] = v.strip()
else:
free_text_parts.append(arg)
# If user provided free text without key=value, treat it as the prompt.
# We map it to a best-effort prompt key (prefers template metadata if available).
if free_text_parts:
prompt_key = await self._resolve_prompt_key(template_id)
if prompt_key not in inputs:
inputs[prompt_key] = " ".join(free_text_parts).strip()
elif self.config.debug:
logger.info(
"DEBUG /run free-text ignored (prompt key already set): %s",
prompt_key,
)
# NOTE: Debug-only payload logging for troubleshooting prompt mismatches.
# Enable with OPENCLAW_CONNECTOR_DEBUG=1 to log template_id + inputs.
if self.config.debug:
logger.info(
"DEBUG /run payload: template=%s inputs=%s approval_flag=%s trusted=%s",
template_id,
inputs,
explicit_approval,
self._is_trusted(req),
)
trusted = self._is_trusted(req)
require_approval = explicit_approval or (not trusted)
res = await self.client.submit_job(
template_id, inputs, require_approval=require_approval
)
if res.get("ok"):
data = res.get("data", {})
trace_id = data.get("trace_id", "unknown")
if data.get("pending"):
approval_id = data.get("approval_id", "unknown")
msg = f"[Approval Requested]\nID: {approval_id}\nTrace: {trace_id}"
if "expires_at" in data:
msg += f"\nExpires: {data['expires_at']}"
if self.poller:
# IMPORTANT:
# For untrusted users, approvals are done in the OpenClaw UI.
# We must start tracking the approval_id so we can map
# approval_id -> executed_prompt_id later and auto-deliver images.
self.poller.track_approval(
approval_id,
req.platform,
req.channel_id,
req.sender_id,
delivery_context=self._delivery_context(req),
)
return CommandResponse(text=msg)
else:
prompt_id = data.get("prompt_id", "unknown")
if self.poller:
self.poller.track_job(
prompt_id,
req.platform,
req.channel_id,
req.sender_id,
delivery_context=self._delivery_context(req),
)
return CommandResponse(
text=f"[Job Submitted]\nID: {prompt_id}\nTemplate: {template_id}\nTrace: {trace_id}"
)
else:
err = res.get("error", "Unknown error")
return CommandResponse(text=f"[Submission Failed] Reason: {err}")
async def _resolve_prompt_key(self, template_id: str) -> str:
"""
Best-effort prompt key resolution.
Prefer template metadata (allowed_inputs), then fall back to common names.
"""
meta = await self._get_template_meta(template_id)
allowed = meta.get("allowed_inputs") or []
# If template explicitly declares a single input, use it.
if isinstance(allowed, list) and len(allowed) == 1:
return str(allowed[0])
preferred = ("positive_prompt", "prompt", "text", "positive", "caption")
if isinstance(allowed, list):
for key in preferred:
if key in allowed:
return key
# Default fallback
return "positive_prompt"
async def _get_template_meta(self, template_id: str) -> Dict[str, Any]:
if template_id in self._template_meta_cache:
return self._template_meta_cache[template_id]
try:
res = await self.client.get_templates()
if res.get("ok"):
for item in res.get("templates", []) or []:
if item.get("id") == template_id:
self._template_meta_cache[template_id] = item
return item
except Exception as e:
if self.config.debug:
logger.info(f"DEBUG template meta fetch failed: {e}")
return {}
async def _handle_interrupt(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
targets = self._parse_stop_targets(args)
if not targets:
res = await self.client.interrupt_output()
if res.get("ok"):
return CommandResponse(text="[Stop] Global Interrupt sent to ComfyUI.")
return CommandResponse(text=f"[Stop Failed] {res.get('error')}")
if len(targets) == 1:
job_id = targets[0]
res = await self.client.cancel_job(job_id)
if res.get("ok"):
return CommandResponse(
text=f"[Stop] Cancellation requested for job {job_id}."
)
# IMPORTANT: Targeted stops must never degrade to no-payload global
# interrupt. Older-host fallback is allowed only with prompt_id set.
if self._jobs_cancel_unsupported(res):
fallback = await self.client.interrupt_output(prompt_id=job_id)
if fallback.get("ok"):
return CommandResponse(
text=(
f"[Stop] Targeted interrupt sent for job {job_id} "
"(jobs cancel unsupported)."
)
)
return CommandResponse(text=f"[Stop Failed] {fallback.get('error')}")
return CommandResponse(text=f"[Stop Failed] {res.get('error')}")
res = await self.client.cancel_jobs(targets)
if res.get("ok"):
return CommandResponse(
text=f"[Stop] Cancellation requested for {len(targets)} jobs."
)
return CommandResponse(text=f"[Stop Failed] {res.get('error')}")
@staticmethod
def _parse_stop_targets(args: List[str]) -> List[str]:
targets: List[str] = []
for arg in args:
for part in str(arg).split(","):
target = part.strip()
if target:
targets.append(target)
return targets
@staticmethod
def _jobs_cancel_unsupported(res: Dict[str, Any]) -> bool:
status = res.get("status")
if status in (404, 405, 501):
return True
error = str(res.get("error", "")).lower()
unsupported_markers = (
"404",
"not found",
"method not allowed",
"unsupported",
"not implemented",
)
return any(marker in error for marker in unsupported_markers)
+22 -16
View File
@@ -18,12 +18,14 @@ If the deployment enables remote control or bridge features, it must also pass *
- [ ] **Admin Boundaries**:
- [ ] Server-side admin write boundary uses `OPENCLAW_ADMIN_TOKEN` (legacy `MOLTBOT_ADMIN_TOKEN`).
- [ ] Connector admin command paths use `OPENCLAW_CONNECTOR_ADMIN_TOKEN`, and must match server admin token when server admin auth is enabled.
- [ ] **Connector Ingress Defaults**: Platform adapters remain disabled unless required token/enable vars are configured (Telegram/Discord/LINE/WhatsApp/WeChat/Kakao/Slack).
- [ ] **Connector Ingress Defaults**: Platform adapters remain disabled unless required token/enable vars are configured (Telegram/Discord/LINE/WhatsApp/WeChat/Kakao/Slack/Feishu).
- [ ] **Connector Allowlists (Strict Posture)**: In `public` deployment or `hardened` runtime posture, active connector platforms must have allowlist coverage before startup (fail-closed; public check code `DP-PUBLIC-009`).
- [ ] **Connector Replay & Visibility**: Duplicate committed connector events are no-ops, retryable pre-commit failures can be retried, and text-only reply suppression does not hide approval/action controls.
- [ ] **Observability**: `/openclaw/logs/tail` and `/openclaw/config` require `OPENCLAW_OBSERVABILITY_TOKEN` (legacy: `MOLTBOT_OBSERVABILITY_TOKEN`) if accessed remotely, or are loopback-only.
- [ ] **SSRF**: LLM `base_url` defaults to known providers. Custom public URLs require `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1` or explicit allowlist; private/reserved IP targets still require `OPENCLAW_ALLOW_INSECURE_BASE_URL=1`.
- [ ] **SSRF**: LLM `base_url` defaults to known providers. Custom public URLs require `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1` or explicit allowlist; private/reserved IP targets require the scoped LLM private-network setting or the broader `OPENCLAW_ALLOW_INSECURE_BASE_URL=1` override.
- [ ] **Public Boundary Contract (S69)**: for `OPENCLAW_DEPLOYMENT_PROFILE=public`, set `OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1` only after reverse-proxy path allowlist + network ACL deny ComfyUI-native high-risk routes.
- [ ] **Budgets**: `OPENCLAW_MAX_INFLIGHT_SUBMITS_TOTAL` (concurrency) and `OPENCLAW_MAX_RENDERED_WORKFLOW_BYTES` (payloads) are enforced.
- [ ] **External Tools**: external tool execution is disabled unless explicitly required; if enabled, the package-owned/default or custom allowlist is reviewed, sandbox policy is explicit for hardened posture, and sandbox/interpreter/timeout/workspace diagnostics are deterministic.
- [ ] **Contracts**: API endpoints match `docs/release/api_contract.md`; Configuration follows `docs/release/config_secrets_contract.md`.
### 2. Documentation & Recipes
@@ -37,24 +39,28 @@ If the deployment enables remote control or bridge features, it must also pass *
### 3. Validation (Must Pass)
Run the full regression suite:
Run the complete OS-specific regression gate:
```powershell
# Windows
powershell -File scripts/run_full_tests_windows.ps1
```
```bash
# 1. Secret Scanning
./.venv/Scripts/python.exe -m pre_commit run detect-secrets --all-files
# 2. Lint & Formatting
./.venv/Scripts/python.exe -m pre_commit run --all-files --show-diff-on-failure
# 3. Backend Unit Tests
MOLTBOT_STATE_DIR="$(pwd)/moltbot_state/_local_unit" ./.venv/Scripts/python.exe scripts/run_unittests.py --start-dir tests --pattern "test_*.py"
# 4. Frontend E2E (Unit/Integration)
# Ensure Node 18+
node -v
npm test
# Linux / WSL
bash scripts/run_full_tests_linux.sh
```
These scripts execute the authoritative `tests/TEST_SOP.md` sequence, including fresh
lockfile reconciliation with `npm ci`, the blocking
`npm audit --audit-level=high` check across production and development dependencies,
secret scanning, pre-commit hooks, governance and backend lanes, adaptive adversarial
validation, and frontend Playwright E2E. A standalone `npm test` result is not a
substitute for the complete release gate.
If staged/manual execution is required, follow the explicit command order in
`tests/TEST_SOP.md`; do not maintain a shortened release-only sequence here.
---
## Gate B: Bridge / Remote Control Safety (Conditional)
+43 -16
View File
@@ -2,9 +2,9 @@
## Quick Links
- Deployment profiles and checklists: [Security Deployment Guide](docs/security_deployment_guide.md)
- Runtime startup hardening behavior: [Runtime Hardening and Startup](docs/runtime_hardening_and_startup.md)
- Pre-exposure checklist: [Security Checklist](docs/security_checklist.md)
- Deployment profiles and checklists: [Security Deployment Guide](security_deployment_guide.md)
- Runtime startup hardening behavior: [Runtime Hardening and Startup](runtime_hardening_and_startup.md)
- Pre-exposure checklist: [Security Checklist](security_checklist.md)
- Deployment self-check command:
- `python scripts/check_deployment_profile.py --profile local|lan|public`
@@ -12,16 +12,16 @@
Only the latest version of ComfyUI-OpenClaw is supported for security updates.
| Version | Supported |
| ------- | ------------------ |
| Latest | :white_check_mark: |
| < 0.2.0 | :x: |
| Version | Supported |
| ------------------------ | ------------------ |
| Latest published release | :white_check_mark: |
| All earlier releases | :x: |
## Reporting a Vulnerability
Please report security vulnerabilities by creating a **private** issue on GitHub if possible, or contact the maintainers directly. Do not open public issues for sensitive security flaws.
### Disclosure Workflow and SLA (S48)
### Disclosure Workflow and SLA
Private reporting workflow:
1. Submit a private report with repro steps, affected version, and impact.
@@ -103,15 +103,20 @@ export OPENCLAW_ADMIN_TOKEN="your-secure-random-admin-token-here"
Then configure your proxy or client to send the header `X-OpenClaw-Obs-Token: your-secure-random-token-here` (legacy: `X-Moltbot-Obs-Token`).
### 1.1 Reasoning Debug Reveal Boundary (Local-only)
### 1.1 Reasoning and Internal Content Redaction Boundary
Operator-facing payloads strip provider reasoning / thinking traces by default across:
Operator-facing payloads strip provider reasoning / thinking traces and explicitly marked internal maintenance/helper prompt content by default across:
- assist responses
- event / SSE payloads
- trace responses
- callback payloads
- connector trace/debug replies
- audit event payload/meta fields
Internal maintenance/helper prompt content has no public or debug reveal path. Privileged reasoning reveal is limited to provider reasoning / thinking traces only.
### 1.2 Reasoning Debug Reveal Boundary (Local-only)
There is a privileged local-debug reveal path for troubleshooting, but it is fail-closed unless **all** of the following are true:
@@ -142,7 +147,7 @@ export OPENCLAW_TRUSTED_PROXIES="127.0.0.1,10.0.0.0/8"
# export MOLTBOT_TRUSTED_PROXIES="127.0.0.1,10.0.0.0/8"
```
### 3. Public Profile Boundary Acknowledgement (S69)
### 3. Public Profile Boundary Acknowledgement
For public profile deployments, you must explicitly acknowledge that reverse-proxy path controls and network ACL boundaries are already enforced:
@@ -159,7 +164,7 @@ If this acknowledgement is missing in public profile, deployment profile checks
Connector ingress posture is fail-closed in strict profiles:
- if connector platform ingress is active (Telegram/Discord/LINE/WhatsApp/WeChat/Kakao/Slack)
- if connector platform ingress is active (Telegram/Discord/LINE/WhatsApp/WeChat/Kakao/Slack/Feishu)
- and matching allowlist variables are missing
- startup/deployment checks fail closed (`DP-PUBLIC-009` for public profile)
@@ -175,6 +180,8 @@ For interactive connector callbacks (actions/modals/workflow style payloads), th
- stale timestamp, replay/duplicate request ID, payload-hash mismatch, or unknown action type are rejected
- workspace-to-installation resolution is fail-closed on missing/ambiguous/inactive/stale-token-ref binding
- policy mapping is explicit (`public`/`run`/`admin`) and untrusted `run` callbacks degrade to approval instead of direct privileged execution
- duplicate committed connector events are acknowledged without re-running completed actions, while retryable failures before action/delivery commit can be retried
- reply visibility is policy-driven; text-only silent/internal/tool-only/no-mention replies can be suppressed without suppressing approval cards, action buttons, allowlist checks, or callback replay checks
Operational note:
@@ -220,7 +227,7 @@ Operational note:
- this path remains backend-only; frontend surfaces stay secret-blind.
### 5. Startup Gate Behavior (R136 + S56)
### 5. Startup Gate Behavior
Startup security gates are fail-closed. Fatal startup gate/bootstrap failures abort route/worker registration and do not continue in a partial state.
@@ -235,7 +242,8 @@ python scripts/check_deployment_profile.py --profile public --strict-warnings
OpenClaw validates custom LLM `base_url` settings to prevent Server-Side Request Forgery (SSRF).
* **Default**: known providers and localhost-safe paths are allowed.
* **Pinned connect contract**: on supported CPython versions (current baseline: 3.10+), `safe_io` dials resolved IPs directly for HTTP/HTTPS and keeps TLS `server_hostname` on the original host; the no-skip `tests.test_s70_ssrf_pinning_regression` lane is intended to fail loudly if stdlib connect behavior drifts.
* **Pinned connect contract**: on supported CPython versions (current baseline: 3.10+), the consolidated `safe_io` outbound executor dials resolved IPs directly for HTTP/HTTPS and keeps TLS `server_hostname` on the original host; the no-skip `tests.test_s70_ssrf_pinning_regression` lane is intended to fail loudly if stdlib connect behavior drifts.
* **Redirect handling**: redirect targets are revalidated against host allowlists, private/reserved-IP blocking, and pinned-connect rules before any follow-up connection is opened.
* **Custom base URL**:
- requires explicit opt-in:
@@ -250,11 +258,28 @@ OpenClaw validates custom LLM `base_url` settings to prevent Server-Side Request
```
- `OPENCLAW_LLM_ALLOWED_HOSTS` only permits additional exact public hosts; it does not bypass the private/reserved-IP block.
- `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1` widens to any public host only.
- `allow_private_network=true` on the LLM setting allows only the configured provider `base_url` host to resolve to a private/reserved IP while keeping exact-host allowlists, scheme/port checks, and DNS pinning.
- `OPENCLAW_ALLOW_INSECURE_BASE_URL=1` is the explicit risk-acceptance override for HTTP or private/reserved IP targets.
- the same override is enforced consistently for config validation, `/openclaw/llm/models`, and outbound provider requests.
- the same scoped/private or insecure decision is enforced consistently for config validation, `/openclaw/llm/models`, and outbound provider requests.
- wildcard values such as `OPENCLAW_LLM_ALLOWED_HOSTS="*"` are not supported.
- avoid broad bypass flags in production (`OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST`, `OPENCLAW_ALLOW_INSECURE_BASE_URL`).
### 6.1 Audit Chain Verification
OpenClaw keeps append-only audit evidence verifiable across restart and retained-log rotation.
Recommended operator check:
```bash
python scripts/verify_audit_chain.py --json
```
Notes:
- verification covers the active `audit.log` plus retained rotated segments in the state directory
- when an audit chain key is not supplied externally, OpenClaw persists a local `audit.log.key` sidecar so the retained chain stays verifiable after restart
- treat any verification failure as an integrity incident and investigate before trusting the retained audit trail
### 7. Rate Limiting
OpenClaw enforces internal rate limits:
@@ -265,7 +290,7 @@ OpenClaw enforces internal rate limits:
### 8. Sidecar Bridge
OpenClaw supports a "Sidecar Bridge" (F10) for safe interaction with external bots (Discord/Slack).
OpenClaw supports a "Sidecar Bridge" for safe interaction with external bots (Discord/Slack).
* **Default**: **DISABLED**.
* **Enable**: Set `OPENCLAW_BRIDGE_ENABLED=1` (legacy `MOLTBOT_BRIDGE_ENABLED=1`).
@@ -282,7 +307,9 @@ OpenClaw supports a "Sidecar Bridge" (F10) for safe interaction with external bo
* [ ] **Public shared-surface ack**: for `OPENCLAW_DEPLOYMENT_PROFILE=public`, set `OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1` only after proxy path allowlist + ACL are verified.
* [ ] **Public path deny rules**: block ComfyUI-native high-risk routes and `/api/*` equivalents unless explicitly required.
* [ ] **Connector strict-posture allowlists**: if connector ingress is active in `public` or `hardened`, ensure platform allowlists are set before startup (`DP-PUBLIC-009` for public profile).
* [ ] **External tools disabled by default**: keep `OPENCLAW_ENABLE_EXTERNAL_TOOLS=0` unless there is a reviewed need; if enabled, verify the tool allowlist, sandbox policy, and deterministic sandbox/interpreter/timeout/workspace diagnostics.
* [ ] **Multi-tenant boundary (if enabled)**: enforce one canonical tenant header path through proxy/app, keep fallback toggles disabled unless a migration window is actively in progress.
* [ ] **Audit integrity check**: run `python scripts/verify_audit_chain.py --json` after restart/rotation-sensitive maintenance and confirm retained audit logs still verify cleanly.
* [ ] **1Password guardrails (if enabled)**: require command allowlist + vault/template validation; in multi-tenant mode, include `{tenant}` in item template.
* [ ] **Startup gate preflight**: run `python scripts/check_deployment_profile.py --profile public --strict-warnings`.
* [ ] **Runtime diagnostics**: review `GET /openclaw/security/doctor` before exposure.
@@ -0,0 +1,96 @@
# ADR-0002: Product Boundary And Packaging Contract
- Status: Accepted
- Date: 2026-04-23
- Owners: OpenClaw maintainers
- Related roadmap items: `R160` with follow-up execution in `R161` and `R162`
## Context
OpenClaw started as a ComfyUI-focused node pack, but the repository now also contains:
- embedded HTTP APIs and operator UI surfaces,
- a standalone Remote Admin Console route,
- connector runtime code for multiple chat platforms,
- split control-plane governance for higher-risk deployments.
That evolution made one question increasingly ambiguous: what is the supported identity of this repo/package today, and which parts are first-class versus optional attached subsystems?
Without a boundary decision, future work such as config decomposition, connector extraction, or packaging hygiene is forced to rely on repo intuition instead of an explicit contract.
## Decision
OpenClaw is defined as a **ComfyUI custom node pack** first, with two explicit first-class identities layered on top of that package:
1. **ComfyUI custom node pack**
- This is the primary distribution artifact and runtime anchor.
- `__init__.py` remains the package entrypoint loaded from `custom_nodes/`.
2. **embedded operator platform**
- In-process OpenClaw APIs, runtime/security governance, sidebar UX, and remote admin surfaces are treated as part of the shipped package, not as separate products.
3. **connector-capable control surface**
- Remote chat control is supported through the in-repo connector sidecar, but the connector remains an **optional attached subsystem**, not the primary package artifact.
## Core vs Attached Subsystems
Core to the package:
- custom node pack entrypoint and exported nodes
- embedded API/runtime governance (`/openclaw/*`, route bootstrap, control-plane policy)
- embedded operator UI surfaces (sidebar plus `/openclaw/admin`)
Optional attached subsystem:
- connector sidecar (`python -m connector`) and platform-specific adapters
This means:
- the connector is supported and intentionally in-repo,
- but the repo is **not** currently defined as a connector-first distribution,
- and the repo is **not** currently defined as a standalone generic backend independent of ComfyUI.
## Supported Topologies
Supported:
1. **embedded local/lan**
- OpenClaw runs inside the ComfyUI process as the primary package artifact.
2. **embedded package with split high-risk control plane**
- The same package stays primary, while higher-risk control surfaces are externalized according to the split-mode contract.
3. **embedded package plus optional connector sidecar**
- The connector runs as a companion process that calls the local OpenClaw APIs.
Unsupported as first-class package identities today:
1. **connector-only distribution**
2. **standalone non-ComfyUI backend package**
Those possibilities are future design questions, not current promises. Connector extraction feasibility remains explicitly deferred to `R162`.
## Consequences
Positive:
- future extraction/pruning decisions now have one explicit contract to evaluate against
- docs and contributor discussions can use the same terms instead of mixing "node pack", "server", and "sidecar" loosely
- `R161` and `R162` can narrow their scope around a known boundary instead of re-litigating product identity
Trade-offs:
- the repo still carries multiple execution surfaces inside one codebase
- connector remains intentionally attached even though it is operationally separable
- some public docs must stay careful not to imply standalone server packaging that does not exist yet
## Rejected Alternatives
1. Treat the connector as an equal primary package artifact today
- Rejected because there is no separate connector package/distribution contract yet.
2. Define OpenClaw as a generic standalone backend package
- Rejected because current runtime ownership still assumes a ComfyUI host process.
3. Keep the boundary implicit and rely on contributor convention
- Rejected because packaging and extraction follow-ups now depend on an explicit contract.
@@ -0,0 +1,89 @@
# ADR-0003: Connector Extraction Feasibility And Split-Package Seams
- Status: Accepted
- Date: 2026-04-24
- Owners: OpenClaw maintainers
- Related roadmap items: `R162` with prior boundary decision in `ADR-0002`
## Context
ADR-0002 established that the connector is an **optional attached subsystem**, not the primary published artifact of this repository. The remaining question is whether that attached subsystem should now be extracted into a separately packaged connector or separate repo.
Current code structure still mixes:
- connector platform adapters and runtime
- shared installation/token lifecycle services
- shared callback signing and replay-protection contracts
- backend delivery/result APIs that the connector calls locally
- tenant/config/auth boundaries that remain owned by the core package
That means extraction is no longer a purely packaging question. It is a shared-contract question.
## Decision
OpenClaw adopts a **no-go-for-split-now** decision for connector extraction.
Current recommendation:
1. Keep the connector **in-repo** as an **optional attached subsystem**.
2. Treat a future **optional extra package** as the only plausible next extraction target.
3. Treat both **sidecar-only distribution** and **separate repo / primary connector package** as **no-go now** options.
## Minimum Stable Seams Required Before Any Split
Any future extraction must first stabilize these seam families:
1. **installation registry and token refs**
- workspace/account binding records
- tenant-scoped token-reference ownership
- installation diagnostics and fail-closed resolution
2. **interactive callback security contract**
- signed callback envelopes
- timestamp / replay / idempotency checks
- action-policy mapping and approval downgrade semantics
3. **delivery and result bridge**
- submission/result polling contract
- callback delivery expectations
- backend result payload compatibility
4. **config/auth and tenant boundary**
- connector runtime config contract
- admin token / auth expectations
- tenant header behavior
- server-side secret/state ownership
## Why Separate Packaging Is A No-Go Now
Current blockers are concrete, not theoretical:
- shared services import connector types and connector adapters import shared services, so extraction would currently create unstable bidirectional package seams
- installation/token/state ownership still lives in shared repo services rather than a versioned connector-boundary package
- connector API/client flows still assume in-repo backend evolution instead of a versioned external backend contract
- `services/sidecar` still imports connector config/client modules directly, so even a packaging-only split would not isolate ownership yet
## Consequences
Positive:
- maintainers now have one explicit go/no-go answer instead of repeatedly re-litigating extraction
- future connector extraction work can target named seam families instead of rediscovering coupling ad hoc
- admin diagnostics can expose the same contract to future packaging or release automation
Trade-offs:
- the repo intentionally keeps connector and core package code together for now
- packaging hygiene remains a future concern rather than a solved distribution problem
- extraction pressure is deferred until shared contracts are versionable on their own
## Rejected Alternatives
1. Extract connector into a separate repo now
- Rejected because current coupling would move instability across package boundaries instead of reducing it.
2. Publish connector as a sidecar-only primary distribution now
- Rejected because current operator workflows still assume the embedded OpenClaw package/runtime remains primary.
3. Leave extraction as an undocumented future possibility
- Rejected because future packaging work needs an explicit seam map and a clear no-go baseline.
@@ -0,0 +1,27 @@
# Service Domain Packages
Bootstrap lifecycle, route registration, and effective security posture have explicit
implementation owners:
- `services/bootstrap/lifecycle.py` owns startup phase, outcome, and optional-warmup state.
- `services/bootstrap/registration.py` owns host route registration and retry coordination.
- `services/posture/effective.py` owns the immutable process security-posture snapshot.
The historical modules remain compatibility aliases:
- `services/startup_lifecycle.py`
- `services/route_bootstrap.py`
- `services/effective_security_posture.py`
Each alias maps its module name to the implementation module object. This preserves one
process singleton and keeps existing imports and patch points compatible. Do not replace
these aliases with copied re-exports: copied module globals can diverge from the state used
by implementation functions. Type-checker-only exports may describe the legacy interface,
but they must stay behind `TYPE_CHECKING` and must not become a second runtime owner.
New implementation code should import the domain-owned modules. Existing consumers may
continue to use the compatibility paths. An implementation module must never import its
compatibility alias; the repository dependency policy enforces that direction.
Package initializers are navigation-only. They must not register routes, resolve posture,
start threads, or re-export mutable process state during import.
+82
View File
@@ -0,0 +1,82 @@
# ComfyUI Asset API Adoption Decision (2026-04-16)
## 2026-07-31 reference anchor update
- Current reference anchor is ComfyUI `9cf91339` (`v0.29.0-12-g9cf91339`, pyproject `0.29.0`).
- SaveImage output sockets, 3D preview refs, typed asset dimensions, grouped asset downloads, and optional `hash` / `asset_hash` aliases do not change the no-go decision.
- ComfyUI asset hashing is host-side opt-in through `--enable-asset-hashing`, so normal filename-backed output refs must not require hash metadata.
- Current host asset metadata may expose `loader_path`; model uploads require `model_type:<folder_name>` tags, and `/features.supports_model_type_tags` advertises that contract. OpenClaw does not upload through or directly consume `/api/assets`, so these facts do not change the no-go decision.
- OpenClaw continues to use `/history` + `/view`; asset-service-only refs stay explicit `asset_api_required` states.
## 2026-06-12 reconfirmation
- Current output parsing is media-aware for ComfyUI result groups `images`, `video`, `audio`, `3d`, and bounded `text`.
- File-like media refs still use `/view` when they provide `filename`, or optional hash-backed preview metadata when the host provides it.
- HDR `.exr` / `.hdr` image refs stay on the `/view` source-preview contract but render as explicit fallback links because OpenClaw does not embed the host HDR viewer.
- Text output previews are bounded and rendered as text, not HTML.
- Asset-service-only identifiers remain explicit fallback states and still do not trigger automatic direct `/api/assets` fetches.
## 2026-05-31 reconfirmation
- Current host reference evidence shows upstream asset responses may expose optional `hash` alongside `asset_hash`.
- OpenClaw accepts `hash` as an alias for hash-backed previews when present, but still resolves those refs through `/view?filename=blake3:...`.
- This does not change the no-go decision for automatic direct `/api/assets` runtime fetches.
## Scope
- Goal: decide whether OpenClaw should adopt upstream `/api/assets` semantics as a normal runtime dependency beyond the bounded `/view` interoperability layer.
## Current baseline
- Current history/output-facing interop already accepts:
- classic ComfyUI output refs (`filename`, `subfolder`, `type`)
- optional asset-hash-backed refs that still resolve through `/view?filename=blake3:...` when host metadata is present
- media-aware output groups (`images`, `video`, `audio`, `3d`, and bounded `text`)
- HDR `.exr` / `.hdr` image refs as explicit `/view` source-preview fallback links, not normal thumbnails
- Current ComfyUI `9cf91339` / `v0.29.0-12-g9cf91339` / pyproject `0.29.0` reference facts:
- `/api/assets*` routes exist, but operational use is feature-gated behind `--enable-assets`
- content hashing is opt-in through `--enable-asset-hashing`, so normal filename-backed refs may omit `asset_hash` / `hash`
- `/features` exposes the `assets` capability flag so hosts can report whether the asset system is enabled
- frontend preview still resolves `blake3:...` asset hashes through `/view`, so hash-backed outputs do not require a direct `/api/assets` fetch
- asset responses may expose optional `hash` alongside `asset_hash`; OpenClaw treats both as hash-backed preview aliases when present
- asset metadata may expose `loader_path`; model uploads require `model_type:<folder_name>` tags, advertised by `/features.supports_model_type_tags`
- Current operator/runtime surfaces in scope:
- sidebar `Jobs`
- callback delivery payloads
- history/result consumption paths derived from `services.comfyui_history`
- Current non-goal:
- no gallery/explorer/runtime flow currently requires direct `/api/assets` fetches to stay functional.
## Decision
- **No-go for first-class `/api/assets` runtime adoption in phase 2.**
- OpenClaw keeps `/history` + `/view` as the supported runtime contract for normal output handling.
- Asset-api-only identifiers are treated as explicit unsupported contracts rather than implicit fetch targets.
## Rationale
1. Current OpenClaw output surfaces still succeed on the existing bounded `/view` contract, including optional asset-hash-backed refs when metadata exists.
2. Adding `/api/assets` as a normal dependency would widen runtime coupling to upstream host behavior without a demonstrated operator need in current features.
3. A silent fallback from `asset id only` to `/api/assets` would weaken boundary clarity and make host drift harder to reason about.
## Approved phase-2 seam
- Preserve current supported refs exactly:
- classic refs -> `/view?filename=...&type=...`
- optional asset-hash-backed refs -> `/view?filename=blake3:...` when metadata exists
- file-like media refs -> `/view` fallback/link surfaces when preview metadata is present
- HDR `.exr` / `.hdr` image refs -> explicit source-preview fallback links
- bounded text refs -> escaped text surfaces, not HTML
- For refs that expose only asset-service identifiers and are not representable through `/view`:
- keep them in normalized output payloads
- mark them as `asset_api_required`
- do not auto-fetch `/api/assets`
- surface a bounded operator-facing message where relevant
## Re-open triggers
Revisit this decision only if one of the following becomes true:
1. A current operator-facing surface cannot complete its supported workflow without direct `/api/assets` semantics.
2. Upstream ComfyUI stops providing `/view`-compatible output metadata for supported runtime flows.
3. OpenClaw intentionally adds a new asset-management feature whose documented contract depends on asset-service metadata beyond hash-backed preview resolution.
+212 -13
View File
@@ -1,23 +1,28 @@
# OpenClaw Connector
The **OpenClaw Connector** (`connector`) is a standalone process that allows you to control your local ComfyUI instance remotely via chat platforms like **Telegram**, **Discord**, **LINE**, **WhatsApp**, **WeChat Official Account**, and **KakaoTalk (Kakao i Open Builder)**.
The **OpenClaw Connector** (`connector`) is a standalone process that allows you to control your local ComfyUI instance remotely via chat platforms like **Telegram**, **Discord**, **LINE**, **WhatsApp**, **WeChat Official Account**, **KakaoTalk (Kakao i Open Builder)**, **Slack**, and **Feishu/Lark**.
Per the product boundary contract, the connector is an **optional attached subsystem**. The primary published artifact of this repo remains the **ComfyUI custom node pack**, and the connector augments that package rather than replacing it.
Current extraction decision: keep the connector **in-repo** as an optional attached subsystem for now. OpenClaw does **not** currently treat a standalone connector package/repo as a supported distribution; see [ADR-0003](adr/ADR-0003-connector-extraction-feasibility-and-seams.md).
## How It Works
The connector runs alongside ComfyUI on your machine.
1. It connects outbound to Telegram/Discord (polling/gateway).
2. LINE/WhatsApp/WeChat/KakaoTalk use inbound webhooks (HTTPS required).
2. LINE/WhatsApp/WeChat/KakaoTalk/Slack use inbound webhooks (HTTPS required), while Feishu/Lark can run in webhook mode or long-connection mode with a separate callback ingress path for interactive actions.
3. It talks to ComfyUI via `localhost`.
4. It relays commands and status updates securely.
**Security**:
- **Transport Model**: Telegram/Discord are outbound. LINE/WhatsApp/WeChat/KakaoTalk/Slack require inbound HTTPS webhook endpoints.
- **Transport Model**: Telegram/Discord are outbound. LINE/WhatsApp/WeChat/KakaoTalk/Slack require inbound HTTPS webhook endpoints. Feishu/Lark supports webhook ingress or long-connection transport, but interactive callbacks still use a bounded local HTTPS callback path.
- **Allowlist/Trust Model**: Allowlists define trusted senders/channels. Non-allowlisted senders are treated as untrusted (for example, `/run` is approval-routed instead of auto-executed).
- **Strict Profile Gate**: In `public` deployment or `hardened` runtime posture, enabling connector ingress without platform allowlist coverage is fail-closed at startup/deployment checks.
- **Local Secrets**: Bot tokens are stored in your local environment, never sent to ComfyUI.
- **Admin Boundary**: Control-plane actions call admin endpoints on the local OpenClaw server and require connector-side admin token configuration for admin command paths.
- **Reply Visibility**: Shared visibility policy can suppress text-only silent/internal/tool-only/no-mention replies without suppressing approval cards, action buttons, or the underlying trust checks.
### Installation and callback contract baseline
@@ -29,6 +34,8 @@ OpenClaw now includes a platform-agnostic baseline for multi-workspace connector
- workspace resolution is fail-closed on missing/ambiguous/inactive/stale bindings
- installation diagnostics can also surface stable health states such as `ok`, `invalid_token`, `revoked`, `workspace_unbound`, and `degraded`
- interactive callback contract enforces signed envelope checks, timestamp window, payload-hash validation, replay/idempotency guardrails, and command-policy mapping (`public`/`run`/`admin`) with explicit force-approval outcomes for untrusted `run` callbacks
- connector replay handling acknowledges duplicate committed events as no-ops while allowing retryable failures before delivery commit to be retried
- text reply visibility is resolved through one connector policy for direct-message, shared-chat, thread, internal-delivery, and tool-only contexts; suppressed text is logged/diagnostic and treated as successful no-op delivery
Admin diagnostics APIs:
@@ -36,6 +43,12 @@ Admin diagnostics APIs:
- `GET /openclaw/connector/installations/{installation_id}`
- `GET /openclaw/connector/installations/resolve?platform=<platform>&workspace_id=<workspace_id>`
- `GET /openclaw/connector/installations/audit`
- `GET /openclaw/connector/extraction-contract`
Extraction diagnostics note:
- `/openclaw/connector/extraction-contract` is an admin-only structural metadata route for maintainers and operators. It returns the current packaging recommendation, candidate extraction options, seam families, and blockers, but it does **not** expose live token or installation-state details beyond the existing diagnostics routes above.
- The extraction contract also includes the static service-env SecretRef propagation policy. It is not a live environment dump and does not expose token values.
Slack multi-workspace notes:
@@ -45,6 +58,16 @@ Slack multi-workspace notes:
- `GET /openclaw/connector/installations` diagnostics may include per-install health metadata plus aggregate `health_counts`.
- Slack lifecycle events such as `tokens_revoked`, `app_uninstalled`, and rate-limit degradation update installation health so outbound replies fail closed or degrade predictably for the affected workspace.
- In multi-workspace mode, outbound replies and delayed result deliveries resolve the bot token by workspace binding and keep Slack thread context when replying back to the originating conversation.
- Slack interactive callbacks use the configured interactions path, signature verification, replay/idempotency checks, and connector policy mapping before accepting action payloads.
- Slack text replies honor the shared reply-visibility policy when context metadata is available; channel no-mention or tool-only text can be suppressed while Block Kit/action responses remain deliverable.
Feishu / Lark notes:
- Feishu bindings can be declared with a single app pair or a multi-account `OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON` manifest; each binding resolves to one normalized installation record with account/workspace identity.
- The connector supports both `feishu` and `lark` API domains through one shared binding contract, so region-specific app hosts do not require a different adapter.
- Websocket-mode Feishu deployments still host a callback route so interactive approval cards and command buttons remain available when message ingress itself is long-connection based.
- Feishu callback actions are signed, replay-guarded, tenant-aware, and deduplicated. Untrusted actors pressing run-affecting buttons are downgraded to approval flow instead of executing directly.
- Feishu text replies honor the shared reply-visibility policy when context metadata is available; group no-mention or tool-only text can be suppressed while interactive cards remain deliverable.
### Multi-tenant boundary behavior
@@ -57,13 +80,14 @@ When backend multi-tenant mode is enabled (`OPENCLAW_MULTI_TENANT_ENABLED=1`):
## Supported Platforms
- **Telegram**: Long-polling (instant response).
- **Telegram**: Long-polling (instant response), including forum topic reply context for immediate replies and delayed result delivery.
- **Discord**: Gateway WebSocket (instant response).
- **LINE**: Webhook (requires inbound HTTPS).
- **WhatsApp**: Webhook (requires inbound HTTPS).
- **WeChat Official Account**: Webhook (requires inbound HTTPS).
- **KakaoTalk (Kakao i Open Builder)**: Webhook (requires inbound HTTPS).
- **Slack (Events API)**: Webhook (requires inbound HTTPS).
- **Feishu / Lark**: Webhook or long-connection transport; interactive callbacks require inbound HTTPS for the callback route.
## Setup
@@ -80,7 +104,7 @@ Set the following environment variables (or put them in a `.env` file if you use
- `OPENCLAW_CONNECTOR_URL`: URL of your ComfyUI (default: `http://127.0.0.1:8188`)
- `OPENCLAW_CONNECTOR_DEBUG`: Set to `1` for verbose logs.
- `OPENCLAW_CONNECTOR_ADMIN_USERS`: Comma-separated list of user IDs allowed to run admin commands (for example `/stop`, approvals, schedules). Admin users are also treated as trusted senders for `/run`.
- `OPENCLAW_CONNECTOR_ADMIN_USERS`: Comma-separated list of user IDs allowed to run admin commands (for example `/stop`, `/cancel`, approvals, schedules). Admin users are also treated as trusted senders for `/run`.
- `OPENCLAW_CONNECTOR_ADMIN_TOKEN`: Admin token sent to OpenClaw (`X-OpenClaw-Admin-Token`).
- `OPENCLAW_LOG_TRUNCATE_ON_START`: Optional backend runtime flag. Set `1` to clear `openclaw.log` once at backend startup to avoid stale-history noise in UI log panels.
- `OPENCLAW_MULTI_TENANT_ENABLED`: Optional backend mode toggle. If `1`, connector diagnostics and installation resolution become tenant-scoped.
@@ -92,11 +116,19 @@ Set the following environment variables (or put them in a `.env` file if you use
- If the OpenClaw server has `OPENCLAW_ADMIN_TOKEN` configured, `OPENCLAW_CONNECTOR_ADMIN_TOKEN` must match it or admin calls return HTTP 403.
- Without `OPENCLAW_CONNECTOR_ADMIN_TOKEN`, admin command flows (`/approve`, `/reject`, `/trace`, schedules) are blocked by connector policy before upstream calls.
**SecretRef service environment behavior:**
- Service/sidecar launch helpers may preserve structured env-backed SecretRef metadata for connector credential variables such as platform bot tokens and signing secrets.
- Diagnostics show only the config path, env var name, source, status, and reason. They do not show raw token values from the installing shell.
- Raw secret strings, legacy `secretref-env:<NAME>` markers, unsupported env names, and gateway/admin auth env vars are rejected instead of being written into service metadata.
- Runtime-only auth secrets such as `OPENCLAW_CONNECTOR_ADMIN_TOKEN`, `OPENCLAW_WORKER_TOKEN`, and bridge device tokens must be provided by the runtime environment or a local secret manager rather than persisted through the connector service-env SecretRef boundary.
**Telegram:**
- `OPENCLAW_CONNECTOR_TELEGRAM_TOKEN`: Your Bot Token (from @BotFather).
- `OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_USERS`: Comma-separated list of User IDs (e.g. `123456, 789012`).
- `OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_CHATS`: Comma-separated list of Chat/Group IDs.
- Telegram forum topics are preserved when Telegram provides `message_thread_id`; command replies and delayed result delivery are sent back to the same topic. Manually configured delivery contexts must use numeric topic/thread IDs.
**Discord:**
@@ -163,7 +195,7 @@ Set the following environment variables (or put them in a `.env` file if you use
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_INSTALL_PATH`: Local install route (default `/slack/install`).
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH`: Local callback route (default `/slack/oauth/callback`).
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_SCOPES`: Comma-separated bot scopes used for install URL generation.
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC`: TTL in seconds for single-use OAuth state tokens (default `600`).
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC`: TTL in seconds for single-use OAuth state tokens (default `600`, clamped to `60..3600`).
- `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS`: Comma-separated user IDs (e.g. `U12345, U67890`).
- `OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS`: Comma-separated channel IDs (e.g. `C12345`).
- `OPENCLAW_CONNECTOR_SLACK_BIND`: Host to bind (default `127.0.0.1`).
@@ -172,12 +204,46 @@ Set the following environment variables (or put them in a `.env` file if you use
- `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION`: `true` (default) to require `@Bot` mention in public channels.
- `OPENCLAW_CONNECTOR_SLACK_REPLY_IN_THREAD`: `true` (default) to reply in threads.
**Feishu / Lark:**
*(Long connection or webhook; callback ingress still requires inbound HTTPS if interactive cards are enabled)*
- `OPENCLAW_CONNECTOR_FEISHU_APP_ID`: App ID for the default Feishu/Lark binding.
- `OPENCLAW_CONNECTOR_FEISHU_APP_SECRET`: App secret for the default binding.
- `OPENCLAW_CONNECTOR_FEISHU_VERIFICATION_TOKEN`: Verification token for webhook event ingress.
- `OPENCLAW_CONNECTOR_FEISHU_ENCRYPT_KEY`: Optional encrypt key for encrypted webhook payloads.
- `OPENCLAW_CONNECTOR_FEISHU_ACCOUNT_ID`: Explicit account ID for the default binding.
- `OPENCLAW_CONNECTOR_FEISHU_DEFAULT_ACCOUNT_ID`: Fallback account ID when binding manifest entries omit one.
- `OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_ID`: Workspace or tenant identifier associated with the default binding.
- `OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_NAME`: Human-readable workspace name used in diagnostics.
- `OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON`: JSON list of account bindings for multi-account / multi-workspace setups.
- `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS`: Comma-separated trusted user IDs.
- `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_CHATS`: Comma-separated trusted chat IDs.
- `OPENCLAW_CONNECTOR_FEISHU_BIND`: Host to bind (default `127.0.0.1`).
- `OPENCLAW_CONNECTOR_FEISHU_PORT`: Port (default `8094`).
- `OPENCLAW_CONNECTOR_FEISHU_PATH`: Event ingress route (default `/feishu/events`).
- `OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH`: Interactive callback route (default `/feishu/callback`).
- `OPENCLAW_CONNECTOR_FEISHU_DOMAIN`: API domain selector (`feishu` or `lark`).
- `OPENCLAW_CONNECTOR_FEISHU_MODE`: Transport mode (`websocket` default, or `webhook`).
- `OPENCLAW_CONNECTOR_FEISHU_REQUIRE_MENTION`: Set `false` to allow commands without explicit mention in shared chats.
- `OPENCLAW_CONNECTOR_FEISHU_REPLY_IN_THREAD`: Set `false` to disable reply threading when the source chat supports it.
**Image Delivery:**
- `OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES`: Max completed images delivered per job (default `4`, clamped to `1..16`).
- `OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES`: Per-image delivery cap in bytes (default `10485760`, clamped to `65536..52428800`).
- `OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC`: Result delivery timeout in seconds (default `600`, clamped to `30..3600`).
- `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL`: Public HTTPS URL of your connector (e.g. `https://your-tunnel.example.com`). Required for sending images.
- `OPENCLAW_CONNECTOR_MEDIA_PATH`: URL path for serving temporary media (default `/media`).
- `OPENCLAW_CONNECTOR_MEDIA_TTL_SEC`: Image expiry in seconds (default `300`).
- `OPENCLAW_CONNECTOR_MEDIA_MAX_MB`: Max image size in MB (default `8`).
- `OPENCLAW_CONNECTOR_MEDIA_TTL_SEC`: Image expiry in seconds (default `300`, clamped to `60..86400`).
- `OPENCLAW_CONNECTOR_MEDIA_MAX_MB`: Max image size in MB (default `8`, clamped to `1..64`).
**Connector numeric guardrails:**
- Platform bind ports (`OPENCLAW_CONNECTOR_LINE_PORT`, `...WHATSAPP_PORT`, `...WECHAT_PORT`, `...KAKAO_PORT`, `...SLACK_PORT`, `...FEISHU_PORT`) must stay within `1..65535`; invalid or out-of-range values fall back to the platform default port.
- `OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM`: Per-user connector rate limit (default `10`, clamped to `1..600`).
- `OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM`: Per-channel connector rate limit (default `30`, clamped to `1..600`).
- `OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH`: Max accepted command text length (default `4096`, clamped to `128..32768`).
> **Note:** Media URLs are signed with a secret derived from `OPENCLAW_CONNECTOR_ADMIN_TOKEN` or a random key.
> To ensure URLs remain valid after connector restarts, **you must set `OPENCLAW_CONNECTOR_ADMIN_TOKEN`**.
@@ -185,6 +251,7 @@ Set the following environment variables (or put them in a `.env` file if you use
> WeChat currently supports text-first control. Image/media upload delivery is not implemented in phase 1.
> Kakao currently supports text-first control and quick replies. Rich media delivery is not enabled in the default Kakao webhook flow.
> Slack supports text responses and image uploads (via `files.upload` API).
> Feishu currently supports text replies plus interactive approval/command cards; richer card templates can be added on top of the same signed callback contract.
### Command authorization policy
@@ -222,6 +289,28 @@ OPENCLAW_COMMAND_ALLOW_FROM_RUN=alice_id,ops_bot_id
If a class-level `OPENCLAW_COMMAND_ALLOW_FROM_*` list is set and non-empty, only listed IDs can run that class.
### Authoritative jobs summary
`/jobs` and its `jobs` / `queue` aliases are Admin-class commands. They require both an
authorized connector admin user and a configured `OPENCLAW_CONNECTOR_ADMIN_TOKEN` before
the connector calls `GET /openclaw/jobs`.
The connector validates jobs contract version 1 before rendering any reply:
- output contains aggregate snapshot/page counts plus at most five job IDs and statuses;
- displayed job IDs are capped at 24 characters and the complete reply is capped at 1,000
characters;
- raw job records, prompts, workflows, outputs, errors, tracebacks, tenant identifiers,
and the upstream payload are never sent to the chat LLM or copied into error messages;
- HTTP 401/403 returns a fixed authorization message without fallback;
- only explicit HTTP 501 `jobs_host_contract_unsupported` or HTTP 503
`jobs_backend_unavailable` responses may fall back to a bounded coarse queue count;
- malformed, unknown-version, oversized, or inconsistent success payloads fail to a fixed
content-free message.
Public `/status` remains separate: it can summarize health and the coarse ComfyUI queue,
but it does not fetch or forward the Admin-only jobs snapshot.
### 3. Usage
#### Running the Connector
@@ -269,7 +358,7 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
```bash
OPENCLAW_CONNECTOR_WECHAT_TOKEN=replace-with-your-wechat-token
OPENCLAW_CONNECTOR_WECHAT_APP_ID=wx1234567890abcdef
OPENCLAW_CONNECTOR_WECHAT_APP_ID=replace-with-your-wechat-app-id
OPENCLAW_CONNECTOR_WECHAT_APP_SECRET=replace-with-app-secret
OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS=openid_1,openid_2
OPENCLAW_CONNECTOR_WECHAT_BIND=127.0.0.1
@@ -277,6 +366,9 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
OPENCLAW_CONNECTOR_WECHAT_PATH=/wechat/webhook
```
Use descriptive placeholders for App IDs in docs and examples. Secret scanners can
treat AppID-shaped samples as real credentials even when they are only documentation.
2. Start the connector:
```bash
@@ -312,6 +404,7 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
- The adapter validates WeChat signature on every request and applies replay/timestamp checks.
- Timestamp skew outside policy window is rejected (`403 Stale Request`).
- XML payload parsing is bounded (size/depth/field caps) and fails closed on parser budget violations.
- DTD / ENTITY declarations are rejected before parser entry; the adapter does not attempt to recover from unsafe XML payloads.
- Runtime XML security gate is fail-closed: unsafe/missing parser baseline blocks ingress startup.
- Current command surface is text-first. Unsupported message/event types are ignored with success response.
- Proactive outbound API messaging requires both `OPENCLAW_CONNECTOR_WECHAT_APP_ID` and `OPENCLAW_CONNECTOR_WECHAT_APP_SECRET`.
@@ -391,7 +484,7 @@ Slack uses the Events API webhook mode in OpenClaw. You must expose the endpoint
- `channels:history` (public channel messages)
- `groups:history` (private channel messages)
- For legacy single-workspace mode, click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`).
- For F58 multi-workspace mode, configure a redirect URL and let OpenClaw handle installs through its OAuth routes.
- For multi-workspace mode, configure a redirect URL and let OpenClaw handle installs through its OAuth routes.
3. **Configure connector environment variables**
@@ -402,6 +495,7 @@ Slack uses the Events API webhook mode in OpenClaw. You must expose the endpoint
OPENCLAW_CONNECTOR_PUBLIC_BASE_URL=https://your-public-host
OPENCLAW_CONNECTOR_SLACK_OAUTH_INSTALL_PATH=/slack/install
OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH=/slack/oauth/callback
OPENCLAW_CONNECTOR_SLACK_INTERACTIONS_PATH=/slack/interactions
OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS=U12345,U67890
OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS=C12345
OPENCLAW_CONNECTOR_SLACK_BIND=127.0.0.1
@@ -413,20 +507,23 @@ Slack uses the Events API webhook mode in OpenClaw. You must expose the endpoint
```
Notes:
- Legacy single-workspace fallback can still set `OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN=xoxb-...`; F58 multi-workspace mode no longer requires that token at startup if OAuth install flow is configured.
- Legacy single-workspace fallback can still set `OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN=xoxb-...`; multi-workspace mode no longer requires that token at startup if OAuth install flow is configured.
- `OPENCLAW_CONNECTOR_ADMIN_TOKEN` must match server `OPENCLAW_ADMIN_TOKEN` if server-side admin token is enabled.
- Slack ingress is fail-closed: invalid/missing signature, stale timestamp, and replayed events are rejected.
- Slack interactive callbacks use the same signing-secret verification and route actions through the connector policy layer before executing run-affecting behavior.
- OAuth callbacks also fail closed on invalid or replayed `state` values.
- External OAuth/install failures intentionally use bounded generic text; inspect connector logs and installation diagnostics for redacted detail instead of expecting raw exception text in the callback response.
4. **Start connector and expose webhook endpoint**
- Start connector: `python -m connector`
- Expose local endpoint to public HTTPS (Cloudflare Tunnel/ngrok/reverse proxy):
- local upstream: `http://127.0.0.1:8095`
- public URL: `https://<public-host>/slack/events`
- interactions URL: `https://<public-host>/slack/interactions`
- install URL: `https://<public-host>/slack/install`
- callback URL: `https://<public-host>/slack/oauth/callback`
5. **Enable Event Subscriptions**
5. **Enable Event Subscriptions and Interactivity**
- Go to **Event Subscriptions** and enable events.
- Set **Request URL** to `https://<public-host>/slack/events`.
- Slack sends `url_verification`; connector responds automatically.
@@ -435,12 +532,15 @@ Slack uses the Events API webhook mode in OpenClaw. You must expose the endpoint
- `message.channels`
- `message.groups`
- `message.im`
- Go to **Interactivity & Shortcuts** and enable interactivity.
- Set **Request URL** to `https://<public-host>/slack/interactions`.
6. **Invite and validate**
- Open `https://<public-host>/slack/install` and complete the workspace install.
- Invite the app to target channels: `/invite @YourBot`.
- In channel: `@YourBot /status` (when `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true`).
- In DM: `/help`.
- For approval or action-capable replies, press a rendered Slack button and confirm the connector logs show a signed interaction accepted or a bounded policy rejection.
- Verify connector logs show signed ingress accepted and replies delivered.
- Verify `GET /openclaw/connector/installations` shows the Slack workspace binding and health state `ok`.
- If you test uninstall/token-revoke scenarios, verify the installation health flips to `revoked` or `invalid_token` and that subsequent replies for that workspace fail closed until reinstalled.
@@ -483,6 +583,72 @@ Notes:
- Startup is fail-closed if `OPENCLAW_CONNECTOR_SLACK_APP_TOKEN` is missing or does not start with `xapp-`.
- In multi-workspace mode, outbound replies still resolve the workspace-specific bot token from the installation registry even though the WebSocket connection itself uses the app-level token.
#### Feishu / Lark Setup (Detailed)
Feishu support can run in either long-connection (`websocket`) mode or webhook mode. Long-connection is usually the simpler default for message ingress, but interactive cards still need a reachable callback route if you want approval buttons and other signed actions.
1. **Create the Feishu or Lark app**
- Create a bot app in the Feishu or Lark developer console.
- Record the `App ID` and `App Secret`.
- If you want webhook ingress, also configure the event subscription verification token.
- If encrypted event delivery is enabled, record the encrypt key as well.
2. **Choose transport mode**
- `OPENCLAW_CONNECTOR_FEISHU_MODE=websocket`
- Uses long connection for message ingress.
- Recommended when you do not want to expose the event route publicly.
- `OPENCLAW_CONNECTOR_FEISHU_MODE=webhook`
- Uses HTTPS webhook delivery for messages.
- Requires a public HTTPS route for `OPENCLAW_CONNECTOR_FEISHU_PATH`.
3. **Configure the default binding**
```bash
OPENCLAW_CONNECTOR_FEISHU_APP_ID=cli_xxx
OPENCLAW_CONNECTOR_FEISHU_APP_SECRET=sec_xxx
OPENCLAW_CONNECTOR_FEISHU_ACCOUNT_ID=acct-default
OPENCLAW_CONNECTOR_FEISHU_DEFAULT_ACCOUNT_ID=acct-default
OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_ID=tenant-alpha
OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_NAME="Alpha Workspace"
OPENCLAW_CONNECTOR_FEISHU_DOMAIN=feishu
OPENCLAW_CONNECTOR_FEISHU_MODE=websocket
OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS=ou_xxx,ou_yyy
OPENCLAW_CONNECTOR_FEISHU_ALLOWED_CHATS=oc_xxx,oc_yyy
```
4. **Optional: multi-account binding manifest**
- Use `OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON` when one connector runtime should host more than one Feishu/Lark app or workspace binding.
- Each entry may include:
- `account_id`
- `app_id`
- `app_secret`
- `workspace_id`
- `workspace_name`
- `verification_token`
- `encrypt_key`
- `domain`
- `mode`
5. **Configure interactive callback ingress**
- Set `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL` to your public HTTPS origin.
- Expose `OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH` (default `/feishu/callback`) through your reverse proxy or tunnel.
- In long-connection mode this callback route is still required for interactive approval cards; message ingress transport does not remove callback security requirements.
6. **Start connector**
- `python -m connector`
- Expect logs showing the chosen Feishu mode and callback/event route bindings.
7. **Verify runtime behavior**
- Run `/status` from an allowlisted Feishu/Lark user.
- Run `/approvals` and confirm the reply renders approval buttons as an interactive card.
- Click `Approve` or `Reject` on a test approval and verify the callback succeeds once, then duplicate clicks are deduped.
Notes:
- `OPENCLAW_CONNECTOR_FEISHU_DOMAIN=lark` switches outbound API host behavior without changing the rest of the connector contract.
- Untrusted users can still see bounded command responses, but run-affecting interactive actions are downgraded to approval flow instead of auto-executing.
- Callback signing secrets are resolved from the bound Feishu installation record; diagnostics expose binding state, not raw secret material.
- Callback/event wrapper failures intentionally return bounded external error codes; inspect logs and installation diagnostics for redacted detail instead of expecting stack traces in callback responses.
## Commands
**General:**
@@ -494,7 +660,8 @@ Notes:
| `/history <id>` | View details of a finished job. |
| `/help` | Show available commands. |
| `/run <template> [k=v] [--approval]` | Submit a job. Use `--approval` to request approval gate instead of creating job immediately. |
| `/stop` | **Global Interrupt**: Stop all running generations. |
| `/stop [job_id ...]` | Stop jobs. With no job IDs, sends an explicit global interrupt. With one or more IDs, requests targeted job cancellation through ComfyUI's jobs API; older single-job hosts may fall back to targeted interrupt. |
| `/cancel [job_id ...]`, `/interrupt [job_id ...]` | Aliases for `/stop` with the same targeted or global behavior. |
**Admin Only:**
*(Requires User ID in `OPENCLAW_CONNECTOR_ADMIN_USERS`)*
@@ -541,6 +708,14 @@ Notes:
- Sender is not in `OPENCLAW_CONNECTOR_ADMIN_USERS`.
- Fix: Add ID to `.env` and restart connector.
- **No visible chat reply after a command**:
- The command may have completed in a context where text-only replies are intentionally suppressed, such as internal delivery, tool-only handling, or a shared chat/channel without an active mention.
- Fix: check connector logs and job/approval state. Approval cards and action buttons should still be delivered when the action requires visible operator input.
- **Duplicate platform event is acknowledged but not executed again**:
- The connector has already committed the action and treats the retry/replay as a successful no-op.
- Fix: check the original event, job, or approval record instead of resending the same action payload. Retry only failures that happened before delivery/action commit.
- **HTTP 403 (Admin Token)**:
- Connector has the right user allowlist, but the upstream OpenClaw server rejected the Admin Token.
- Fix: Ensure `OPENCLAW_CONNECTOR_ADMIN_TOKEN` matches the server's `OPENCLAW_ADMIN_TOKEN`.
@@ -564,3 +739,27 @@ Notes:
- **Slack commands ignored in channels**:
- `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true` and message does not mention the bot.
- Fix: mention bot explicitly (`@Bot /status`) or set `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=false` if policy allows.
- **Slack OAuth callback shows only a generic install failure**:
- This is expected on current builds; callback responses intentionally suppress raw exception text.
- Fix: inspect connector logs plus `GET /openclaw/connector/installations` / `/resolve` diagnostics to find the redacted root cause.
- **I need to know whether the connector is supported as a separate package/repo yet**:
- Current builds intentionally keep the connector in-repo as an optional attached subsystem.
- Fix: review `GET /openclaw/connector/extraction-contract` or [ADR-0003](adr/ADR-0003-connector-extraction-feasibility-and-seams.md) for the current no-split recommendation and the seam blockers that still need to be versioned first.
- **Feishu callback buttons fail with signature or stale-action errors**:
- Callback route is not using the same bound app secret, request arrived too late, or the button payload was replayed.
- Fix: verify binding diagnostics, public callback route, connector clock, and that the same action is not being resent by proxy/retry middleware.
- **Feishu long-connection messages work but card actions do nothing**:
- `OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH` is not exposed publicly, or the callback URL is not routed to the connector bind host/port.
- Fix: expose the callback route over HTTPS even when `OPENCLAW_CONNECTOR_FEISHU_MODE=websocket`.
- **Feishu `/run` action becomes approval instead of executing immediately**:
- Callback actor is untrusted under current allowlist/policy mapping.
- Fix: add the user/chat to `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS` or `_ALLOWED_CHATS`, or keep the approval downgrade as the intended posture.
- **Feishu callback returns a bounded code such as `callback_rejected` or `event_rejected`**:
- This is expected on current builds; callback wrappers intentionally avoid echoing raw exception detail to the caller.
- Fix: inspect connector logs and installation/binding diagnostics for the redacted failure context.
+1 -1
View File
@@ -71,7 +71,7 @@ sudo ufw allow from 192.168.1.0/24 to any port 8188
- ❌ Do not forward port 8188 on your router.
- ❌ Do not use `--listen 0.0.0.0` on a laptop connected to public WiFi.
- ❌ Do not set `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=true` on LAN/shared deployments.
- ❌ Do not assume LAN Remote Admin access also permits LAN-hosted custom LLM targets; `OPENCLAW_LLM_ALLOWED_HOSTS` alone does not allow private/reserved IP `base_url` values.
- ❌ Do not assume LAN Remote Admin access also permits LAN-hosted custom LLM targets; `OPENCLAW_LLM_ALLOWED_HOSTS` alone does not allow private/reserved IP `base_url` values. Use the scoped LLM private-network setting only for reviewed targets.
## Testing
+3 -3
View File
@@ -35,7 +35,7 @@ No special configuration is required.
- **Admin Token**: Not required for loopback-only operations (unless `OPENCLAW_ADMIN_TOKEN` is explicitly set).
- **Webhooks**: Disabled by default.
- **Local LLM (optional)**:
- Ollama: `http://127.0.0.1:11434`
- Ollama: `http://127.0.0.1:11434/v1`
- LM Studio: `http://localhost:1234/v1`
- Keep SSRF relax flags disabled:
- `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=0`
@@ -58,6 +58,6 @@ No special configuration is required.
1. Open `http://127.0.0.1:8188` in your browser.
2. Open the OpenClaw tab in the sidebar.
3. Go to **Settings** -> **Health**.
4. If using Ollama, verify `http://127.0.0.1:11434/api/tags` responds.
5. In **Settings -> LLM**, set provider to `Ollama (Local)` and click **Load Models**.
4. If using Ollama, verify the daemon is reachable first via the native Ollama health/list surface `http://127.0.0.1:11434/api/tags`.
5. In **Settings -> LLM**, set provider to `Ollama (Local)`, leave **Base URL** empty to use the built-in `http://127.0.0.1:11434/v1` default (or set that exact loopback URL explicitly), and click **Load Models**.
6. All checks should be green.
-20
View File
@@ -1,20 +0,0 @@
# /etc/default/openclaw.env
# Secure environment configuration for OpenClaw
# Admin Token (Required for remote ops)
OPENCLAW_ADMIN_TOKEN=change-me-to-a-strong-secret
# Observability Token (Required for remote logs)
# (Legacy: MOLTBOT_OBSERVABILITY_TOKEN)
OPENCLAW_OBSERVABILITY_TOKEN=change-me-too
# Bridge (Default: 0/Disabled)
OPENCLAW_BRIDGE_ENABLED=0
# OPENCLAW_BRIDGE_DEVICE_TOKEN=
# Optional startup log hygiene (truncate openclaw.log once per process start)
# OPENCLAW_LOG_TRUNCATE_ON_START=1
# Network
# Bind to localhost by default
COMFYUI_LISTEN=127.0.0.1
+19
View File
@@ -0,0 +1,19 @@
# Copy this public template to /etc/default/openclaw.env before starting the service.
# Replace every placeholder locally; never commit the deployed environment file.
# Admin Token (required for remote operations)
OPENCLAW_ADMIN_TOKEN=replace-with-a-strong-secret
# Observability Token (required for remote logs)
# Legacy name: MOLTBOT_OBSERVABILITY_TOKEN
OPENCLAW_OBSERVABILITY_TOKEN=replace-with-an-observability-secret
# Bridge (default: disabled)
OPENCLAW_BRIDGE_ENABLED=0
# OPENCLAW_BRIDGE_DEVICE_TOKEN=
# Optional startup log hygiene (truncate openclaw.log once per process start)
# OPENCLAW_LOG_TRUNCATE_ON_START=1
# Bind to localhost by default
COMFYUI_LISTEN=127.0.0.1
+3 -3
View File
@@ -65,9 +65,9 @@ If Remote Admin is running on this Windows host, but your custom/OpenAI-compatib
- `OPENCLAW_LLM_ALLOWED_HOSTS` only allows additional exact public hosts.
- `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1` still applies only to public hosts.
- Private/reserved LAN IPs still require `OPENCLAW_ALLOW_INSECURE_BASE_URL=1`.
- Private/reserved LAN IPs still require the scoped `allow_private_network` LLM setting for that configured target, or `OPENCLAW_ALLOW_INSECURE_BASE_URL=1`.
- `OPENCLAW_LLM_ALLOWED_HOSTS=*` is not supported.
- On current builds, that same override is honored by both Remote Admin validation and `/openclaw/llm/models` refresh requests after a full restart.
- On current builds, that same scoped/private or insecure decision is honored by both Remote Admin validation and `/openclaw/llm/models` refresh requests after a full restart.
Recommended verification in the same embedded runtime:
@@ -75,7 +75,7 @@ Recommended verification in the same embedded runtime:
.\python_embeded\python.exe -c "import os; print(repr(os.environ.get('OPENCLAW_LLM_ALLOWED_HOSTS')))"
```
If you intentionally accept the risk and enable `OPENCLAW_ALLOW_INSECURE_BASE_URL=1`, restart ComfyUI fully after changing the env vars.
If you intentionally accept the broader risk and enable `OPENCLAW_ALLOW_INSECURE_BASE_URL=1`, restart ComfyUI fully after changing the env vars.
## Service Mode (NSSM)
+38
View File
@@ -0,0 +1,38 @@
# Frontend Tab Wiring
OpenClaw's frontend uses modular vanilla ES modules loaded by the ComfyUI extension host. Keep tab work inside this architecture unless a future migration decision explicitly changes the runtime model.
## Tab Registration
- Register the OpenClaw host sidebar entry through `registerOpenClawSidebar(app, tabDefinition)` from `web/openclaw_sidebar_registration.js`; it prefers ComfyUI's current sidebar store API and falls back to the deprecated frontend facade for older host bundles.
- Register tabs through `tabManager.registerTab({ id, title, icon, render, dispose? })`.
- Keep `id` stable; it is used for pane ids and active-tab storage.
- Treat `render(pane)` as the only place that mutates a tab pane.
- Return a promise from `render` only when the tab genuinely performs async work; async failures are routed through the tab error boundary.
- Use optional `dispose(pane)` for pending-render cleanup. Returning `true` requests a fresh render
when the user revisits the tab; completed panes should remain reusable.
## DOM Helpers
- Prefer shared helpers from `web/openclaw_utils.js` for new shell/tab wiring:
- `createDomElement(...)` for text-safe element construction.
- `appendChildren(...)` for optional child nodes.
- `queryRequired(...)` when a selector is mandatory for the tab to function.
- Use `textContent` semantics for user-visible text. Do not add raw HTML helper paths for convenience.
- Keep legacy class aliasing centralized through existing normalization and alias helpers.
- Keep Settings-specific status, LLM, secrets, logs, DOM, and lifecycle behavior in the focused
`web/tabs/settings_tab_*.js` owners rather than rebuilding a monolithic renderer.
## API Contracts
- Use `OpenClawAPI.fetch(...)` normalized results instead of direct `fetch` from tabs.
- Check `result.ok` before reading `result.data`.
- Preserve admin-token handling inside `OpenClawAPI` and shared session helpers.
- Add endpoint methods to the matching config, generation, resource, model, or event owner module;
preserve `web/openclaw_api.js` as the transport/session facade and keep one shared singleton.
## Verification
- Add Vitest coverage for new shared helpers or tab wiring behavior.
- Use Playwright harness specs for user-visible tab behavior such as active panes, rendered content, and action outcomes.
- For async tabs, cover switching/disposal and prove stale completion cannot mutate the new pane.
+48 -13
View File
@@ -4,27 +4,46 @@ This document summarizes the current OpenClaw sidebar UI structure and how to ve
## UI Structure
- Entry: `web/openclaw.js` registers the extension and sidebar tab.
- Entry: `web/openclaw.js` registers the extension; host sidebar registration is routed through `web/openclaw_sidebar_registration.js` so current ComfyUI sidebar-store hosts and older frontend facade hosts share one compatibility path.
- Shell: `web/openclaw_ui.js` now acts as the composition root for the sidebar shell and public singleton exports.
- Actions: `web/openclaw_actions.js` owns submit/cancel/retry wiring and guarded action routing for the shell.
- Queue monitor: `web/openclaw_queue_monitor.js` owns queue polling lifecycle and transient banner/status updates used by the shell.
- Event/task polling: admin-console and model/task views consume deterministic delta metadata (`effective_since_seq`, `next_since_seq`, reset/truncation hints) instead of assuming every refresh is a full snapshot.
- Notification center: `web/openclaw_notification_center.js` owns persistent in-app notification storage, dedupe, acknowledge, dismiss, and deep-link behavior.
- Banner runtime: `web/openclaw_banner_manager.js` owns transient banner state and shell-facing banner transitions.
- Tabs: `web/openclaw_tabs.js` manages tab registration, rendering, and remount safety.
- API: `web/openclaw_api.js` provides a normalized fetch wrapper and OpenClaw endpoints (legacy Moltbot endpoints still work).
- Host surface: `web/openclaw_host_surface.js` resolves the active frontend host surface and stamps explicit metadata so standalone frontend vs desktop-embedded behavior stays testable.
- Output refs: `web/openclaw_asset_refs.js` normalizes classic history refs and newer asset-backed output refs onto the same bounded `/view` preview contract.
- Tabs: `web/openclaw_tabs.js` manages tab registration, rendering, remount safety, and optional
pending-render disposal before switching panes.
- API: `web/openclaw_api.js` owns normalized transport, session, timeout, retry, and singleton
behavior. Config, generation, resource, model, and event endpoint families live in focused
`web/openclaw_api_*.js` owner modules behind the same public API (legacy Moltbot endpoints still
work).
- Settings: `web/tabs/settings_tab.js` composes status, LLM, secrets, logs, and DOM owner modules.
Its lifecycle owner invalidates stale async generations and clears scheduled work when the tab
is disposed, preventing late responses from mutating a remounted pane.
- Host surface: `web/openclaw_host_surface.js` resolves standalone frontend, legacy fixed-bundle
Desktop, and current managed-install Comfy-Desktop separately, then stamps explicit metadata so
generation-specific behavior stays testable.
- Output refs: `web/openclaw_asset_refs.js` normalizes classic history refs, optional `asset_hash`/`hash` refs when host metadata is present, and current previewable media groups (`images`, `video`, `audio`, `3d`, bounded inline or file-backed `text`) onto one media-aware contract. Allowlisted text files under the host `files` key stay on same-origin `/view` and use a 5-second, 64-KiB streaming, strict textual-MIME/UTF-8 reader with a 4,096-character display cap. HDR `.exr` / `.hdr` image refs show source-preview fallback links instead of normal thumbnails, text reaches the DOM only as literal text, and asset-service-only refs remain explicit fallback states instead of silently auto-fetching `/api/assets`.
- Styles: `web/openclaw.css` provides shared design tokens and component classes.
- Errors and compatibility helpers: `web/openclaw_utils.js` provides `showError()` / `clearError()` plus runtime legacy-class alias helpers used to keep canonical `openclaw-*` markup compatible with existing `moltbot-*` selectors.
Refactor note:
- `web/openclaw_ui.js` should stay focused on shell composition, shared singleton ownership, and exports.
- New shell behaviors should prefer the extracted action/queue modules unless they truly belong to top-level shell assembly.
- New API methods should be added to the matching route-family owner rather than growing the
transport facade; keep `openclawApi` as the only shared singleton.
- New Settings behavior should stay in the matching status/LLM/secrets/logs/DOM owner and use the
shared generation lifecycle for delayed or asynchronous UI changes.
- New tab markup should use canonical `openclaw-*` classes; legacy `moltbot-*` aliases are generated centrally at runtime instead of being duplicated in each template.
- New host sidebar registration changes should stay in `web/openclaw_sidebar_registration.js` rather than duplicating ComfyUI frontend API detection inside the extension entrypoint.
- Host-sensitive behaviors should consume the shared host-surface helper rather than inferring desktop vs standalone frontend from ad-hoc globals.
- Output preview flows should consume the shared asset-ref normalizer rather than assembling `/view` URLs independently in each tab.
- Graph/widget flows should preserve host-shaped promoted-widget source metadata and non-numeric node IDs, including Parameter Lab replay/apply paths.
- Parameter Lab flows should keep scalar/count/byte validation aligned with the backend policy and
use exact request-ID queue receipts; they must not infer prompt ownership from a globally recent
prompt when the host request boundary is unsupported or ambiguous.
- Output preview flows should consume the shared asset-ref normalizer rather than assembling `/view` URLs independently in each tab, treating non-image or HDR media as broken images, or silently widening runtime behavior to direct `/api/assets` fetches.
- Explorer/preflight consumers should treat inventory diagnostics as snapshot-first and surface `snapshot_ts`, `scan_state`, `stale`, and `last_error` instead of blocking the UI on full rescans.
- Explorer/preflight rendering should keep actionable missing-node/model failures separate from suppressed inactive-branch findings returned by the backend.
## Feature Gating (Capabilities)
@@ -42,9 +61,16 @@ If `assist_streaming` is unavailable or the stream transport degrades, Planner/R
## Host-Surface Contract
- OpenClaw treats standalone `ComfyUI_frontend` and `desktop` as distinct frontend host surfaces.
- The sidebar stamps its resolved host surface at mount time so desktop bundle drift is explicit in diagnostics and regression tests.
- Graph/widget compatibility code should route through shared host helpers to keep nested-subgraph and promoted-widget behavior aligned with current upstream host semantics.
- OpenClaw treats standalone `ComfyUI_frontend`, legacy fixed-bundle `desktop`, and current
managed-install `comfy_desktop` as distinct frontend host surfaces.
- The sidebar stamps its resolved host surface and refreshed host-reference metadata at mount time so desktop bundle drift is explicit in diagnostics and regression tests.
- The standalone Remote Admin Console stamps the same host-surface metadata on its document root,
including legacy Desktop `0.9.4`, fixed core `0.22.3`, embedded frontend `1.43.18`, and lagging
parity relative to standalone frontend `1.49.1`. It also exposes current Comfy-Desktop
`1.0.32-rc.1` with `installation_specific` hosted versions. Presence of
`window.__comfyDesktop2` identifies that host generation only; it does not authorize privileged
capability calls or inspect bridge members.
- Graph/widget compatibility code should route through shared host helpers to keep nested-subgraph and promoted-widget behavior aligned with current upstream host semantics, including preserving source metadata and string-shaped node IDs.
## Standalone Remote Admin Console
@@ -71,6 +97,7 @@ If `assist_streaming` is unavailable or the stream transport degrades, Planner/R
3. Clear token and verify write actions fail with explicit auth/policy errors.
4. Connect SSE, then trigger a run; verify event stream updates and fallback polling still works.
5. Confirm there is no blank/overflow breakage on narrow mobile widths.
6. If you are validating desktop parity, confirm the page root resolves the expected host-surface metadata instead of silently defaulting to standalone assumptions.
## Quick Manual Checks
@@ -79,14 +106,22 @@ If `assist_streaming` is unavailable or the stream transport degrades, Planner/R
3. Confirm the sidebar host-surface metadata resolves correctly for the current environment instead of defaulting silently.
4. Planner: click **Plan Generation** with minimal input and confirm either live preview/stage updates appear (when streaming is supported) or a readable fallback result/error appears.
5. Refiner: click **Refine Prompts** (with or without image) and confirm either live preview/stage updates appear (when streaming is supported) or a readable fallback result/error appears.
6. Jobs: verify output previews still resolve for both classic history refs and any asset-backed refs surfaced by callback/history payloads, and that repeated polls do not duplicate rows after reconnect/resume.
7. Explorer: verify preflight inventory can show `refreshing` / `stale` / `error` state without freezing the tab while deep scan work continues.
8. Library/Approvals: if backend endpoints are not enabled, confirm the UI shows a clear error state (no crashes).
9. If you simulate/fake a stream failure in dev tools, confirm Planner/Refiner retry through the classic non-stream path without duplicate submits or broken loading state.
6. Jobs: verify output previews still resolve for classic history refs, optional hash-backed refs when host metadata is present, and supported media-aware refs (`images`, `video`, `audio`, `3d`, bounded inline/file-backed `text`); allowlisted text files should show literal bounded content or a deterministic source-link fallback, HDR `.exr` / `.hdr` image refs should render as explicit source-preview fallback links, asset-service-only refs should stay explicit as a bounded fallback state, and repeated polls should not duplicate rows after reconnect/resume.
7. Parameter Lab: verify bounded scalar sweep/compare values queue with an exact request receipt,
and verify unsupported structured values or unknown host queue-event shapes fail visibly without
assigning another prompt's lifecycle.
8. Explorer: verify preflight inventory can show `refreshing` / `stale` / `error` state without freezing the tab while deep scan work continues, and verify inactive-branch suppressed findings render separately from actionable failures.
9. Library/Approvals: if backend endpoints are not enabled, confirm the UI shows a clear error state (no crashes).
10. If you simulate/fake a stream failure in dev tools, confirm Planner/Refiner retry through the classic non-stream path without duplicate submits or broken loading state.
## E2E (Playwright) Checks
- Run: `npm test`
- Tests live in: `tests/e2e/specs/`
- Harness: `tests/e2e/test-harness.html` (mocks ComfyUI core + basic OpenClaw API calls)
- Harness bootstrap now retries one transient `openclaw.js` module-fetch failure before surfacing a hard load error, so CI-only first-request flakiness does not get misreported as a permanent sidebar failure.
- Web helper/self-test harness: `web/tests/e2e-harness.html` (includes frontend helper and wrapper idempotence checks)
- Frontend unit contracts also freeze API exports/signatures, singleton identity, Settings DOM
identities, owner direction, and stale-generation disposal across the decomposed modules.
- Desktop host parity lane: `tests/e2e/specs/desktop_host_parity.spec.js` verifies standalone vs desktop host evidence separately and covers both sidebar and Remote Admin host-sensitive behavior under the shared harness shims.
- When investigating suspected harness flakes locally, prefer `npm run test:stress -- <spec>` so the same shared bootstrap path is exercised repeatedly without changing the default `npm test` contract.
+49
View File
@@ -0,0 +1,49 @@
# Legacy Compatibility Governance
OpenClaw keeps selected legacy compatibility aliases so older workflows, browser extensions, and deployment scripts have a predictable migration path. New integrations should use the canonical OpenClaw names.
Compatibility aliases are governed by explicit status, review cadence, telemetry, and removal criteria. An alias is not removed just because a canonical replacement exists; removal requires usage evidence and regression coverage.
## Status Labels
- `deprecated-observed`: the alias is still accepted, emits telemetry or warnings where practical, and should move to the canonical surface.
- `retained-compatibility`: the alias remains available for older workflows or deployments, with review based on diagnostics, tests, and operator reports.
## Review Policy
Every legacy alias has:
- a review cadence in days
- a telemetry or evidence signal
- a review trigger
- concrete removal criteria
Removal requires all of these conditions:
- no observed compatibility usage for two consecutive review windows
- a documented canonical migration path
- targeted regression coverage and release notes for the removal
## Governed Aliases
| Key | Surface | Legacy alias | Canonical surface | Status | Telemetry or evidence |
| --- | --- | --- | --- | --- | --- |
| `api-path-moltbot-prefix` | API path | `/moltbot/*` and `/api/moltbot/*` | `/openclaw/*` and `/api/openclaw/*` | `deprecated-observed` | `legacy_api_hits` |
| `header-x-moltbot-aliases` | Header | `X-Moltbot-*` request headers | `X-OpenClaw-*` request headers | `deprecated-observed` | `legacy_api_hits` and warning logs |
| `environment-moltbot-prefix` | Environment | `MOLTBOT_*` environment variables | `OPENCLAW_*` environment variables | `retained-compatibility` | configuration diagnostics and warning logs |
| `ui-class-moltbot-prefix` | UI class | `moltbot-*` CSS classes and local UI keys | `openclaw-*` CSS classes and local UI keys | `retained-compatibility` | frontend compatibility helper tests and operator reports |
| `workflow-node-moltbot-classes` | Workflow node | `Moltbot*` node class aliases | `OpenClaw*` node classes and `openclaw` node category | `retained-compatibility` | workflow portability diagnostics and node-registration regression tests |
The historical `moltbot` node category is no longer the current display category. Current shipped nodes use `openclaw`; legacy workflow compatibility is preserved through the `Moltbot*` class aliases rather than through legacy category metadata.
## Operator Visibility
Legacy API path requests expose deprecation response headers when the response type supports headers:
- `Deprecation: true`
- `X-OpenClaw-Compatibility-Key`
- `X-OpenClaw-Compatibility-Status`
- `X-OpenClaw-Compatibility-Telemetry`
- `X-OpenClaw-Canonical-Path`
Use these headers with server logs and `legacy_api_hits` to decide whether a deployment still depends on legacy route aliases.
+77 -15
View File
@@ -1,8 +1,8 @@
openapi: "3.0.3"
info:
title: "ComfyUI-OpenClaw API"
version: "1.0.6"
description: "Generated from docs/release/api_contract.md (R66 baseline)."
version: "1.0.15"
description: "Generated from docs/release/api_contract.md."
servers:
- url: "/openclaw"
description: "Direct OpenClaw prefix"
@@ -22,7 +22,7 @@ paths:
/health:
get:
operationId: "get_health"
summary: "System status, uptime, and dependencies."
summary: "System status, uptime, dependencies, and startup lifecycle diagnostics."
responses:
200:
description: "OK"
@@ -64,7 +64,7 @@ paths:
description: "OK"
x-openclaw-auth: "Observability"
x-openclaw-section: "1.1 Core Observability & System"
description: "Trace payloads redact provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Trace payloads redact provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/trace/{id}"
x-openclaw-auth-tier: "observability"
security:
@@ -87,7 +87,7 @@ paths:
description: "OK"
x-openclaw-auth: "Observability"
x-openclaw-section: "1.1 Core Observability & System"
description: "Event payloads redact provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Event payloads redact provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/events"
x-openclaw-auth-tier: "observability"
security:
@@ -105,7 +105,7 @@ paths:
description: "OK"
x-openclaw-auth: "Observability"
x-openclaw-section: "1.1 Core Observability & System"
description: "SSE event payloads redact provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "SSE event payloads redact provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/events/stream"
x-openclaw-auth-tier: "observability"
security:
@@ -145,16 +145,30 @@ paths:
/jobs:
get:
operationId: "get_jobs"
summary: "List recent jobs (Stub/Not Implemented)."
summary: "List recent jobs through the versioned bounded in-process jobs read model."
responses:
200:
description: "OK"
x-openclaw-auth: "Observability"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.1 Core Observability & System"
x-openclaw-legacy-path: "/moltbot/jobs"
x-openclaw-auth-tier: "observability"
x-openclaw-auth-tier: "admin"
security:
- OpenClawObservabilityToken:
- OpenClawAdminToken:
[]
/preflight:
post:
operationId: "post_preflight"
summary: "Analyze a workflow or API prompt payload for missing nodes/models and portability diagnostics."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.1 Core Observability & System"
x-openclaw-legacy-path: "/moltbot/preflight"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/preflight/inventory:
get:
@@ -249,7 +263,7 @@ paths:
description: "OK"
x-openclaw-auth: "Admin/Local"
x-openclaw-section: "1.3 Assist, LLM & Chat"
description: "Structured assist payloads preserve final answer fields while redacting provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Structured assist payloads preserve final answer fields while redacting provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/assist/planner"
x-openclaw-auth-tier: "admin"
security:
@@ -267,7 +281,7 @@ paths:
description: "OK"
x-openclaw-auth: "Admin/Local"
x-openclaw-section: "1.3 Assist, LLM & Chat"
description: "Structured assist payloads preserve final answer fields while redacting provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Structured assist payloads preserve final answer fields while redacting provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/assist/refiner"
x-openclaw-auth-tier: "admin"
security:
@@ -285,7 +299,7 @@ paths:
description: "OK"
x-openclaw-auth: "Admin/Local"
x-openclaw-section: "1.3 Assist, LLM & Chat"
description: "Streaming assist final payloads redact provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Streaming assist final payloads redact provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/assist/planner/stream"
x-openclaw-auth-tier: "admin"
security:
@@ -304,7 +318,7 @@ paths:
description: "OK"
x-openclaw-auth: "Admin/Local"
x-openclaw-section: "1.3 Assist, LLM & Chat"
description: "Streaming assist final payloads redact provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Streaming assist final payloads redact provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/assist/refiner/stream"
x-openclaw-auth-tier: "admin"
security:
@@ -376,6 +390,20 @@ paths:
security:
- OpenClawAdminToken:
[]
/connector/extraction-contract:
get:
operationId: "get_connector_extraction_contract"
summary: "Get the machine-readable connector extraction recommendation, seam families, static service-env SecretRef propagation policy, and current blockers."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3B Connector Installation Diagnostics"
x-openclaw-legacy-path: "/moltbot/connector/extraction-contract"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/models/search:
get:
operationId: "get_models_search"
@@ -516,7 +544,7 @@ paths:
/models:
get:
operationId: "get_models"
summary: "List available models from configured provider. Request-time fetch uses the same SSRF contract as saved `base_url` validation, including the explicit insecure override for private-IP/HTTP targets."
summary: "List available models from configured provider. Request-time fetch uses the same SSRF contract as saved `base_url` validation, including scoped private-network allowance and the explicit insecure override for private-IP/HTTP targets."
responses:
200:
description: "OK"
@@ -791,6 +819,40 @@ paths:
required: true
schema:
type: "string"
/tools:
get:
operationId: "get_tools"
summary: "List allowed external tools and their declared sandbox metadata."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin Token Required"
x-openclaw-section: "1.5A External Tools"
x-openclaw-legacy-path: "/moltbot/tools"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/tools/{name}/run:
post:
operationId: "post_tools_name_run"
summary: "Execute a named allowlisted external tool with validated arguments."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin Token Required"
x-openclaw-section: "1.5A External Tools"
x-openclaw-legacy-path: "/moltbot/tools/{name}/run"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
parameters:
- name: "name"
in: "path"
required: true
schema:
type: "string"
/bridge/health:
get:
operationId: "get_bridge_health"
+96 -10
View File
@@ -1,12 +1,12 @@
# OpenClaw API Contract (v1)
> **Status**: normative
> **Version**: 1.0.6
> **Date**: 2026-03-27
> **Version**: 1.0.15
> **Date**: 2026-07-10
This document defines the public API contract for OpenClaw. It serves as the authoritative baseline for client compatibility and breaking change policies.
## 0. Tenant Boundary Context (S49)
## 0. Tenant Boundary Context
Default behavior remains single-tenant compatible (`tenant_id=default`).
@@ -37,7 +37,7 @@ All new integrations should use the `/openclaw/` prefix. Use of `/moltbot/` is d
| Method | Path | Legacy Path | Auth | Description |
| :--- | :--- | :--- | :--- | :--- |
| `GET` | `/health` | `/moltbot/health` | None | System status, uptime, and dependencies. |
| `GET` | `/health` | `/moltbot/health` | None | System status, uptime, dependencies, and startup lifecycle diagnostics. |
| `GET` | `/capabilities` | `/moltbot/capabilities` | None | Feature flags and supported extensions (includes optional UX/runtime features such as assist streaming support). |
| `GET` | `/logs/tail` | `/moltbot/logs/tail` | Observability | Tail recent log lines (rate-limited). |
| `GET` | `/trace/{prompt_id}` | `/moltbot/trace/{id}` | Observability | Get execution trace by prompt ID. |
@@ -45,12 +45,32 @@ All new integrations should use the `/openclaw/` prefix. Use of `/moltbot/` is d
| `GET` | `/events/stream` | `/moltbot/events/stream` | Observability | SSE stream of job lifecycle events with resume support. |
| `GET` | `/config` | `/moltbot/config` | Observability | Read-only view of sanitized provider config. |
| `PUT` | `/config` | `/moltbot/config` | Admin | Update system configuration. |
| `GET` | `/jobs` | `/moltbot/jobs` | Observability | List recent jobs (Stub/Not Implemented). |
| `GET` | `/jobs` | `/moltbot/jobs` | Admin | List recent jobs through the versioned bounded in-process jobs read model. |
| `POST` | `/preflight` | `/moltbot/preflight` | Admin | Analyze a workflow or API prompt payload for missing nodes/models and portability diagnostics. |
| `GET` | `/preflight/inventory` | `/moltbot/preflight/inventory` | Admin | Snapshot-first inventory of nodes/models for operator diagnostics, including refresh-state metadata. |
Jobs list contract:
- `GET /openclaw/jobs` and its browser/legacy aliases are Admin-only and return
`contract_version: 1` from the bounded in-process ComfyUI jobs adapter.
- Supported query fields are `status`, `workflow_id`, `sort_by`, `sort_order`, `limit`,
and `offset`. Status values are `pending`, `in_progress`, `completed`, `failed`, and
`cancelled`; sorting supports `created_at` or `execution_duration` with `asc`/`desc`.
- The default/maximum page sizes are 50/200 and the source/offset window is capped at
10,000. Successful responses contain only `ok`, `contract_version`, `jobs`,
`pagination`, `source`, and `scan` at the top level.
- Job summaries allow only `id`, `status`, bounded priority/timestamps, `outputs_count`,
and bounded `workflow_id`. `preview_output` is never included, and list responses never
include raw prompts, workflows, execution errors, tracebacks, current inputs/outputs,
tenant/client/trace identifiers, reasoning, or internal content.
- An authoritative empty snapshot is HTTP 200 with `jobs: []`. Missing host helpers use
HTTP 501 `jobs_host_contract_unsupported`; unavailable or malformed snapshots use HTTP
503 `jobs_backend_unavailable`. These failures are never converted into empty success.
Reasoning-content redaction contract:
- operator-visible trace and events payloads strip provider reasoning / thinking-like fields by default
- operator-visible trace and events payloads strip provider reasoning / thinking-like fields and explicitly marked internal maintenance/helper content by default
- audit event payload/meta fields follow the same internal-content and reasoning-like redaction boundary before retention
- privileged reveal is opt-in only and requires:
- request header `X-OpenClaw-Debug-Reveal-Reasoning: 1` or query `debug_reasoning=1`
- server-side enablement via `OPENCLAW_DEBUG_REASONING_REVEAL=1`
@@ -59,12 +79,34 @@ Reasoning-content redaction contract:
- non-hardened runtime profile
- deployment profile `local` or `lan`
- clients MUST treat reveal behavior as debug-only and MUST NOT depend on reasoning payload presence in normal operation
- explicitly marked internal maintenance/helper content has no public or debug reveal path
Inventory diagnostics contract:
- `/preflight/inventory` is snapshot-first and may return before deep scan work finishes
- clients SHOULD treat `snapshot_ts`, `scan_state`, `stale`, and `last_error` as first-class diagnostics fields rather than assuming a blocking full-rescan model
Preflight workflow diagnostics contract:
- `POST /openclaw/preflight` accepts both API prompt dictionaries and frontend workflow JSON when supplied by operator tooling
- response summaries distinguish actionable `missing_nodes` / `missing_models` from `suppressed_missing_nodes` / `suppressed_missing_models`
- suppressed findings represent muted or bypassed root nodes or subgraph branches when the submitted workflow shape provides enough frontend ancestry metadata
- clients SHOULD display suppressed findings as informational context rather than blocking workflow readiness
History and output-ref contract:
- history/output consumers SHOULD treat the normalized output-ref contract as media-aware
- current previewable output groups are `images`, `video`, `audio`, `3d`, bounded inline `text`, and allowlisted file-backed text refs from the host `files` key
- file-like refs that can be represented through `/view` remain on the bounded `/history` + `/view` preview path
- file-backed text admission is limited to `.txt`, `.md`, `.markdown`, `.json`, `.csv`, `.yaml`, `.yml`, `.xml`, and `.log`; clients MUST build the URL from normalized filename/subfolder/type fields rather than trust a history-provided URL
- browser text previews MUST remain same-origin `/view` GET requests, reject redirects and active/ambiguous MIME types, use strict UTF-8, stream at most 64 KiB within 5 seconds, display at most 4,096 characters, and degrade to a source link when safe streaming is unavailable
- fetched text MUST be inserted as literal text; HTML, Markdown, SVG/XML, ANSI, or script interpretation is not part of this contract
- `asset_hash` / `hash` metadata is optional because current ComfyUI host asset hashing is opt-in through `--enable-asset-hashing`; clients MUST NOT require hashes for normal filename-backed previews
- refs with `asset_hash` or `hash` values, when host metadata provides them, preview through `/view?filename=blake3:...`
- refs that only expose upstream asset-service identifiers remain explicit `asset_api_required` states; clients MUST NOT silently infer direct `/api/assets` fetching from that marker
- HDR `.exr` / `.hdr` image refs remain image refs but should be rendered as explicit source-preview fallback links unless the client implements a safe HDR-specific viewer
- legacy callback/image-only consumers may continue using image-only extraction paths; non-image media refs should be rendered as explicit fallback/link/text surfaces unless the client implements a safe media-specific renderer
### 1.2 Webhooks & Triggers
**Auth**: Requires configured webhook secret or Admin Token.
@@ -76,6 +118,13 @@ Inventory diagnostics contract:
| `POST` | `/webhook/validate` | `/moltbot/webhook/validate` | Webhook Secret | Dry-run validation of webhook payload. |
| `POST` | `/triggers/fire` | `/moltbot/triggers/fire` | Admin | Fire an ad-hoc workflow trigger from external system. |
ComfyUI prompt submission interoperability:
- OpenClaw-generated ComfyUI `/prompt` payloads include `extra_data.comfy_usage_source = "comfyui-openclaw"` when the caller has not supplied a value
- caller-provided `extra_data.comfy_usage_source` is preserved
- attribution is a stable product identifier and MUST NOT include prompt text, tenant ids, trace ids, URLs, tokens, or secrets
- existing `extra_data.openclaw` and legacy `extra_data.moltbot` metadata remain caller-owned except for OpenClaw tenant metadata insertion under `extra_data.openclaw.tenant_id`
### 1.3 Assist, LLM & Chat
**Assist Base Path**: `/openclaw/assist/`
@@ -90,8 +139,9 @@ Inventory diagnostics contract:
Assist payload redaction contract:
- structured assist responses preserve final operator-visible answer fields but strip provider reasoning / chain-of-thought style fields by default
- structured assist responses preserve final operator-visible answer fields but strip provider reasoning / chain-of-thought style fields and explicitly marked internal maintenance/helper content by default
- when the privileged reveal gate is allowed, debug reasoning is exposed only in a separate debug payload and not merged back into the normal structured answer fields
- explicitly marked internal maintenance/helper content is not exposed by the privileged reasoning reveal gate
### 1.3B Connector Installation Diagnostics
@@ -104,11 +154,13 @@ Assist payload redaction contract:
| `GET` | `/connector/installations/{installation_id}` | `/moltbot/connector/installations/{installation_id}` | Admin | Get one redacted connector installation record. |
| `GET` | `/connector/installations/resolve` | `/moltbot/connector/installations/resolve` | Admin | Run fail-closed workspace resolution diagnostics (`platform`, `workspace_id`). |
| `GET` | `/connector/installations/audit` | `/moltbot/connector/installations/audit` | Admin | List installation lifecycle audit evidence (redacted). |
| `GET` | `/connector/extraction-contract` | `/moltbot/connector/extraction-contract` | Admin | Get the machine-readable connector extraction recommendation, seam families, static service-env SecretRef propagation policy, and current blockers. |
Connector diagnostics contract notes:
- installation records may expose operator-safe health metadata under `installation.metadata.health` (for example `ok`, `invalid_token`, `revoked`, `degraded`) without exposing token material
- `/connector/installations` diagnostics may include aggregate `health_counts` in addition to lifecycle `status_counts`
- `/connector/installations/resolve` may expose a stable `health_code` alongside the legacy `reject_reason` so clients can distinguish `workspace_unbound` vs token-health failures without parsing status text
- `/connector/extraction-contract` is structural packaging metadata and static service-env SecretRef policy only; clients MUST NOT treat it as a live installation-health, live environment dump, or token-status feed
### 1.3C Model Management & Installations
@@ -127,6 +179,9 @@ Connector diagnostics contract notes:
Model-manager contract notes:
- `/models/downloads` supports `since_seq` cursor polling and may return deterministic delta metadata (`requested_since_seq`, `effective_since_seq`, `next_since_seq`, truncation/reset hints) alongside the task list
- `model_type` values SHOULD use current ComfyUI folder keys where applicable, including `text_encoders`, `diffusion_models`, `clip_vision`, `style_models`, `upscale_models`, `vae_approx`, `gligen`, `latent_upscale_models`, `hypernetworks`, `photomaker`, `model_patches`, `audio_encoders`, `background_removal`, `frame_interpolation`, `geometry_estimation`, `optical_flow`, and `detection`
- legacy aliases such as `ckpt`, `checkpoints`, `loras`, `controlnets`, `clip`, `text_encoder`, `unet`, `diffusion_model`, `upscale_model`, `latent_upscale_model`, `hypernetwork`, `model_patch`, and `audio_encoder` are normalized before filtering or import destination resolution
- current ComfyUI folder keys that are not managed model-file destinations fail closed for download creation: `configs` (configuration YAML), `diffusers` (folder-valued trees), `classifiers` (extensionless classifier artifacts), `custom_nodes` (executable plugin code), and `datasets` (user-managed training data)
- download creation requires structured provenance metadata (`publisher`, `license`, `source_url`) and a 64-char `expected_sha256`
- import keeps fail-closed destination/filename validation and re-checks the staged file hash before activation
@@ -138,7 +193,7 @@ Model-manager contract notes:
| :--- | :--- | :--- | :--- | :--- |
| `POST` | `/chat` | `/moltbot/llm/chat` | Admin/Local | Unified chat interface for assistant interactions. |
| `POST` | `/test` | `/moltbot/llm/test` | Admin | Test LLM connectivity and configuration. |
| `GET` | `/models` | `/moltbot/llm/models` | Admin | List available models from configured provider. Request-time fetch uses the same SSRF contract as saved `base_url` validation, including the explicit insecure override for private-IP/HTTP targets. |
| `GET` | `/models` | `/moltbot/llm/models` | Admin | List available models from configured provider. Request-time fetch uses the same SSRF contract as saved `base_url` validation, including scoped private-network allowance and the explicit insecure override for private-IP/HTTP targets. |
### 1.4 Templates & Assets
@@ -175,6 +230,36 @@ Model-manager contract notes:
| `POST` | `/approvals/{id}/approve` | Approve a pending request. |
| `POST` | `/approvals/{id}/reject` | Reject a pending request. |
Schedule `delivery` is normalized before persistence. Supported fields are
`platform`, `target_id` (legacy aliases such as `channel_id` are accepted),
`thread_id` (aliases such as `thread_ts`, `topic_id`, and `message_thread_id`
are accepted), `workspace_id`, `account_id`, `mode`, and `failure_alert`.
Omitting `delivery` on update preserves the existing target, `delivery: null`
clears it, and `{"enabled": false}` or `{"mode": "none"}` records explicit
no-delivery. Invalid delivery targets are rejected before persistence with
bounded codes: `delivery_malformed`, `delivery_ambiguous`, or
`delivery_unsupported`.
### 1.5A External Tools
**OpenClaw path prefix**: `/openclaw/`
**Legacy Base Path**: `/moltbot/`
**Auth**: Admin Token Required
**Feature flag**: `OPENCLAW_ENABLE_EXTERNAL_TOOLS=true`
| Method | Path | Legacy Path | Auth | Description |
| :--- | :--- | :--- | :--- | :--- |
| `GET` | `/tools` | `/moltbot/tools` | Admin Token Required | List allowed external tools and their declared sandbox metadata. |
| `POST` | `/tools/{name}/run` | `/moltbot/tools/{name}/run` | Admin Token Required | Execute a named allowlisted external tool with validated arguments. |
Tool execution contract notes:
- tools are disabled unless `OPENCLAW_ENABLE_EXTERNAL_TOOLS` is truthy
- tool definitions load from package-owned `data/tools_allowlist.json` unless `OPENCLAW_TOOLS_CONFIG_PATH` is set
- in public/split high-risk surfaces, tool execution can be blocked by the surface guard even when the feature flag is enabled
- execution responses preserve the current HTTP payload shape: failed runs return `ok=false`, `tool`, `error`, redacted `output`, `exit_code`, and `duration_ms`
- the service-level tool runner classifies common local failures with stable diagnostics such as `sandbox_runtime_unavailable`, `interpreter_missing`, `timeout`, and `workspace_violation`; clients should still follow this API document for the current HTTP response shape
### 1.6 Bridge (Sidecar)
**Base Path**: `/bridge/`
@@ -221,6 +306,7 @@ All JSON responses (success or error) share a common structure:
| `413` | Payload Too Large | Input size exceeds `OPENCLAW_MAX_RENDERED_WORKFLOW_BYTES` or similar limits. |
| `429` | Too Many Requests | Rate limit or Execution Budget exceeded. |
| `500` | Internal Error | Unhandled server exception. |
| `501` | Not Implemented | Required current-host contract is unavailable (for example `jobs_host_contract_unsupported`). |
| `503` | Unavailable | Feature disabled or service not wired. |
Tenant-boundary error notes:
@@ -240,7 +326,7 @@ Tenant-boundary error notes:
- `error`
- `keepalive`
- Clients MUST treat `final` as the source of truth for structured assist results. `delta` preview text is best-effort and may be truncated or differ from the final parsed payload.
- Event-stream and polling payloads redact provider reasoning / thinking traces by default; reveal is debug-only and gated by the same privileged local-debug contract used by trace/assist surfaces.
- Event-stream and polling payloads redact provider reasoning / thinking traces and explicitly marked internal maintenance/helper content by default; reasoning reveal is debug-only and gated by the same privileged local-debug contract used by trace/assist surfaces.
- Clients SHOULD gracefully fall back to non-streaming assist endpoints when streaming capability is absent or streaming transport fails.
### 2.4 Pagination & Scan Diagnostics (Management Query Contract)
@@ -271,7 +357,7 @@ These limits are contractual and strictly enforced. Clients MUST handle `413` an
| **Payload Size** | Rendered workflow | 512KB | `OPENCLAW_MAX_RENDERED_WORKFLOW_BYTES` |
| **Webhook Body** | Raw JSON body | 10MB | `MAX_BODY_SIZE` (internal constant) |
| **Trigger Inputs** | Input variables | 32KB | Hardcoded in `api/triggers.py` |
| **Log Tail** | Max lines | 500 | Hardcoded in `api/routes.py` |
| **Log Tail** | Max lines | 500 | Hardcoded in `api/route_handlers.py` |
---
+30 -4
View File
@@ -8,10 +8,13 @@ All pull requests must pass the repository SOP gate before merge.
| --- | --- | --- |
| Secret detection | `pre-commit run detect-secrets --all-files` | Prevent secret leakage |
| Pre-commit hooks | `pre-commit run --all-files --show-diff-on-failure` | Enforce formatting and static checks |
| Frontend dependency audit | `npm audit --production` | Fail on production dependency vulnerabilities in the shipped Node dependency surface |
| Production dependency boundary | `python scripts/verify_production_dependencies.py` | Parse tracked production imports without importing modules; block ownership, direction, cycle, and dynamic-import drift |
| Frontend dependency audit | `npm ci` then `npm audit --audit-level=high` | Reconcile the lockfile and fail on high/critical vulnerabilities across production and development dependencies |
| Backend dependency audit | `pip-audit -r requirements.txt` | Audit declared Python project dependencies without scanning unrelated CI runner/toolchain packages |
| GitHub CodeQL analysis | `.github/workflows/codeql.yml` | Run repository-native static security analysis for Python, JavaScript/TypeScript, and GitHub Actions on push, pull request, and weekly schedule |
| Coverage governance | `python scripts/verify_quality_governance.py` | Fail closed on coverage-policy, mutation-threshold, SOP-guidance, and survivor-allowlist drift |
| Backend unit tests | `python scripts/run_unittests.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json` | Validate backend behavior and skip governance |
| Test debt governance | `python scripts/verify_test_debt_governance.py` | Fail closed on stale or under-documented skip-policy / mutation allowlist debt entries |
| Backend unit coverage gate | `python scripts/run_backend_coverage.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json --coverage-json .tmp/coverage/backend_unit_coverage.json` | Validate backend behavior, skip governance, and the active coverage floor from the shared local/CI artifact path |
| Adversarial gate | `python scripts/run_adversarial_gate.py --profile auto --seed 42` | Enforce adaptive fuzz/mutation verification with smoke=>extended escalation on high-risk diffs |
| Frontend E2E | `npm test` | Validate UI and frontend/backend integration |
@@ -37,12 +40,35 @@ If a change intentionally modifies contract behavior:
- Coverage governance is part of the standard gate, not an optional reporting step.
- Dependency-audit governance is part of CI parity:
- Node audit should continue to target production dependencies only.
- Node audit must cover production and development dependencies because build and test tooling is part of the acceptance trust boundary.
- A separate production-only audit may be retained as a runtime-boundary readback, but it is not a substitute for the full blocking audit.
- Python audit must stay scoped to `requirements.txt`; env-wide bare `pip-audit` is out of contract because it can fail on tool-only transient packages that are not part of the repo dependency surface.
- GitHub Actions workflow files are part of the security boundary:
- workflows using `GITHUB_TOKEN` must declare explicit least-privilege `permissions:` instead of relying on repository defaults
- missing or broadened workflow token scope should be treated as CI-policy drift, not an acceptable implementation shortcut
- CodeQL analysis must stay versioned in `.github/workflows/codeql.yml`; do not rely on UI-only default-setup drift for the repository baseline
- CodeQL rollout remains visibility-first until the active backlog is burned down; treat new workflow findings as triage input, not an automatic merge blocker, unless the gating policy is explicitly tightened in roadmap/docs
- `pyproject.toml` must keep:
- `fail_under >= 35.0`
- `fail_under >= 55.0`
- `show_missing = true`
- `skip_covered = true`
- staged coverage ratchet policy (`tests/coverage_governance_policy.json`) is the source of truth for:
- current enforced floor
- next planned ratchet target
- hotspot families and any temporary exceptions
- `fail_under` must match the current stage floor declared in `tests/coverage_governance_policy.json`; do not ratchet the floor by editing `pyproject.toml` alone.
- Coverage hotspot review should use:
- `python scripts/report_coverage_governance.py --coverage-json <path-to-coverage.json>`
- release-cycle promotion evidence must be retained in:
- `tests/coverage_promotion_reviews.json`
- ratchet-55 reviews must contain consecutive release boundaries, immutable commit and
full-suite artifact identity, every required hotspot percentage, and owned regression suites
- backend coverage gate should use:
- `python scripts/run_backend_coverage.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json --coverage-json .tmp/coverage/backend_unit_coverage.json`
- Test debt governance remains fail-closed:
- no-skip modules in `tests/skip_policy.json` must keep explicit metadata (`reason` + `review_after`) and point at live test modules
- mutation survivor allowlist entries must carry `review_after` dates and point at live repo files
- review dates in the past are governance debt, not advisory comments
- Mutation governance remains adaptive:
- smoke profile threshold: `20.0%`
- extended profile threshold: `80.0%`
@@ -0,0 +1,80 @@
# CodeQL and Secret Scanning Policy Reference
Date: 2026-04-08
Scope: Repository planning guidance for GitHub-native security scanning policy during and after the residual alert wave.
Current status:
- the repository now uses the committed advanced CodeQL workflow as its authoritative scanner baseline
- GitHub `Code scanning` and `Secret scanning` were both brought back to `0` open findings during the `S91` closeout
## 1. Why CodeQL Belongs in GitHub Actions
This repository has a large security surface:
- Python backend services
- JavaScript frontend and test helpers
- GitHub Actions workflows
- connector ingress paths
- filesystem and model-management flows
The residual GitHub findings demonstrate that static security analysis is catching issues that ordinary local happy-path tests do not reliably surface.
The correct home for CodeQL in this repository is the GitHub Actions security-validation layer, not the mandatory local development loop.
## 2. Local vs Remote Validation Boundary
Local validation should focus on:
- targeted regressions for the changed bug surface
- repo-local contract tests
- the smallest credible transaction seam for each fix
GitHub-hosted validation should own:
- CodeQL scans
- code-scanning alert lifecycle
- long-running static dataflow analysis
- alert triage over the default branch
- secret-scanning closure workflow when a finding is confirmed to be historical or non-live
## 3. Recommended CodeQL Rollout Model
Recommended rollout order:
1. enable repository-native CodeQL in GitHub Actions
2. start in visibility/baseline mode
3. review new findings against changed files first
4. graduate to stricter gating only after the backlog is reduced
Recommended initial policy:
- languages: Python, JavaScript/TypeScript, GitHub Actions
- query suite: begin with standard security queries; expand only if runtime/cost remains acceptable
- gating: report-only at first, then fail on new high-severity findings after baseline stabilization
## 4. Secret Scanning Policy
Secret-scanning findings must be handled differently from CodeQL findings:
- removing or editing repository content does not automatically guarantee closure
- historical example values can continue to alert
- closure requires provenance review
Before closing a secret-scanning alert:
1. determine whether the value was ever real or was always an example / placeholder
2. confirm whether any rotation or revocation is required
3. avoid copying raw secret material into planning docs, issue text, or commit messages
4. record the closure rationale in planning and implementation evidence
## 5. Residual-Wave Review Checklist
For each remaining GitHub Security family:
- identify whether the finding is a true vulnerability, a scanner-visible dangerous pattern, or a probable false positive
- prefer code changes that make the safe boundary obvious to both humans and scanners
- add a hot-spot comment at the fix point when regression risk is high
- add targeted regression coverage if a local seam exists
- verify GitHub rescans after push
- dismiss only as a last resort, with recorded rationale
+46 -15
View File
@@ -3,22 +3,39 @@
```openclaw-compat-matrix-meta
{
"anchors": {
"comfyui": "v0.18.1-19-g2a1f4026",
"comfyui_frontend": "1.43.6+bcb39b1bf",
"desktop": "0.8.26 (core 0.18.2 / frontend 1.41.21)"
"comfy_desktop": "1.0.32-rc.1 (85e28b7a / v1.0.32-rc.1-3-g85e28b7)",
"comfyui": "9cf91339 (v0.29.0-12-g9cf91339 / pyproject 0.29.0)",
"comfyui_frontend": "1.49.1 (4b3866b838 / v1.49.1-19-g4b3866b838)",
"desktop": "0.9.4 (core 0.22.3 / frontend 1.43.18)"
},
"evidence": {
"evidence_id": "compat-matrix-20260327",
"updated_at": "2026-03-26T16:00:00+00:00",
"updated_by": "manual"
"evidence_id": "compat-matrix-refresh-20260731",
"updated_at": "2026-07-31T04:03:00+08:00",
"updated_by": "host-reference-alignment"
},
"last_validated_date": "2026-03-26",
"matrix_version": "v0.2.2",
"host_surfaces": {
"comfy_desktop": {
"anchor_key": "comfy_desktop",
"core_version": null,
"frontend_version": null,
"generation": "managed_install",
"hosted_version_mode": "installation_specific"
},
"desktop": {
"anchor_key": "desktop",
"core_version": "0.22.3",
"frontend_version": "1.43.18",
"generation": "legacy_fixed_bundle",
"hosted_version_mode": "fixed"
}
},
"last_validated_date": "2026-07-30",
"matrix_version": "v0.2.9",
"policy": {
"max_age_days": 45,
"warn_age_days": 30
},
"schema_version": 1
"schema_version": 2
}
```
@@ -28,17 +45,31 @@ This document tracks the current reference anchors and validated environments fo
| Component | Validated Range | Best Effort / Experimental | Notes |
| :--- | :--- | :--- | :--- |
| **ComfyUI** | `v0.18.1-19-g2a1f4026` reference anchor | Older snapshots | Current upstream reference repo head used for compatibility review |
| **ComfyUI Frontend** | `1.43.6+bcb39b1bf` reference anchor | Minor drift around the anchor | Sidebar extension contract (`registerSidebarTab`) still matches this repo |
| **ComfyUI Desktop** | `0.8.26 (core 0.18.2 / frontend 1.41.21)` reference anchor | Desktop bundle may lag standalone frontend | Treat desktop parity as a distinct host surface, not an alias of standalone frontend HEAD |
| **ComfyUI** | `9cf91339` reference anchor (`v0.29.0-12-g9cf91339`; `pyproject.toml` version `0.29.0`) | Older tagged snapshots | Current local upstream reference repo snapshot used for compatibility review |
| **ComfyUI Frontend** | `1.49.1` reference anchor (`4b3866b838`; `v1.49.1-19-g4b3866b838`) | Minor drift around the anchor | Sidebar extension contract remains compatible; prefer the current sidebar store API with deprecated facade fallback |
| **Legacy Desktop** | `0.9.4 (core 0.22.3 / frontend 1.43.18)` reference anchor | Legacy fixed bundle may lag standalone frontend | Preserve the recorded fixed-bundle contract for existing parity coverage |
| **Current Comfy-Desktop** | `1.0.32-rc.1` reference anchor (`85e28b7a`; `v1.0.32-rc.1-3-g85e28b7`) | Hosted component versions vary by installation | Treat the managed-install generation separately; do not infer fixed core/frontend versions from the application release |
| **Python** | 3.10, 3.11, 3.12 | 3.9 | 3.13 not yet validated |
| **Torch** | 2.1.2+ | 1.13+ | CUDA 11.8/12.1 verified |
## Host-Surface Notes
- **ComfyUI host runtime**: current bootstrap assumptions remain aligned with upstream `PromptServer` startup and route registration flow.
- **Frontend host surface**: current sidebar integration contract remains compatible with the standalone frontend reference anchor, but nested-subgraph and promoted-widget behavior should be treated as a regression-sensitive seam.
- **Desktop host surface**: desktop currently embeds an older frontend bundle than the standalone frontend reference. Validate desktop-specific behavior against the desktop anchor instead of assuming standalone-frontend parity.
- **ComfyUI host runtime**: current bootstrap assumptions remain aligned with upstream `PromptServer` startup and route registration flow, including `/api`-prefixed canonical API routing.
- **Frontend host surface**: current sidebar integration contract remains compatible with the standalone frontend reference anchor, while inactive subgraph diagnostics and promoted-widget behavior remain regression-sensitive seams.
- **Legacy Desktop host surface**: Desktop `0.9.4` embeds frontend `1.43.18`, which lags the standalone frontend `1.49.1` reference. Validate this fixed bundle against its own anchor.
- **Current Comfy-Desktop host surface**: application `1.0.32-rc.1` is a managed-install generation. Its hosted ComfyUI and frontend versions are `installation_specific`; the application anchor must not be cross-wired into fixed hosted-version claims.
## Residual Host-Contract Decisions
- **SaveImage output refs**: OpenClaw consumes runtime `/history` output refs and does not infer graph-rewrite behavior from output-node socket shape. `SaveImage` output sockets are allowed to exist without changing the normalized output-ref contract.
- **3D output refs**: `Load3DAdvanced` and related 3D preview refs remain media-aware output refs. File-like refs and optional hash-backed 3D refs stay on the bounded `/view` preview contract; clients without a 3D renderer should show an explicit fallback/link surface.
- **HDR image output refs**: `.exr` and `.hdr` image refs stay on the bounded `/view` source-preview contract but render as explicit fallback/link surfaces unless a client implements a safe HDR-specific viewer.
- **File-backed text output refs**: allowlisted text files under the host `files` output key normalize to text refs on the existing `/view` route. Job Monitor uses same-origin, redirect-free, strict MIME/UTF-8 streaming with fixed 5-second, 64-KiB transfer, and 4,096-character display limits; failures remain source-link fallbacks and content is never interpreted as HTML or Markdown.
- **Promoted widget source scope and structured widgets**: OpenClaw graph helpers preserve host-shaped promoted-widget source metadata and keep non-numeric node IDs stable. Backend preflight remains a conservative model-key whitelist; structured `COLORS` / `BOUNDING_BOXES` inputs and frontend source metadata are not treated as model references, and OpenClaw does not claim full host frontend active-scope parity without a richer graph-instance contract.
- **Asset dimensions and grouped assets**: typed width/height metadata and grouped multi-download behavior are host-frontend display/download concerns. They do not change OpenClaw fetch routing, and asset-service-only identifiers remain explicit `asset_api_required` states rather than implicit `/api/assets` fetches.
- **Asset loader paths and model tags**: current host asset metadata may expose `loader_path`; model uploads require `model_type:<folder_name>` tags, advertised by `/features.supports_model_type_tags`. OpenClaw does not upload through or directly consume `/api/assets`, so these schema facts do not change the existing `/history` + `/view` contract.
- **Sidebar registration**: prefer the current `sidebarTab.registerSidebarTab` host API and retain the deprecated `extensionManager.registerSidebarTab` fallback for older or desktop-embedded frontend hosts.
- **Node runtime policy**: the standalone ComfyUI frontend development workspace currently declares `node >=25 <26` and `pnpm >=11.3`, but OpenClaw keeps its package engine at `>=18.0.0` because this custom-node package runs its own Playwright/Vitest harness and does not build the host frontend workspace. OpenClaw acceptance remains governed by `tests/TEST_SOP.md` and `tests/E2E_TESTING_SOP.md`, which require Node.js 18+ and CI-parity validation on the project test harness.
## Operating Systems
+60 -10
View File
@@ -1,8 +1,8 @@
# OpenClaw Config & Secrets Contract (v1)
> **Status**: normative
> **Version**: 1.0.5
> **Date**: 2026-03-07
> **Version**: 1.0.6
> **Date**: 2026-06-04
This document defines the authoritative configuration contract for OpenClaw. It enumerates all supported environment variables, their precedence rules, and security classifications.
@@ -31,7 +31,7 @@ Controls the core LLM client used by nodes (Planner, Refiner, etc.).
| `OPENCLAW_LLM_BASE_URL` | No | Provider default | Override base URL (crucial for local/compatible providers). |
| `OPENCLAW_LLM_TIMEOUT`| No | `120` | Request timeout in seconds. |
Optional local secret-manager path (S11, disabled by default):
Optional local secret-manager path (disabled by default):
| Variable | Required | Default | Description |
| :--- | :--- | :--- | :--- |
@@ -58,13 +58,17 @@ Multi-tenant note:
| :--- | :--- | :--- |
| `OPENCLAW_LLM_ALLOWED_HOSTS` | - | Comma-separated list of additional exact public hosts for custom base URLs. |
| `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST` | `0` | Set `1` to bypass host allowlist and allow any public IP. |
| `OPENCLAW_LLM_ALLOW_PRIVATE_NETWORK` | `0` | Set `1` to allow the configured LLM `base_url` host to resolve to private/reserved IPs while keeping exact-host scope and DNS pinning. |
| `OPENCLAW_ALLOW_INSECURE_BASE_URL` | `0` | Set `1` to allow HTTP or private IP targets (Dangerous!). |
Notes:
- Local providers (`ollama`, `lmstudio`) are loopback-only by design and should use `localhost` / `127.0.0.1` / `::1`.
- Built-in local-provider defaults are OpenAI-compatible URLs:
- `ollama` -> `http://127.0.0.1:11434/v1`
- `lmstudio` -> `http://localhost:1234/v1`
- Local loopback provider targets do not require enabling insecure SSRF flags.
- `OPENCLAW_LLM_ALLOWED_HOSTS` does not allow private/reserved IPs; those still require `OPENCLAW_ALLOW_INSECURE_BASE_URL=1`.
- The same insecure override applies to config-save validation, `/openclaw/llm/models`, and outbound provider requests.
- `OPENCLAW_LLM_ALLOWED_HOSTS` does not allow private/reserved IPs; those require scoped `allow_private_network` for the configured LLM target or `OPENCLAW_ALLOW_INSECURE_BASE_URL=1`.
- The same scoped private-network setting and insecure override apply to config-save validation, `/openclaw/llm/models`, and outbound provider requests.
- Wildcard entries such as `*` are not supported in `OPENCLAW_LLM_ALLOWED_HOSTS`.
### 2.2 Security & Authentication
@@ -137,24 +141,69 @@ Controls the `connector` sidecar process and outbound delivery.
| `OPENCLAW_CONNECTOR_SLACK_OAUTH_INSTALL_PATH` | Slack | Local install route path (default `/slack/install`). |
| `OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH` | Slack | Local OAuth callback route path (default `/slack/oauth/callback`). |
| `OPENCLAW_CONNECTOR_SLACK_OAUTH_SCOPES` | Slack | Comma-separated bot scopes for install URL generation. |
| `OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC` | Slack | TTL for single-use OAuth state tokens (default `600`). |
| `OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC` | Slack | TTL for single-use OAuth state tokens (default `600`, clamped to `60..3600`). |
| `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS` | Slack | Comma-separated trusted user IDs. |
| `OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS` | Slack | Comma-separated trusted channel IDs. |
| `OPENCLAW_CONNECTOR_FEISHU_APP_ID` | Feishu/Lark | App ID for the default binding. |
| `OPENCLAW_CONNECTOR_FEISHU_APP_SECRET` | Feishu/Lark | App secret for the default binding. |
| `OPENCLAW_CONNECTOR_FEISHU_VERIFICATION_TOKEN` | Feishu/Lark | Verification token for webhook event ingress. |
| `OPENCLAW_CONNECTOR_FEISHU_ENCRYPT_KEY` | Feishu/Lark | Optional encrypt key for encrypted webhook payloads. |
| `OPENCLAW_CONNECTOR_FEISHU_ACCOUNT_ID` | Feishu/Lark | Explicit account ID for the default binding. |
| `OPENCLAW_CONNECTOR_FEISHU_DEFAULT_ACCOUNT_ID` | Feishu/Lark | Fallback account ID for multi-binding setups. |
| `OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_ID` | Feishu/Lark | Workspace or tenant identifier associated with the binding. |
| `OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_NAME` | Feishu/Lark | Human-readable workspace name surfaced in diagnostics. |
| `OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON` | Feishu/Lark | JSON list of account/workspace binding records for multi-account setups. |
| `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS` | Feishu/Lark | Comma-separated trusted user IDs. |
| `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_CHATS` | Feishu/Lark | Comma-separated trusted chat IDs. |
| `OPENCLAW_CONNECTOR_FEISHU_DOMAIN` | Feishu/Lark | API domain selector (`feishu` or `lark`). |
| `OPENCLAW_CONNECTOR_FEISHU_MODE` | Feishu/Lark | Transport mode (`websocket` or `webhook`). |
| `OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH` | Feishu/Lark | Local interactive callback route path (default `/feishu/callback`). |
| `OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM` | Core | Per-user connector rate limit (default `10`, clamped to `1..600`). |
| `OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM` | Core | Per-channel connector rate limit (default `30`, clamped to `1..600`). |
| `OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH` | Core | Max accepted connector command text length (default `4096`, clamped to `128..32768`). |
Connector posture rules:
- In strict posture (`OPENCLAW_DEPLOYMENT_PROFILE=public` or `OPENCLAW_RUNTIME_PROFILE=hardened`), active connector platforms without allowlist coverage are fail-closed.
- Public deployment profile check surfaces this as `DP-PUBLIC-009`.
- Connector reply visibility is policy-driven and does not introduce new secret/config knobs: text-only silent/internal/tool-only/no-mention replies can be suppressed by context, while approval cards and action buttons remain deliverable.
- Connector replay handling treats duplicate committed events as successful no-ops and allows retry only for failures before action/delivery commit.
- Slack multi-workspace installs persist only encrypted token refs in `connector_installations.json`; raw bot/app tokens remain in encrypted secret storage and must not appear in diagnostics or exported config surfaces.
- Feishu/Lark bindings persist normalized installation identity plus secret references only; app secrets and callback signing material must stay in encrypted/local secret storage and must not appear in diagnostics or exported config surfaces.
- Connector service-env propagation preserves only structured env-backed SecretRef metadata for supported connector credential variables. It reports secret-blind status/reason fields and rejects raw secrets, legacy marker strings, unsupported env names, missing envs, and runtime-only auth tokens such as admin, worker, and bridge tokens. Raw token values must not be written into diagnostics or service metadata.
- Connector bind-port envs (`OPENCLAW_CONNECTOR_LINE_PORT`, `...WHATSAPP_PORT`, `...WECHAT_PORT`, `...KAKAO_PORT`, `...SLACK_PORT`, `...FEISHU_PORT`) must stay within `1..65535`; invalid or out-of-range values fall back to the documented platform defaults instead of crashing startup.
**Delivery & Media:**
| Variable | Description |
| :--- | :--- |
| `OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC` | Timeout (sec) for delivering results to chat (default: `600`). |
| `OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC` | Timeout (sec) for delivering results to chat (default `600`, clamped to `30..3600`). |
| `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL` | Public base URL for serving images to LINE/Webhooks. |
| `OPENCLAW_CONNECTOR_MEDIA_PATH` | Local directory for staging media files. |
| `OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES` | Max completed images delivered per job (default `4`, clamped to `1..16`). |
| `OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES` | Per-image delivery cap in bytes (default `10485760`, clamped to `65536..52428800`). |
| `OPENCLAW_CONNECTOR_MEDIA_TTL_SEC` | Media expiry in seconds (default `300`, clamped to `60..86400`). |
| `OPENCLAW_CONNECTOR_MEDIA_MAX_MB` | Max staged media size in MB (default `8`, clamped to `1..64`). |
### 2.5 Execution Budgets & Limits
### 2.5 External Tools and Runtime Hygiene
External tool execution is opt-in and admin-gated. Package-owned defaults, runtime state, and local validation artifacts are separate ownership classes.
| Variable | Default | Description |
| :--- | :--- | :--- |
| `OPENCLAW_ENABLE_EXTERNAL_TOOLS` | `false` | Enables `/openclaw/tools` and `/openclaw/tools/{name}/run`. Keep disabled unless a reviewed deployment needs allowlisted external CLI execution. |
| `OPENCLAW_TOOLS_CONFIG_PATH` | package `data/tools_allowlist.json` | Explicit path to a custom tools allowlist. If unset, OpenClaw uses the package-owned shipped allowlist rather than a state-dir shadow file. |
| `OPENCLAW_TOOL_SANDBOX_RUNTIME_AVAILABLE` | `1` | Runtime availability marker used by hardened tool execution diagnostics. In hardened mode, `0` fails closed before tool execution. |
| `OPENCLAW_TOOL_SANDBOX_DIR` | `{state_dir}/tool_sandbox` | Optional override for external-tool scratch/temp workspace. Legacy alias: `MOLTBOT_TOOL_SANDBOX_DIR`. |
Ownership rules:
- package resources such as the default tool allowlist are read from the installed custom-node pack
- state-owned runtime cache/sandbox paths live under `OPENCLAW_STATE_DIR` unless explicitly overridden
- repo-local generated folders such as `.tmp/`, `.venv/`, and `node_modules/` are local tooling artifacts, not runtime state
- OpenClaw does not automatically repair, migrate, or delete runtime dependency caches
- tool execution results classify common local failures with deterministic diagnostics such as `sandbox_runtime_unavailable`, `interpreter_missing`, `timeout`, and `workspace_violation`
### 2.6 Execution Budgets & Limits
Contractual limits to prevent resource exhaustion.
@@ -168,12 +217,13 @@ Contractual limits to prevent resource exhaustion.
| `OPENCLAW_MAX_INFLIGHT_SUBMITS_PER_TENANT` | `1` | Per-tenant concurrent submit cap (applies when multi-tenant mode is enabled). |
| `OPENCLAW_MAX_RENDERED_WORKFLOW_BYTES` | `524288` | Max size (bytes) of a rendered workflow JSON (512KB). |
### 2.6 Runtime & Diagnostics
### 2.7 Runtime & Diagnostics
| Variable | Description |
| :--- | :--- |
| `OPENCLAW_STATE_DIR` | Directory for persistent state (DBs, history, logs). Default: `ComfyUI/user/default/openclaw` |
| `OPENCLAW_STATE_DIR` | Directory for persistent state (DBs, history, logs, runtime cache). Defaults to the platform user-data directory, such as `%LOCALAPPDATA%\comfyui-openclaw\`, `~/Library/Application Support/comfyui-openclaw/`, or `~/.local/share/comfyui-openclaw/`. |
| `OPENCLAW_LOG_TRUNCATE_ON_START` | Set `1` to truncate active log file (`openclaw.log`) once at process startup before new handlers write records. |
| `OPENCLAW_STARTUP_WARMUP_TIMEOUT_SEC` | Optional timeout for non-blocking startup warmups. Warmup timeout degrades health diagnostics but does not block required route startup. |
| `OPENCLAW_DIAGNOSTICS` | Comma-separated list of subsystems to enable debug logging for (e.g. `webhook.*,templates`). Safe-redacted. |
| `OPENCLAW_CONNECTOR_DEBUG` | Set `1` to enable verbose debug logging in Connector. |
+2
View File
@@ -20,8 +20,10 @@ Users should audit these flags before deploying to a public or untrusted network
| :--- | :--- | :--- | :--- |
| `OPENCLAW_CONNECTOR_ADMIN_TOKEN` | *None* | **Medium** | Required for admin commands (stop/approve/trace) if server auth is enabled. If missing, admin commands fail safe. |
| `OPENCLAW_ALLOW_REMOTE_ADMIN` | `0` | **High** | Be careful! Allows admin actions from non-loopback IPs if token is present (including writes from `/openclaw/admin` remote console). Default is loopback-only for admin. |
| `OPENCLAW_ENABLE_EXTERNAL_TOOLS` | `0` | **High** | Enables admin-gated external tool listing/execution routes. Requires reviewed tool allowlist and sandbox policy; keep disabled on public surfaces unless explicitly justified. |
| `OPENCLAW_BRIDGE_ENABLED` | `0` | **High** | Enables the sidecar bridge for remote orchestration. Requires `OPENCLAW_BRIDGE_DEVICE_TOKEN` (and in public posture also mTLS + device allowlist controls). |
| `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST` | `0` | **High** | Bypasses the known-host allowlist for LLM `base_url`. Allows SSRF to public IPs. |
| `OPENCLAW_LLM_ALLOW_PRIVATE_NETWORK` | `0` | **High** | Allows the configured LLM `base_url` host to resolve to private/reserved IPs while preserving exact-host scope and DNS pinning. |
| `OPENCLAW_ALLOW_INSECURE_BASE_URL` | `0` | **Critical** | Allows HTTP (non-HTTPS) or private IP `base_url` for LLM. Risk of internal network scanning (SSRF). |
| `OPENCLAW_MULTI_TENANT_ENABLED` | `0` | **High** | Enables fail-closed tenant boundary mode. Requests without valid tenant context can be rejected by design. |
| `OPENCLAW_TENANT_HEADER` | `X-OpenClaw-Tenant-Id` | **Low** | Customizes tenant header extraction key for multi-tenant mode. Keep stable across all clients/proxies. |
@@ -0,0 +1,38 @@
# GitHub CodeQL Baseline Reference
Date: 2026-04-08
## Purpose
This note records the repository decision for GitHub CodeQL rollout and the specific operational assumptions used in `S82`.
## Official GitHub References Reviewed
- GitHub Docs: `Configuring default setup for code scanning`
- Default setup is recommended when first enabling code scanning because it chooses the simplest working analysis method automatically.
- Default setup can be edited for languages/query-suite choices, but it remains UI-managed.
- GitHub Docs: `Configuring advanced setup for code scanning`
- Advanced setup is the path for repository-owned workflow configuration.
- Advanced setup uses a committed workflow file and standard GitHub Actions syntax.
- The generated workflow normally scans on `push`, `pull_request`, and schedule.
- GitHub Docs: `CodeQL code scanning for compiled languages`
- `build-mode: none` is valid for interpreted languages and is acceptable for Python and JavaScript/TypeScript in this repository.
## Repository Decision
- Keep CodeQL in the GitHub Actions security-validation layer, not as a new local mandatory SOP command.
- Use an in-repo workflow (`.github/workflows/codeql.yml`) so security scanning policy is reviewable in git.
- Start visibility-first:
- no immediate merge-blocking policy based solely on CodeQL
- use findings as triage input while the backlog is still being reduced
- Cover the repository languages that matter for the current risk surface:
- `python`
- `javascript-typescript`
- `actions`
## Operational Notes
- The repository now uses the committed `.github/workflows/codeql.yml` workflow as the authoritative advanced CodeQL baseline.
- GitHub `default setup` was switched to `not-configured` during `S91`, so scanner policy now lives in version control instead of the repository UI.
- The first successful advanced-baseline runs completed on `main` during the residual security closeout wave.
- Local acceptance for CodeQL workflow changes must use repo-local seam tests against the workflow contract; do not require a full local CodeQL run unless a future task explicitly asks for it.
@@ -0,0 +1,99 @@
# GitHub Security Residual Wave Reference
Date: 2026-04-08
Scope: Residual GitHub Security findings that remained open after the first `S73-S81` remediation wave.
## 1. Purpose
This reference captures the follow-up repair strategy that was used for the residual GitHub Security findings that still pointed at `main` after the initial CodeQL / Dependabot cleanup wave.
This document is planning-only guidance. It is not itself an implementation record.
Closure note:
- this intake reference is now historical context only
- the residual wave was closed during `S91`, with GitHub `Code scanning` and `Secret scanning` reduced to `0` open findings on 2026-04-08
## 2. Intake Residual Findings Baseline
Authenticated GitHub Security review showed this residual baseline at intake:
- Dependabot: `0` open alerts
- Code scanning: `19` open alerts
- Secret scanning: `1` open alert
Residual CodeQL families at intake:
1. `py/path-injection`
- intake concentration: `services/model_manager_transfer.py`
- count at intake: `9`
2. `py/weak-sensitive-data-hashing`
- intake concentration: `services/redaction.py`, `services/audit.py`, `services/bridge_token_lifecycle.py`
- count at intake: `3`
3. `py/stack-trace-exposure`
- intake concentration: `connector/platforms/slack_webhook.py`, `connector/platforms/feishu_webhook.py`
- count at intake: `2`
4. `py/clear-text-logging-sensitive-data`
- intake concentration: `api/bridge.py`, `services/audit.py`
- count at intake: `2`
5. `py/clear-text-storage-sensitive-data`
- intake concentration: `services/audit.py`
- count at intake: `1`
6. `py/xml-bomb`
- intake concentration: `connector/platforms/wechat_webhook.py`
- count at intake: `1`
7. `js/incomplete-sanitization`
- intake concentration: `tests/e2e/specs/notifications.spec.js`
- count at intake: `1`
Residual secret-scanning family:
- `Tencent WeChat API App ID`
- count at intake: `1`
- observed as a historical docs/example-style finding pending confirmation and closure workflow
## 3. Repair Principles
- Prefer real code fixes over dismissals.
- Keep the repair chain scoped to the exact residual findings.
- Add focused guard comments at high-risk fix points.
- Add or extend the smallest credible local regression seam for each repaired family.
- Do not run local CodeQL; GitHub remains the source of truth for scanner retirement.
- For this bug-fixing chain only, do not force unrelated full local test sweeps when no direct seam exists.
## 4. Proposed Execution Order
1. Residual logging / storage / identity-tag cleanup
2. Residual model-transfer path-boundary cleanup
3. Secret-scanning provenance review and closure handling
4. GitHub Security rescan verification and dismiss/close workflow
5. Repository-native CodeQL GitHub Actions baseline activation
## 5. Acceptance Model
Each implementation item in the chain should follow:
1. plan file
2. targeted implementation
3. targeted verification
4. implementation record
5. acceptance commit
The chain-wide source of truth for test procedure remains:
- `tests/TEST_SOP.md`
- `tests/E2E_TESTING_NOTICE.md`
- `tests/E2E_TESTING_SOP.md`
For this residual GitHub Security chain, the explicit local validation strategy is:
- reproduce and pin the exact local seam when possible
- run targeted regressions for the affected contract surface
- do not run local full CodeQL
- use GitHub rescans after push as the authoritative scanner-retirement check
## 6. Closure Policy
- Code scanning alerts should be fixed in code first and then re-checked after GitHub rescans the pushed `main` branch.
- Secret-scanning alerts require explicit provenance review before closure.
- False positives may be dismissed only after the code/test surface is demonstrably safe and the dismissal rationale is recorded.
+30 -9
View File
@@ -50,7 +50,8 @@ interface BannerStatus {
- **Monitoring**: Polls `/health` every 10s.
- **Triggers**: Checks `stats.observability.total_dropped > 0`.
- **Display**: Simple DOM injection of canonical `.openclaw-banner` markup; legacy `.moltbot-banner` compatibility selectors remain available through centralized runtime aliasing.
- **Limitations**: No connectivity state handling, no 'info'/'success' states, simplistic dedupe.
- **Connectivity posture**: Queue-monitor disconnect warnings should tolerate initial sidebar/bootstrap races and only escalate after bounded repeated failure or post-healthy disconnect evidence, so transient startup misses do not become durable incident noise.
- **Limitations**: No 'info'/'success' states, simplistic dedupe.
## 1.1 Notification Center (F66)
@@ -84,6 +85,7 @@ interface NotificationEntry {
- Entries are deduplicated by source-specific keys and persisted in local storage across reloads.
- `Dismiss` hides an entry from the active list without deleting the historical record from storage.
- `Acknowledge` clears unread state while keeping the entry visible.
- Notification `message` / `source` fields are treated as untrusted text at the render sink and must stay escaped before DOM insertion; notification content is not a supported HTML surface.
- Sources with jump targets should attach a tab/action deep link so operators can navigate directly to the affected surface.
- Canonical `openclaw-*` DOM/class ownership should be authored once in the shell/templates; any retained `moltbot-*` class compatibility must come from shared runtime alias helpers instead of duplicated markup.
@@ -120,7 +122,7 @@ interface ContextAction {
}
```
## 3. Parameter Lab (F52)
## 3. Parameter Lab
Contracts for bounded parameter sweeps and experiment orchestration.
@@ -133,20 +135,39 @@ Contracts for bounded parameter sweeps and experiment orchestration.
{
"node_id": "10",
"widget_name": "cfg",
"values": [6.0, 7.0, 8.0]
"values": [6.0, 7.0, 8.0],
"strategy": "grid"
},
{
"node_id": "3",
"node_id": "loader-alpha",
"widget_name": "seed",
"strategy": "random",
"count": 3
"values": [41, 42]
}
],
"max_runs": 20,
"batch_size": 1
]
}
```
Contract notes:
- `node_id` is a string-preserving host graph identifier. It may be numeric text such as `"10"` or a non-numeric host ID, and clients must not coerce it to a number when storing, comparing, or replaying experiment parameters.
- Experiment parameter keys such as `"10.cfg"` are display/storage keys derived from the original `node_id` plus `widget_name`; they are not a separate numeric node contract.
- Sweep values are limited to bounded strings, booleans, integers, and finite numbers. Null,
arrays, objects, non-finite numbers, overlong strings, presentation-ambiguous duplicates, and
unsupported strategies fail validation instead of being coerced.
- Sweep creation supports `grid` strategy only. The backend policy is authoritative and limits a
request to 5 MiB, workflow text to 4 MiB, eight dimensions, 50 values per dimension, and 50
generated combinations. Compare creation accepts at most eight scalar items.
### Queue ownership receipt
- The coordinator observes the host's reviewed `promptQueueing` and `promptQueued` request
boundaries, correlating their integer `requestId` and `batchCount` fields.
- It writes a transient UUID receipt only into the matching serialized workflow and returns the
exact `promptId` / `requestId` pair used to route bounded lifecycle event metadata.
- Unsupported event APIs, malformed or missing boundaries, pre-existing unobserved host queue
activity, receipt collisions, timeouts, and ambiguous batch ownership fail explicitly. There is
no fallback to a globally recent prompt ID.
### Experiment Result Schema (JSON)
```json
+866
View File
@@ -0,0 +1,866 @@
# Recent Updates History
This file preserves the longer update archive that was previously embedded directly in the README.
The README now keeps only the most recent 3-5 update summaries and links here for the full historical record.
Newest entries appear first.
<details>
<summary><strong>Startup, security posture, and architecture boundaries hardened</strong></summary>
- Added a dependency-light production source verifier to pre-commit. It parses tracked Python
imports without importing application modules and fails on unknown ownership, forbidden
dependency direction, cycles, and unreviewed dynamic imports.
- Patched transitive frontend development dependencies `ws`, `postcss`, and resolver-owned
`nanoid` without changing the root manifest or runtime dependency boundary. Windows/Linux
full-test, pre-push, and CI security paths now run a fresh `npm ci` and block high/critical
findings across the complete production and development dependency tree.
- Replaced coarse startup reporting with typed, redacted phase and state outcomes, bounded retry
and timing metadata, and explicit optional warmup results.
- Consolidated process-static deployment and security decisions into one immutable, secret-free
effective posture snapshot reused by startup, control-plane, and surface guards.
- Moved startup lifecycle, route registration, and effective posture implementations into focused
service-domain owner packages while preserving legacy module identity aliases.
- Replaced the public systemd environment file with an `.env.example`-style template and retained
a hard version-control boundary around secret-bearing environment files.
</details>
<details>
<summary><strong>Host alignment, Parameter Lab, and native workflow ownership refreshed</strong></summary>
- Split host metadata between legacy fixed-bundle Desktop and current managed-install
Comfy-Desktop. Presence of `window.__comfyDesktop2` identifies the current host generation but
never authorizes privileged bridge capability calls.
- Excluded ComfyUI's `datasets` user-data root from model inventory and Model Manager destination
handling so training data is not treated as managed model weights.
- Bounded Parameter Lab creation and persistence to string, boolean, integer, and finite-number
values, with byte/count limits, grid-only sweep validation, and explicit rejection of nested or
ambiguous values.
- Correlated Parameter Lab queue ownership through reviewed `promptQueueing` / `promptQueued`
request IDs and transient workflow receipts, failing closed on unsupported, malformed, busy, or
ambiguous host queue boundaries.
- Recognized advanced 3D `result` references as bounded source links without consuming later
metadata or rendering binary content.
- Documented native ComfyUI ownership for video/webcam inputs, audio and text-to-speech flows, and
the Graph/Workflows workspace instead of introducing duplicate OpenClaw node or workspace stacks.
</details>
<details>
<summary><strong>Maintainability, scale safeguards, and verification governance strengthened</strong></summary>
- Added a pinned incremental Ruff/Mypy policy to local, pre-commit, and CI validation. Existing
debt remains explicitly governed while new production-path findings fail the gate.
- Established deterministic scale baselines for 10,000-record jobs history, bounded connector
summaries, and 1,024 frontend output refs. Exact call counts, payload bounds, and stable digests
are enforced; host-sensitive elapsed time remains advisory.
- Classified and hardened selected config, connector, and platform-adapter exception boundaries,
preserving cancellation, compatibility fallback, public status mapping, and redacted logging.
- Decomposed API route and configuration ownership, connector command dispatch, Slack and Feishu
ingress/installation/delivery seams, and frontend API/Settings ownership behind stable facades.
Public routes, patch seams, security controls, singleton identity, DOM structure, and host
lifecycle behavior remain contract-tested.
- Promoted the backend coverage floor from 45% to 55% only after reconstructing two consecutive
release snapshots, retaining full-suite artifact hashes and all required hotspot percentages,
and assigning targeted regression owners. Incomplete, nonconsecutive, malformed, or atomically
mismatched promotion evidence now fails closed.
</details>
<details>
<summary><strong>Secure jobs visibility and connector summaries completed</strong></summary>
- Replaced the placeholder jobs listing with an Admin-only, versioned in-process read
model over current ComfyUI queue/history state, including five lifecycle states and
bounded status/workflow filters, sorting, pagination, and source/scan diagnostics.
- Reduced every listed job to an allowlisted summary and excluded raw prompts, workflows,
execution errors, tracebacks, current inputs/outputs, tenant/client/trace identifiers,
reasoning, and internal content from successful responses and audit details.
- Preserved authoritative empty snapshots while distinguishing unsupported host contracts
(HTTP 501) from unavailable or malformed snapshots (HTTP 503), so failures cannot look
like an empty queue.
- Added an Admin-class connector `/jobs` summary that validates contract version 1,
renders bounded aggregate counts and short IDs, keeps the raw payload out of the chat
LLM, and permits only a coarse queue-count fallback for explicit 501/503 conditions.
</details>
<details>
<summary><strong>Host compatibility reference anchors refreshed</strong></summary>
- Refreshed the active compatibility baseline to ComfyUI `1377a2f7` (`v0.27.0-47-g1377a2f7`, pyproject `0.27.0`) and standalone frontend `1.48.1` (`ceb5ae1eba`, `v1.48.1-1-gceb5ae1eba`).
- Kept Desktop pinned separately at `0.9.4` with core `0.22.3` and embedded frontend `1.43.18`, explicitly lagging the standalone frontend reference.
- Documented current host asset `loader_path` and namespaced model-tag schema without adopting direct `/api/assets` runtime access or changing OpenClaw's Node.js 18+ test policy.
- Added bounded Job Monitor previews for allowlisted text refs emitted under the host `files` output key, using only same-origin `/view`, strict textual MIME/UTF-8 streaming limits, literal DOM text, and explicit source-link fallback states.
</details>
<details>
<summary><strong>Host compatibility, output previews, media safety, and graph guards refreshed</strong></summary>
- Published host compatibility notes now pin the current ComfyUI, standalone frontend, and Desktop reference anchors while keeping Desktop embedded-frontend lag explicit.
- Output previews keep filename-backed refs first-class, accept optional `asset_hash` / `hash` metadata when present, and leave asset-service-only identifiers as explicit fallback states.
- LINE and WhatsApp connector media URLs now force dangerous active content such as SVG/HTML/JS/CSS/XML to download with no-sniff response headers while preserving safe image delivery.
- Job Monitor now treats HDR `.exr` and `.hdr` image outputs as explicit source-preview fallback links instead of normal thumbnails, matching the current host expectation without bundling a HDR viewer.
- Parameter Lab and graph-helper coverage now preserve non-numeric node IDs and promoted-widget source metadata, while structured color/box widget inputs stay out of missing-model diagnostics.
</details>
<details>
<summary><strong>Targeted connector cancellation and host contract guard coverage refreshed</strong></summary>
- Connector `/stop`, `/cancel`, and `/interrupt` commands now keep no-argument global interrupt explicit while routing supplied job IDs through targeted ComfyUI job cancellation.
- Single-job cancellation on older hosts can fall back only to targeted interrupt; multi-job cancellation failures no longer degrade into a global interrupt.
- Compatibility guard coverage now documents SaveImage-style output refs, 3D preview refs, typed asset dimensions, grouped asset behavior, sidebar registration fallback, and the OpenClaw Node.js runtime policy.
- OpenClaw keeps its own package/test harness on Node.js `>=18.0.0` while documenting that standalone ComfyUI frontend development may require a newer Node engine.
</details>
<details>
<summary><strong>Package hygiene, runtime cache ownership, and tool diagnostics tightened</strong></summary>
- Moved developer-only verification helpers out of the repository root and into the dedicated developer tooling area, keeping the custom-node package root focused on shipped package entrypoints and metadata.
- Made the default external-tool allowlist package-owned at `data/tools_allowlist.json`; custom allowlists are now explicitly routed through `OPENCLAW_TOOLS_CONFIG_PATH` instead of being accidentally masked by state-dir or bind-mounted source layouts.
- Added a dependency-light runtime hygiene contract that separates package resources, state-directory runtime cache/sandbox paths, and repo-local generated validation artifacts.
- Preserved the no-automatic-repair posture for runtime dependency caches: OpenClaw does not delete, migrate, or repair generated runtime dependency caches without an explicit future implementation.
- Added deterministic tool execution diagnostics for missing sandbox runtime, missing executable/interpreter, timeout, workspace/path violation, and process failures while keeping hardened missing-runtime behavior fail-closed and avoiding Docker or broader fallback execution.
</details>
<details>
<summary><strong>ComfyUI host compatibility, media outputs, model folders, and prompt attribution refreshed</strong></summary>
- Refreshed the published compatibility baseline for ComfyUI `51bf508a` (`v0.27.0-25-g51bf508a`, pyproject `0.27.0`), standalone frontend `1.47.6`, and Desktop `0.9.4` with core `0.22.3` plus embedded frontend `1.43.18`.
- Reconciled active prompt state after backend or SSE reconnects so completed prompts are not left in the active queue lane after a host recovery.
- Updated sidebar registration to prefer the current ComfyUI sidebar store API and keep the deprecated frontend facade as a compatibility fallback for older hosts.
- Aligned Model Manager and preflight diagnostics with current ComfyUI model folder names, including newer managed keys such as `gligen`, `latent_upscale_models`, `hypernetworks`, `photomaker`, `model_patches`, `geometry_estimation`, and `detection`, while retaining legacy aliases such as `clip` and `unet`.
- Made output parsing media-aware for current previewable result groups (`images`, `video`, `audio`, `3d`, and bounded `text`) while keeping image callbacks compatible and keeping asset-only identifiers as explicit fallback states instead of silently upgrading to `/api/assets`.
- OpenClaw prompt submissions now include stable `comfy_usage_source` attribution when missing, without overwriting caller-provided attribution or copying prompt/tenant/trace content into that field.
</details>
<details>
<summary><strong>Connector replay, reply visibility, and scheduled delivery behavior aligned with current chat workflows</strong></summary>
- Connector event handling now distinguishes duplicate committed actions from retryable pre-delivery failures across supported chat adapters, reducing accidental re-execution while still allowing safe retries.
- Reply visibility is now governed by a shared connector policy for direct messages, shared chats, threads, internal delivery, and tool-only contexts; suppressed text is logged as a successful no-op instead of a delivery failure.
- Telegram topics, Slack threads/workspaces, and Feishu account/workspace context are preserved for immediate replies and delayed result or approval follow-up, while approval/action buttons remain visible.
</details>
<details>
<summary><strong>Startup lifecycle diagnostics, connector SecretRef service boundaries, and internal prompt isolation aligned with the current runtime</strong></summary>
- Health diagnostics now distinguish required startup readiness, optional warmup degradation, and fatal startup failures; optional warmups run after route registration and no longer block baseline API availability.
- Connector/service launch planning now has a secret-blind env-backed SecretRef boundary that preserves supported connector credential references without expanding raw token values, while rejecting raw secrets, legacy marker strings, unsupported envs, and runtime-only auth tokens.
- Operator-visible and audit payload sanitization now removes explicitly marked internal maintenance/helper prompt content before normal reasoning redaction, while leaving ordinary user text intact.
</details>
<details>
<summary><strong>Host compatibility anchors and inactive-branch preflight diagnostics aligned with current ComfyUI hosts</strong></summary>
- Refreshed the published compatibility matrix for current ComfyUI, standalone frontend, and desktop reference anchors, keeping desktop embedded-frontend lag explicit instead of assuming standalone-frontend parity.
- Updated workflow portability and preflight diagnostics so muted or bypassed workflow branches are separated from actionable missing-node/model failures when frontend workflow metadata is available.
- Explorer now surfaces inactive-branch findings as suppressed diagnostics, so operators can still inspect them without treating them as current workflow blockers.
- Tightened repository ignore rules so public release documentation is not accidentally hidden from version control.
</details>
<details>
<summary><strong>Slack interactive callbacks, canonical node categories, and hardening governance aligned with the current runtime</strong></summary>
- Added Slack interactive callback handling for Block Kit actions, modal submissions, and workflow-style payloads, with signed ingress verification, replay/idempotency checks, bounded external errors, and policy-aware routing for run-affecting actions.
- Aligned shipped node metadata on the canonical `openclaw` category while keeping legacy `Moltbot*` class aliases available for existing workflows.
- Tightened node and frontend maintainability by moving batch-variant randomized seed imports to module scope and keeping tab DOM wiring on shared text-safe helper paths.
- Added explicit verification ownership for the `safe_io` and security-boundary hotspot families so future coverage ratchets depend on targeted regressions instead of broad coverage alone.
- Hardened exception-boundary governance around selected startup and connector paths so unexpected route/bootstrap or trust-parsing failures are surfaced instead of silently masked.
</details>
<details>
<summary><strong>Packaging boundaries, node portability guidance, config ownership seams, and connector extraction diagnostics aligned with the current runtime</strong></summary>
- Made the supported packaging model explicit: the ComfyUI custom node pack remains the primary artifact, the embedded operator platform is the first-class runtime identity, and the connector stays an optional attached subsystem rather than a separate published package.
- Added a stable node portability contract so inventory/preflight diagnostics can expose OpenClaw node metadata and deterministic replacement hints when a workflow depends on nodes that are not available in the current host.
- Consolidated the remaining high-churn package-boundary import hotspots onto shared import-fallback helpers so minimal or partially optional environments degrade predictably instead of crashing on module import.
- Split runtime-config ownership into focused storage, policy, and operator-projection seams while keeping the public runtime-config facade and precedence contract stable for existing callers and operators.
- Added admin-only connector extraction diagnostics at `/openclaw/connector/extraction-contract` (with legacy `/moltbot/*` parity) so maintainers can query the current no-split recommendation, seam families, and blockers from one machine-readable source of truth.
</details>
<details>
<summary><strong>Verification governance, config bootstrap hygiene, and connector env hardening aligned with the current runtime</strong></summary>
- Promoted the staged coverage-ratchet baseline to the then-enforced `45%` floor, added retained review-cycle evidence for hotspot families, and wired backend coverage collection through one shared local/CI helper instead of ad hoc `fail_under` edits.
- Added focused connector and config/bootstrap hotspot regressions, reviewed the governed hotspot-family coverage summaries, and retired the temporary promotion-gap exceptions now that both promotion-blocking families are represented by explicit review evidence.
- Added fail-closed test-debt governance for no-skip modules and mutation-survivor allowlist entries, with explicit `reason` and `review_after` metadata now enforced by the standard full-test flow.
- Hardened pack metadata/version fallback parsing and made config/bootstrap imports side-effect-safe, so pack version fallback stays deterministic and importing config helpers no longer creates the state directory or log file before first real use.
- Added bounded connector numeric env parsing for delivery, media, timeout, rate-limit, command-length, OAuth TTL, and bind-port settings, so malformed values degrade to documented defaults or clamps with warnings instead of crashing startup.
</details>
<details>
<summary><strong>Output contract, outbound egress handling, Security Doctor structure, and audit verification tooling aligned with the current runtime</strong></summary>
- Kept `/history` + `/view` as the supported runtime output contract for current operator flows, and made asset-service-only refs stay explicit as a bounded fallback state instead of silently guessing a direct `/api/assets` fetch path.
- Consolidated outbound safe HTTP execution behind one shared `safe_io` executor seam so local-provider checks, connector callbacks, and redirect handling now follow the same SSRF-safe validation, pinning, and redirect re-check rules.
- Split Security Doctor internals into focused endpoint, runtime, connector, report, and remediation modules while keeping the operator-facing doctor API and remediation workflow unchanged.
- Added retained audit-chain verification tooling, including a persisted `audit.log.key` sidecar when no environment key is provided, so operators can verify the current audit log plus retained rotations after restart or log rotation.
</details>
<details>
<summary><strong>Provider URL parity and CI harness resilience tightened for local LLM defaults and Playwright bootstrap stability</strong></summary>
- Fixed the built-in `Ollama (Local)` provider default so OpenClaw's OpenAI-compatible requests now target the correct `/v1` surface by default, and existing loopback-root overrides are normalized onto the same bounded path instead of failing on `/models` or `/chat/completions` at the daemon root.
- Added a provider URL contract matrix that pins built-in provider defaults, adapter endpoint assembly, and bounded Ollama normalization in one regression lane so future `LM Studio`, `Ollama`, and custom OpenAI-compatible drift is caught before release.
- Hardened the shared Playwright harness bootstrap so a single transient `openclaw.js` module-fetch failure in CI is retried once instead of failing the whole UI load, while still surfacing real import/runtime errors as hard test failures.
</details>
<details>
<summary><strong>PNG Info sidebar workflow added with ComfyUI metadata extraction, better large-image handling, and lower-noise operator alerts</strong></summary>
- Added a new `PNG Info` sidebar tab with drag-and-drop, file picker, scoped paste, preview rendering, prompt copy actions, structured summary cards, and raw metadata inspection for saved generation images.
- Added backend metadata parsing for A1111 infotext and ComfyUI `prompt` / `workflow` metadata, including prompt/sampler/model/size extraction from standard ComfyUI graphs and a larger dedicated payload ceiling for original metadata-bearing images.
- Improved operator-facing UX by making large-image failures explain the metadata-preservation constraint more clearly, letting the PNG Info input area scroll with the rest of the content, and moving prompt copy surfaces to the top of the information area.
- Reduced noise in ComfyUI prompt extraction so generic custom `CLIPTextEncode*` nodes now prefer explicit prompt-bearing keys instead of surfacing parser/config strings as if they were prompt text.
- Tightened queue-monitor alert sensitivity so sidebar startup races no longer generate persistent disconnect noise unless the backend stays unavailable long enough to look like a real incident.
</details>
<details>
<summary><strong>Repo-native CodeQL baseline and residual GitHub Security verification chain completed</strong></summary>
- Added a versioned GitHub Actions `CodeQL` workflow that scans Python, JavaScript/TypeScript, and GitHub Actions on push, pull request, manual dispatch, and a weekly schedule, so static security analysis now has an explicit in-repo baseline instead of depending only on opaque UI configuration.
- Kept the rollout visibility-first: CodeQL is now a GitHub Actions security lane and documented CI boundary, but it is not treated as a new local mandatory full-SOP command; local acceptance stays seam-first while GitHub-hosted scanning owns the repository-wide static-analysis baseline.
- Closed the acceptance-gap that surfaced during the residual verification push by propagating `defusedxml` through `requirements.txt`, preflight checks, local acceptance bootstraps, and CI preflight installation, with a repo-local dependency-parity regression seam to prevent future drift.
- Re-ran the full existing pre-push acceptance gate successfully after the parity fix: detect-secrets, pre-commit, governance verification, backend full suites, real-backend lanes, adaptive adversarial gate, and Playwright E2E.
</details>
<details>
<summary><strong>Security hardening wave completed across CI permissions, path boundaries, redaction, connector ingress, notification rendering, and GitHub security closure</strong></summary>
- Verified the minimal Vite development-tooling hotfix path already merged cleanly, so the repo now resolves the patched `vite` version without broadening the frontend toolchain scope.
- Added explicit least-privilege GitHub Actions `permissions:` declarations and a repo-local regression seam so workflow token scope drift is now treated as a tracked security regression instead of an implicit repository default.
- Hardened checkpoint, integrity, and managed model-transfer path handling to fail closed on invalid IDs, traversal markers, and rebased install targets, with focused regression coverage on every flagged filesystem sink.
- Replaced raw security-sensitive identifiers in bridge, auth, audit, proxy, and safe-IO diagnostics with stable redacted tags, and upgraded sensitive hashing paths to keyed constructions instead of plain or hardcoded hash inputs.
- Tightened connector ingress failure handling so WeChat rejects unsafe XML declarations before parser entry, while Slack and Feishu return bounded external failure text/codes instead of echoing raw exception detail.
- Added a targeted Playwright seam proving notification payloads render as escaped text rather than live markup, locking the production notification sink against future HTML-interpolation regressions.
- Completed the GitHub-side closeout for the same wave by switching the repository from GitHub code-scanning default setup to the versioned advanced CodeQL workflow, dismissing the final residual CodeQL false positives with recorded rationale, resolving the historical docs-only secret-scanning false positive, and bringing GitHub `Code scanning` / `Secret scanning` back to `0` open findings as of `2026-04-08`.
</details>
<details>
<summary><strong>Desktop host parity lane, refreshed compatibility anchors, and live-backend mock parity completed</strong></summary>
- Added an executable desktop-host regression lane for the OpenClaw sidebar and Remote Admin Console, so desktop-specific runtime drift is now verified separately from standalone frontend assumptions instead of being left to unit-only host detection.
- Added shared Playwright host/runtime shims and remote-admin baseline mocks so desktop-host metadata, approvals refresh behavior, and host-sensitive UI evidence stay deterministic under the test harness.
- Refreshed the recorded compatibility anchors against the current reference ComfyUI, ComfyUI Frontend, and Desktop hosts, keeping desktop embedded-frontend lag explicit in the published compatibility matrix and governance checks.
- Updated the mocked live-backend parity lane so image-output surfaces now return deterministic mocked output artifacts, closing the remaining preview/result gap in the real-backend-style E2E contract.
- Re-validated the combined batch on WSL with the full SOP gate: detect-secrets, pre-commit, governance verification, backend full suites, strict implementation-record lint, real-backend lanes, adaptive adversarial gate, and Playwright E2E.
</details>
<details>
<summary><strong>Feishu connector chain completed with long-connection transport, tenant-aware bindings, and interactive approval callbacks</strong></summary>
- Added a Feishu/Lark connector baseline that supports both long-connection and webhook ingress modes, keeps transport behavior aligned through the shared connector authorization model, and makes host-domain differences explicit through `feishu` vs `lark` account binding metadata instead of ad hoc runtime branching.
- Added Feishu account/workspace installation bindings with fail-closed resolution, tenant-aware diagnostics, normalized installation records, and support for multi-account binding manifests so one connector runtime can host more than one Feishu workspace contract safely.
- Added Feishu interactive-card callback handling for approval and command actions, including signed callback envelopes, stale/replay rejection, duplicate-action dedupe, actor-context mapping, and explicit approval downgrade when untrusted users press run-affecting actions.
- Updated the connector runtime so websocket-mode Feishu deployments also host the callback ingress surface, keeping interactive-card approvals available even when message ingress is handled over long connection instead of pure webhook mode.
- Re-validated the full Feishu batch on WSL with the full SOP gate: detect-secrets, pre-commit, governance verification, backend full suites, strict implementation-record lint, real-backend lanes, adaptive adversarial gate, and Playwright E2E.
</details>
<details>
<summary><strong>Snapshot-first diagnostics, delta polling contracts, schema alignment, and optional-dependency import hardening completed</strong></summary>
- Moved Explorer inventory diagnostics onto a snapshot-first contract so `/openclaw/preflight/inventory` returns quickly with explicit `snapshot_ts`, `scan_state`, `stale`, and `last_error` metadata while deep refresh continues in the background.
- Hardened event and managed-download polling around deterministic cursor metadata, so operator surfaces can resume from `effective` and `next` sequence markers instead of relying on duplicate-prone full refresh loops.
- Unified webhook and managed-model request/documentation fixtures around one shared contract bundle, tightened model-import destination validation to reject traversal markers fail-closed, and kept the published API/OpenAPI surfaces aligned with the runtime validators.
- Removed the remaining import-time `aiohttp` traps from high-impact route/service modules by moving them onto one bounded compatibility seam, so minimal environments degrade deterministically at call time instead of crashing on module import.
- Re-validated the full batch on WSL with the full SOP gate: detect-secrets, pre-commit, governance verification, backend full suites, strict implementation-record lint, real-backend lanes, adaptive adversarial gate, and Playwright E2E.
</details>
<details>
<summary><strong>Frontend host compatibility, asset-backed output interop, and CI audit alignment completed</strong></summary>
- Hardened frontend host compatibility against current standalone frontend and desktop bundle drift by moving graph/widget compatibility logic onto shared host helpers, adding explicit sidebar host-surface stamping, and surfacing desktop embedded-frontend parity through compatibility diagnostics instead of implicit assumptions.
- Added a bounded asset-output interoperability seam so classic ComfyUI history refs and newer asset-backed refs both resolve through the existing `/view` contract, preserving current temp/output behavior while allowing hash-backed previews where upstream metadata provides them.
- Updated output/history-facing frontend and backend parsers together, so `Jobs` previews, callback payload image refs, and history extraction follow one canonical path rather than duplicating view-URL assembly logic in separate layers.
- Refreshed compatibility anchors against the current reference repos and fixed the CI Python dependency audit path so the enforced audit checks declared project requirements instead of scanning unrelated runner/toolchain packages.
- Re-validated the implementation on WSL with the full SOP gate: detect-secrets, pre-commit, backend full suites, strict implementation-record lint, adaptive adversarial gate, and Playwright E2E.
</details>
<details>
<summary><strong>Exception-fidelity cleanup and verification-governance baseline completed</strong></summary>
- Preserved original traceback origins on the remaining planner/refiner/vision/config failure paths and aligned request-time default `LLMClient` refresh so runtime config hot-reload no longer mutates long-lived service state just to get a fresh client.
- Added explicit coverage governance in `pyproject.toml`, including the then-active `45%` `fail_under`, visible missing-line reporting, and skip-covered output, so baseline quality drift is no longer implicit.
- Added a stdlib-only governance verifier that fails closed when coverage config, adversarial mutation thresholds, SOP guidance, or mutation-survivor allowlist shape drift away from the enforced baseline.
- Wired the governance verifier into Linux/Windows full-test flows and the repo pre-push gate, keeping local CI-parity checks aligned with the enforced verification contract.
- Re-validated the full implementation on WSL with the full SOP gate: detect-secrets, pre-commit, governance verification, backend full suites, adaptive adversarial gate, and Playwright E2E.
</details>
<details>
<summary><strong>Maintainability wave completed across routes, model operations, admin shell, and compatibility cleanup</strong></summary>
- Split route registration into focused route-family registrars while keeping one startup composition root and preserving legacy `/moltbot/*` plus `/api/*` fallback behavior.
- Split Model Manager internals into dedicated catalog, task-lifecycle, and transfer/security service slices without changing the accepted managed-download, resume, import, and recovery contract.
- Extracted sidebar notification/banner runtime and standalone admin-console browser logic into dedicated modules so the shell stays a composition root instead of a growing page-level hotspot.
- Centralized runtime generation of legacy `moltbot-*` class aliases and removed residual duplicated node image-helper wrappers, so canonical `openclaw-*` markup and shared image encoding logic now have one maintained path.
- Re-validated the full batch on WSL with the full SOP gate: detect-secrets, pre-commit, backend full suites, adaptive adversarial gate, and Playwright E2E.
</details>
<details>
<summary><strong>Slack multi-workspace installation flow completed, with final egress and notification-center hardening</strong></summary>
- Added Slack multi-workspace OAuth install/callback handling with single-use state validation, workspace-scoped installation binding, encrypted token refs, and workspace-aware reply routing for inbound events and delayed result delivery.
- Expanded connector diagnostics so Slack installation health now surfaces stable fail-closed states such as `ok`, `invalid_token`, `revoked`, `workspace_unbound`, and `degraded` without exposing token material.
- Moved Slack OAuth token exchange onto the same SSRF-safe outbound layer used by other protected network paths, closing the late-stage egress policy regression found during the full acceptance sweep.
- Fixed a notification-center persistence regression so dismissed model-manager failure alerts stay hidden after reload instead of being immediately re-created by repeated background refresh failures, while historical storage remains intact.
- Re-validated the final implementation on WSL with the full SOP gate: detect-secrets, pre-commit, backend full suites, adaptive adversarial gate, and Playwright E2E.
</details>
<details>
<summary><strong>Planning, startup/config hardening, compatibility governance, and frontend hotspot reduction batch</strong></summary>
- Normalized the active maintainer planning surface and clarified the docs-only test-flow exemption in the project SOP guidance.
- Hardened route/bootstrap registration around a declarative manifest and centralized validation seam so startup wiring is less fragile under delayed readiness and import-order edge cases.
- Completed the next config-unification pass around one effective-config read facade, reducing precedence drift across backend and frontend-facing config consumers.
- Centralized legacy compatibility handling for backend headers and frontend API/storage fallbacks so deprecation behavior is explicit, shared, and regression-covered.
- Split the frontend shell hotspot and LLM model-list helper logic into smaller seams, then fixed the timer-binding regression uncovered during full-gate Playwright validation.
</details>
<details>
<summary><strong>Private-host LLM SSRF contract clarified across Remote Admin, docs, and deployment guidance</strong></summary>
- Clarified that `OPENCLAW_LLM_ALLOWED_HOSTS` only extends the exact public-host allowlist for custom LLM `base_url` values and does not permit private/reserved LAN targets by itself.
- Updated Remote Admin and model-refresh SSRF error messages so operators can distinguish public-host allowlisting, scoped private-network allowance, and the explicit insecure override for private-IP targets.
- Documented Windows portable env inheritance expectations, including the need to set variables before launching `python_embeded\python.exe`, restart after changes, and avoid unsupported wildcard entries such as `*`.
- Fixed request-time parity so Remote Admin validation, `/openclaw/llm/models`, and outbound provider requests now honor the same scoped private-network allowance or explicit insecure override for intentional private-host/HTTP LLM targets.
- Added a pre-commit autofix guard that regenerates `docs/openapi.yaml` when OpenAPI contract/generator inputs change, preventing generated-spec drift from surfacing only at push time.
- Added regression coverage for the clarified SSRF error contract and re-validated with the full SOP gate.
</details>
<details>
<summary><strong>Inventory indexing moved to snapshot-first refresh with background deep-scan</strong></summary>
- Changed `/openclaw/preflight/inventory` to return a fast snapshot first, then refresh inventory state in the background instead of blocking on full directory traversal.
- Added snapshot freshness/status metadata (`snapshot_ts`, `scan_state`, `stale`, `last_error`) so the API and explorer UI can surface refresh progress and degraded scan results explicitly.
- Added bounded traversal checkpoints and background refresh scheduling to reduce latency spikes on large model directories while keeping later reads convergent.
- Added backend regression coverage for snapshot, stale/error, and API-state transitions, then validated with the full SOP gate.
</details>
<details>
<summary><strong>Model Manager reliability upgrade: resumable downloads and restart-safe recovery</strong></summary>
- Added resumable managed download support using staged `.part` artifacts plus checkpoint metadata, so interrupted transfers can continue via HTTP Range when upstream contracts are compatible.
- Added deterministic fallback-to-full restart paths when resume preconditions fail (range unsupported, validator drift, content-range mismatch) without bypassing existing provenance/SHA256 import gates.
- Added persisted download task registry with startup recovery replay and bounded replay limit control (`OPENCLAW_MODEL_DOWNLOAD_RECOVERY_REPLAY_LIMIT`) to prevent unbounded restart churn.
- Added backend regression coverage for resume success, fallback behavior, and replay-limit overflow handling, then validated with the full SOP gate.
</details>
<details>
<summary><strong>Reasoning trace redaction hardening and privileged local-debug reveal gate</strong></summary>
- Added a shared reasoning-redaction boundary helper so reasoning/thinking-like fields are stripped by default from assist responses, event/SSE payloads, trace responses, callback payloads, and connector-facing trace formatting.
- Added an explicit privileged reveal path that now requires request opt-in, server-side debug enablement, admin authorization, loopback source, and permissive local posture, with audit visibility for reveal attempts.
- Kept final user-visible answers intact while preventing internal reasoning traces from leaking through default operator-facing serializers.
- Closed a serializer compatibility regression found during full-gate validation and hardened WSL `/mnt/*` Playwright stability with environment-aware worker and readiness-timeout guardrails.
- Validated with the full SOP gate on WSL (detect-secrets, pre-commit, backend full suite, real-backend lanes, adversarial gate, and frontend Playwright E2E).
</details>
<details>
<summary><strong>Embedded model operations UX update: new Model Manager tab and Parameter Lab icon fix</strong></summary>
- Added a dedicated `Model Manager` sidebar tab for model search, managed download task queueing, task lifecycle monitoring, and completed-task import into managed install paths.
- Added frontend regression coverage for the new tab flow (sidebar visibility/switching plus queue/import interaction path in Playwright E2E).
- Fixed the `Parameter Lab` tab icon contract by using a PrimeIcon class so the tab icon renders correctly in the sidebar.
</details>
<details>
<summary><strong>Multi-tenant isolation baseline, optional local secret sourcing, and layered config unification completed</strong></summary>
- Added a fail-closed tenant boundary model with tenant-scoped config/secret resolution, connector installation isolation, approvals/presets/templates visibility boundaries, and per-tenant execution concurrency caps.
- Added optional local 1Password CLI key sourcing with explicit enablement, command allowlist, template validation, and bounded fail-closed lookup behavior.
- Unified config precedence across runtime/config/provider call paths around a shared layered resolver (`env > runtime override > persisted > default`) with compatibility aliases preserved.
- Completed full verification gate pass on `dev` (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E).
</details>
<details>
<summary><strong>Optional local secret-manager baseline for safer key sourcing</strong></summary>
- Added a pluggable backend secret-provider chain for API keys (`env -> optional 1Password CLI -> encrypted server store -> none`) so operators can keep runtime keys out of plaintext deployment config where needed.
- Added fail-closed 1Password guardrails requiring explicit enablement, executable allowlist, command path validation, and bounded lookup timeout behavior.
- Added regression coverage for precedence resolution, allowlist/failure fallback behavior, and no-secret-leak logging expectations.
- Completed full verification gate pass on `dev` (detect-secrets, pre-commit, backend unit suites, adversarial/retry/real-backend lanes, and frontend Playwright E2E).
</details>
<details>
<summary><strong>Todays implementation roundup across frontend quality, planner contracts, and connector security baselines</strong></summary>
- Completed the frontend quality bundle by stabilizing canonical style ownership, adding baseline frontend unit coverage, and expanding regression coverage for Library/Approvals/admin-console parity.
- Completed SSRF pinning regression hardening with dedicated no-skip coverage for pinned connect paths, multi-IP failover ordering, and TLS wrap degradation branches.
- Completed planner profile/system-prompt externalization with validated file-backed registry loading, runtime-safe fallback/reload behavior, and synchronized profile sourcing across API, node, and Planner tab.
- Completed connector contract baseline with multi-workspace installation lifecycle registry, encrypted token references, fail-closed workspace resolution, and reusable interactive callback security decisions (signature/timestamp/hash/replay/idempotency/policy mapping) plus admin diagnostics APIs.
- Completed full verification gate pass on `dev` (detect-secrets, pre-commit, backend unit suites, adversarial/retry/real-backend lanes, and frontend Playwright E2E).
</details>
<details>
<summary><strong>Connector multi-workspace installation and interactive callback contract baseline</strong></summary>
- Added a persistent connector installation registry with normalized installation identity (`platform`, `workspace_id`, `installation_id`, `token_refs`, `status`, `updated_at`) and explicit lifecycle transitions (`created`, `active`, `rotating`, `revoked`, `deactivated`, `uninstalled`).
- Enforced fail-closed workspace resolution for connector ingress (`missing`, `ambiguous`, `inactive`, and `stale token ref` bindings are rejected deterministically).
- Added reusable interactive callback security contract primitives (signed envelope, timestamp window, payload hash verification, replay/idempotency enforcement, ack/deferred callback lifecycle, and policy mapping to `public` / `run` / `admin` with explicit force-approval handling).
- Added admin read/diagnostic APIs for connector installation state, resolution evidence, and lifecycle audit visibility, with redacted outputs only.
- Completed full verification gate pass on `dev` (detect-secrets, pre-commit, backend unit suites, adversarial/retry/real-backend lanes, and frontend Playwright E2E).
</details>
<details>
<summary><strong>Planner registry externalization with runtime-safe profile alignment</strong></summary>
- Moved planner profiles and the planner system prompt into validated file-backed defaults under `data/planner/`, with state-dir override precedence for operator-managed customization without source edits.
- Added a planner profile list API so the Assist planner route, Prompt Planner node, and Planner tab resolve profiles from one synchronized source-of-truth.
- Kept runtime behavior fail-closed with schema validation, prompt placeholder validation, embedded fallback defaults, and lazy reload on planner file changes.
- Completed full verification gate pass on `dev` (detect-secrets, pre-commit, backend unit suites, adversarial/retry/real-backend lanes, and frontend Playwright E2E).
</details>
<details>
<summary><strong>Frontend quality baseline for Library and Approvals surfaces</strong></summary>
- Canonicalized active frontend styling ownership around `openclaw-*`, including shell/tab-manager cleanup and a deterministic split of `web/openclaw.css` into core and legacy-alias modules.
- Added a frontend unit-test lane with Vitest + jsdom plus baseline coverage for shared UI helpers and extracted Library tab state logic.
- Expanded Playwright coverage for `Library` and `Approvals`, including success/degraded paths and approvals parity between the sidebar and the Remote Admin Console.
- Completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, adversarial/retry/real-backend lanes, and frontend Playwright E2E).
</details>
<details>
<summary><strong>Audit event clarity and connector ingress fail-closed hardening</strong></summary>
- Normalized audit helper behavior so config/secret/LLM-test convenience wrappers now emit one canonical audit event per action, reducing duplicate noise while preserving legacy compatibility paths.
- Added shared connector allowlist posture evaluation and enforced fail-closed startup behavior for public/hardened deployments when connector ingress is active without allowlist coverage.
- Kept local/permissive posture as warning-only, with synchronized visibility across startup checks, deployment profile checks, and Security Doctor diagnostics.
- Added focused regression coverage and completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E).
</details>
<details>
<summary><strong>Startup fail-closed bootstrap hardening and public boundary guardrail</strong></summary>
- Enforced strict fail-closed startup propagation so bootstrap security-gate failures are no longer logged-and-continued; route/worker registration now aborts deterministically on fatal startup failures.
- Added an explicit public deployment boundary acknowledgement contract:
- `OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1` (legacy alias supported)
- public profile gate now fails deterministically when this acknowledgement is missing.
- Added a dedicated Security Doctor boundary posture check and machine-readable environment marker so shared ComfyUI/OpenClaw surface risk is visible to operators.
- Synchronized deployment/operator docs for public boundary controls (reverse proxy path allowlist + network ACL requirements).
- Completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E).
</details>
<details>
<summary><strong>Core runtime maintainability and contract hardening batch</strong></summary>
- Refactored startup/bootstrap responsibilities into clearer service slices to keep the entry path thin and easier to validate.
- Hardened provider adapter error contracts with safer HTTP error propagation and retry-after handling consistency.
- Replaced fragile JSON object extraction logic in LLM output parsing with stdlib decoder-based behavior for stronger edge-case resilience.
- Unified node/runtime consistency by converging shared image encoding helpers and internal node naming compatibility paths.
- Added and aligned regression coverage, then completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E).
</details>
<details>
<summary><strong>Security and reliability hotfix chain: startup gate cleanup, atomic audit writes, and clearer CSRF override posture</strong></summary>
- Cleaned up unreachable startup security-gate code after fatal raise paths, keeping fail-closed behavior explicit and reducing maintenance ambiguity.
- Hardened append-only audit integrity by making hash-chain write flow atomic under a process lock to avoid concurrent chain-fork risk.
- Added explicit startup warning when localhost no-origin override is enabled, plus a dedicated Security Doctor posture check/violation mapping for operator visibility.
- Added focused regression coverage for startup warning/doctor posture and audit lock path behavior.
- Completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E).
</details>
<details>
<summary><strong>Standalone remote admin mobile console for phone/desktop operations</strong></summary>
- Added an independent remote admin entry page at `/openclaw/admin` (legacy `/moltbot/admin`), separate from the ComfyUI side panel.
- Added a mobile-first admin console layout for operational flows:
- dashboard (health, provider/key state, scheduler/runs summary, recent error lines)
- jobs/events (recent runs + SSE connect/poll fallback)
- approvals (approve/reject)
- schedules/triggers (toggle/run/fire)
- config (read + guarded write)
- doctor/diagnostics and quick actions (retry/model refresh/drill via existing policy gates)
- Preserved backend security boundaries: remote write actions still require explicit admin-token and remote-admin policy conditions.
- Completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E).
</details>
<details>
<summary><strong>Executor lane split and callback I/O isolation for better saturation resilience</strong></summary>
- Added dedicated executor lanes for LLM vs I/O workloads with bounded worker controls.
- Migrated callback delivery and outbound HTTP callback paths to the I/O lane, reducing interference with LLM execution paths.
- Added queue/saturation diagnostics and executor metrics exposure in health/stat telemetry.
- Added targeted regression coverage for lane split behavior and callback I/O lane migration.
- Completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E).
</details>
<details>
<summary><strong>Runtime lifecycle consistency, structured logging opt-in, and generated OpenAPI spec</strong></summary>
- Completed a focused runtime operability and contract maturity batch with full SOP verification:
- added graceful shutdown/reset consistency hooks so scheduler/failover runtime state flushes and resets are deterministic
- added opt-in structured JSON logging for core execution paths (including queue submit and LLM client) with bounded metadata events
- added machine-readable OpenAPI spec generation and committed `docs/openapi.yaml` for integrator/review tooling use
- added regression coverage for runtime lifecycle state handling, structured logging behavior, and OpenAPI generation drift
- completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E)
</details>
<details>
<summary><strong>Assist streaming UX and frontend fetch-wrapper safety hardening</strong></summary>
- Completed a focused assist UX + frontend transport reliability batch with full SOP verification:
- added optional streaming assist paths for Planner/Refiner with incremental preview updates and staged progress events
- added backend streaming endpoints for planner/refiner assist flows with capability-gated frontend enablement and safe fallback to the existing non-stream path
- added frontend live preview rendering for Planner/Refiner while preserving cancel/stale-response safety behavior
- added idempotent fetch-wrapper composition guards to prevent duplicate wrapper stacking during repeated frontend bootstrap/setup
- added backend/parser/frontend regression coverage for streaming assist behavior and fetch-wrapper idempotence, plus full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E)
</details>
<details>
<summary><strong>Recent hardening and reliability improvements: runtime guardrails, crypto drills, compatibility governance, and safer management queries</strong></summary>
- Completed a focused reliability + operations hardening batch with full SOP verification:
- consolidated shared frontend/backed helper paths to reduce duplicated cancellation, JSON parsing, and import-fallback logic
- added runtime guardrails diagnostics/contract enforcement so runtime-only safety limits stay visible and cannot be persisted back into config
- added cryptographic lifecycle drill automation with machine-readable evidence for rotation, revoke, key-loss recovery, and token-compromise scenarios
- added compatibility matrix governance metadata plus a refresh workflow script and operator-doctor freshness/drift warnings
- hardened management query pagination behavior with deterministic malformed-input handling, bounded scans, and clearer cursor diagnostics for admin/event list paths
- completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E)
</details>
<details>
<summary><strong>Latest completion: automation composer endpoint, safer payload drafting, and full verification pass</strong></summary>
- Completed the automation payload composer flow for safe draft generation:
- added a new admin-only compose endpoint for trigger/webhook payload drafts (generate-only, no execution side effects)
- added strict server-side validation and normalization for trigger/webhook draft payloads
- added tool-calling schema support for automation payload composition with deterministic fallback behavior
- exposed composer capability flag for frontend/runtime feature probing
- added and extended backend tests for API handler, composer service, schema/validator coverage, and capability contract
- completed full validation gate pass (detect-secrets, pre-commit, backend test lanes, adversarial smoke gate, and frontend Playwright E2E)
</details>
<details>
<summary><strong>Slack app support closeout: secure Events API ingress, connector parity, and no-skip verification lanes</strong></summary>
- Completed Slack implementation hardening chain with full SOP validation:
- added Slack Events API adapter with signed ingress checks, replay/dedupe handling, bot-loop suppression, allowlist enforcement, and thread-aware reply delivery
- wired Slack runtime policy into existing connector authorization boundaries so command trust behavior stays consistent with other platforms
- added dedicated Slack verification lanes for ingress contract coverage and real-backend flow parity, both enforced by skip-policy and full-test scripts
- added optional Slack Socket Mode fallback transport with fail-closed startup checks and transport-parity behavior aligned to Events API safety controls
- expanded observability redaction coverage for Slack token families and added endpoint-level drift tests for logs/trace/config safety
- aligned local full-test scripts so Slack phase-2 suites run explicitly as part of the Slack integration gate step
- synchronized verification evidence through detect-secrets, pre-commit, backend unit + real lanes, adversarial gate, and frontend E2E full pass
</details>
<details>
<summary><strong>Post-Wave E closeout: Hardening chain completed</strong></summary>
- Completed on 2026-02 with full SOP validation:
- Bundle A: established security invariants registry and startup/CI invariant gates, plus route-plane explicit-classification governance to prevent unmanaged endpoint exposure drift
- Bundle B: converged outbound egress to a single safe path and added CI/local dependency parity preflight to prevent local-pass/CI-fail runtime drift
- Bundle C: added adversarial verification execution gates (bounded fuzz + mutation smoke with artifacts) and dual-lane retry partition hardening for deterministic degrade/audit behavior
- end-to-end verification evidence was synchronized across CI, local full-test scripts, and implementation records
</details>
<details>
<summary><strong>Wave E closeout: deployment guardrails, contract parity, and verification hardening chain completed</strong></summary>
- Completed Wave E with full SOP validation:
- Bundle A delivered startup deployment gate enforcement and deployment-profile matrix parity, then locked critical operator flow parity (including degraded-path behavior)
- Bundle B closed security contract parity gaps across token/mapping/route/signature state matrices and threat-intel resilience paths
- Bundle C completed signed policy posture control, bounded security anomaly telemetry, deterministic adversarial fuzz harness coverage, and mutation-baseline evidence generation
- full detect-secrets + pre-commit + backend unit + frontend E2E gate passed and evidence is recorded in the Bundle C implementation record
</details>
<details>
<summary><strong>Wave D closeout: control-plane split, ingress and supply-chain hardening, and verification governance baseline</strong></summary>
- Completed Wave D closeout full SOP validation:
- enforced split-mode control-plane boundaries for public deployments while preserving embedded daily UX flows
- finalized external control-plane adapter reliability behavior and split-mode degraded/blocked-action guidance
- completed secrets-at-rest hardening v2 with split-compatible secret-reference behavior
- closed bridge token lifecycle, legacy webhook ingress clamp, and public MAE route-plane enforcement gaps
- replaced registry signature placeholder posture with trust-root based cryptographic verification and signer governance
- established verification governance baseline with skip-budget enforcement, reject/degrade triple-assert contracts, and defect-first record lint gating
</details>
<details>
<summary><strong>Wave A/B/C closeout: stability baseline, high-risk security gates, and operator UX completion</strong></summary>
- Completed baseline runtime/config/connector stability improvements:
- runtime provenance and manager-aware environment freshness checks
- safer config merge behavior for object arrays
- connector session invalidation resilience for 401/410 revoke paths
- durable replay/idempotency storage for webhook/bridge flows
- stricter outbound egress policy controls for callback and LLM targets
- Completed high-risk security and supply-chain hardening:
- stronger external tool path resolution and allowlist enforcement
- bridge/device binding hardening with mTLS validation controls
- pack archive canonicalization and full manifest coverage enforcement
- global DoS governance (quota/priority/storage controls)
- signed release provenance pipeline and SBOM-integrity validation
- Completed Wave C operator UX and functionality closeout:
- Wave C functionality closeout accepted on 2026-02-18 with full SOP validation
- deterministic operator guidance banners and deep-link recovery behavior
- capability-aware in-canvas quick actions with guarded mutation flow
- Parameter Lab schema lock and bounded sweep/compare orchestration
- compare winner-selection safety contract and expanded Wave C regression coverage
</details>
<details>
<summary><strong>Audit trail and external tool sandbox hardening closeout</strong></summary>
- Added non-repudiation audit coverage for sensitive config/secrets/tools/approvals/bridge and startup-dangerous-override paths.
- Standardized audit envelopes and append-only hash-chain logging to improve forensic traceability.
- Added stricter external tool sandbox controls:
- hardened-mode fail-closed when sandbox posture/runtime is unsafe
- explicit network allowlist requirement when tooling enables egress
- pre-exec filesystem path allowlist enforcement for tool arguments
- Expanded security regression coverage for audit contract paths and sandbox policy enforcement.
</details>
<details>
<summary><strong>Endpoint inventory hardening and route drift detection coverage</strong></summary>
- Added explicit endpoint security metadata across API handlers so auth/risk posture is machine-readable and auditable.
- Added route inventory manifest generation to inspect registered API surfaces consistently.
- Added drift regression tests that fail when any registered endpoint is missing security metadata.
- Extended drift coverage to include optional bridge and packs routes to prevent false-green route scans.
</details>
<details>
<summary><strong>Operator UX improvements: context toolbox, parameter lab history/replay, and compare workflow baseline</strong></summary>
- Added in-canvas OpenClaw quick actions on node context menus: Inspect, Doctor, Queue Status, Compare, and Settings.
- Improved operator recovery flow by wiring quick actions to capability-aware targets with deterministic fallback guidance when optional endpoints are unavailable.
- Added Parameter Lab history flow so operators can browse saved experiments, load details, and replay run parameters back into the current graph.
- Added compare workflow baseline in Parameter Lab, including a dedicated compare endpoint with bounded fan-out and stricter payload validation.
- Expanded auth and regression coverage so compare routes remain admin-protected and route-registration drift is caught earlier.
</details>
<details>
<summary><strong>Pack security hardening: path traversal defense and strict API validation</strong></summary>
- Added path traversal protection for pack uninstall and pack path resolution.
- Hardened pack install path construction by validating pack metadata segments (`name`, `version`) and enforcing root-bounded path resolution.
- Added stricter input validation on pack API route handlers for pack lifecycle operations.
- Expanded regression coverage for traversal attempts and invalid input handling in pack flows.
</details>
<details>
<summary><strong>Runtime profile hardening and bridge startup compatibility checks</strong></summary>
- Added explicit runtime profiles with centralized resolution so startup behavior is deterministic across environments.
- Added a hardened startup security gate that fails closed when mandatory controls are not correctly configured.
- Added module capability boundaries so routes/workers only boot when their owning module is enabled.
- Added a bridge protocol handshake path with version compatibility checks during sidecar startup.
- Expanded regression coverage for profile resolution, startup gating, module boundaries, and bridge handshake behavior.
</details>
<details>
<summary><strong>Connector platform parity and sidecar worker runtime improvements</strong></summary>
- Added stronger KakaoTalk response handling:
- strict QuickReply cap with safe truncation
- empty-response guard to avoid invalid platform payloads
- more predictable output shaping and sanitization behavior
- Added WeChat Official Account encrypted webhook support:
- AES encrypted ingress (`encrypt_type=aes`) with signature verification and fail-closed decrypt/app-id validation
- expanded event normalization coverage (`subscribe`, `unsubscribe`, `CLICK`, `VIEW`, `SCAN`)
- deterministic dedupe behavior for event payloads without `MsgId`
- bounded ACK-first flow with deferred reply handling for slow paths
- Added sidecar worker bridge alignment end-to-end:
- worker poll/result/heartbeat bridge endpoints
- contract-driven sidecar client endpoint resolution and idempotency header behavior
- dedicated E2E test coverage for worker route registration, auth, and round-trip behavior
</details>
<details>
<summary><strong>Security Hardening: Auth/Observability boundaries, connector command controls, registry trust policy, transform isolation, integrity checks, and safe tooling controls</strong></summary>
- Delivered observability tier hardening with explicit sensitivity split:
- Public-safe: `/openclaw/health`
- Observability token: `/openclaw/config`, `/openclaw/events`, `/openclaw/events/stream`
- Admin-only: `/openclaw/logs/tail`, `/openclaw/trace/{prompt_id}`, `/openclaw/secrets/status`, `/openclaw/security/doctor`
- Delivered constrained transform isolation hardening:
- process-boundary execution via `TransformProcessRunner`
- timeout/output caps and network-deny worker posture
- feature-gated default-off behavior for safer rollout
- Delivered approval/checkpoint integrity hardening:
- canonical JSON + SHA-256 integrity envelopes
- tamper detection and fail-closed handling on integrity violations
- migration-safe loading behavior for legacy persistence files
- Delivered external tooling execution policy:
- allowlist-driven tool definitions (`data/tools_allowlist.json`)
- strict argument validation, bounded timeout/output, and redacted output handling
- gated by `OPENCLAW_ENABLE_EXTERNAL_TOOLS` plus admin access policy
- Extended security doctor coverage with wave-2 checks:
- validates transform isolation posture
- reports external tooling posture
- verifies integrity module availability
- Auth-coverage contract tests were updated to include new tool routes and prevent future route-auth drift regressions.
- Added connector command authorization hardening:
- separates command visibility from command execution privileges
- centralizes per-command access checks to reduce cross-platform auth drift
- supports explicit allow-list policy controls for sensitive command classes
- adds operator-configurable command policy controls via `OPENCLAW_COMMAND_OVERRIDES` and `OPENCLAW_COMMAND_ALLOW_FROM_{PUBLIC|RUN|ADMIN}`
- Added registry anti-abuse controls for remote distribution paths:
- bounded request-rate controls and deduplication windows reduce abuse and accidental hot loops
- stale anti-abuse state pruning keeps long-running deployments stable
- Added registry preflight and trust-policy hardening:
- static package safety checks are enforced before activation paths
- policy-driven signature/trust posture supports audit and strict enforcement modes
- registry trust mode is operator-controlled via `OPENCLAW_REGISTRY_POLICY` and preflight verification enforces fail-closed file-path requirements
</details>
<details>
<summary><strong>Sprint A: closes out with five concrete reliability and security improvements</strong></summary>
- Configuration save/apply now returns explicit apply metadata, so callers can see what was actually applied, what requires restart, and which effective provider/model is active.
- The Settings update flow adds defensive guards against stale or partial state, reducing accidental overwrites.
- Provider/model precedence is now deterministic across save, test, and chat paths, and prevents model contamination when switching providers.
- In localhost convenience mode (no admin token configured), chat requests enforce same-origin CSRF protection: same-origin requests are allowed, cross-origin requests are denied.
- Model-list fetching now uses a bounded in-memory cache keyed by provider and base URL, with a 5-minute TTL and LRU eviction cap to improve responsiveness and stability.
</details>
<details>
<summary><strong>Sprint B: ships security doctor diagnostics, registry quarantine gates, and constrained transforms defaults</strong></summary>
- Added the Security Doctor surface (`GET /openclaw/security/doctor`) for operator-focused security posture checks across endpoint exposure, token boundaries, SSRF posture, state-dir permissions, redaction drift, runtime mode, feature flags, and API key posture.
- Added optional remote pack registry quarantine controls with explicit lifecycle states, SHA256 integrity verification, bounded local persistence, and per-entry audit trail; this path remains disabled by default and fail-closed.
- Added optional constrained transform execution with trusted-directory + integrity pinning, timeout and output-size caps, and bounded chain execution semantics; transforms remain disabled by default and mapping-only behavior remains intact unless explicitly enabled.
</details>
<details>
<summary><strong>Settings contract, frontend graceful degradation, and provider drift governance</strong></summary>
- Enforced a strict settings write contract with schema-coerced values and explicit unknown-key rejection, reducing save/apply regressions across ComfyUI variants.
- Hardened frontend behavior to degrade safely when optional routes or runtime capabilities are unavailable, with clearer recovery hints instead of brittle failures.
- Added provider alias/deprecation governance and normalization coverage to reduce preset drift as upstream model IDs and endpoint shapes evolve.
</details>
<details>
<summary><strong>Mapping v1, job event stream, and operator doctor</strong></summary>
- Added webhook mapping engine v1 with declarative field mapping + type coercion, enabling external payload normalization without custom adapter code paths.
- Added real-time job event stream support via SSE (`/openclaw/events/stream`) with bounded buffering and polling fallback (`/openclaw/events`) for compatibility.
- Added Operator Doctor diagnostics tooling for runtime/deployment checks (Python/Node environment, state-dir posture, and contract readiness signals).
</details>
<details>
<summary><strong> Security doctor, registry quarantine, and constrained transforms</strong></summary>
- Added Security Doctor diagnostics surface (`GET /openclaw/security/doctor`) for operator-focused security posture checks and guarded remediation flow.
- Added optional remote registry quarantine lifecycle controls with integrity verification, bounded local persistence, and explicit trust/audit gates.
- Added optional constrained transform execution with integrity pinning, timeout/output caps, and bounded chain semantics; default posture remains disabled/fail-closed.
</details>
@@ -0,0 +1,50 @@
# Residual Security Execution Chain
Date: 2026-04-08
## 1. Purpose
This document summarizes the public security closeout status for the remaining GitHub Security findings after the first remediation wave and the initial residual follow-up fixes.
Maintainer-only execution records remain internal; this page is limited to public-facing status and remediation areas.
## 2. Remediation Areas
1. GitHub Security residual alert verification, dismissal, and closure execution wave.
2. Residual audit and bridge alert retirement sweep.
3. Residual model-manager path-boundary false-positive retirement wave.
4. GitHub code-scanning mode switch and final residual alert closure wave.
## 3. Final State
- Authenticated GitHub verification confirmed the repaired findings retired or were closed with explicit rationale after the advanced CodeQL switch.
- The audit and bridge identifier cleanup removed the true residual sinks and reduced the remaining audit findings to GitHub-managed false positives.
- The model-manager path-boundary proof stayed fail-closed, and the remaining `py/path-injection` alerts were retired through authenticated false-positive dismissal after the advanced rescan.
- GitHub code scanning default setup was switched off, the committed `.github/workflows/codeql.yml` run on `main` succeeded, the final residual CodeQL alerts were dismissed with recorded rationale, and the historical secret-scanning docs example was resolved.
## 4. Execution Rules
- Add hotspot comments at every high-risk repair seam.
- Update or add the smallest credible regression seam for each fix.
- Use targeted local tests for the changed contract surface.
- Use GitHub rescans after push as the source of truth for code-scanning retirement.
- Do not dismiss unresolved true positives.
- Do not close the historical secret-scanning alert until provenance and placeholder status are fully confirmed.
## 5. Closure Evidence
Authenticated GitHub evidence on 2026-04-08:
- `GET /code-scanning/default-setup` now returns `state=not-configured`
- the in-repo `CodeQL` workflow completed successfully on `main` head `0abdafab73e42ea4503992e7bc8cf76ef05fae03`
- `GET /code-scanning/alerts?state=open` now returns `0`
- secret-scanning alert `#1` is now `resolved` with `resolution=false_positive`
## 6. Expected End State
This chain is now complete:
- the remaining code-scanning alerts were either fixed in code or dismissed with explicit false-positive rationale
- the historical secret-scanning alert was closed with recorded provenance evidence
- the repository now relies on the committed advanced CodeQL workflow rather than GitHub default setup
- the final GitHub-side actions were performed with the required repository-administration and alert-write permissions
+5 -4
View File
@@ -18,7 +18,7 @@
- **Environment**: macOS, older Windows versions.
- **Python**: 3.12.
- **ComfyUI**: nightly builds and farther-from-anchor upstream drift.
- **Desktop host**: desktop bundle variants outside the current recorded desktop anchor, including cases where the embedded frontend lags standalone frontend.
- **Desktop host**: legacy fixed-bundle variants outside the recorded legacy anchor and current managed-install variants whose installed host components fall outside their own supported anchors.
### Tier 3: Unsupported
@@ -34,9 +34,10 @@
## Compatibility Anchor Policy
- The authoritative compatibility reference points are recorded in [`compatibility_matrix.md`](/mnt/c/Users/Ray/Documents/我的專案/ComfyUI-OpenClaw/docs/release/compatibility_matrix.md).
- `ComfyUI`, standalone `ComfyUI_frontend`, and `desktop` are tracked as separate host surfaces.
- Desktop should not be assumed to match standalone frontend HEAD; the embedded frontend version may intentionally lag and must be evaluated against its own recorded bundle anchor.
- The authoritative compatibility reference points are recorded in [`compatibility_matrix.md`](compatibility_matrix.md).
- `ComfyUI`, standalone `ComfyUI_frontend`, legacy `desktop`, and current `comfy_desktop` are tracked as separate anchors.
- Legacy Desktop is a fixed bundle and must be evaluated against its recorded core/frontend versions.
- Current Comfy-Desktop is a managed-install generation; hosted ComfyUI and frontend versions are installation-specific and must not be inferred from the application version.
- Upstream reference refreshes should update the matrix anchors before being treated as the new default support baseline.
## Reporting Issues
+8 -4
View File
@@ -18,16 +18,20 @@ Operators should use this to understand the risks of deployment.
* **Access**: Read-only logs (`/openclaw/logs/tail`), config (`/openclaw/config`), health.
* **Mechanism**: `OPENCLAW_OBSERVABILITY_TOKEN`.
* **Redaction**: Logs/Config are redacted by default to prevent secret leakage.
* **Reasoning-content posture**: provider reasoning / thinking traces are stripped by default from operator-visible assist responses, event streams, trace responses, callback payloads, and connector trace replies; privileged reveal is local-debug only, admin-gated, auditable, and fail-closed outside permissive local posture.
* **Reasoning/internal-content posture**: provider reasoning / thinking traces and explicitly marked internal maintenance/helper prompt content are stripped by default from operator-visible assist responses, event streams, trace responses, callback payloads, connector trace replies, and audit event payload/meta fields. Privileged reasoning reveal is local-debug only, admin-gated, auditable, and fail-closed outside permissive local posture; internal maintenance/helper prompt content has no public or debug reveal path.
### 3. The "Connector" Boundary (ChatOps)
* **Who**: Chat users (Telegram/Discord/LINE).
* **Access**:
* **User**: `submit_job` (via Allowlisted templates), `query_status`.
* **Admin (Chat)**: `approve_request`, `cancel_job`, `trace`.
* **Admin (Chat)**: `approve_request`, `cancel_job`, `trace`, and privacy-minimized
`list_jobs` summaries.
* **Mechanism**: Chat platform auth + OpenClaw User Allowlist (or `require_approval` policy).
* **Risk**: Spam/DoS (mitigated by Budgets + Rate Limits), or Prompt Injection (mitigated by Template Constraints).
* **Risk**: Spam/DoS (mitigated by Budgets + Rate Limits), Prompt Injection (mitigated by
Template Constraints), or job metadata disclosure (mitigated by Admin-only authorization,
allowlisted bounded fields, content-free errors, and keeping raw jobs payloads out of the
chat LLM).
---
@@ -50,7 +54,7 @@ Operators should use this to understand the risks of deployment.
* *Mitigation*: Known-host allowlist by default. Custom URLs need explicit opt-in + DNS validation.
* **Callback Delivery**: `POST` results to webhook targets.
* *Risk*: SSRF / Information Leakage.
* *Mitigation*: DNS-safe validation (no private IPs) + operator-payload redaction, including reasoning-content stripping by default.
* *Mitigation*: DNS-safe validation (no private IPs) + operator-payload redaction, including reasoning/internal-content stripping by default.
* **Image Fetching**: `image_url` inputs.
* *Mitigation*: SafeIO module (size limits, no file://).
+63
View File
@@ -0,0 +1,63 @@
# Verification Governance
This document summarizes the public verification-governance contract that sits on top of the repository test workflow.
For the authoritative execution order and acceptance rules, follow:
- `tests/TEST_SOP.md`
- `tests/E2E_TESTING_NOTICE.md`
- `tests/E2E_TESTING_SOP.md`
- `docs/release/ci_regression_policy.md`
## Standard Governance Checks
The standard local and CI-parity validation flow includes two explicit governance checks:
- `python scripts/verify_quality_governance.py`
- keeps `pyproject.toml` coverage settings aligned with the staged ratchet policy in `tests/coverage_governance_policy.json`
- protects mutation-threshold, SOP-guidance, and coverage-policy drift
- `python scripts/verify_test_debt_governance.py`
- fails closed on stale or under-documented entries in `tests/skip_policy.json`
- fails closed on stale or under-documented entries in `tests/mutation_survivor_allowlist.json`
Backend unit coverage should be gathered through the shared helper instead of ad hoc `coverage run` invocations:
```bash
python scripts/run_backend_coverage.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json --coverage-json .tmp/coverage/backend_unit_coverage.json
```
## Coverage Review Surface
Before any future coverage-floor promotion, review hotspot-family coverage with:
```bash
python scripts/report_coverage_governance.py --coverage-json <path-to-coverage.json>
```
This report is the governed review surface for critical families such as:
- `safe_io`
- security boundaries
- connector config and ingress seams
- config and bootstrap seams
Retained release-cycle review evidence lives in:
- `tests/coverage_promotion_reviews.json`
The current enforced stage is `ratchet-55`, which means the repository floor is now
`fail_under = 55.0`. The promotion is backed by two consecutive ratchet-45 release-cycle
reviews with immutable release commits, full-suite artifact hashes, all required hotspot
percentages, and named regression owners.
## Governance Baseline
- `tests/coverage_governance_policy.json` is the source of truth for the current enforced floor, next planned ratchet target, hotspot families, and temporary exceptions.
- `pyproject.toml` coverage settings must stay aligned with the active stage floor declared in `tests/coverage_governance_policy.json`.
- `tests/coverage_promotion_reviews.json` is the retained promotion-evidence ledger for reviewed hotspot summaries across release cycles.
- Ratchet-55 evidence must identify consecutive release boundaries, the reviewed commit,
full-suite command and artifact SHA-256, all required hotspot percentages, and owned suites.
- Rollback is atomic: a future approved rollback must move both the policy current stage and
`pyproject.toml` floor together; config drift fails the governance check.
- Test-debt governance remains fail-closed; review metadata such as `reason` and `review_after` must stay current for governed skip-policy and mutation-survivor entries.
- Detailed CI-gate composition and merge requirements remain documented in `docs/release/ci_regression_policy.md` and `tests/TEST_SOP.md`.
+81 -1
View File
@@ -6,6 +6,9 @@ This guide explains the startup security model and bridge compatibility behavior
- Runtime profile selection
- Hardened startup enforcement behavior
- Typed startup lifecycle diagnostics
- Process-static effective security posture
- External tool sandbox diagnostics
- Module startup boundaries
- Bridge protocol handshake compatibility
@@ -48,6 +51,52 @@ In `minimal` mode, these checks are warning-first for local/LAN posture, but `pu
Startup bootstrap no longer swallows fatal security-gate errors.
If a critical startup gate fails, initialization aborts deterministically instead of continuing with partial route registration.
### Startup lifecycle diagnostics
The health response includes a `startup` diagnostic object with:
- `schema_version`: diagnostic schema version
- `phase`: `package_import`, `required_initialization`, `host_wait`,
`route_registration`, `complete`, or `optional_warmup`
- `state`: `starting`, `initializing`, `waiting_for_host`, `registering_routes`, `ready`,
`degraded`, or `fatal`
- `reason_code`: stable, content-free transition reason
- `ready`: whether required route/service startup completed
- `degraded` and `fatal`: explicit terminal posture flags
- `attempt` and `max_attempts`: bounded host-wait retry progress
- `elapsed_ms`, `phase_elapsed_ms`, and `ready_elapsed_ms`: bounded lifecycle timing
- `warmups`: bounded optional warmup entries with name, state, reason, timeout, and duration
Required startup work still fails closed. Optional warmups such as model inventory refresh run after
route registration and do not block baseline API availability. A failed or timed-out optional
warmup changes the startup state to `degraded`; individual warmup states are `pending`, `running`,
`succeeded`, `failed`, or `timed_out`.
Optional warmup timeout can be tuned with:
- `OPENCLAW_STARTUP_WARMUP_TIMEOUT_SEC`
- legacy alias: `MOLTBOT_STARTUP_WARMUP_TIMEOUT_SEC`
### Effective security posture snapshot
During the process-wide route bootstrap, OpenClaw resolves deployment, runtime, connector,
control-plane, and surface decisions once into an immutable `EffectiveSecurityPosture` snapshot.
The snapshot records configuration presence and stable decision codes, not secret values.
Startup gates, control-plane policy, and surface authorization reuse this same object identity so
process-static security decisions cannot drift between modules. Request-dynamic controls such as
authentication, replay checks, and rate limiting still evaluate each request using their normal
runtime inputs.
The owner modules are:
- `services/bootstrap/lifecycle.py`
- `services/bootstrap/registration.py`
- `services/posture/effective.py`
Legacy imports remain identity-preserving aliases. See
[Service Domain Packages](architecture/service_domain_packages.md) for the ownership contract.
## Public deployment shared-surface acknowledgement
When running deployment profile checks for public posture (`OPENCLAW_DEPLOYMENT_PROFILE=public`),
@@ -70,7 +119,7 @@ Gate behavior:
Connector token/enable markers activate ingress posture checks for:
- Telegram, Discord, LINE, WhatsApp, WeChat, KakaoTalk, Slack
- Telegram, Discord, LINE, WhatsApp, WeChat, KakaoTalk, Slack, Feishu/Lark
When a platform is active, at least one platform-specific allowlist variable must be configured.
@@ -78,6 +127,37 @@ When a platform is active, at least one platform-specific allowlist variable mus
- `hardened` runtime profile: fail-closed at startup gate
- non-strict local/LAN posture: warning posture in Security Doctor (`s32_allowlist_coverage`)
## External tool sandbox diagnostics
External tool execution is opt-in and remains admin-gated. Set `OPENCLAW_ENABLE_EXTERNAL_TOOLS=true` only for reviewed local/LAN workflows that need allowlisted CLI execution.
Current runtime behavior:
- default tool definitions are loaded from the package-owned `data/tools_allowlist.json`
- custom tool definitions must be supplied with `OPENCLAW_TOOLS_CONFIG_PATH`
- tool scratch/temp execution paths default to the state directory's `tool_sandbox/`
- `OPENCLAW_TOOL_SANDBOX_DIR` can override the scratch path for an explicitly reviewed deployment
- legacy `MOLTBOT_TOOL_SANDBOX_DIR` remains a compatibility alias
Hardened posture behavior:
- if `OPENCLAW_RUNTIME_PROFILE=hardened` and the sandbox runtime is marked unavailable, tool execution fails closed before `subprocess.run`
- tools without an explicit `sandbox` block fail closed in hardened mode
- network-enabled tools require `allow_network_hosts` in hardened mode
- filesystem allowlists are checked before execution; out-of-scope paths are blocked
Common service-level diagnostic codes:
- `sandbox_runtime_unavailable`
- `sandbox_policy_missing`
- `network_hosts_missing`
- `interpreter_missing`
- `timeout`
- `workspace_violation`
- `process_failed`
These diagnostics are designed to help operators fix local runtime setup without silently falling back to broader host execution. OpenClaw does not currently install Docker images, repair sandbox runtimes, or auto-delete runtime dependency caches.
## Localhost no-origin override posture
`OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN` controls a localhost convenience escape hatch for clients
+7
View File
@@ -15,8 +15,11 @@
- WeChat: `OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS`
- KakaoTalk: `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS`
- Slack: `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS` / `_ALLOWED_CHANNELS`
- Feishu/Lark: `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS` / `_ALLOWED_CHATS`
- [ ] Verify startup banner shows "No trusted users" warning if allowlists are empty.
- [ ] For strict posture (`OPENCLAW_DEPLOYMENT_PROFILE=public` or `OPENCLAW_RUNTIME_PROFILE=hardened`), do not enable connector ingress without allowlists; startup/deployment checks fail closed.
- [ ] Verify duplicate or retried connector events are acknowledged without re-running completed actions.
- [ ] Verify reply-visibility suppression only applies to text-only silent/internal/tool-only/no-mention contexts and does not suppress approval cards or action buttons.
### 2. Webhook Security (LINE)
@@ -42,7 +45,9 @@
- [ ] For shared/LAN/public exposure, keep `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0` (or unset).
- [ ] For `OPENCLAW_DEPLOYMENT_PROFILE=public`, set `OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1` only after reverse-proxy path allowlist + network ACL explicitly block ComfyUI-native high-risk routes.
- [ ] For `OPENCLAW_DEPLOYMENT_PROFILE=public`, if any connector platform token/enable flag is set, confirm corresponding allowlist coverage before startup (`DP-PUBLIC-009`).
- [ ] Keep `OPENCLAW_ENABLE_EXTERNAL_TOOLS=0` unless external tool execution is explicitly required; if enabled, review `OPENCLAW_TOOLS_CONFIG_PATH`, sandbox policy, and deterministic runtime diagnostics before exposure.
- [ ] Run `GET /openclaw/security/doctor` and verify no `csrf_no_origin_override` warning before exposure.
- [ ] Run `python scripts/verify_audit_chain.py --json` after restart/rotation-sensitive maintenance and confirm retained audit logs still verify cleanly.
### 6. Debug Mode
@@ -66,6 +71,8 @@
| Rate limiting | Enabled | 10 req/min/user, 30 req/min/channel |
| Debug mode | Disabled | No sensitive logging |
| Replay protection | Enabled | LINE webhooks reject replays >5min old |
| Connector replay and reply visibility | Enabled | Duplicate committed events are no-ops; text-only suppressed replies do not suppress approval/action controls |
| Slack / Feishu interactive callbacks | Signed + deduped | Callback actions reject stale/replayed envelopes and degrade untrusted run actions to approval flow |
## 📞 Support
+10 -8
View File
@@ -23,6 +23,7 @@ Before using OpenClaw in any internet-facing setup, you must explicitly accept:
3. The operator/deployer is responsible for network isolation, auth boundaries, key management, monitoring, and incident response.
4. If you cannot satisfy the `public` profile baseline and checklist, do not deploy publicly. Use `local` or private/VPN-only access instead.
5. High-risk capabilities (external tools, registry sync, transforms, remote admin) must remain disabled unless there is a reviewed and time-bounded operational requirement.
6. If external tools are enabled, review the allowlist path (`data/tools_allowlist.json` or `OPENCLAW_TOOLS_CONFIG_PATH`), sandbox policy, and runtime diagnostics before exposing the deployment beyond localhost.
## 0.1 Shared-Port Boundary Statement (Critical)
@@ -113,7 +114,7 @@ OPENCLAW_ADMIN_TOKEN=change-this-local-admin-token
2. Keep remote admin disabled.
3. Keep external tools/registry sync/transforms disabled unless explicitly needed.
4. For local LLM providers (Ollama/LM Studio), use loopback URLs only (`localhost`/`127.0.0.1`/`::1`); keep `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=0` and `OPENCLAW_ALLOW_INSECURE_BASE_URL=0`.
5. `OPENCLAW_LLM_ALLOWED_HOSTS` is only for additional exact public hosts; it does not permit RFC1918/private LAN targets.
5. `OPENCLAW_LLM_ALLOWED_HOSTS` is only for additional exact public hosts; it does not permit RFC1918/private LAN targets. Use the scoped `allow_private_network` LLM setting only when a reviewed local deployment needs a private target.
6. The same LLM SSRF contract applies consistently to config validation, `/openclaw/llm/models`, and outbound provider requests.
7. Keep `OPENCLAW_DEBUG_REASONING_REVEAL=0` unless you are doing short-lived local admin debugging and explicitly need privileged reasoning reveal.
8. Keep `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0` unless you explicitly need local CLI/no-origin compatibility.
@@ -156,7 +157,7 @@ OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0
2. Use distinct admin and observability tokens.
3. Keep bridge/tools/registry/transforms disabled unless there is a reviewed requirement.
4. Keep `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0` for LAN deployments.
5. If your LLM is on another LAN/private-IP host, that still counts as an insecure `base_url` target; `OPENCLAW_LLM_ALLOWED_HOSTS` alone is not sufficient.
5. If your LLM is on another LAN/private-IP host, `OPENCLAW_LLM_ALLOWED_HOSTS` alone is not sufficient; use the scoped `allow_private_network` LLM setting for that configured target, or the broader insecure override only after review.
6. The same LLM SSRF contract applies consistently to config validation, `/openclaw/llm/models`, and outbound provider requests.
7. Run:
- `python scripts/check_deployment_profile.py --profile lan`
@@ -228,16 +229,17 @@ OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0
5. Enforce split control plane in public posture (`OPENCLAW_CONTROL_PLANE_MODE=split` + external URL/TOKEN).
6. Keep `OPENCLAW_DEBUG_REASONING_REVEAL=0`; privileged reasoning reveal is for local debugging only and must not be enabled on public user planes.
7. If any connector platform token/enable flag is configured, set corresponding platform allowlist vars before startup (`DP-PUBLIC-009` fail-closed).
8. Keep risky features disabled on public user-facing plane.
9. Keep `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0` in public deployments.
9. Verify split posture from capabilities:
8. For connector approvals/actions, verify duplicate platform retries do not re-run completed actions and text-only reply suppression does not hide approval/action controls.
9. Keep risky features disabled on public user-facing plane.
10. Keep `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0` in public deployments.
11. Verify split posture from capabilities:
- `GET /openclaw/capabilities` and confirm `control_plane.mode=split`
10. Run:
12. Run:
- `python scripts/check_deployment_profile.py --profile public`
11. Validate with project test and release gates before rollout:
13. Validate with project test and release gates before rollout:
- `tests/TEST_SOP.md`
- [RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md)
12. Ensure `/openclaw/admin` is blocked at public edge unless a separately hardened private admin plane is in place.
14. Ensure `/openclaw/admin` is blocked at public edge unless a separately hardened private admin plane is in place.
## 6. Bridge in Public Profile (only when absolutely required)
+1 -1
View File
@@ -185,7 +185,7 @@ Example commands:
```bash
python scripts/run_crypto_lifecycle_drills.py --pretty
python scripts/run_crypto_lifecycle_drills.py --scenarios planned_rotation,emergency_revoke --output .planning/logs/crypto_drills.json --pretty
python scripts/run_crypto_lifecycle_drills.py --scenarios planned_rotation,emergency_revoke --output crypto_drills.json --pretty
```
Evidence bundle contract (JSON):
+3 -2
View File
@@ -10,9 +10,10 @@
- `docs/connector.md`
- `docs/security_deployment_guide.md`
- `docs/security_key_lifecycle_sop.md`
- `.planning/roadmap.md` (latest implementation status and remaining work)
## Status Note
- Bridge APIs and connector runtime are available.
- Standalone sidecar/gateway evolution is tracked in `.planning/roadmap.md`.
- Connector/sidecar runtime remains an optional attached subsystem; the primary package artifact is the ComfyUI custom node pack.
- Connector extraction remains a no-go-for-split-now decision until the shared installation/callback/delivery/reply-visibility/config seams are independently versioned; see `docs/adr/ADR-0003-connector-extraction-feasibility-and-seams.md`.
- Standalone sidecar/gateway evolution is tracked in maintainer roadmap records.
+110 -4
View File
@@ -28,6 +28,25 @@ Notes:
- If your pack folder name is not `comfyui-openclaw`, the smoke script may need `OPENCLAW_PACK_IMPORT_NAME=your-folder-name`.
- If imports fail with a `services.*` module error, check for name collisions with other custom nodes and prefer package-relative imports.
## Imported workflow shows missing OpenClaw nodes
Current builds expose node portability metadata so "missing custom node" can be diagnosed separately from a generic import failure.
What to check:
1. Open the Explorer / inventory diagnostics view or inspect `/openclaw/preflight/inventory`.
2. Confirm whether the workflow references `openclaw:*` nodes that are not present on the current host.
3. Look for portability/replacement guidance rather than renaming nodes blindly.
4. If Explorer shows inactive-branch suppressed findings, inspect them as context but do not treat them as active blockers unless the corresponding branch is enabled.
Notes:
- Compatibility class names such as `Moltbot*` still exist for older workflows, but the canonical portability contract is anchored on `openclaw:*` node identities.
- Current shipped nodes use the `openclaw` category in ComfyUI; seeing older `moltbot` category text usually means the installed pack is stale or ComfyUI has not been restarted after update.
- Current diagnostics may include deterministic replacement hints when an unavailable OpenClaw node can degrade to a more portable workflow pattern.
- Muted or bypassed root nodes and subgraph branches are separated into suppressed diagnostics when the workflow payload includes enough frontend metadata. Plain API prompt JSON remains deterministic, but it may not contain frontend ancestry needed to identify inactive subgraph context.
- If no portability guidance is present and the pack itself is loaded correctly, treat that as a real contract gap rather than assuming the workflow can be repaired by arbitrary JSON edits.
## Operator Doctor
Run the built-in diagnostic tool to verify environment readiness (libraries, permissions, contract files):
@@ -44,6 +63,89 @@ Explorer / inventory note:
- A response showing `scan_state=refreshing` or `stale=true` does not necessarily mean the inventory path is broken; it can mean the cached snapshot was returned quickly while a deeper model scan continues in the background.
- Treat `last_error` as the primary signal that the background scan actually failed.
## Jobs list or connector `/jobs` reports authorization or backend errors
`GET /openclaw/jobs` is an Admin-only bounded read model. For direct API/browser calls,
send the configured Admin token and use only the documented `status`, `workflow_id`,
`sort_by`, `sort_order`, `limit`, and `offset` query fields.
Interpret results as follows:
- HTTP 200 with `jobs: []` is an authoritative empty snapshot.
- HTTP 401/403 means Admin authentication or tenant authorization failed; verify
`OPENCLAW_ADMIN_TOKEN`, and for connector commands also verify
`OPENCLAW_CONNECTOR_ADMIN_TOKEN` plus the sender's Admin allowlist/class policy.
- HTTP 501 `jobs_host_contract_unsupported` means the active ComfyUI host does not expose
the required queue/history helper contract.
- HTTP 503 `jobs_backend_unavailable` means the host snapshot was unavailable or malformed;
it must not be treated as an empty queue.
The connector `/jobs` command returns fixed, content-free failures. It can show a bounded
coarse queue-count fallback only for the explicit 501/503 conditions above; authorization,
unknown-version, malformed, or oversized responses do not fall back and never echo the raw
upstream payload.
## External tool execution is disabled or fails with sandbox diagnostics
External tools are disabled by default and require an admin boundary plus an explicit feature flag.
Checklist:
1. Confirm the feature flag is enabled only for the deployment that needs it:
- `OPENCLAW_ENABLE_EXTERNAL_TOOLS=true`
2. Confirm the request is authenticated as an admin when using:
- `GET /openclaw/tools`
- `POST /openclaw/tools/{name}/run`
3. Confirm the tool definition exists in the allowlist:
- default allowlist: package-owned `data/tools_allowlist.json`
- custom allowlist: set `OPENCLAW_TOOLS_CONFIG_PATH=/path/to/tools_allowlist.json`
4. If the result or logs report `sandbox_runtime_unavailable`, do not bypass hardened mode blindly:
- make the sandbox runtime available, then set `OPENCLAW_TOOL_SANDBOX_RUNTIME_AVAILABLE=1`
- or keep tooling disabled until the deployment can fail closed safely
5. If the result or logs report `interpreter_missing`, install the executable referenced by the tool allowlist or update the command path.
6. If the result or logs report `timeout`, review the command behavior before increasing the tool's `timeout_sec`.
7. If the result or logs report `workspace_violation`, move inputs under the configured filesystem allowlist or update the tool sandbox policy.
Notes:
- Tool scratch/temp paths default to the configured state directory's `tool_sandbox/`.
- `OPENCLAW_TOOL_SANDBOX_DIR` can override the scratch path for reviewed deployments.
- Runtime cache and sandbox scratch paths are generated state, not package resources.
- OpenClaw does not automatically repair, migrate, or delete runtime dependency caches.
## Jobs preview shows an explicit media or asset fallback state
Current OpenClaw builds keep `/history` + `/view` as the supported runtime preview contract for job results.
If a result ref only exposes an upstream asset-service identifier and cannot be represented through `/view`, OpenClaw keeps that ref explicit instead of silently guessing a direct `/api/assets` fetch.
What this means:
- `asset_api_required` is a bounded compatibility state, not a generic parser failure.
- Classic history refs should continue to preview normally even when hash metadata is absent. Optional hash-backed refs exposed as `asset_hash` or `hash` still map onto `/view` when ComfyUI host metadata provides them.
- Current media-aware outputs can include `images`, `video`, `audio`, `3d`, bounded inline text, and allowlisted file-backed text; normal images render as thumbnails, HDR `.exr` / `.hdr` image refs render as explicit source-preview fallback links, and text renders as literal bounded content. A file-backed text response that is oversized, slow, redirected, non-text, invalid UTF-8, or unavailable as a safe browser stream remains an explicit source-link fallback instead of exposing response details.
- If an operator workflow starts depending on direct asset-service identifiers, treat that as a contract gap and review [`docs/asset_api_adoption_decision.md`](asset_api_adoption_decision.md) before widening the runtime dependency.
## Verify audit-chain continuity after restart or rotation
Use the retained-chain verifier:
```bash
python scripts/verify_audit_chain.py
```
JSON output:
```bash
python scripts/verify_audit_chain.py --json
```
Notes:
- The verifier checks the current `audit.log` and any retained rotated audit segments in the state directory.
- When no audit chain key is supplied from environment/config, OpenClaw persists `audit.log.key` so verification still works across restart and rotation.
- Treat verification failure as an audit-integrity incident until proven otherwise.
## Webhooks return `403 auth_not_configured`
Set webhook auth environment variables as described in the README quick-start section, then restart ComfyUI.
@@ -61,6 +163,10 @@ Checklist:
3. In OpenClaw Settings:
- Provider: `Ollama (Local)` or `LM Studio (Local)`
- Base URL: leave empty to use the provider default, or set a loopback URL explicitly
- Provider defaults:
- `Ollama (Local)` -> `http://127.0.0.1:11434/v1`
- `LM Studio (Local)` -> `http://localhost:1234/v1`
- If an older saved Ollama URL is still set to `http://127.0.0.1:11434`, update it to `/v1` or clear the field so the built-in default can be applied
4. Keep these flags disabled:
- `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=0`
- `OPENCLAW_ALLOW_INSECURE_BASE_URL=0`
@@ -71,7 +177,7 @@ This is expected under the current SSRF policy.
- `OPENCLAW_ALLOW_REMOTE_ADMIN=1` only allows remote admin access; it does not relax outbound LLM egress rules.
- `OPENCLAW_LLM_ALLOWED_HOSTS` only extends the exact-host allowlist for custom public hosts.
- Private/reserved IP targets such as `192.168.x.x`, `10.x.x.x`, and `172.16.x.x` remain blocked unless `OPENCLAW_ALLOW_INSECURE_BASE_URL=1` is also set.
- Private/reserved IP targets such as `192.168.x.x`, `10.x.x.x`, and `172.16.x.x` remain blocked unless the scoped `allow_private_network` LLM setting is enabled for the configured target, or `OPENCLAW_ALLOW_INSECURE_BASE_URL=1` is also set.
- `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1` does not allow private/reserved IPs.
- `OPENCLAW_LLM_ALLOWED_HOSTS=*` is not a wildcard and will not bypass the policy.
@@ -81,7 +187,7 @@ Correct setup flow:
2. If you need a custom public LLM host, set:
- `OPENCLAW_ALLOW_CUSTOM_BASE_URL=1`
- `OPENCLAW_LLM_ALLOWED_HOSTS=<exact-host>` or `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1`
3. If you intentionally need a LAN/private-IP target, set `OPENCLAW_ALLOW_INSECURE_BASE_URL=1`, accept the SSRF risk, and fully restart ComfyUI.
3. If you intentionally need a LAN/private-IP target, prefer enabling `allow_private_network` only for that configured LLM target. Use `OPENCLAW_ALLOW_INSECURE_BASE_URL=1` only when you intentionally accept the broader SSRF risk, then fully restart ComfyUI.
4. On Windows portable, set environment variables in the same launcher that starts `python_embeded\\python.exe`, or restart after `setx` / System Properties changes.
5. Verify the effective value in the same embedded Python runtime:
@@ -91,8 +197,8 @@ python_embeded\python.exe -c "import os; print(repr(os.environ.get('OPENCLAW_LLM
Safer alternative:
- keep the LLM behind a reviewed public HTTPS reverse proxy and allowlist that public host, instead of enabling `OPENCLAW_ALLOW_INSECURE_BASE_URL`
- on current builds, once that override is intentionally enabled and the process is restarted, both Remote Admin validation and `/openclaw/llm/models` should follow the same decision
- keep the LLM behind a reviewed public HTTPS reverse proxy and allowlist that public host, instead of enabling private-network or insecure overrides
- on current builds, the scoped private-network setting and insecure override are both applied consistently by Remote Admin validation and `/openclaw/llm/models`
## Admin Token: server-side vs UI
@@ -16,6 +16,8 @@
- `web/openclaw_notification_center.js`
- `web/openclaw_banner_manager.js`
- `web/openclaw_tabs.js`
- `web/openclaw_api.js` plus focused config/generation/resource/model/event API owners
- `web/tabs/settings_tab.js` plus focused status/LLM/secrets/logs/DOM/lifecycle owners
- `web/admin_console_app.js`
- `web/admin_console_api.js`
- Runtime model:
@@ -69,8 +71,9 @@ Scored 1-5 (higher is better), weighted by current risk profile:
1. OpenClaw frontend is host-coupled to ComfyUI extension lifecycle and remount behavior; framework migration introduces significant integration and lifecycle risk with limited near-term operator value.
2. Current architecture already has critical stability controls (`ErrorBoundary`, tab remount safety, capability-gated registration, compatibility aliases, Vitest + Playwright lanes).
Recent decomposition work further reduced shell/admin/runtime hotspot size without introducing a framework dependency.
3. Most remaining roadmap priorities are functionality/security features (`F53/F54/F58/F59`), not frontend rendering abstraction gaps; migration now would consume high-risk bandwidth with weak ROI.
Recent decomposition work further reduced shell, API, Settings, and admin/runtime hotspot size
without introducing a framework dependency, while adding explicit stale-render disposal.
3. Most remaining product priorities are functionality and security features, not frontend rendering abstraction gaps; migration now would consume high-risk bandwidth with weak ROI.
## Decision
+2 -3
View File
@@ -1,5 +1,6 @@
import json
import logging
import random
from typing import Any, Dict, List, Tuple
try:
@@ -55,7 +56,7 @@ class OpenClawBatchVariants:
OUTPUT_IS_LIST = (True, True, True)
FUNCTION = "generate_variants"
CATEGORY = "moltbot"
CATEGORY = "openclaw"
def generate_variants(
self,
@@ -95,8 +96,6 @@ class OpenClawBatchVariants:
# Let's stick to simple increment for now or random python if implied?
# "randomized" usually means unpredictable.
# Let's implement a simple hash for now to be deterministic but "jumpy"
import random
r = random.Random(seed_base + i)
current_seed = r.randint(0, 0xFFFFFFFFFFFFFFFF)
+1 -1
View File
@@ -63,7 +63,7 @@ class OpenClawImageToPrompt:
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("caption", "tags", "prompt_suggestion")
FUNCTION = "generate_prompt"
CATEGORY = "moltbot"
CATEGORY = "openclaw"
# R154: keep the compatibility method name, but bind the shared helper
# directly so node wrappers do not duplicate image conversion logic.
+97
View File
@@ -0,0 +1,97 @@
"""
Stable portability metadata for exported OpenClaw nodes.
This module must stay dependency-light because it is imported from the package
entrypoint during ComfyUI custom-node loading.
"""
from __future__ import annotations
import copy
from typing import Any, Dict
PORTABILITY_CONTRACT_VERSION = 1
_NODE_PORTABILITY_MAPPINGS: Dict[str, Dict[str, Any]] = {
"MoltbotPromptPlanner": {
"display_name": "openclaw: Prompt Planner",
"portable_mode": "materialize_standard_fields",
"fallback_kind": "manual_rewire",
"portable_summary": (
"Resolve planner outputs ahead of runtime and feed standard prompt/"
"parameter fields into downstream ComfyUI nodes."
),
"standard_field_targets": ["positive", "negative", "params_json"],
"return_names": ["positive", "negative", "params_json"],
"replacement_hints": [
"Run the planning step before export and persist the generated positive/negative prompts plus params_json as standard workflow inputs.",
"Remove the OpenClaw planner node from the portable workflow and wire the downstream sampler from normal STRING/parameter fields.",
],
},
"MoltbotPromptRefiner": {
"display_name": "openclaw: Prompt Refiner",
"portable_mode": "materialize_standard_fields",
"fallback_kind": "manual_rewire",
"portable_summary": (
"Persist refined prompt text and param_patch_json outside the graph, "
"then inject the reviewed values through standard ComfyUI fields."
),
"standard_field_targets": [
"refined_positive",
"refined_negative",
"param_patch_json",
"rationale",
],
"return_names": [
"refined_positive",
"refined_negative",
"param_patch_json",
"rationale",
],
"replacement_hints": [
"Review the refinement result outside the portable workflow and store the refined prompt text plus param patch as static inputs.",
"Keep the rationale as operator notes only; the portable workflow should consume the reviewed prompt fields, not the OpenClaw node.",
],
},
"MoltbotImageToPrompt": {
"display_name": "openclaw: Image to Prompt",
"portable_mode": "materialize_standard_fields",
"fallback_kind": "manual_rewire",
"portable_summary": (
"Precompute caption/tags/prompt suggestion externally and feed the "
"chosen text into standard ComfyUI prompt fields."
),
"standard_field_targets": ["caption", "tags", "prompt_suggestion"],
"return_names": ["caption", "tags", "prompt_suggestion"],
"replacement_hints": [
"Run the image-to-prompt step before export and store the selected text outputs as normal workflow inputs.",
"Do not expect vanilla ComfyUI to reproduce the vision analysis step inside the graph; only the resulting text fields are portable.",
],
},
"MoltbotBatchVariants": {
"display_name": "openclaw: Batch Variants",
"portable_mode": "materialize_standard_fields",
"fallback_kind": "manual_rewire",
"portable_summary": (
"Expand variant lists before export and execute the resulting prompt/"
"parameter combinations through standard batch or queue tooling."
),
"standard_field_targets": [
"positive_list",
"negative_list",
"params_json_list",
],
"return_names": ["positive_list", "negative_list", "params_json_list"],
"replacement_hints": [
"Generate the variant list before export and save the expanded prompt/parameter combinations outside the portable workflow.",
"Use standard ComfyUI batch/queue mechanisms to run each prepared variant instead of relying on the OpenClaw batch node.",
],
},
}
def get_node_portability_mappings() -> Dict[str, Dict[str, Any]]:
return copy.deepcopy(_NODE_PORTABILITY_MAPPINGS)
NODE_PORTABILITY_MAPPINGS = get_node_portability_mappings()
+1 -1
View File
@@ -59,7 +59,7 @@ class OpenClawPromptPlanner:
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("positive", "negative", "params_json")
FUNCTION = "plan_generation"
CATEGORY = "moltbot"
CATEGORY = "openclaw"
def plan_generation(
self, profile: str, requirements: str, style_directives: str, seed: int
+1 -1
View File
@@ -75,7 +75,7 @@ class OpenClawPromptRefiner:
"rationale",
)
FUNCTION = "refine_prompt"
CATEGORY = "moltbot"
CATEGORY = "openclaw"
# R154: keep the compatibility method name, but bind the shared helper
# directly so node wrappers do not duplicate image conversion logic.
+640 -947
View File
File diff suppressed because it is too large Load Diff

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