255 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
rookiestar28 3806951006 docs: regenerate openapi spec 2026-03-27 18:38:27 +08:00
rookiestar28 f462f464e5 chore: apply the auto formatted file 2026-03-27 17:10:07 +08:00
rookiestar28 7eaf62ef29 bump version to v0.8.0 2026-03-27 17:06:42 +08:00
rookiestar28 ec11c16ac2 docs: record r141-r145 public contract updates 2026-03-27 16:52:20 +08:00
rookiestar28 72c74c153f refactor: harden optional dependency import parity 2026-03-27 16:24:13 +08:00
rookiestar28 b46dbbebae refactor: unify schema and io boundary fixtures 2026-03-27 15:57:15 +08:00
rookiestar28 fd0967ff78 refactor: add delta-first event and task cursors 2026-03-27 15:29:37 +08:00
rookiestar28 d2126d193c docs: align readme and docs with recent host updates 2026-03-27 14:52:10 +08:00
rookiestar28 f9d68de2bd refactor: add bounded asset surface interop 2026-03-27 13:39:16 +08:00
rookiestar28 4bc7a357bd refactor: add desktop host parity contract 2026-03-27 13:15:52 +08:00
rookiestar28 e583a0e0aa refactor: harden frontend host compatibility 2026-03-27 01:51:47 +08:00
rookiestar28 b404d35e74 docs: refresh compatibility anchors from reference repos 2026-03-27 00:23:24 +08:00
rookiestar28 7217b3e0de fix: scope pip audit to declared dependencies 2026-03-26 21:50:59 +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
rookiestar28 1c993cc64b docs: slim readme and split troubleshooting guide 2026-03-21 14:25:32 +08:00
rookiestar28 d2512fd166 chore: remove a useless file 2026-03-21 03:27:15 +08:00
rookiestar28 674d74ff5e test: add optional playwright flake stress mode 2026-03-20 16:14:13 +08:00
rookiestar28 a1a022a1ca test: harden notification center dismissal regression 2026-03-20 15:06:16 +08:00
rookiestar28 85494dc2bf docs: record governance baseline updates 2026-03-20 14:17:24 +08:00
rookiestar28 37a3f071d5 chore: enforce coverage and mutation governance 2026-03-20 12:34:09 +08:00
rookiestar28 7e0c1ee074 fix: preserve traceback and avoid mutating llm clients 2026-03-20 12:16:23 +08:00
rookiestar28 bf2345310e bump version to v0.7.3 2026-03-20 11:03:07 +08:00
rookiestar28 a111886552 docs: align public docs with frontend and route refactors 2026-03-20 10:58:30 +08:00
rookiestar28 fdef19eb0d refactor: centralize legacy class aliases and node image helpers 2026-03-20 05:15:51 +08:00
rookiestar28 bd318eb0ac refactor: decompose frontend shell and admin console 2026-03-20 04:58:25 +08:00
rookiestar28 9c50ddc4e9 refactor: decompose model manager lifecycle services 2026-03-20 04:34:21 +08:00
rookiestar28 971f1e2c82 refactor: split route family registrars 2026-03-20 01:06:12 +08:00
rookiestar28 ee0f279f78 bump version to v0.7.2 2026-03-20 00:43:36 +08:00
rookiestar28 0bd091ab71 docs: align readme with recent platform and ux changes 2026-03-20 00:35:43 +08:00
rookiestar28 cd20efb010 docs: record slack oauth hardening updates 2026-03-20 00:30:41 +08:00
rookiestar28 5e51433d7e fix: enforce safe oauth egress and notification dismissal 2026-03-20 00:13:53 +08:00
rookiestar28 52cb09e4be feat: add slack multi-workspace oauth support 2026-03-19 23:49:49 +08:00
rookiestar28 0d7020211c feat: add persistent operator notification center 2026-03-19 23:19:41 +08:00
rookiestar28 1b104b170b refactor: harden rate limit diagnostics and cooldowns 2026-03-19 22:56:02 +08:00
rookiestar28 7519eb0efd docs: record recent hardening updates 2026-03-19 20:51:11 +08:00
rookiestar28 a58cf621fa refactor: decompose ui and model list hotspots 2026-03-19 19:54:16 +08:00
rookiestar28 e5e4af3657 refactor: centralize legacy compatibility governance 2026-03-19 19:11:58 +08:00
rookiestar28 3b4f0dedba refactor: add effective config facade 2026-03-19 18:49:58 +08:00
rookiestar28 c1365130d3 refactor: harden route bootstrap contract 2026-03-19 18:47:49 +08:00
rookiestar28 af9598f326 docs: normalize roadmap source of truth 2026-03-19 18:46:12 +08:00
rookiestar28 3099abaff0 docs: exempt docs-only changes from test SOP 2026-03-19 18:43:05 +08:00
rookiestar28 f22c006aac Tighten bugfix testing SOP 2026-03-18 10:51:50 +08:00
477 changed files with 76677 additions and 12683 deletions
+78 -34
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,33 +198,47 @@ jobs:
name: Security Audit (S23)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Frontend Audit (npm)
run: |
# Audit only production dependencies, ignore dev
npm audit --production
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Frontend Audit (npm)
run: |
# Development tooling is part of the build/test trust boundary.
npm ci
npm audit --audit-level=high
- name: Install backend deps
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
- name: Install pip-audit
run: pip install pip-audit
- name: Backend Audit (pip)
run: pip-audit
run: |
# IMPORTANT: audit declared project deps, not the whole CI tool environment.
# Env-wide `pip-audit` also scans pip-audit's own transient dependencies and
# can fail on toolchain-only packages that are outside the repo dependency contract.
pip-audit -r requirements.txt
adversarial-smoke:
name: Adversarial Gate (adaptive)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install test deps
run: |
python -m pip install --upgrade pip
@@ -215,7 +256,7 @@ jobs:
python scripts/run_adversarial_gate.py --profile auto --seed 42 --artifact-dir .tmp/adversarial
- name: Upload adversarial artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: adversarial-adaptive-manifest
path: .tmp/adversarial/
@@ -226,10 +267,13 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install test deps
run: |
python -m pip install --upgrade pip
@@ -245,7 +289,7 @@ jobs:
python scripts/run_adversarial_gate.py --profile extended --artifact-dir .tmp/adversarial-extended
- name: Upload adversarial artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: adversarial-extended-manifest
path: .tmp/adversarial-extended/
+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
+407 -1032
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()
+5 -2
View File
@@ -3,13 +3,14 @@ Approval API Endpoints (S7/F12).
REST endpoints for managing approval requests.
"""
from __future__ import annotations
import logging
from typing import Callable, Optional
from aiohttp import web
try:
from ..services.access_control import resolve_token_info
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.approvals.models import ApprovalStatus
from ..services.approvals.service import get_approval_service
from ..services.audit import emit_audit_event
@@ -19,6 +20,7 @@ try:
except ImportError:
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
from services.access_control import resolve_token_info # type: ignore
from services.aiohttp_compat import import_aiohttp_web
from services.approvals.models import ApprovalStatus
from services.approvals.service import get_approval_service
from services.audit import emit_audit_event # type: ignore
@@ -33,6 +35,7 @@ except ImportError:
from services.webhook_auth import AuthError
logger = logging.getLogger("ComfyUI-OpenClaw.api.approvals")
web = import_aiohttp_web()
class ApprovalHandlers:
+21 -6
View File
@@ -1,18 +1,19 @@
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from typing import Any, Dict, Optional
from aiohttp import web
try:
from ..services.access_control import require_admin_token
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.async_utils import run_in_thread
from ..services.automation_composer import AutomationComposerService
from ..services.planner import PlannerService
from ..services.planner_registry import get_planner_registry
from ..services.rate_limit import check_rate_limit
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.reasoning_redaction import (
audit_reasoning_reveal,
resolve_reasoning_reveal,
@@ -22,11 +23,12 @@ try:
except ImportError:
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
from services.access_control import require_admin_token
from services.aiohttp_compat import import_aiohttp_web
from services.async_utils import run_in_thread
from services.automation_composer import AutomationComposerService
from services.planner import PlannerService
from services.planner_registry import get_planner_registry
from services.rate_limit import check_rate_limit
from services.rate_limit import build_rate_limit_response, check_rate_limit
from services.reasoning_redaction import (
audit_reasoning_reveal,
resolve_reasoning_reveal,
@@ -51,6 +53,7 @@ else:
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.assist")
web = import_aiohttp_web()
# Payload size limits (character count for strings, base64 length for images)
MAX_REQUIREMENTS_LEN = 8000
@@ -90,7 +93,13 @@ class AssistHandlers:
if not authorized:
return web.json_response({"error": "Unauthorized"}, status=401)
if not check_rate_limit(request, "admin"):
return web.json_response({"error": "Rate limit exceeded"}, status=429)
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=False,
)
return None
async def _parse_json_body(
@@ -557,7 +566,13 @@ class AssistHandlers:
return web.json_response({"error": "Unauthorized"}, status=401)
if not check_rate_limit(request, "admin"):
return web.json_response({"error": "Rate limit exceeded"}, status=429)
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=False,
)
try:
data = await request.json()
+32 -8
View File
@@ -25,7 +25,8 @@ try:
from ..services.bridge_handshake import verify_handshake
from ..services.execution_budgets import BudgetExceededError
from ..services.idempotency_store import IdempotencyStore
from ..services.rate_limit import check_rate_limit
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.redaction import stable_redaction_tag
from ..services.sidecar.auth import is_bridge_enabled, require_bridge_auth
from ..services.sidecar.bridge_contract import (
BRIDGE_ENDPOINTS,
@@ -43,7 +44,8 @@ except ImportError:
from services.bridge_handshake import verify_handshake
from services.execution_budgets import BudgetExceededError
from services.idempotency_store import IdempotencyStore
from services.rate_limit import check_rate_limit
from services.rate_limit import build_rate_limit_response, check_rate_limit
from services.redaction import stable_redaction_tag
from services.sidecar.auth import is_bridge_enabled, require_bridge_auth
from services.sidecar.bridge_contract import (
BRIDGE_ENDPOINTS,
@@ -82,6 +84,10 @@ MAX_FILES_COUNT = 10
_startup_time = time.time()
def _bridge_sensitive_tag(value: Optional[str], *, label: str) -> str:
return stable_redaction_tag(value, label=label)
class BridgeHandlers:
"""Handlers for bridge API endpoints."""
@@ -107,7 +113,7 @@ class BridgeHandlers:
def _bridge_token(self, device_id: Optional[str], scope: Optional[str] = None):
scopes = {scope} if scope else set()
return SimpleNamespace(
token_id=f"bridge:{device_id or 'unknown'}",
token_id=f"bridge:{_bridge_sensitive_tag(device_id, label='device')}",
role="bridge",
scopes=scopes,
)
@@ -255,7 +261,13 @@ class BridgeHandlers:
scope=BridgeScope.JOB_SUBMIT.value,
details={"reason": "rate_limit"},
)
return web.json_response({"error": "Rate limit exceeded"}, status=429)
return build_rate_limit_response(
request,
"bridge",
web_module=web,
error="Rate limit exceeded",
include_ok=False,
)
# Parse payload
try:
@@ -484,7 +496,13 @@ class BridgeHandlers:
scope=BridgeScope.DELIVERY.value,
details={"reason": "rate_limit"},
)
return web.json_response({"error": "Rate limit exceeded"}, status=429)
return build_rate_limit_response(
request,
"bridge",
web_module=web,
error="Rate limit exceeded",
include_ok=False,
)
# Parse payload
try:
@@ -704,7 +722,10 @@ class BridgeHandlers:
store_key = f"wr:{idempotency_key}"
is_dup, _ = self._idempotency_store.check_and_record(store_key, ttl=86400)
if is_dup:
logger.info(f"Duplicate worker result suppressed: {idempotency_key}")
logger.info(
"Duplicate worker result suppressed for %s",
_bridge_sensitive_tag(idempotency_key, label="idem"),
)
return web.json_response(
{
"ok": True,
@@ -733,7 +754,8 @@ class BridgeHandlers:
self._worker_results[job_id] = {
"status": data.get("status", "completed"),
"outputs": data.get("outputs", {}),
"worker_id": device_id,
# IMPORTANT: keep worker identity redacted in cached bridge state.
"worker_id": _bridge_sensitive_tag(device_id, label="device"),
"timestamp": time.time(),
}
@@ -749,7 +771,9 @@ class BridgeHandlers:
scope=BridgeScope.JOB_SUBMIT.value,
details={"status": data.get("status", "completed")},
)
logger.info(f"F46: Worker result accepted for job={job_id} from={device_id}")
# IMPORTANT: keep this success log constant. Request-auth CodeQL still treats
# the surrounding handler scope as credential-tainted even when job_id is benign.
logger.info("F46: Worker result accepted.")
return web.json_response(response_data, status=201)
@endpoint_metadata(
+5 -1
View File
@@ -3,12 +3,14 @@ Capabilities API Handler (R19).
GET /openclaw/capabilities (legacy: /moltbot/capabilities)
"""
from aiohttp import web
from __future__ import annotations
# Import discipline
if __package__ and "." in __package__:
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.capabilities import get_capabilities
else:
from services.aiohttp_compat import import_aiohttp_web
from services.capabilities import get_capabilities
@@ -27,6 +29,8 @@ else:
endpoint_metadata,
)
web = import_aiohttp_web()
@endpoint_metadata(
auth=AuthTier.PUBLIC,
+23 -4
View File
@@ -24,7 +24,7 @@ if __package__ and "." in __package__:
get_checkpoint,
list_checkpoints,
)
from ..services.rate_limit import check_rate_limit
from ..services.rate_limit import build_rate_limit_payload, check_rate_limit
from ..services.request_ip import get_client_ip
else: # pragma: no cover (test-only import mode)
from models.schemas import MAX_BODY_SIZE # type: ignore
@@ -35,7 +35,10 @@ else: # pragma: no cover (test-only import mode)
get_checkpoint,
list_checkpoints,
)
from services.rate_limit import check_rate_limit # type: ignore
from services.rate_limit import ( # type: ignore
build_rate_limit_payload,
check_rate_limit,
)
from services.request_ip import get_client_ip # type: ignore
@@ -105,7 +108,15 @@ async def list_checkpoints_handler(request: web.Request) -> web.Response:
raise RuntimeError("aiohttp not available")
if not check_rate_limit(request, "admin"):
return _json_resp({"ok": False, "error": "rate_limit_exceeded"}, 429)
return _json_resp(
build_rate_limit_payload(
request,
"admin",
error="rate_limit_exceeded",
include_ok=True,
),
429,
)
allowed, error = require_admin_token(request)
if not allowed:
@@ -136,7 +147,15 @@ async def create_checkpoint_handler(request: web.Request) -> web.Response:
raise RuntimeError("aiohttp not available")
if not check_rate_limit(request, "admin"):
return _json_resp({"ok": False, "error": "rate_limit_exceeded"}, 429)
return _json_resp(
build_rate_limit_payload(
request,
"admin",
error="rate_limit_exceeded",
include_ok=True,
),
429,
)
# Body Size Check
if request.content_length and request.content_length > MAX_BODY_SIZE:
+112 -986
View File
File diff suppressed because it is too large Load Diff
+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
)
+36 -4
View File
@@ -25,18 +25,27 @@ except ImportError: # pragma: no cover
if __package__ and "." in __package__:
from ..services.access_control import require_admin_token, resolve_token_info
from ..services.connector_extraction_contract import (
get_connector_extraction_contract,
)
from ..services.connector_installation_registry import (
get_connector_installation_registry,
)
from ..services.rate_limit import check_rate_limit
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
else: # pragma: no cover
from services.access_control import require_admin_token # type: ignore
from services.access_control import resolve_token_info # type: ignore
from services.connector_extraction_contract import ( # type: ignore
get_connector_extraction_contract,
)
from services.connector_installation_registry import ( # type: ignore
get_connector_installation_registry,
)
from services.rate_limit import check_rate_limit # type: ignore
from services.rate_limit import ( # type: ignore
build_rate_limit_response,
check_rate_limit,
)
from services.tenant_context import ( # type: ignore
TenantBoundaryError,
request_tenant_scope,
@@ -62,8 +71,12 @@ logger = logging.getLogger("ComfyUI-OpenClaw.api.connector_contracts")
def _require_admin(request) -> Optional[web.Response]:
if not check_rate_limit(request, "admin"):
return web.json_response(
{"ok": False, "error": "Rate limit exceeded"}, status=429
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
allowed, err = require_admin_token(request)
if not allowed:
@@ -234,3 +247,22 @@ async def connector_installation_audit_handler(request):
{"ok": False, "error": exc.code, "message": str(exc)},
status=403,
)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="Connector extraction contract",
description="Returns the machine-readable connector extraction feasibility contract.",
audit="connector.extraction_contract.get",
plane=RoutePlane.ADMIN,
)
async def connector_extraction_contract_handler(request):
if (guard := _require_admin(request)) is not None:
return guard
return web.json_response(
{
"ok": True,
"contract": get_connector_extraction_contract(),
}
)
+37 -10
View File
@@ -30,7 +30,7 @@ if __package__ and "." in __package__:
from ..services.job_events import get_job_event_store
from ..services.management_query import normalize_cursor_limit
from ..services.metrics import metrics
from ..services.rate_limit import check_rate_limit
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.reasoning_redaction import (
audit_reasoning_reveal,
resolve_reasoning_reveal,
@@ -43,7 +43,10 @@ else: # pragma: no cover
from services.job_events import get_job_event_store # type: ignore
from services.management_query import normalize_cursor_limit # type: ignore
from services.metrics import metrics # type: ignore
from services.rate_limit import check_rate_limit # type: ignore
from services.rate_limit import ( # type: ignore
build_rate_limit_response,
check_rate_limit,
)
from services.reasoning_redaction import ( # type: ignore
audit_reasoning_reveal,
resolve_reasoning_reveal,
@@ -112,10 +115,12 @@ async def events_stream_handler(request: web.Request) -> web.StreamResponse:
# Rate limit
if not check_rate_limit(request, "events"):
return web.json_response(
{"ok": False, "error": "rate_limit_exceeded"},
status=429,
headers={"Retry-After": "60"},
return build_rate_limit_response(
request,
"events",
web_module=web,
error="rate_limit_exceeded",
include_ok=True,
)
# Access control (same as logs/tail)
@@ -223,10 +228,12 @@ async def events_poll_handler(request: web.Request) -> web.Response:
# Rate limit
if not check_rate_limit(request, "events"):
return web.json_response(
{"ok": False, "error": "rate_limit_exceeded"},
status=429,
headers={"Retry-After": "60"},
return build_rate_limit_response(
request,
"events",
web_module=web,
error="rate_limit_exceeded",
include_ok=True,
)
# Access control
@@ -316,6 +323,26 @@ async def events_poll_handler(request: web.Request) -> web.Response:
"cursor_status": cursor_status,
"warnings": page.warnings,
},
"delta": {
"cursor_key": "since",
"requested_since_seq": since_requested,
"effective_since_seq": since_effective,
"next_since_seq": (events[-1].seq if events else since_effective),
"latest_seq": latest_seq,
"earliest_retained_seq": scan.get("earliest_retained_seq"),
"latest_retained_seq": scan.get("latest_retained_seq"),
"cursor_status": cursor_status,
"snapshot": since_requested == 0,
"truncated": bool(
scan.get("truncated")
or (
events
and isinstance(scan.get("latest_retained_seq"), int)
and int(scan.get("latest_retained_seq")) > int(events[-1].seq)
)
),
"warnings": page.warnings,
},
"scan": scan,
}
)
+27 -3
View File
@@ -7,16 +7,16 @@ from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from aiohttp import web
try:
from ..services.access_control import require_admin_token, resolve_token_info
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
from ..services.management_query import normalize_cursor_limit
from ..services.model_manager import ModelManagerError, model_manager
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
except ImportError: # pragma: no cover
@@ -24,12 +24,14 @@ except ImportError: # pragma: no cover
require_admin_token,
resolve_token_info,
)
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.endpoint_manifest import ( # type: ignore
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
from services.management_query import normalize_cursor_limit # type: ignore
from services.model_manager import ModelManagerError, model_manager # type: ignore
from services.tenant_context import ( # type: ignore
TenantBoundaryError,
@@ -37,6 +39,7 @@ except ImportError: # pragma: no cover
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.model_manager")
web = import_aiohttp_web()
def _json(data: Dict[str, Any], status: int = 200) -> web.Response:
@@ -159,6 +162,22 @@ async def model_download_list_handler(request: web.Request) -> web.Response:
if deny:
return deny
token_info = resolve_token_info(request)
since_seq = None
delta_warnings = []
if "since_seq" in request.query:
page = normalize_cursor_limit(
request.query,
cursor_key="since_seq",
default_cursor=0,
min_cursor=0,
default_limit=100,
max_limit=200,
)
since_seq = int(page.cursor or 0)
delta_warnings = list(page.warnings)
limit = page.limit
else:
limit = _parse_int(request.query.get("limit"), 100, 1, 200)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
@@ -166,9 +185,14 @@ async def model_download_list_handler(request: web.Request) -> web.Response:
result = model_manager.list_download_tasks(
tenant_id=tenant.tenant_id,
state=request.query.get("state", ""),
limit=_parse_int(request.query.get("limit"), 100, 1, 200),
limit=limit,
offset=_parse_int(request.query.get("offset"), 0, 0, 10_000),
since_seq=since_seq,
)
if since_seq is not None:
result.setdefault("pagination", {})["warnings"] = delta_warnings
if "delta" in result:
result["delta"]["warnings"] = delta_warnings
return _json({"ok": True, **result})
except TenantBoundaryError as exc:
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
+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)
+22 -6
View File
@@ -24,8 +24,9 @@ if __package__ and "." in __package__:
get_model_inventory_snapshot,
run_preflight_check,
)
from ..services.rate_limit import check_rate_limit
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.request_ip import get_client_ip
from ..services.workflow_portability import get_workflow_portability_contract
else: # pragma: no cover (test-only import mode)
from models.schemas import MAX_BODY_SIZE # type: ignore
from services.access_control import is_loopback, require_admin_token # type: ignore
@@ -34,8 +35,14 @@ else: # pragma: no cover (test-only import mode)
get_model_inventory_snapshot,
run_preflight_check,
)
from services.rate_limit import check_rate_limit # type: ignore
from services.rate_limit import ( # type: ignore
build_rate_limit_response,
check_rate_limit,
)
from services.request_ip import get_client_ip # type: ignore
from services.workflow_portability import ( # type: ignore
get_workflow_portability_contract,
)
# R98: Endpoint Metadata
if __package__ and "." in __package__:
@@ -102,8 +109,12 @@ async def preflight_handler(request: web.Request) -> web.Response:
# Rate limit: admin-grade endpoint (inventory leak + CPU cost)
if not check_rate_limit(request, "admin"):
return web.json_response(
{"ok": False, "error": "rate_limit_exceeded"}, status=429
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="rate_limit_exceeded",
include_ok=True,
)
# Body Size Check
@@ -176,8 +187,12 @@ async def inventory_handler(request: web.Request) -> web.Response:
# Rate Limit
if not check_rate_limit(request, "admin"):
return web.json_response(
{"ok": False, "error": "rate_limit_exceeded"}, status=429
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="rate_limit_exceeded",
include_ok=True,
)
# Admin boundary (localhost convenience mode if no token configured)
@@ -202,6 +217,7 @@ async def inventory_handler(request: web.Request) -> web.Response:
"ok": True,
"nodes": node_classes,
"models": inventory_snapshot["models"],
"portability_contract": get_workflow_portability_contract(),
"snapshot_ts": inventory_snapshot["snapshot_ts"],
"scan_state": inventory_snapshot["scan_state"],
"stale": inventory_snapshot["stale"],
+35 -22
View File
@@ -8,31 +8,44 @@ import os
import time
from typing import Optional
from aiohttp import web
if __package__ and "." in __package__:
from ..services.import_fallback import import_attrs_dual
else:
from services.import_fallback import import_attrs_dual # type: ignore
try:
from ..services.access_control import require_admin_token, resolve_token_info
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
from ..services.presets import Preset, preset_store
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
except ImportError:
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
from services.access_control import require_admin_token, resolve_token_info
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
from services.presets import Preset, preset_store
from services.tenant_context import TenantBoundaryError, request_tenant_scope
(require_admin_token, resolve_token_info) = import_attrs_dual(
__package__,
"..services.access_control",
"services.access_control",
("require_admin_token", "resolve_token_info"),
)
(import_aiohttp_web,) = import_attrs_dual(
__package__,
"..services.aiohttp_compat",
"services.aiohttp_compat",
("import_aiohttp_web",),
)
(AuthTier, RiskTier, RoutePlane, endpoint_metadata) = import_attrs_dual(
__package__,
"..services.endpoint_manifest",
"services.endpoint_manifest",
("AuthTier", "RiskTier", "RoutePlane", "endpoint_metadata"),
)
(Preset, preset_store) = import_attrs_dual(
__package__,
"..services.presets",
"services.presets",
("Preset", "preset_store"),
)
(TenantBoundaryError, request_tenant_scope) = import_attrs_dual(
__package__,
"..services.tenant_context",
"services.tenant_context",
("TenantBoundaryError", "request_tenant_scope"),
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.presets")
web = import_aiohttp_web()
class PresetHandlers:
+4 -2
View File
@@ -7,9 +7,8 @@ from __future__ import annotations
from pathlib import Path
from aiohttp import web
if __package__ and "." in __package__:
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
@@ -17,6 +16,7 @@ if __package__ and "." in __package__:
endpoint_metadata,
)
else: # pragma: no cover (test-only import mode)
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.endpoint_manifest import ( # type: ignore
AuthTier,
RiskTier,
@@ -24,6 +24,8 @@ else: # pragma: no cover (test-only import mode)
endpoint_metadata,
)
web = import_aiohttp_web()
# CRITICAL: resolve HTML path relative to this module's package root.
# Do not switch to cwd-based resolution; ComfyUI may launch from arbitrary directories.
+3 -2
View File
@@ -13,10 +13,9 @@ import logging
import time
from typing import Any, Dict
from aiohttp import web
try:
from ..services.access_control import require_admin_token, resolve_token_info
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
@@ -39,6 +38,7 @@ except ImportError:
require_admin_token,
resolve_token_info,
)
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.endpoint_manifest import ( # type: ignore
AuthTier,
RiskTier,
@@ -61,6 +61,7 @@ except ImportError:
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.rewrite_recipes")
web = import_aiohttp_web()
def _json(data: Dict[str, Any], status: int = 200) -> web.Response:
+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)
+266
View File
@@ -0,0 +1,266 @@
"""
R151 route-family registrar helpers.
Keep route composition data-driven without importing api.routes back into this
module, which would reintroduce circular bootstrap fragility.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterable
@dataclass(frozen=True)
class RouteSpec:
method: str
path: str
handler: Any
def register_route_family(
server, register_route_fn, specs: Iterable[RouteSpec]
) -> None:
for spec in specs:
register_route_fn(server, spec.method, spec.path, spec.handler)
def build_core_route_specs(
prefix: str, handlers: dict[str, Any]
) -> tuple[RouteSpec, ...]:
return (
RouteSpec("GET", f"{prefix}/admin", handlers["remote_admin_page_handler"]),
RouteSpec("GET", f"{prefix}/health", handlers["health_handler"]),
RouteSpec("GET", f"{prefix}/logs/tail", handlers["logs_tail_handler"]),
RouteSpec("GET", f"{prefix}/jobs", handlers["jobs_handler"]),
RouteSpec("GET", f"{prefix}/trace/{{prompt_id}}", handlers["trace_handler"]),
RouteSpec("POST", f"{prefix}/webhook", handlers["webhook_handler"]),
RouteSpec(
"POST",
f"{prefix}/webhook/submit",
handlers["webhook_submit_handler"],
),
RouteSpec(
"POST",
f"{prefix}/webhook/validate",
handlers["webhook_validate_handler"],
),
RouteSpec("GET", f"{prefix}/capabilities", handlers["capabilities_handler"]),
RouteSpec("GET", f"{prefix}/config", handlers["config_get_handler"]),
RouteSpec("PUT", f"{prefix}/config", handlers["config_put_handler"]),
RouteSpec("POST", f"{prefix}/llm/test", handlers["llm_test_handler"]),
RouteSpec("POST", f"{prefix}/llm/chat", handlers["llm_chat_handler"]),
RouteSpec("GET", f"{prefix}/llm/models", handlers["llm_models_handler"]),
RouteSpec("GET", f"{prefix}/templates", handlers["templates_list_handler"]),
RouteSpec("POST", f"{prefix}/preflight", handlers["preflight_handler"]),
RouteSpec(
"GET",
f"{prefix}/preflight/inventory",
handlers["inventory_handler"],
),
RouteSpec("POST", f"{prefix}/pnginfo", handlers["pnginfo_handler"]),
RouteSpec("GET", f"{prefix}/checkpoints", handlers["list_checkpoints_handler"]),
RouteSpec(
"POST",
f"{prefix}/checkpoints",
handlers["create_checkpoint_handler"],
),
RouteSpec(
"GET",
f"{prefix}/checkpoints/{{id}}",
handlers["get_checkpoint_handler"],
),
RouteSpec(
"DELETE",
f"{prefix}/checkpoints/{{id}}",
handlers["delete_checkpoint_handler"],
),
RouteSpec(
"GET",
f"{prefix}/rewrite/recipes",
handlers["rewrite_recipes_list_handler"],
),
RouteSpec(
"POST",
f"{prefix}/rewrite/recipes",
handlers["rewrite_recipe_create_handler"],
),
RouteSpec(
"GET",
f"{prefix}/rewrite/recipes/{{recipe_id}}",
handlers["rewrite_recipe_get_handler"],
),
RouteSpec(
"PUT",
f"{prefix}/rewrite/recipes/{{recipe_id}}",
handlers["rewrite_recipe_update_handler"],
),
RouteSpec(
"DELETE",
f"{prefix}/rewrite/recipes/{{recipe_id}}",
handlers["rewrite_recipe_delete_handler"],
),
RouteSpec(
"POST",
f"{prefix}/rewrite/recipes/{{recipe_id}}/dry-run",
handlers["rewrite_recipe_dry_run_handler"],
),
RouteSpec(
"POST",
f"{prefix}/rewrite/recipes/{{recipe_id}}/apply",
handlers["rewrite_recipe_apply_handler"],
),
RouteSpec("GET", f"{prefix}/models/search", handlers["model_search_handler"]),
RouteSpec(
"POST",
f"{prefix}/models/downloads",
handlers["model_download_create_handler"],
),
RouteSpec(
"GET",
f"{prefix}/models/downloads",
handlers["model_download_list_handler"],
),
RouteSpec(
"GET",
f"{prefix}/models/downloads/{{task_id}}",
handlers["model_download_get_handler"],
),
RouteSpec(
"POST",
f"{prefix}/models/downloads/{{task_id}}/cancel",
handlers["model_download_cancel_handler"],
),
RouteSpec("POST", f"{prefix}/models/import", handlers["model_import_handler"]),
RouteSpec(
"GET",
f"{prefix}/models/installations",
handlers["model_installations_list_handler"],
),
RouteSpec(
"GET",
f"{prefix}/secrets/status",
handlers["secrets_status_handler"],
),
RouteSpec("PUT", f"{prefix}/secrets", handlers["secrets_put_handler"]),
RouteSpec(
"GET",
f"{prefix}/events/stream",
handlers["events_stream_handler"],
),
RouteSpec("GET", f"{prefix}/events", handlers["events_poll_handler"]),
RouteSpec(
"DELETE",
f"{prefix}/secrets/{{provider}}",
handlers["secrets_delete_handler"],
),
RouteSpec(
"GET",
f"{prefix}/security/doctor",
handlers["security_doctor_handler"],
),
RouteSpec("GET", f"{prefix}/tools", handlers["tools_list_handler"]),
RouteSpec(
"POST",
f"{prefix}/tools/{{name}}/run",
handlers["tools_run_handler"],
),
RouteSpec("POST", f"{prefix}/lab/sweep", handlers["create_sweep_handler"]),
RouteSpec("POST", f"{prefix}/lab/compare", handlers["create_compare_handler"]),
RouteSpec(
"GET",
f"{prefix}/lab/experiments",
handlers["list_experiments_handler"],
),
RouteSpec(
"GET",
f"{prefix}/lab/experiments/{{exp_id}}",
handlers["get_experiment_handler"],
),
RouteSpec(
"POST",
f"{prefix}/lab/experiments/{{exp_id}}/runs/{{run_id}}",
handlers["update_experiment_handler"],
),
RouteSpec(
"POST",
f"{prefix}/lab/experiments/{{exp_id}}/winner",
handlers["select_apply_winner_handler"],
),
)
def build_assist_route_specs(prefix: str, assist) -> tuple[RouteSpec, ...]:
return (
RouteSpec(
"GET",
f"{prefix}/assist/planner/profiles",
assist.planner_profiles_handler,
),
RouteSpec("POST", f"{prefix}/assist/planner", assist.planner_handler),
RouteSpec(
"POST",
f"{prefix}/assist/planner/stream",
assist.planner_stream_handler,
),
RouteSpec("POST", f"{prefix}/assist/refiner", assist.refiner_handler),
RouteSpec(
"POST",
f"{prefix}/assist/refiner/stream",
assist.refiner_stream_handler,
),
RouteSpec(
"POST",
f"{prefix}/assist/automation/compose",
assist.compose_handler,
),
)
def build_connector_installation_route_specs(
prefix: str, handlers: dict[str, Any]
) -> tuple[RouteSpec, ...]:
return (
RouteSpec(
"GET",
f"{prefix}/connector/installations",
handlers["connector_installations_list_handler"],
),
RouteSpec(
"GET",
f"{prefix}/connector/extraction-contract",
handlers["connector_extraction_contract_handler"],
),
RouteSpec(
"GET",
f"{prefix}/connector/installations/resolve",
handlers["connector_installation_resolve_handler"],
),
RouteSpec(
"GET",
f"{prefix}/connector/installations/audit",
handlers["connector_installation_audit_handler"],
),
RouteSpec(
"GET",
f"{prefix}/connector/installations/{{installation_id}}",
handlers["connector_installation_get_handler"],
),
)
def build_pack_route_specs(prefix: str, packs) -> tuple[RouteSpec, ...]:
return (
RouteSpec("GET", f"{prefix}/packs", packs.list_packs_handler),
RouteSpec("POST", f"{prefix}/packs/import", packs.import_pack_handler),
RouteSpec(
"GET",
f"{prefix}/packs/export/{{name}}/{{version}}",
packs.export_pack_handler,
),
RouteSpec(
"DELETE",
f"{prefix}/packs/{{name}}/{{version}}",
packs.delete_pack_handler,
),
)
+324 -689
View File
File diff suppressed because it is too large Load Diff
+25 -3
View File
@@ -3,11 +3,11 @@ Scheduler CRUD API (R4).
REST endpoints for managing persistent schedules.
"""
from __future__ import annotations
import logging
from typing import Optional
from aiohttp import web
# Import discipline:
# - ComfyUI runtime: package-relative imports only (prevents collisions with other custom nodes).
# - Unit tests: allow top-level fallbacks.
@@ -15,17 +15,32 @@ from aiohttp import web
# IMPORTANT: Avoid a broad `try/except ImportError` here. Falling back to `services.*` in ComfyUI
# can silently import another pack's module and break auth/approval semantics.
if __package__ and "." in __package__:
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.scheduler.delivery_contract import (
DeliveryContractError,
normalize_schedule_delivery,
)
from ..services.scheduler.models import Schedule, TriggerType
from ..services.scheduler.storage import get_schedule_store
from ..services.templates import is_template_allowed
from ..services.webhook_auth import AuthError
else: # pragma: no cover (test-only import mode)
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.scheduler.delivery_contract import ( # type: ignore
DeliveryContractError,
normalize_schedule_delivery,
)
from services.scheduler.models import Schedule, TriggerType # type: ignore
from services.scheduler.storage import get_schedule_store # type: ignore
from services.templates import is_template_allowed # type: ignore
from services.webhook_auth import AuthError # type: ignore
logger = logging.getLogger("ComfyUI-OpenClaw.api.schedules")
web = import_aiohttp_web()
def _delivery_error_response(exc: DeliveryContractError) -> web.Response:
return web.json_response({"error": str(exc), "code": exc.code}, status=400)
def _get_scheduler_runner():
@@ -154,6 +169,8 @@ class ScheduleHandlers:
timezone=data.get("timezone", "local"),
enabled=data.get("enabled", True),
)
except DeliveryContractError as e:
return _delivery_error_response(e)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
@@ -206,7 +223,10 @@ class ScheduleHandlers:
if "inputs" in data:
existing.inputs = data["inputs"]
if "delivery" in data:
existing.delivery = data["delivery"]
try:
existing.delivery = normalize_schedule_delivery(data["delivery"])
except DeliveryContractError as e:
return _delivery_error_response(e)
if "timezone" in data:
existing.timezone = data["timezone"]
if "enabled" in data:
@@ -215,6 +235,8 @@ class ScheduleHandlers:
# Re-validate
try:
existing.validate()
except DeliveryContractError as e:
return _delivery_error_response(e)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
+17 -8
View File
@@ -15,22 +15,23 @@ Endpoints:
- DELETE /openclaw/secrets/{provider}: Clear provider secret
"""
from __future__ import annotations
import logging
import os
from typing import Optional
from aiohttp import web
# Import discipline:
# - ComfyUI runtime: package-relative imports only.
# - Unit tests: allow top-level fallbacks.
if __package__ and "." in __package__:
from ..models.schemas import MAX_BODY_SIZE
from ..services.access_control import require_admin_token, resolve_token_info
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.audit import emit_audit_event
from ..services.csrf_protection import require_same_origin_if_no_token
from ..services.metrics import metrics
from ..services.rate_limit import check_rate_limit
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.request_ip import get_client_ip
from ..services.runtime_config import get_admin_token, is_loopback_client
from ..services.secret_store import get_secret_store
@@ -38,15 +39,21 @@ else: # pragma: no cover (test-only import mode)
from models.schemas import MAX_BODY_SIZE # type: ignore
from services.access_control import require_admin_token # type: ignore
from services.access_control import resolve_token_info # type: ignore
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.audit import emit_audit_event # type: ignore
from services.csrf_protection import require_same_origin_if_no_token # type: ignore
from services.metrics import metrics # type: ignore
from services.rate_limit import check_rate_limit # type: ignore
from services.rate_limit import ( # type: ignore
build_rate_limit_response,
check_rate_limit,
)
from services.request_ip import get_client_ip # type: ignore
from services.runtime_config import get_admin_token # type: ignore
from services.runtime_config import is_loopback_client
from services.secret_store import get_secret_store # type: ignore
web = import_aiohttp_web()
# R98: Endpoint Metadata
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
@@ -120,10 +127,12 @@ def _rate_limit_admin(request: web.Request) -> Optional[web.Response]:
if check_rate_limit(request, "admin"):
return None
metrics.increment("rate_limit_exceeded")
return web.json_response(
{"ok": False, "error": "rate_limit_exceeded"},
status=429,
headers={"Retry-After": "60"},
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="rate_limit_exceeded",
include_ok=True,
)
+11 -4
View File
@@ -41,11 +41,14 @@ except ImportError: # pragma: no cover
if __package__ and "." in __package__:
from ..services.access_control import require_admin_token
from ..services.rate_limit import check_rate_limit
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.security_doctor import run_security_doctor
else: # pragma: no cover (test-only)
from services.access_control import require_admin_token # type: ignore
from services.rate_limit import check_rate_limit # type: ignore
from services.rate_limit import ( # type: ignore
build_rate_limit_response,
check_rate_limit,
)
from services.security_doctor import run_security_doctor # type: ignore
# R98: Endpoint Metadata
@@ -90,8 +93,12 @@ async def security_doctor_handler(request: web.Request) -> web.Response:
# S17: Rate limit
if not check_rate_limit(request, "admin"):
return web.json_response(
{"ok": False, "error": "Rate limit exceeded"}, status=429
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
# Admin boundary
+11 -6
View File
@@ -22,7 +22,7 @@ if __package__ and "." in __package__:
require_observability_access,
resolve_token_info,
)
from ..services.rate_limit import check_rate_limit
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.templates import get_template_service
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
else: # pragma: no cover (test-only import mode)
@@ -30,7 +30,10 @@ else: # pragma: no cover (test-only import mode)
require_observability_access,
resolve_token_info,
)
from services.rate_limit import check_rate_limit # type: ignore
from services.rate_limit import ( # type: ignore
build_rate_limit_response,
check_rate_limit,
)
from services.templates import get_template_service # type: ignore
from services.tenant_context import ( # type: ignore
TenantBoundaryError,
@@ -108,10 +111,12 @@ async def templates_list_handler(request: web.Request) -> web.Response:
# Reuse the admin bucket to avoid unbounded enumeration from remote callers.
if not check_rate_limit(request, "admin"):
return web.json_response(
{"ok": False, "error": "Rate limit exceeded"},
status=429,
headers={"Retry-After": "60"},
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
try:
+42 -30
View File
@@ -3,38 +3,55 @@ S12: API Handlers for External Tools.
Protected by Admin Token and Feature Flag.
"""
from __future__ import annotations
import json
import logging
from aiohttp import web
try:
from ..services.access_control import require_admin_token, resolve_token_info
from ..services.audit import emit_audit_event
from ..services.tool_runner import get_tool_runner, is_tools_enabled
except ImportError:
from services.access_control import require_admin_token # type: ignore
from services.access_control import resolve_token_info # type: ignore
from services.audit import emit_audit_event # type: ignore
from services.tool_runner import get_tool_runner, is_tools_enabled
# R98: Endpoint Metadata
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
from ..services.import_fallback import import_attrs_dual
else:
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
from services.import_fallback import import_attrs_dual # type: ignore
(require_admin_token, resolve_token_info) = import_attrs_dual(
__package__,
"..services.access_control",
"services.access_control",
("require_admin_token", "resolve_token_info"),
)
(import_aiohttp_web,) = import_attrs_dual(
__package__,
"..services.aiohttp_compat",
"services.aiohttp_compat",
("import_aiohttp_web",),
)
(emit_audit_event,) = import_attrs_dual(
__package__,
"..services.audit",
"services.audit",
("emit_audit_event",),
)
(get_tool_runner, is_tools_enabled) = import_attrs_dual(
__package__,
"..services.tool_runner",
"services.tool_runner",
("get_tool_runner", "is_tools_enabled"),
)
(AuthTier, RiskTier, RoutePlane, endpoint_metadata) = import_attrs_dual(
__package__,
"..services.endpoint_manifest",
"services.endpoint_manifest",
("AuthTier", "RiskTier", "RoutePlane", "endpoint_metadata"),
)
(check_surface,) = import_attrs_dual(
__package__,
"..services.surface_guard",
"services.surface_guard",
("check_surface",),
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.tools")
web = import_aiohttp_web()
@endpoint_metadata(
@@ -84,11 +101,6 @@ async def tools_run_handler(request: web.Request) -> web.Response:
Requires: Admin Token.
"""
# S62: Block tool execution in public+split mode
try:
# CRITICAL: package-relative import must stay first in ComfyUI runtime.
from ..services.surface_guard import check_surface
except ImportError:
from services.surface_guard import check_surface # type: ignore
blocked = check_surface("tool_execution", request)
if blocked:
return blocked
+5 -2
View File
@@ -4,14 +4,14 @@ Endpoint for firing workflow triggers from external systems.
With S7 approval gate support.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
from typing import Optional
from aiohttp import web
# Import discipline:
# - ComfyUI runtime: this pack is loaded as a package; MUST use package-relative imports to avoid
# collisions with other custom nodes or other top-level modules named `services`.
@@ -23,6 +23,7 @@ from aiohttp import web
# can silently import the WRONG module (another custom node or ComfyUI-adjacent package), causing
# template allowlists to appear "missing" even when `data/templates/manifest.json` is correct.
if __package__ and "." in __package__:
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
@@ -34,6 +35,7 @@ if __package__ and "." in __package__:
from ..services.trace import generate_trace_id
from ..services.webhook_auth import AuthError
else: # pragma: no cover (test-only import mode)
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.endpoint_manifest import ( # type: ignore
AuthTier,
RiskTier,
@@ -46,6 +48,7 @@ else: # pragma: no cover (test-only import mode)
from services.webhook_auth import AuthError # type: ignore
logger = logging.getLogger("ComfyUI-OpenClaw.api.triggers")
web = import_aiohttp_web()
# Default: require approval for external triggers (secure-by-default)
REQUIRE_APPROVAL_DEFAULT = (
+70 -41
View File
@@ -9,49 +9,77 @@ POST /moltbot/webhook
- Returns normalized internal request
"""
from __future__ import annotations
import json
import logging
from aiohttp import web
if __package__ and "." in __package__:
from ..services.import_fallback import import_attrs_dual
else:
from services.import_fallback import import_attrs_dual # type: ignore
try:
from .errors import APIError, ErrorCode, create_error_response
except ImportError:
# Build-time / Test fallback
from api.errors import APIError, ErrorCode, create_error_response
try:
from ..models.schemas import MAX_BODY_SIZE, WebhookJobRequest
from ..services.metrics import metrics
from ..services.rate_limit import check_rate_limit
from ..services.trace import get_effective_trace_id
from ..services.webhook_auth import get_auth_summary, require_auth
except ImportError:
from models.schemas import MAX_BODY_SIZE, WebhookJobRequest
from services.metrics import metrics
from services.rate_limit import check_rate_limit
from services.trace import get_effective_trace_id
from services.webhook_auth import get_auth_summary, require_auth
try:
from ..services.diagnostics_flags import diagnostics
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
except ImportError:
from services.diagnostics_flags import diagnostics
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
(APIError, ErrorCode, create_error_response) = import_attrs_dual(
__package__,
".errors",
"api.errors",
("APIError", "ErrorCode", "create_error_response"),
)
(
MAX_BODY_SIZE,
WebhookJobRequest,
) = import_attrs_dual(
__package__,
"..models.schemas",
"models.schemas",
("MAX_BODY_SIZE", "WebhookJobRequest"),
)
(import_aiohttp_web,) = import_attrs_dual(
__package__,
"..services.aiohttp_compat",
"services.aiohttp_compat",
("import_aiohttp_web",),
)
(metrics,) = import_attrs_dual(
__package__,
"..services.metrics",
"services.metrics",
("metrics",),
)
(build_rate_limit_response, check_rate_limit) = import_attrs_dual(
__package__,
"..services.rate_limit",
"services.rate_limit",
("build_rate_limit_response", "check_rate_limit"),
)
(get_effective_trace_id,) = import_attrs_dual(
__package__,
"..services.trace",
"services.trace",
("get_effective_trace_id",),
)
(get_auth_summary, require_auth) = import_attrs_dual(
__package__,
"..services.webhook_auth",
"services.webhook_auth",
("get_auth_summary", "require_auth"),
)
(diagnostics,) = import_attrs_dual(
__package__,
"..services.diagnostics_flags",
"services.diagnostics_flags",
("diagnostics",),
)
(AuthTier, RiskTier, RoutePlane, endpoint_metadata) = import_attrs_dual(
__package__,
"..services.endpoint_manifest",
"services.endpoint_manifest",
("AuthTier", "RiskTier", "RoutePlane", "endpoint_metadata"),
)
# R46: Scoped logger for safe-by-default redaction
logger = diagnostics.get_logger("ComfyUI-OpenClaw.api.webhook", "webhook")
web = import_aiohttp_web()
@endpoint_metadata(
@@ -71,11 +99,12 @@ async def webhook_handler(request: web.Request) -> web.Response:
# S17: Rate Limit
if not check_rate_limit(request, "webhook"):
metrics.inc("webhook_denied")
return create_error_response(
message="Rate limit exceeded",
code=ErrorCode.RATE_LIMIT_EXCEEDED,
status=429,
detail={"retry_after": "60"},
return build_rate_limit_response(
request,
"webhook",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
try:
+14 -8
View File
@@ -6,8 +6,6 @@ Connects S2 (Auth) -> R8 (Normalization) -> R3 (Idempotency) -> F5 (Execution).
import json
import logging
from aiohttp import web
# Import discipline:
# - ComfyUI runtime: package-relative imports only (prevents collisions with other custom nodes).
# - Unit tests: allow top-level fallbacks.
@@ -17,13 +15,14 @@ from aiohttp import web
# import another pack's top-level `services` module and break allowlists/auth in surprising ways.
if __package__ and "." in __package__:
from ..models.schemas import MAX_BODY_SIZE, WebhookJobRequest
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.callback_delivery import start_callback_watch
from ..services.execution_budgets import BudgetExceededError
from ..services.idempotency_store import IdempotencyStore
from ..services.job_events import JobEventType, get_job_event_store
from ..services.metrics import metrics
from ..services.queue_submit import submit_prompt
from ..services.rate_limit import check_rate_limit
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.templates import get_template_service
from ..services.trace import get_effective_trace_id
from ..services.trace_store import trace_store
@@ -35,13 +34,17 @@ if __package__ and "." in __package__:
)
else: # pragma: no cover (test-only import mode)
from models.schemas import MAX_BODY_SIZE, WebhookJobRequest
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.callback_delivery import start_callback_watch # type: ignore
from services.execution_budgets import BudgetExceededError # type: ignore
from services.idempotency_store import IdempotencyStore # type: ignore
from services.job_events import JobEventType, get_job_event_store # type: ignore
from services.metrics import metrics # type: ignore
from services.queue_submit import submit_prompt # type: ignore
from services.rate_limit import check_rate_limit # type: ignore
from services.rate_limit import ( # type: ignore
build_rate_limit_response,
check_rate_limit,
)
from services.templates import get_template_service # type: ignore
from services.trace import get_effective_trace_id # type: ignore
from services.trace_store import trace_store # type: ignore
@@ -69,6 +72,7 @@ else:
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.webhook_submit")
web = import_aiohttp_web()
def safe_error_response(status: int, error: str, detail: str = "") -> web.Response:
@@ -110,10 +114,12 @@ async def webhook_submit_handler(request: web.Request) -> web.Response:
# S17: Rate Limit
if not check_rate_limit(request, "webhook"):
metrics.inc("webhook_denied")
return web.json_response(
{"ok": False, "error": "rate_limit_exceeded"},
status=429,
headers={"Retry-After": "60"},
return build_rate_limit_response(
request,
"webhook",
web_module=web,
error="rate_limit_exceeded",
include_ok=True,
)
try:
+11 -6
View File
@@ -39,7 +39,7 @@ if __package__ and "." in __package__:
from ..models.schemas import MAX_BODY_SIZE, WebhookJobRequest
from ..services.execution_budgets import BudgetExceededError, check_render_size
from ..services.metrics import metrics
from ..services.rate_limit import check_rate_limit
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.templates import get_template_service
from ..services.trace import get_effective_trace_id
from ..services.webhook_auth import require_auth
@@ -51,7 +51,10 @@ else: # pragma: no cover (test-only import mode)
check_render_size,
)
from services.metrics import metrics # type: ignore
from services.rate_limit import check_rate_limit # type: ignore
from services.rate_limit import ( # type: ignore
build_rate_limit_response,
check_rate_limit,
)
from services.templates import get_template_service # type: ignore
from services.trace import get_effective_trace_id # type: ignore
from services.webhook_auth import require_auth # type: ignore
@@ -106,10 +109,12 @@ async def webhook_validate_handler(request: web.Request) -> web.Response:
# S17: Rate limit (same bucket as submit)
if not check_rate_limit(request, "webhook"):
metrics.inc("webhook_denied")
return web.json_response(
{"ok": False, "error": "rate_limit_exceeded"},
status=429,
headers={"Retry-After": "60"},
return build_rate_limit_response(
request,
"webhook",
web_module=web,
error="rate_limit_exceeded",
include_ok=True,
)
# S2: Content-Type + body size
Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+97 -39
View File
@@ -11,12 +11,14 @@ try:
GENERIC_LLM_API_KEY_ENV_KEYS,
get_first_present_env,
)
from .services.effective_config import get_effective_llm_api_key
except Exception:
try:
from services.config_layers import ( # type: ignore
GENERIC_LLM_API_KEY_ENV_KEYS,
get_first_present_env,
)
from services.effective_config import get_effective_llm_api_key # type: ignore
except Exception:
GENERIC_LLM_API_KEY_ENV_KEYS = (
"OPENCLAW_LLM_API_KEY",
@@ -31,28 +33,40 @@ except Exception:
return env_map.get(key)
return None
def get_effective_llm_api_key(provider=None, tenant_id=None): # type: ignore
return get_first_present_env(GENERIC_LLM_API_KEY_ENV_KEYS)
# Pack metadata
PACK_NAME = "ComfyUI-OpenClaw"
PACK_START_TIME = time.time()
def _read_pyproject_version() -> Optional[str]:
"""
Read version from pyproject.toml ([project].version) as the single source of truth.
def _extract_toml_section(text: str, header: str) -> Optional[str]:
pattern = re.compile(
rf"(?ms)^\ufeff?\[{re.escape(header)}\]\s*$\n(?P<body>.*?)(?=^\[|\Z)"
)
match = pattern.search(text)
if not match:
return None
return match.group("body")
Uses a lightweight regex parse to avoid non-stdlib TOML dependencies.
"""
try:
pack_dir = os.path.dirname(os.path.abspath(__file__))
pyproject_path = os.path.join(pack_dir, "pyproject.toml")
if not os.path.exists(pyproject_path):
return None
text = ""
with open(pyproject_path, "r", encoding="utf-8") as f:
text = f.read()
# Prefer stdlib TOML parser if available (Python 3.11+), then fallback to regex.
def _extract_toml_string_assignment(section_text: str, key: str) -> Optional[str]:
match = re.search(
rf"(?m)^\s*{re.escape(key)}\s*=\s*['\"]([^'\"]+)['\"]\s*$",
section_text,
)
if not match:
return None
value = match.group(1).strip()
return value or None
def _parse_pyproject_version_text(
text: str, *, prefer_tomllib: bool = True
) -> Optional[str]:
if prefer_tomllib:
try:
from tomllib import loads as _toml_loads # type: ignore
except Exception:
@@ -67,21 +81,41 @@ def _read_pyproject_version() -> Optional[str]:
except Exception:
pass
# Find the [project] section and parse `version = "..."` within it.
# IMPORTANT: tolerate BOM/CRLF so the UI version does not silently fall back to 0.1.0.
# This is intentionally conservative to avoid false matches in other sections.
m = re.search(
r"(?ms)^\ufeff?\\[project\\]\\s*(?:[^\\[]*?)^version\\s*=\\s*[\"']([^\"']+)[\"']\\s*$",
text,
)
if not m:
# IMPORTANT: keep this fallback section-bounded.
# Matching any `version = ...` outside `[project]` silently reports the wrong build.
project_section = _extract_toml_section(text, "project")
if project_section is None:
return None
return _extract_toml_string_assignment(project_section, "version")
def _read_pyproject_version_from_path(
pyproject_path: os.PathLike[str] | str, *, prefer_tomllib: bool = True
) -> Optional[str]:
"""
Read version from pyproject.toml ([project].version) as the single source of truth.
Uses a lightweight regex parse to avoid non-stdlib TOML dependencies.
"""
try:
pyproject_path = os.fspath(pyproject_path)
if not os.path.exists(pyproject_path):
return None
ver = (m.group(1) or "").strip()
return ver or None
with open(pyproject_path, "r", encoding="utf-8") as f:
text = f.read()
return _parse_pyproject_version_text(
text,
prefer_tomllib=prefer_tomllib,
)
except Exception:
return None
def _read_pyproject_version() -> Optional[str]:
pack_dir = os.path.dirname(os.path.abspath(__file__))
return _read_pyproject_version_from_path(os.path.join(pack_dir, "pyproject.toml"))
# Version: single source of truth is pyproject.toml (line 4 in this repo).
PACK_VERSION = _read_pyproject_version() or "0.1.0"
@@ -93,24 +127,39 @@ LEGACY2_ENV_API_KEY = GENERIC_LLM_API_KEY_ENV_KEYS[2]
# Data directory (R11: use portable state directory)
try:
# Prefer package-relative import (ComfyUI loads custom nodes by file loader)
from .services.state_dir import get_log_path, get_state_dir # type: ignore
from .services.state_dir import ( # type: ignore
get_log_path,
get_state_dir,
peek_log_path,
peek_state_dir,
)
except Exception:
try:
# Fallback for unit tests / direct sys.path imports
from services.state_dir import get_log_path, get_state_dir
from services.state_dir import (
get_log_path,
get_state_dir,
peek_log_path,
peek_state_dir,
)
except Exception:
get_state_dir = None
get_log_path = None
peek_state_dir = None
peek_log_path = None
if get_state_dir and get_log_path:
DATA_DIR = get_state_dir()
LOG_FILE = get_log_path()
if peek_state_dir and peek_log_path:
DATA_DIR = peek_state_dir()
LOG_FILE = peek_log_path()
else:
# Last-resort fallback during early import or if state_dir is unavailable
PACK_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(PACK_DIR, "data")
LOG_FILE = os.path.join(DATA_DIR, "openclaw.log")
_IMPORT_DATA_DIR = DATA_DIR
_IMPORT_LOG_FILE = LOG_FILE
# IMPORTANT: startup log truncation must run once per process.
# Multiple module-level loggers call setup_logger(); repeated truncation would
# erase fresh logs emitted after the first logger initialization.
@@ -146,6 +195,16 @@ def _maybe_truncate_log_on_start(logger: logging.Logger) -> None:
logger.warning(f"Failed to truncate startup log file {LOG_FILE}: {e}")
def _ensure_log_targets() -> tuple[str, str]:
global DATA_DIR, LOG_FILE
if DATA_DIR != _IMPORT_DATA_DIR or LOG_FILE != _IMPORT_LOG_FILE:
return DATA_DIR, LOG_FILE
if get_state_dir and get_log_path:
DATA_DIR = get_state_dir()
LOG_FILE = get_log_path()
return DATA_DIR, LOG_FILE
class RedactedFormatter(logging.Formatter):
"""
Custom formatter to redact sensitive information (like API keys) from logs.
@@ -165,14 +224,12 @@ class RedactedFormatter(logging.Formatter):
def get_api_key() -> Optional[str]:
"""
Retrieves the LLM API key from environment variables.
Retrieves the effective LLM API key via the unified config facade.
Preference order:
1) OPENCLAW_LLM_API_KEY
2) (legacy) MOLTBOT_LLM_API_KEY
3) (legacy) CLAWDBOT_LLM_API_KEY
This keeps logger redaction aligned with the same provider/key resolution
path used by runtime consumers.
"""
value = get_first_present_env(GENERIC_LLM_API_KEY_ENV_KEYS)
value = get_effective_llm_api_key()
return value or None
@@ -191,6 +248,7 @@ def setup_logger(name: str = "ComfyUI-OpenClaw") -> logging.Logger:
# Only add handler if not already added to avoid duplicates on reload
if not logger.handlers:
data_dir, log_file = _ensure_log_targets()
_maybe_truncate_log_on_start(logger)
api_key = get_api_key()
sensitive = [api_key] if api_key else []
@@ -205,9 +263,9 @@ def setup_logger(name: str = "ComfyUI-OpenClaw") -> logging.Logger:
# File handler with rotation (5MB, 3 backups)
try:
os.makedirs(DATA_DIR, exist_ok=True)
os.makedirs(data_dir, exist_ok=True)
file_handler = RotatingFileHandler(
LOG_FILE,
log_file,
maxBytes=5 * 1024 * 1024, # 5MB
backupCount=3,
encoding="utf-8",
@@ -223,5 +281,5 @@ def setup_logger(name: str = "ComfyUI-OpenClaw") -> logging.Logger:
return logger
# Global config accessor if needed
logger = setup_logger()
# Global logger handle for compatibility; actual handler bootstrap is lazy.
logger = logging.getLogger("ComfyUI-OpenClaw")
+74 -1
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()
+358 -49
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,12 +239,51 @@ class ConnectorConfig:
slack_allowed_users: List[str] = field(default_factory=list)
slack_allowed_channels: List[str] = field(default_factory=list)
slack_bind_host: str = "127.0.0.1"
slack_bind_port: int = 8095
slack_bind_port: int = DEFAULT_SLACK_BIND_PORT
slack_webhook_path: str = "/slack/events"
slack_interactions_path: str = "/slack/interactions"
slack_require_mention: bool = True
slack_reply_in_thread: bool = True
slack_mode: str = "events" # F57: events | socket
slack_app_token: Optional[str] = None # F57: required in socket mode (xapp-...)
slack_client_id: Optional[str] = None
slack_client_secret: Optional[str] = None
slack_oauth_redirect_uri: Optional[str] = None
slack_oauth_install_path: str = "/slack/install"
slack_oauth_callback_path: str = "/slack/oauth/callback"
slack_oauth_scopes: List[str] = field(
default_factory=lambda: [
"app_mentions:read",
"channels:history",
"chat:write",
"files:write",
"groups:history",
"im:history",
"mpim:history",
]
)
slack_oauth_state_ttl_sec: int = DEFAULT_SLACK_OAUTH_STATE_TTL_SEC
# Feishu / Lark (F67)
feishu_app_id: Optional[str] = None
feishu_app_secret: Optional[str] = None
feishu_verification_token: Optional[str] = None
feishu_encrypt_key: Optional[str] = None
feishu_account_id: Optional[str] = None
feishu_default_account_id: Optional[str] = None
feishu_workspace_id: Optional[str] = None
feishu_workspace_name: Optional[str] = None
feishu_bindings_json: Optional[str] = None
feishu_allowed_users: List[str] = field(default_factory=list)
feishu_allowed_chats: List[str] = field(default_factory=list)
feishu_bind_host: str = "127.0.0.1"
feishu_bind_port: int = DEFAULT_FEISHU_BIND_PORT
feishu_webhook_path: str = "/feishu/events"
feishu_callback_path: str = "/feishu/callback"
feishu_domain: str = "feishu" # feishu | lark
feishu_mode: str = "websocket" # websocket | webhook
feishu_require_mention: bool = True
feishu_reply_in_thread: bool = True
# Privileged Access (ID match across platforms; Telegram Int vs Discord Str handled by router)
admin_users: List[str] = field(default_factory=list)
@@ -109,13 +291,19 @@ class ConnectorConfig:
# Media Host (F33)
public_base_url: Optional[str] = None
media_path: str = "/media"
media_ttl_sec: int = 300
media_max_mb: int = 8
media_ttl_sec: int = DEFAULT_MEDIA_TTL_SEC
media_max_mb: int = DEFAULT_MEDIA_MAX_MB
# Security (F32)
rate_limit_user_rpm: int = 10 # Requests per minute per user
rate_limit_channel_rpm: int = 30 # Requests per minute per channel
max_command_length: int = 4096 # Max characters in a single command
rate_limit_user_rpm: int = (
DEFAULT_RATE_LIMIT_USER_RPM # Requests per minute per user
)
rate_limit_channel_rpm: int = (
DEFAULT_RATE_LIMIT_CHANNEL_RPM # Requests per minute per channel
)
max_command_length: int = (
DEFAULT_MAX_COMMAND_LENGTH # Max characters in a single command
)
llm_max_tokens_per_request: int = 1024 # LLM token budget
# R80: Command Auth Policy
@@ -148,14 +336,23 @@ def load_config() -> ConnectorConfig:
cfg.state_path = os.environ.get("OPENCLAW_CONNECTOR_STATE_PATH")
# Delivery
cfg.delivery_max_images = int(
os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES", "4")
cfg.delivery_max_images = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES",
default=DEFAULT_DELIVERY_MAX_IMAGES,
minimum=MIN_DELIVERY_MAX_IMAGES,
maximum=MAX_DELIVERY_MAX_IMAGES,
)
cfg.delivery_max_bytes = int(
os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES", str(10 * 1024 * 1024))
cfg.delivery_max_bytes = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES",
default=DEFAULT_DELIVERY_MAX_BYTES,
minimum=MIN_DELIVERY_MAX_BYTES,
maximum=MAX_DELIVERY_MAX_BYTES,
)
cfg.delivery_timeout_sec = int(
os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC", "600")
cfg.delivery_timeout_sec = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC",
default=DEFAULT_DELIVERY_TIMEOUT_SEC,
minimum=MIN_DELIVERY_TIMEOUT_SEC,
maximum=MAX_DELIVERY_TIMEOUT_SEC,
)
# Telegram
@@ -191,9 +388,13 @@ def load_config() -> ConnectorConfig:
cfg.line_allowed_groups = [u.strip() for u in l_groups.split(",") if u.strip()]
cfg.line_bind_host = os.environ.get("OPENCLAW_CONNECTOR_LINE_BIND", "127.0.0.1")
if l_port := os.environ.get("OPENCLAW_CONNECTOR_LINE_PORT"):
if l_port.isdigit():
cfg.line_bind_port = int(l_port)
cfg.line_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_LINE_PORT",
default=DEFAULT_LINE_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.line_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_LINE_PATH", "/line/webhook"
)
@@ -216,9 +417,13 @@ def load_config() -> ConnectorConfig:
cfg.whatsapp_bind_host = os.environ.get(
"OPENCLAW_CONNECTOR_WHATSAPP_BIND", "127.0.0.1"
)
if wa_port := os.environ.get("OPENCLAW_CONNECTOR_WHATSAPP_PORT"):
if wa_port.isdigit():
cfg.whatsapp_bind_port = int(wa_port)
cfg.whatsapp_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_WHATSAPP_PORT",
default=DEFAULT_WHATSAPP_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.whatsapp_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_WHATSAPP_PATH", "/whatsapp/webhook"
)
@@ -233,9 +438,13 @@ def load_config() -> ConnectorConfig:
if wc_users := os.environ.get("OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS"):
cfg.wechat_allowed_users = [u.strip() for u in wc_users.split(",") if u.strip()]
cfg.wechat_bind_host = os.environ.get("OPENCLAW_CONNECTOR_WECHAT_BIND", "127.0.0.1")
if wc_port := os.environ.get("OPENCLAW_CONNECTOR_WECHAT_PORT"):
if wc_port.isdigit():
cfg.wechat_bind_port = int(wc_port)
cfg.wechat_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_WECHAT_PORT",
default=DEFAULT_WECHAT_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.wechat_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_WECHAT_PATH", "/wechat/webhook"
)
@@ -245,9 +454,13 @@ def load_config() -> ConnectorConfig:
cfg.kakao_enabled = True
cfg.kakao_bind_host = os.environ.get("OPENCLAW_CONNECTOR_KAKAO_BIND", "127.0.0.1")
if kp := os.environ.get("OPENCLAW_CONNECTOR_KAKAO_PORT"):
if kp.isdigit():
cfg.kakao_bind_port = int(kp)
cfg.kakao_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_KAKAO_PORT",
default=DEFAULT_KAKAO_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.kakao_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_KAKAO_PATH", "/kakao/webhook"
)
@@ -262,12 +475,19 @@ def load_config() -> ConnectorConfig:
if sc := os.environ.get("OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS"):
cfg.slack_allowed_channels = [u.strip() for u in sc.split(",") if u.strip()]
cfg.slack_bind_host = os.environ.get("OPENCLAW_CONNECTOR_SLACK_BIND", "127.0.0.1")
if sp := os.environ.get("OPENCLAW_CONNECTOR_SLACK_PORT"):
if sp.isdigit():
cfg.slack_bind_port = int(sp)
cfg.slack_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_SLACK_PORT",
default=DEFAULT_SLACK_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.slack_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_SLACK_PATH", "/slack/events"
)
cfg.slack_interactions_path = os.environ.get(
"OPENCLAW_CONNECTOR_SLACK_INTERACTIONS_PATH", "/slack/interactions"
)
if (
os.environ.get("OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION", "").lower()
== "false"
@@ -280,31 +500,120 @@ def load_config() -> ConnectorConfig:
cfg.slack_reply_in_thread = False
cfg.slack_mode = os.environ.get("OPENCLAW_CONNECTOR_SLACK_MODE", "events").lower()
cfg.slack_app_token = os.environ.get("OPENCLAW_CONNECTOR_SLACK_APP_TOKEN")
cfg.slack_client_id = os.environ.get("OPENCLAW_CONNECTOR_SLACK_CLIENT_ID")
cfg.slack_client_secret = os.environ.get("OPENCLAW_CONNECTOR_SLACK_CLIENT_SECRET")
cfg.slack_oauth_redirect_uri = os.environ.get(
"OPENCLAW_CONNECTOR_SLACK_OAUTH_REDIRECT_URI"
)
cfg.slack_oauth_install_path = os.environ.get(
"OPENCLAW_CONNECTOR_SLACK_OAUTH_INSTALL_PATH", "/slack/install"
)
cfg.slack_oauth_callback_path = os.environ.get(
"OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH", "/slack/oauth/callback"
)
if slack_scopes := os.environ.get("OPENCLAW_CONNECTOR_SLACK_OAUTH_SCOPES"):
parsed_scopes = [
scope.strip() for scope in slack_scopes.split(",") if scope.strip()
]
if parsed_scopes:
cfg.slack_oauth_scopes = parsed_scopes
cfg.slack_oauth_state_ttl_sec = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC",
default=DEFAULT_SLACK_OAUTH_STATE_TTL_SEC,
minimum=MIN_SLACK_OAUTH_STATE_TTL_SEC,
maximum=MAX_SLACK_OAUTH_STATE_TTL_SEC,
)
# Feishu / Lark (F67)
cfg.feishu_app_id = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_APP_ID")
cfg.feishu_app_secret = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_APP_SECRET")
cfg.feishu_verification_token = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_VERIFICATION_TOKEN"
)
cfg.feishu_encrypt_key = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_ENCRYPT_KEY")
cfg.feishu_account_id = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_ACCOUNT_ID")
cfg.feishu_default_account_id = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_DEFAULT_ACCOUNT_ID"
)
cfg.feishu_workspace_id = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_ID")
cfg.feishu_workspace_name = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_NAME"
)
cfg.feishu_bindings_json = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON")
if fu := os.environ.get("OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS"):
cfg.feishu_allowed_users = [u.strip() for u in fu.split(",") if u.strip()]
if fc := os.environ.get("OPENCLAW_CONNECTOR_FEISHU_ALLOWED_CHATS"):
cfg.feishu_allowed_chats = [u.strip() for u in fc.split(",") if u.strip()]
cfg.feishu_bind_host = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_BIND", "127.0.0.1")
cfg.feishu_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_FEISHU_PORT",
default=DEFAULT_FEISHU_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.feishu_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_PATH", "/feishu/events"
)
cfg.feishu_callback_path = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH", "/feishu/callback"
)
cfg.feishu_domain = (
os.environ.get("OPENCLAW_CONNECTOR_FEISHU_DOMAIN", "feishu").strip() or "feishu"
)
cfg.feishu_mode = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_MODE", "websocket"
).lower()
if (
os.environ.get("OPENCLAW_CONNECTOR_FEISHU_REQUIRE_MENTION", "").lower()
== "false"
):
cfg.feishu_require_mention = False
if (
os.environ.get("OPENCLAW_CONNECTOR_FEISHU_REPLY_IN_THREAD", "").lower()
== "false"
):
cfg.feishu_reply_in_thread = False
# Admin
if admins := os.environ.get("OPENCLAW_CONNECTOR_ADMIN_USERS"):
cfg.admin_users = [u.strip() for u in admins.split(",") if u.strip()]
# Security (F32)
if rpm := os.environ.get("OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM"):
if rpm.isdigit():
cfg.rate_limit_user_rpm = int(rpm)
if rpm := os.environ.get("OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM"):
if rpm.isdigit():
cfg.rate_limit_channel_rpm = int(rpm)
if max_len := os.environ.get("OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH"):
if max_len.isdigit():
cfg.max_command_length = int(max_len)
cfg.rate_limit_user_rpm = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM",
default=DEFAULT_RATE_LIMIT_USER_RPM,
minimum=MIN_RATE_LIMIT_RPM,
maximum=MAX_RATE_LIMIT_RPM,
)
cfg.rate_limit_channel_rpm = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM",
default=DEFAULT_RATE_LIMIT_CHANNEL_RPM,
minimum=MIN_RATE_LIMIT_RPM,
maximum=MAX_RATE_LIMIT_RPM,
)
cfg.max_command_length = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH",
default=DEFAULT_MAX_COMMAND_LENGTH,
minimum=MIN_MAX_COMMAND_LENGTH,
maximum=MAX_MAX_COMMAND_LENGTH,
)
# Media Host (F33)
cfg.public_base_url = os.environ.get("OPENCLAW_CONNECTOR_PUBLIC_BASE_URL")
cfg.media_path = os.environ.get("OPENCLAW_CONNECTOR_MEDIA_PATH", "/media")
if ttl := os.environ.get("OPENCLAW_CONNECTOR_MEDIA_TTL_SEC"):
if ttl.isdigit():
cfg.media_ttl_sec = int(ttl)
if mb := os.environ.get("OPENCLAW_CONNECTOR_MEDIA_MAX_MB"):
if mb.isdigit():
cfg.media_max_mb = int(mb)
cfg.media_ttl_sec = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_MEDIA_TTL_SEC",
default=DEFAULT_MEDIA_TTL_SEC,
minimum=MIN_MEDIA_TTL_SEC,
maximum=MAX_MEDIA_TTL_SEC,
)
cfg.media_max_mb = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_MEDIA_MAX_MB",
default=DEFAULT_MEDIA_MAX_MB,
minimum=MIN_MEDIA_MAX_MB,
maximum=MAX_MEDIA_MAX_MB,
)
# R80: Command Auth Policy
import json
+11 -2
View File
@@ -4,7 +4,7 @@ Shared data models for request/response.
"""
from dataclasses import dataclass, field
from typing import List, Optional
from typing import Any, Dict, List, Optional
@dataclass
@@ -16,6 +16,9 @@ class CommandRequest:
message_id: str
text: str
timestamp: float
workspace_id: str = ""
thread_id: str = ""
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
@@ -42,10 +45,16 @@ class Platform:
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[Dict[str, Any]] = None,
):
"""Send an image to the channel."""
pass
async def send_message(self, channel_id: str, text: str):
async def send_message(
self,
channel_id: str,
text: str,
delivery_context: Optional[Dict[str, Any]] = None,
):
"""Send a text message to the channel."""
pass
+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"
+7 -1
View File
@@ -230,6 +230,7 @@ class DiscordGateway:
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[dict] = None,
):
"""Send image via Discord API."""
if not self.session:
@@ -279,7 +280,12 @@ class DiscordGateway:
logger.error(f"Discord send_image error: {e}")
raise
async def send_message(self, channel_id: str, text: str):
async def send_message(
self,
channel_id: str,
text: str,
delivery_context: Optional[dict] = None,
):
"""Send text message."""
if not self.session:
return
@@ -0,0 +1,421 @@
"""Owned Feishu card, response, and media-delivery mixin."""
# ruff: noqa: UP006, UP035, UP045 -- preserve frozen facade annotations.
from __future__ import annotations
import json
import secrets
from dataclasses import dataclass
from typing import Any, Dict, Optional
from services.safe_io import STANDARD_OUTBOUND_POLICY, SafeIOHTTPError
from ..reply_visibility import decide_reply_visibility
from .feishu_installation_manager import FeishuBinding
# mypy: disable-error-code="attr-defined,no-any-return"
@dataclass
class FeishuDeliveryTarget:
channel_id: str
reply_to_message_id: str = ""
workspace_id: str = ""
account_id: str = ""
class FeishuDeliveryMixin:
def _build_card_button_value(
self,
button: Dict[str, Any],
*,
target: FeishuDeliveryTarget,
binding: FeishuBinding,
signing_secret: str,
) -> Dict[str, Any]:
contract = self._callback_contract_for_binding(
binding=binding,
signing_secret=signing_secret,
)
command_text = str(button.get("value", "") or "").strip()
callback_payload = {
"label": str(button.get("label", "") or "").strip(),
"command": command_text,
"approval_id": str(button.get("approval_id", "") or "").strip(),
"workspace_id": target.workspace_id or binding.workspace_id,
"account_id": target.account_id or binding.account_id,
"channel_id": target.channel_id,
"message_id": target.reply_to_message_id,
}
envelope = contract.build_envelope(
request_id=secrets.token_hex(12),
workspace_id=callback_payload["workspace_id"],
action_type=self._adapter_infer_callback_action_type(command_text, button),
payload=callback_payload,
)
return {
"callback_envelope": dict(envelope.__dict__),
"payload": callback_payload,
}
def _build_interactive_card(
self,
target: FeishuDeliveryTarget,
text: str,
buttons: list[dict],
*,
binding: FeishuBinding,
secrets: Dict[str, str],
) -> Dict[str, Any]:
signing_secret = str(
secrets.get("app_secret", "") or binding.app_secret or ""
).strip()
if not signing_secret:
raise RuntimeError("feishu_callback_signing_secret_missing")
actions = []
for button in buttons[:6]:
command_text = str(button.get("value", "") or "").strip()
if not command_text:
continue
actions.append(
{
"tag": "button",
"type": str(button.get("style", "") or "default"),
"text": {
"tag": "plain_text",
"content": str(button.get("label", "") or "OpenClaw"),
},
"value": self._build_card_button_value(
button,
target=target,
binding=binding,
signing_secret=signing_secret,
),
}
)
return {
"config": {"wide_screen_mode": True},
"header": {
"template": "blue",
"title": {"tag": "plain_text", "content": "OpenClaw"},
},
"elements": [
{"tag": "markdown", "content": text or "OpenClaw"},
{"tag": "action", "actions": actions},
],
}
async def _send_interactive_reply(
self,
target: FeishuDeliveryTarget,
text: str,
buttons: list[dict],
) -> None:
resolution, binding, secrets = self._resolve_delivery_binding(
workspace_id=target.workspace_id,
account_id=target.account_id,
)
if binding is None or not resolution.ok:
self._adapter_logger().warning(
"Feishu interactive reply dropped: no workspace binding available (%s / %s)",
target.workspace_id or "no-workspace",
target.account_id or "no-account",
)
return
token = await self._get_tenant_access_token(
binding=binding,
workspace_id=target.workspace_id,
account_id=target.account_id,
)
api_base = self._adapter_resolve_domain_base(binding.domain)
card = self._build_interactive_card(
target,
text,
buttons,
binding=binding,
secrets=secrets,
)
payload = {
"content": json.dumps(card, ensure_ascii=False),
"msg_type": "interactive",
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json; charset=utf-8",
}
if target.reply_to_message_id:
url = (
f"{api_base}/open-apis/im/v1/messages/"
f"{target.reply_to_message_id}/reply"
)
else:
url = f"{api_base}/open-apis/im/v1/messages?receive_id_type=chat_id"
payload["receive_id"] = target.channel_id
try:
data = self._adapter_safe_request_json(
method="POST",
url=url,
json_body=payload,
headers=headers,
content_type="application/json; charset=utf-8",
timeout_sec=15,
allow_hosts=self._adapter_allowed_api_hosts(binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "interactive_reply"},
)
self._adapter_logger().warning(
"Feishu interactive reply failed: status=%s", exc.status_code
)
return
if data.get("code", 0) != 0:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=str(data.get("msg", "unknown") or "unknown"),
status_code=200,
details={"phase": "interactive_reply"},
)
self._adapter_logger().warning(
"Feishu interactive reply failed: %s", data.get("msg", "unknown")
)
async def _send_reply(
self,
target: FeishuDeliveryTarget,
text: str,
*,
delivery_context: Optional[Dict[str, Any]] = None,
) -> None:
ctx = dict(delivery_context or {})
if target.workspace_id:
ctx.setdefault("workspace_id", target.workspace_id)
if target.account_id:
ctx.setdefault("account_id", target.account_id)
if target.reply_to_message_id:
ctx.setdefault("thread_id", target.reply_to_message_id)
decision = decide_reply_visibility(
delivery_context=ctx,
platform="feishu",
channel_kind=str(ctx.get("chat_type", "") or ""),
in_thread=bool(target.reply_to_message_id),
text=text,
)
if decision.suppressed:
self._adapter_logger().info(
"Suppressed Feishu reply channel=%s reason=%s",
target.channel_id,
decision.reason,
)
return
resolution, binding, _ = self._resolve_delivery_binding(
workspace_id=target.workspace_id,
account_id=target.account_id,
)
if binding is None or not resolution.ok:
self._adapter_logger().warning(
"Feishu reply dropped: no workspace binding available (%s / %s)",
target.workspace_id or "no-workspace",
target.account_id or "no-account",
)
return
token = await self._get_tenant_access_token(
binding=binding,
workspace_id=target.workspace_id,
account_id=target.account_id,
)
api_base = self._adapter_resolve_domain_base(binding.domain)
payload = {
"content": json.dumps({"text": text}, ensure_ascii=False),
"msg_type": "text",
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json; charset=utf-8",
}
if target.reply_to_message_id:
url = (
f"{api_base}/open-apis/im/v1/messages/"
f"{target.reply_to_message_id}/reply"
)
else:
url = f"{api_base}/open-apis/im/v1/messages?receive_id_type=chat_id"
payload["receive_id"] = target.channel_id
try:
data = self._adapter_safe_request_json(
method="POST",
url=url,
json_body=payload,
headers=headers,
content_type="application/json; charset=utf-8",
timeout_sec=15,
allow_hosts=self._adapter_allowed_api_hosts(binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "reply"},
)
self._adapter_logger().warning(
"Feishu reply failed: status=%s", exc.status_code
)
return
if data.get("code", 0) != 0:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=str(data.get("msg", "unknown") or "unknown"),
status_code=200,
details={"phase": "reply"},
)
self._adapter_logger().warning(
"Feishu reply failed: %s",
data.get("msg", "unknown"),
)
async def send_message(
self,
channel_id: str,
text: str,
delivery_context: Optional[Dict[str, Any]] = None,
):
ctx = dict(delivery_context or {})
await self._send_reply(
self._adapter_delivery_target(
channel_id=channel_id,
reply_to_message_id=str(ctx.get("thread_id", "") or "").strip(),
workspace_id=str(ctx.get("workspace_id", "") or "").strip(),
account_id=str(ctx.get("account_id", "") or "").strip(),
),
text,
delivery_context=ctx,
)
async def send_image(
self,
channel_id: str,
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[Dict[str, Any]] = None,
):
ctx = dict(delivery_context or {})
resolution, binding, _ = self._resolve_delivery_binding(
workspace_id=str(ctx.get("workspace_id", "") or "").strip(),
account_id=str(ctx.get("account_id", "") or "").strip(),
)
if binding is None or not resolution.ok:
self._adapter_logger().warning(
"Feishu image dropped: no workspace binding available (%s / %s)",
str(ctx.get("workspace_id", "") or "").strip() or "no-workspace",
str(ctx.get("account_id", "") or "").strip() or "no-account",
)
return
token = await self._get_tenant_access_token(
binding=binding,
workspace_id=str(ctx.get("workspace_id", "") or "").strip(),
account_id=str(ctx.get("account_id", "") or "").strip(),
)
api_base = self._adapter_resolve_domain_base(binding.domain)
upload_headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
upload_body, upload_content_type = self._adapter_build_multipart_form(
fields={"image_type": "message"},
file_field="image",
filename=filename,
file_bytes=image_data,
file_content_type="image/png",
)
try:
upload_payload = self._adapter_safe_request_json(
method="POST",
url=f"{api_base}/open-apis/im/v1/images",
raw_body=upload_body,
headers=upload_headers,
content_type=upload_content_type,
timeout_sec=30,
allow_hosts=self._adapter_allowed_api_hosts(binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "image_upload"},
)
self._adapter_logger().warning(
"Feishu image upload failed: status=%s", exc.status_code
)
return
image_key = str(
(upload_payload.get("data") or {}).get("image_key", "") or ""
).strip()
if upload_payload.get("code", 0) != 0 or not image_key:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=str(upload_payload.get("msg", "unknown") or "unknown"),
status_code=200,
details={"phase": "image_upload"},
)
self._adapter_logger().warning(
"Feishu image upload failed: %s",
upload_payload.get("msg", "unknown"),
)
return
message_payload = {
"content": json.dumps({"image_key": image_key}, ensure_ascii=False),
"msg_type": "image",
}
thread_id = str(ctx.get("thread_id", "") or "").strip()
if thread_id:
send_url = f"{api_base}/open-apis/im/v1/messages/{thread_id}/reply"
else:
send_url = f"{api_base}/open-apis/im/v1/messages?receive_id_type=chat_id"
message_payload["receive_id"] = channel_id
try:
self._adapter_safe_request_json(
method="POST",
url=send_url,
json_body=message_payload,
headers={
"Accept": "application/json",
"Authorization": f"Bearer {token}",
},
content_type="application/json; charset=utf-8",
timeout_sec=30,
allow_hosts=self._adapter_allowed_api_hosts(binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "image_send"},
)
self._adapter_logger().warning(
"Feishu image send failed: status=%s", exc.status_code
)
if caption:
await self.send_message(
channel_id,
caption,
delivery_context=ctx,
)
@@ -0,0 +1,462 @@
"""Owned Feishu webhook ingress and callback transaction mixin."""
# ruff: noqa: UP006, UP035, UP045 -- preserve frozen facade annotations.
from __future__ import annotations
import json
import secrets
import time
from typing import Any, Dict, Optional, Tuple
from services.connector_callback_contract import (
CallbackActorContext,
CallbackDecisionCode,
ConnectorCallbackContract,
)
from ..contract import CommandRequest
from .feishu_installation_manager import FeishuBinding
# mypy: disable-error-code="attr-defined,index,no-any-return"
class FeishuIngressMixin:
async def handle_event(self, request):
_, web = self._adapter_import_aiohttp_web()
try:
body = await request.read()
except Exception:
return self._adapter_make_response(web, status=400, text="Bad request")
if len(body) > self._adapter_max_body_bytes():
return self._adapter_make_response(
web, status=413, text="Payload too large"
)
try:
payload = json.loads(body or b"{}")
except json.JSONDecodeError:
return self._adapter_make_response(web, status=400, text="Bad JSON")
if self._is_challenge(payload):
if not self._verify_request_token(payload):
return self._adapter_make_response(
web, status=401, text="Invalid verification token"
)
return self._adapter_make_json_response(
web, {"challenge": str(payload.get("challenge", "") or "")}
)
if not self._verify_request_token(payload):
return self._adapter_make_response(
web, status=401, text="Invalid verification token"
)
try:
await self.process_event_payload(payload)
except ValueError as exc:
safe_code = self._adapter_safe_external_error_code("event_rejected", exc)
self._adapter_logger().warning("Feishu event rejected: %s", safe_code)
return self._adapter_make_response(
web,
status=400,
text=safe_code,
)
return self._adapter_make_response(web, status=200, text="OK")
async def handle_callback(self, request):
_, web = self._adapter_import_aiohttp_web()
try:
body = await request.read()
except Exception:
return self._adapter_make_response(web, status=400, text="Bad request")
if len(body) > self._adapter_max_body_bytes():
return self._adapter_make_response(
web, status=413, text="Payload too large"
)
try:
payload = json.loads(body or b"{}")
except json.JSONDecodeError:
return self._adapter_make_response(web, status=400, text="Bad JSON")
try:
response = await self.process_callback_payload(payload)
except ValueError as exc:
safe_code = self._adapter_safe_external_error_code("callback_rejected", exc)
self._adapter_logger().warning("Feishu callback rejected: %s", safe_code)
return self._adapter_make_json_response(
web,
{
"ok": False,
"error": safe_code,
},
status=403,
)
return self._adapter_make_json_response(web, response)
def _is_challenge(self, payload: Dict[str, Any]) -> bool:
return bool(
payload.get("challenge")
and str(payload.get("type", "") or "").strip().lower() == "url_verification"
)
def _verify_request_token(self, payload: Dict[str, Any]) -> bool:
try:
self._resolve_inbound_binding(payload)
return True
except ValueError:
return False
def _extract_callback_action(self, payload: Dict[str, Any]) -> Tuple[
Dict[str, Any],
Dict[str, Any],
Dict[str, Any],
Dict[str, Any],
str,
str,
]:
header = payload.get("header") or {}
event = payload.get("event") or {}
action = payload.get("action") or event.get("action") or {}
if not action and isinstance(event.get("actions"), list):
first_action = event.get("actions")[0] if event.get("actions") else {}
if isinstance(first_action, dict):
action = first_action
if not isinstance(action, dict):
raise ValueError("invalid_callback_action")
raw_value = action.get("value") or {}
if isinstance(raw_value, str):
raw_value = self._adapter_json_loads_safe(raw_value)
if not isinstance(raw_value, dict):
raise ValueError("invalid_callback_value")
envelope = raw_value.get("callback_envelope") or {}
callback_payload = raw_value.get("payload") or {}
if not isinstance(envelope, dict) or not isinstance(callback_payload, dict):
raise ValueError("invalid_callback_envelope")
workspace_id = str(
header.get("tenant_key")
or event.get("tenant_key")
or callback_payload.get("workspace_id")
or ""
).strip()
account_id = str(callback_payload.get("account_id", "") or "").strip()
return header, event, envelope, callback_payload, workspace_id, account_id
def _callback_contract_for_binding(
self,
*,
binding: FeishuBinding,
signing_secret: str,
) -> ConnectorCallbackContract:
cache_key = self._cache_key_for_binding(binding)
if (
self._callback_contracts.get(cache_key) is not None
and self._callback_contract_secrets.get(cache_key) == signing_secret
):
return self._callback_contracts[cache_key]
contract = ConnectorCallbackContract(
signing_secret=signing_secret,
installation_registry=self._installation_manager.registry,
action_policy_map=self._adapter_callback_policy_map(),
)
self._callback_contracts[cache_key] = contract
self._callback_contract_secrets[cache_key] = signing_secret
return contract
def _actor_context_for_callback(
self,
*,
actor_id: str,
actor_open_id: str,
channel_id: str,
message_id: str,
workspace_id: str,
account_id: str,
command_text: str,
) -> Tuple[CallbackActorContext, CommandRequest]:
request = CommandRequest(
platform="feishu",
sender_id=actor_id or actor_open_id,
channel_id=channel_id or actor_id or actor_open_id,
username=actor_id or actor_open_id,
message_id=message_id or f"cb-{secrets.token_hex(4)}",
text=command_text,
timestamp=time.time(),
workspace_id=workspace_id,
thread_id=message_id,
metadata={
"account_id": account_id,
"sender_open_id": actor_open_id,
"interactive_callback": True,
},
)
actor = CallbackActorContext(
is_admin=self.router._is_admin(request.sender_id),
is_trusted=self.router._is_trusted(request),
user_id=request.sender_id,
tenant_id=workspace_id or request.workspace_id or "",
)
return actor, request
def _build_callback_response(
self,
*,
ok: bool,
text: str,
response_type: str = "info",
card: Optional[Dict[str, Any]] = None,
duplicate: bool = False,
decision_code: str = "",
) -> Dict[str, Any]:
response = {
"ok": ok,
"duplicate": duplicate,
"decision_code": decision_code,
"toast": {
"type": response_type,
"content": text[:500] if text else "",
},
}
if card is not None:
response["card"] = card
return response
def _build_request(
self,
payload: Dict[str, Any],
*,
binding: FeishuBinding,
bot_open_id: str,
) -> Optional[CommandRequest]:
header = payload.get("header") or {}
if (
str(header.get("event_type", "") or "").strip()
not in self._adapter_supported_event_types()
):
return None
event = payload.get("event") or {}
message = event.get("message") or {}
sender = event.get("sender") or {}
sender_id = sender.get("sender_id") or {}
mentions = self._adapter_normalize_mentions(message)
sender_user_id = str(sender_id.get("user_id", "") or "").strip()
sender_open_id = str(sender_id.get("open_id", "") or "").strip()
chat_id = str(message.get("chat_id", "") or "").strip()
chat_type = str(message.get("chat_type", "") or "").strip().lower()
message_id = str(message.get("message_id", "") or "").strip()
workspace_id = (
str(header.get("tenant_key", "") or "").strip() or binding.workspace_id
)
if not sender_user_id and not sender_open_id:
return None
if not chat_id or not message_id:
return None
if sender_open_id and bot_open_id and sender_open_id == bot_open_id:
return None
raw_text = self._adapter_parse_message_text(message)
if not raw_text:
return None
mentioned_bot = False
if bot_open_id:
for mention in mentions:
open_id = str(((mention.get("id") or {}).get("open_id")) or "").strip()
if open_id and open_id == bot_open_id:
mentioned_bot = True
break
text = self._adapter_strip_bot_mention(raw_text, mentions, bot_open_id)
if (
chat_type == "group"
and self.config.feishu_require_mention
and not mentioned_bot
):
return None
effective_sender = sender_user_id or sender_open_id
return CommandRequest(
platform="feishu",
sender_id=effective_sender,
channel_id=chat_id,
username=effective_sender,
message_id=message_id,
text=text,
timestamp=time.time(),
workspace_id=workspace_id,
thread_id=(
str(message.get("root_id", "") or "").strip()
or (message_id if self.config.feishu_reply_in_thread else "")
),
metadata={
"account_id": binding.account_id,
"chat_type": chat_type,
"mentioned_bot": mentioned_bot,
"message_type": str(message.get("message_type", "") or "").strip(),
"sender_open_id": sender_open_id,
},
)
async def process_event_payload(
self,
payload: Dict[str, Any],
*,
binding: Optional[FeishuBinding] = None,
) -> None:
header = payload.get("header") or {}
event_id = str(header.get("event_id", "") or "").strip()
if not event_id:
raise ValueError("Missing event_id")
if not self._replay_guard.check_and_record(event_id):
return
effective_binding = binding or self._resolve_inbound_binding(payload)
bot_open_id = self._cached_bot_open_id(effective_binding)
message = (payload.get("event") or {}).get("message") or {}
chat_type = str(message.get("chat_type", "") or "").strip().lower()
if not bot_open_id and chat_type == "group":
bot_open_id = await self._fetch_bot_open_id(
binding=effective_binding, allow_degrade=True
)
request = self._build_request(
payload,
binding=effective_binding,
bot_open_id=bot_open_id,
)
if request is None:
return
if self._user_allowlist.entries:
user_result = self._user_allowlist.evaluate(str(request.sender_id))
if user_result.decision == "deny":
return
if self._chat_allowlist.entries:
chat_result = self._chat_allowlist.evaluate(str(request.channel_id))
if chat_result.decision == "deny":
return
response = await self.router.handle(request)
resp_text = str(getattr(response, "text", "") or "").strip()
buttons = getattr(response, "buttons", []) or []
target = self._adapter_delivery_target(
channel_id=request.channel_id,
reply_to_message_id=request.thread_id,
workspace_id=request.workspace_id,
account_id=str(request.metadata.get("account_id", "") or ""),
)
if buttons:
await self._send_interactive_reply(target, resp_text, buttons)
elif resp_text:
await self._send_reply(
target,
resp_text,
delivery_context={
"workspace_id": request.workspace_id,
"thread_id": request.thread_id,
"account_id": str(request.metadata.get("account_id", "") or ""),
"chat_type": str(request.metadata.get("chat_type", "") or ""),
"mentioned_bot": bool(request.metadata.get("mentioned_bot")),
},
)
async def process_callback_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
_, _, envelope_dict, callback_payload, workspace_id, account_id = (
self._extract_callback_action(payload)
)
resolution, binding, secrets = self._resolve_delivery_binding(
workspace_id=workspace_id,
account_id=account_id,
)
if binding is None or not resolution.ok:
raise ValueError(resolution.reject_reason or "missing_binding")
signing_secret = str(
secrets.get("app_secret", "") or binding.app_secret or ""
).strip()
if not signing_secret:
raise ValueError("missing_callback_signing_secret")
contract = self._callback_contract_for_binding(
binding=binding,
signing_secret=signing_secret,
)
event = payload.get("event") or {}
operator = payload.get("operator") or event.get("operator") or {}
operator_id = operator.get("operator_id") or operator.get("sender_id") or {}
actor_id = str(
operator.get("user_id")
or operator_id.get("user_id")
or callback_payload.get("actor_user_id")
or ""
).strip()
actor_open_id = str(
operator.get("open_id")
or operator_id.get("open_id")
or callback_payload.get("actor_open_id")
or ""
).strip()
command_text = str(callback_payload.get("command", "") or "").strip()
actor, request = self._actor_context_for_callback(
actor_id=actor_id,
actor_open_id=actor_open_id,
channel_id=str(
payload.get("open_chat_id")
or event.get("open_chat_id")
or callback_payload.get("channel_id")
or ""
).strip(),
message_id=str(
payload.get("open_message_id")
or event.get("open_message_id")
or callback_payload.get("message_id")
or ""
).strip(),
workspace_id=workspace_id or binding.workspace_id,
account_id=binding.account_id,
command_text=command_text,
)
decision = contract.evaluate(
platform="feishu",
envelope_dict=envelope_dict,
payload=callback_payload,
actor=actor,
)
if decision.decision_code == CallbackDecisionCode.REJECT_REPLAY.value:
return self._build_callback_response(
ok=True,
text="Action already processed.",
response_type="info",
duplicate=True,
decision_code=decision.decision_code,
)
if not decision.ok and not decision.requires_approval:
raise ValueError(decision.message or decision.decision_code)
request.text = (
self._adapter_force_approval_command(request.text)
if decision.requires_approval
else request.text
)
request_id = str(envelope_dict.get("request_id", "") or "")
contract.acknowledge_request(request_id)
try:
response = await self.router.handle(request)
except Exception:
# IMPORTANT: failures before route completion remain retryable.
# After router.handle returns, the action may already have side effects,
# so completion failures must not release the claim for rerouting.
contract.release_request_retryable(
request_id, reason="feishu_callback_failed_before_commit"
)
raise
contract.complete_request(request_id)
response_text = str(getattr(response, "text", "") or "").strip() or (
"Action processed."
)
response_buttons = getattr(response, "buttons", []) or []
card = None
if response_buttons:
card = self._build_interactive_card(
self._adapter_delivery_target(
channel_id=request.channel_id,
reply_to_message_id=request.thread_id,
workspace_id=request.workspace_id,
account_id=binding.account_id,
),
response_text,
response_buttons,
binding=binding,
secrets=secrets,
)
return self._build_callback_response(
ok=True,
text=response_text,
response_type="success",
card=card,
decision_code=decision.decision_code,
)
@@ -0,0 +1,198 @@
"""Owned Feishu installation, tenant-token, and bot-identity mixin."""
# ruff: noqa: UP006, UP035, UP045 -- preserve frozen facade annotations.
from __future__ import annotations
import time
from typing import Any, Dict, Optional, Tuple
from services.connector_installation_registry import InstallationResolution
from services.safe_io import STANDARD_OUTBOUND_POLICY, SafeIOHTTPError
from .feishu_installation_manager import FeishuBinding
# mypy: disable-error-code="attr-defined,no-any-return"
class FeishuInstallationMixin:
def _resolve_inbound_binding(self, payload: Dict[str, Any]) -> FeishuBinding:
header = payload.get("header") or {}
verification_token = (
str(payload.get("token", "") or "").strip()
or str(header.get("token", "") or "").strip()
or str(((payload.get("event") or {}).get("token")) or "").strip()
)
workspace_id = str(header.get("tenant_key", "") or "").strip()
return self._installation_manager.resolve_inbound_binding(
verification_token=verification_token,
workspace_id=workspace_id,
account_id=self._bound_account_id,
)
def _cache_key_for_binding(self, binding: FeishuBinding) -> str:
return binding.installation_id or binding.account_id
def _cached_bot_open_id(self, binding: FeishuBinding) -> str:
return (
self._bot_open_ids.get(self._cache_key_for_binding(binding), "")
or self._bot_open_id
)
def _resolve_delivery_binding(
self, *, workspace_id: str = "", account_id: str = ""
) -> Tuple[InstallationResolution, Optional[FeishuBinding], Dict[str, str]]:
return self._installation_manager.resolve_binding(
workspace_id=workspace_id,
account_id=account_id or self._bound_account_id,
)
async def _get_tenant_access_token(
self,
*,
binding: Optional[FeishuBinding] = None,
workspace_id: str = "",
account_id: str = "",
) -> str:
resolution, effective_binding, secrets = self._resolve_delivery_binding(
workspace_id=workspace_id,
account_id=account_id or (binding.account_id if binding else ""),
)
if effective_binding is None or not resolution.ok:
raise RuntimeError(
f"feishu_binding_resolution_failed:{resolution.reject_reason or 'missing_binding'}"
)
cache_key = self._cache_key_for_binding(effective_binding)
if self._tenant_access_tokens.get(
cache_key
) and self._tenant_access_token_expires_at.get(cache_key, 0.0) > (
time.time() + 30
):
return self._tenant_access_tokens[cache_key]
app_secret = str(
secrets.get("app_secret", "") or effective_binding.app_secret
).strip()
payload = {
"app_id": effective_binding.app_id,
"app_secret": app_secret,
}
url = f"{self._adapter_resolve_domain_base(effective_binding.domain)}/open-apis/auth/v3/tenant_access_token/internal"
try:
data = self._adapter_safe_request_json(
method="POST",
url=url,
json_body=payload,
headers={"Accept": "application/json"},
content_type="application/json; charset=utf-8",
timeout_sec=15,
allow_hosts=self._adapter_allowed_api_hosts(effective_binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "tenant_access_token"},
)
raise RuntimeError(
f"feishu_token_fetch_failed:{exc.status_code}:{exc.reason}"
) from exc
if data.get("code", 0) != 0:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=str(data.get("msg", "unknown") or "unknown"),
status_code=200,
details={"phase": "tenant_access_token"},
)
raise RuntimeError(
f"feishu_token_fetch_failed:200:{data.get('msg', 'unknown')}"
)
token = str(data.get("tenant_access_token", "") or "").strip()
if not token:
raise RuntimeError("feishu_token_fetch_failed:missing_token")
expire = int(
data.get("expire", self._adapter_token_ttl_sec())
or self._adapter_token_ttl_sec()
)
self._tenant_access_tokens[cache_key] = token
self._tenant_access_token_expires_at[cache_key] = time.time() + max(60, expire)
if resolution.installation is not None:
self._installation_manager.mark_resolution_success(
resolution.installation.installation_id,
effective_binding.workspace_id,
)
return token
async def _fetch_bot_open_id(
self,
*,
binding: Optional[FeishuBinding] = None,
workspace_id: str = "",
account_id: str = "",
allow_degrade: bool = False,
) -> str:
resolution, effective_binding, _ = self._resolve_delivery_binding(
workspace_id=workspace_id,
account_id=account_id or (binding.account_id if binding else ""),
)
if effective_binding is None or not resolution.ok:
return ""
cache_key = self._cache_key_for_binding(effective_binding)
if self._bot_open_ids.get(cache_key):
return self._bot_open_ids[cache_key]
token = await self._get_tenant_access_token(binding=effective_binding)
url = f"{self._adapter_resolve_domain_base(effective_binding.domain)}/open-apis/bot/v3/info"
try:
data = self._adapter_safe_request_json(
method="GET",
url=url,
headers={
"Accept": "application/json",
"Authorization": f"Bearer {token}",
},
timeout_sec=15,
allow_hosts=self._adapter_allowed_api_hosts(effective_binding.domain),
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=exc.reason,
status_code=exc.status_code,
details={"phase": "bot_info"},
)
if allow_degrade:
return ""
return ""
if data.get("code", 0) != 0:
if resolution.installation is not None:
self._installation_manager.mark_api_error(
resolution.installation.installation_id,
error_code=str(data.get("msg", "unknown") or "unknown"),
status_code=200,
details={"phase": "bot_info"},
)
return ""
bot_open_id = str(
(((data.get("data") or {}).get("bot") or {}).get("open_id")) or ""
).strip()
if bot_open_id:
self._bot_open_ids[cache_key] = bot_open_id
self._bot_open_id = bot_open_id
return bot_open_id
async def prime_bot_identity(self) -> None:
try:
await self._fetch_bot_open_id(
account_id=self._bound_account_id
or str(self.config.feishu_account_id or "").strip()
or str(self.config.feishu_default_account_id or "").strip(),
workspace_id=str(self.config.feishu_workspace_id or "").strip(),
allow_degrade=True,
)
except Exception as exc:
self._adapter_logger().debug("Feishu bot identity fetch failed: %s", exc)
@@ -0,0 +1,474 @@
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, replace
from typing import Any, Dict, List, Optional, Tuple
from connector.config import ConnectorConfig
try:
from services.connector_installation_registry import (
ConnectorInstallation,
ConnectorInstallationRegistry,
InstallationResolution,
get_connector_installation_registry,
)
from services.secret_store import SecretStore, get_secret_store
from services.state_dir import get_state_dir
from services.tenant_context import DEFAULT_TENANT_ID, get_current_tenant_id
except ImportError: # pragma: no cover
from services.connector_installation_registry import ( # type: ignore
ConnectorInstallation,
ConnectorInstallationRegistry,
InstallationResolution,
get_connector_installation_registry,
)
from services.secret_store import SecretStore, get_secret_store # type: ignore
from services.state_dir import get_state_dir # type: ignore
from services.tenant_context import ( # type: ignore
DEFAULT_TENANT_ID,
get_current_tenant_id,
)
logger = logging.getLogger(__name__)
@dataclass
class FeishuBinding:
account_id: str
app_id: str
app_secret: str
workspace_id: str = ""
workspace_name: str = ""
tenant_id: str = DEFAULT_TENANT_ID
verification_token: str = ""
encrypt_key: str = ""
domain: str = "feishu"
mode: str = "websocket"
@property
def installation_id(self) -> str:
return f"feishu:{self.account_id}"
def public_metadata(self) -> Dict[str, Any]:
metadata = {
"account_id": self.account_id,
"app_id": self.app_id,
"domain": self.domain,
"transport_mode": self.mode,
}
if self.workspace_name:
metadata["workspace_name"] = self.workspace_name
return metadata
class FeishuInstallationManager:
def __init__(
self,
config: ConnectorConfig,
*,
registry: Optional[ConnectorInstallationRegistry] = None,
secret_store: Optional[SecretStore] = None,
state_dir: Optional[str] = None,
):
self.config = config
self._state_dir = state_dir or get_state_dir()
self._registry = registry or get_connector_installation_registry(
state_dir=self._state_dir
)
self._secret_store = secret_store or get_secret_store(self._state_dir)
self._bindings: Dict[str, FeishuBinding] = {}
self._load_bindings()
def _normalize_nonempty(self, value: Any, field_name: str) -> str:
text = str(value or "").strip()
if not text:
raise ValueError(f"{field_name}_missing")
return text
def _normalize_optional(self, value: Any) -> str:
return str(value or "").strip()
def _binding_from_payload(self, raw: Dict[str, Any]) -> FeishuBinding:
app_id = self._normalize_nonempty(raw.get("app_id"), "app_id")
app_secret = self._normalize_nonempty(raw.get("app_secret"), "app_secret")
account_id = self._normalize_optional(raw.get("account_id")) or app_id
workspace_id = self._normalize_optional(raw.get("workspace_id"))
return FeishuBinding(
account_id=account_id,
app_id=app_id,
app_secret=app_secret,
workspace_id=workspace_id,
workspace_name=self._normalize_optional(raw.get("workspace_name")),
tenant_id=self._normalize_optional(raw.get("tenant_id"))
or DEFAULT_TENANT_ID,
verification_token=self._normalize_optional(raw.get("verification_token")),
encrypt_key=self._normalize_optional(raw.get("encrypt_key")),
domain=self._normalize_optional(raw.get("domain")) or "feishu",
mode=self._normalize_optional(raw.get("mode")) or self.config.feishu_mode,
)
def _load_bindings(self) -> None:
bindings: List[FeishuBinding] = []
if self.config.feishu_bindings_json:
raw = json.loads(self.config.feishu_bindings_json)
if not isinstance(raw, list) or not raw:
raise ValueError("feishu_bindings_json must be a non-empty JSON list")
bindings = [self._binding_from_payload(item or {}) for item in raw]
elif self.config.feishu_app_id and self.config.feishu_app_secret:
account_id = (
self._normalize_optional(self.config.feishu_account_id)
or self._normalize_optional(self.config.feishu_default_account_id)
or self._normalize_optional(self.config.feishu_app_id)
)
if account_id:
bindings = [
FeishuBinding(
account_id=account_id,
app_id=self.config.feishu_app_id,
app_secret=self.config.feishu_app_secret,
workspace_id=self._normalize_optional(
self.config.feishu_workspace_id
),
workspace_name=self._normalize_optional(
self.config.feishu_workspace_name
),
tenant_id=DEFAULT_TENANT_ID,
verification_token=self._normalize_optional(
self.config.feishu_verification_token
),
encrypt_key=self._normalize_optional(
self.config.feishu_encrypt_key
),
domain=self.config.feishu_domain,
mode=self.config.feishu_mode,
)
]
for binding in bindings:
if binding.account_id in self._bindings:
raise ValueError(f"duplicate_feishu_account:{binding.account_id}")
self._bindings[binding.account_id] = binding
if binding.workspace_id:
self._sync_binding(binding, reason="config_load")
def has_bindings(self) -> bool:
return bool(self._bindings)
def binding_count(self) -> int:
return len(self._bindings)
@property
def registry(self):
return self._registry
def bindings(self) -> List[FeishuBinding]:
return list(self._bindings.values())
def binding_configs(self) -> List[ConnectorConfig]:
return [
self.config_for_binding(binding.account_id) for binding in self.bindings()
]
def config_for_binding(self, account_id: str) -> ConnectorConfig:
binding = self.get_binding(account_id)
if binding is None:
raise ValueError(f"unknown_feishu_account:{account_id}")
return replace(
self.config,
feishu_app_id=binding.app_id,
feishu_app_secret=binding.app_secret,
feishu_verification_token=binding.verification_token,
feishu_encrypt_key=binding.encrypt_key,
feishu_account_id=binding.account_id,
feishu_workspace_id=binding.workspace_id or self.config.feishu_workspace_id,
feishu_workspace_name=binding.workspace_name
or self.config.feishu_workspace_name,
feishu_domain=binding.domain,
feishu_mode=binding.mode or self.config.feishu_mode,
)
def get_binding(self, account_id: str) -> Optional[FeishuBinding]:
binding = self._bindings.get(str(account_id or "").strip())
if binding is None:
return None
return replace(binding)
def _sync_binding(
self, binding: FeishuBinding, *, reason: str
) -> ConnectorInstallation:
token_values = {"app_secret": binding.app_secret}
if binding.verification_token:
token_values["verification_token"] = binding.verification_token
if binding.encrypt_key:
token_values["encrypt_key"] = binding.encrypt_key
inst = self._registry.upsert_installation(
platform="feishu",
tenant_id=binding.tenant_id,
workspace_id=binding.workspace_id,
installation_id=binding.installation_id,
token_values=token_values,
status="active",
metadata=binding.public_metadata(),
status_reason=reason,
)
return self._registry.activate_installation(
inst.installation_id, reason=reason or "feishu_binding_ready"
)
def ensure_workspace_binding(
self, account_id: str, workspace_id: str
) -> ConnectorInstallation:
binding = self._bindings.get(str(account_id or "").strip())
if binding is None:
raise ValueError(f"unknown_feishu_account:{account_id}")
normalized_workspace = self._normalize_nonempty(workspace_id, "workspace_id")
if binding.workspace_id and binding.workspace_id != normalized_workspace:
raise ValueError("feishu_workspace_mismatch")
if binding.workspace_id == normalized_workspace:
inst = self._registry.get_installation(binding.installation_id)
if inst is not None:
return inst
binding.workspace_id = normalized_workspace
self._bindings[binding.account_id] = binding
return self._sync_binding(binding, reason="workspace_bound")
def installation_id_for_account(self, account_id: str) -> str:
return f"feishu:{str(account_id or '').strip()}"
def _secrets_for_installation(
self, installation: ConnectorInstallation
) -> Dict[str, str]:
secrets: Dict[str, str] = {}
for name, ref in dict(installation.token_refs or {}).items():
value = self._secret_store.get_secret(ref, tenant_id=installation.tenant_id)
if value:
secrets[name] = value
return secrets
def resolve_binding(
self,
*,
workspace_id: str = "",
account_id: str = "",
) -> Tuple[InstallationResolution, Optional[FeishuBinding], Dict[str, str]]:
normalized_workspace = self._normalize_optional(workspace_id)
normalized_account = self._normalize_optional(account_id)
if normalized_account:
binding = self._bindings.get(normalized_account)
if binding is None:
return (
InstallationResolution(
ok=False,
reject_reason="missing_binding",
audit_code="conn_install.resolve_missing",
health_code="workspace_unbound",
),
None,
{},
)
if normalized_workspace:
try:
self.ensure_workspace_binding(
binding.account_id, normalized_workspace
)
except ValueError:
return (
InstallationResolution(
ok=False,
reject_reason="tenant_mismatch",
audit_code="conn_install.resolve_tenant_mismatch",
health_code="degraded",
),
replace(binding),
{},
)
inst = self._registry.get_installation(binding.installation_id)
if inst is not None and inst.workspace_id:
resolution = self._registry.resolve_installation(
"feishu",
inst.workspace_id,
tenant_id=get_current_tenant_id(),
)
if not resolution.ok:
return resolution, replace(binding), {}
return (
resolution,
replace(binding),
self._secrets_for_installation(resolution.installation),
)
return (
InstallationResolution(
ok=True,
audit_code="conn_install.resolve_ok",
health_code="ok",
),
replace(binding),
{
"app_secret": binding.app_secret,
"verification_token": binding.verification_token,
"encrypt_key": binding.encrypt_key,
},
)
if normalized_workspace:
matching_unbound = [
binding
for binding in self._bindings.values()
if not binding.workspace_id and self.binding_count() == 1
]
if matching_unbound:
inst = self.ensure_workspace_binding(
matching_unbound[0].account_id, normalized_workspace
)
return (
InstallationResolution(
ok=True,
installation=inst,
audit_code="conn_install.resolve_ok",
health_code="ok",
),
replace(matching_unbound[0]),
self._secrets_for_installation(inst),
)
resolution = self._registry.resolve_installation(
"feishu",
normalized_workspace,
tenant_id=get_current_tenant_id(),
)
if not resolution.ok or resolution.installation is None:
return resolution, None, {}
account_id = str(
(resolution.installation.metadata or {}).get("account_id", "") or ""
).strip()
binding = self._bindings.get(account_id)
return (
resolution,
replace(binding) if binding else None,
self._secrets_for_installation(resolution.installation),
)
default_account = self._normalize_optional(
self.config.feishu_default_account_id
)
if default_account:
return self.resolve_binding(account_id=default_account)
if self.binding_count() == 1:
only_binding = next(iter(self._bindings.values()))
return self.resolve_binding(account_id=only_binding.account_id)
return (
InstallationResolution(
ok=False,
reject_reason="ambiguous_binding",
audit_code="conn_install.resolve_ambiguous",
health_code="degraded",
),
None,
{},
)
def resolve_inbound_binding(
self,
*,
verification_token: str = "",
workspace_id: str = "",
account_id: str = "",
) -> FeishuBinding:
normalized_token = self._normalize_optional(verification_token)
normalized_workspace = self._normalize_optional(workspace_id)
normalized_account = self._normalize_optional(account_id)
if normalized_account:
resolution, binding, _ = self.resolve_binding(
workspace_id=normalized_workspace,
account_id=normalized_account,
)
if not resolution.ok or binding is None:
raise ValueError(resolution.reject_reason or "missing_binding")
return binding
candidates = list(self._bindings.values())
if normalized_token:
candidates = [
binding
for binding in candidates
if binding.verification_token == normalized_token
]
if not candidates:
raise ValueError("invalid_verification_token")
if normalized_workspace:
exact = [
binding
for binding in candidates
if binding.workspace_id == normalized_workspace
]
if exact:
candidates = exact
else:
unbound = [
binding for binding in candidates if not binding.workspace_id
]
if len(unbound) == 1:
self.ensure_workspace_binding(
unbound[0].account_id, normalized_workspace
)
return replace(self._bindings[unbound[0].account_id])
if len(candidates) == 1:
binding = candidates[0]
if normalized_workspace and binding.workspace_id:
self.ensure_workspace_binding(binding.account_id, normalized_workspace)
return replace(binding)
raise ValueError("ambiguous_binding")
def mark_installation_health(
self,
installation_id: str,
*,
health_code: str,
reason: str,
details: Optional[Dict[str, Any]] = None,
) -> None:
self._registry.update_installation_health(
installation_id,
health_code=health_code,
reason=reason,
details=details,
)
def mark_resolution_success(self, installation_id: str, workspace_id: str) -> None:
self._registry.update_installation_health(
installation_id,
health_code="ok",
reason="workspace_resolved",
details={"workspace_id": workspace_id},
)
def classify_error_health(self, error_code: str, status_code: int = 0) -> str:
normalized = str(error_code or "").strip().lower()
if (
status_code in (401, 403)
or "invalid" in normalized
or "unauth" in normalized
):
return "invalid_token"
if "revoke" in normalized:
return "revoked"
return "degraded"
def mark_api_error(
self,
installation_id: str,
*,
error_code: str,
status_code: int = 0,
details: Optional[Dict[str, Any]] = None,
) -> str:
health_code = self.classify_error_health(error_code, status_code=status_code)
self.mark_installation_health(
installation_id,
health_code=health_code,
reason=error_code or f"http_{status_code}",
details=details,
)
return health_code
@@ -0,0 +1,120 @@
"""
Feishu long-connection client (F67).
This keeps the event-normalization path shared with the webhook adapter so
transport choice does not change router or delivery behavior.
"""
from __future__ import annotations
import asyncio
import inspect
import logging
from typing import Any, Optional
from ..config import ConnectorConfig
from ..router import CommandRouter
from .feishu_installation_manager import FeishuInstallationManager
from .feishu_webhook import FeishuWebhookServer
logger = logging.getLogger(__name__)
def _import_feishu_sdk():
# CRITICAL: keep this optional import lazy; CI and unit tests must remain
# runnable without the Feishu SDK installed.
try:
import lark_oapi as sdk # type: ignore
except ModuleNotFoundError:
try:
import larksuiteoapi as sdk # type: ignore
except ModuleNotFoundError:
return None
return sdk
class FeishuLongConnectionClient(FeishuWebhookServer):
def __init__(
self,
config: ConnectorConfig,
router: CommandRouter,
*,
installation_manager: Optional[FeishuInstallationManager] = None,
bound_account_id: str = "",
):
super().__init__(
config,
router,
installation_manager=installation_manager,
bound_account_id=bound_account_id,
)
self._ws_client: Any = None
self._run_task: Optional[asyncio.Task] = None
async def start(self):
if not self.config.feishu_app_id or not self.config.feishu_app_secret:
logger.info(
"Feishu long-connection disabled "
"(OPENCLAW_CONNECTOR_FEISHU_APP_ID / APP_SECRET missing)"
)
return
sdk = _import_feishu_sdk()
if sdk is None:
logger.warning(
"Feishu SDK not installed. Skipping long-connection adapter."
)
return
await self.prime_bot_identity()
self._ws_client = self._build_ws_client(sdk)
starter = getattr(self._ws_client, "start", None)
if not callable(starter):
logger.error("Feishu SDK client does not expose a start() method.")
self._ws_client = None
return
logger.info(
"Starting Feishu long-connection client (%s)", self.config.feishu_domain
)
maybe = self._start_client(starter)
if inspect.isawaitable(maybe):
self._run_task = asyncio.create_task(maybe)
async def stop(self):
if self._run_task:
self._run_task.cancel()
try:
await self._run_task
except asyncio.CancelledError:
pass
stopper = getattr(self._ws_client, "stop", None)
if callable(stopper):
maybe = stopper()
if inspect.isawaitable(maybe):
await maybe
def _build_ws_client(self, sdk):
domain = getattr(getattr(sdk, "Domain", object()), "Lark", None)
if str(self.config.feishu_domain or "").strip().lower() != "lark":
domain = getattr(getattr(sdk, "Domain", object()), "Feishu", domain)
kwargs = {
"app_id": self.config.feishu_app_id,
"app_secret": self.config.feishu_app_secret,
}
if domain is not None:
kwargs["domain"] = domain
ws_cls = getattr(sdk, "WSClient", None)
if ws_cls is None:
raise RuntimeError("Feishu SDK missing WSClient")
return ws_cls(**kwargs)
def _start_client(self, starter):
try:
return starter(event_handler=self._handle_long_connection_event)
except TypeError:
return starter(self._handle_long_connection_event)
async def _handle_long_connection_event(self, payload: Any):
if hasattr(payload, "to_dict"):
payload = payload.to_dict()
if not isinstance(payload, dict):
return
await self.process_event_payload(payload)
+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")
+9 -2
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."""
@@ -286,6 +287,7 @@ class LINEWebhookServer:
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[dict] = None,
):
"""
Send image via LINE using public URL.
@@ -368,7 +370,12 @@ class LINEWebhookServer:
err = await resp.text()
logger.error(f"LINE image push failed: {resp.status} {err}")
async def send_message(self, channel_id: str, text: str):
async def send_message(
self,
channel_id: str,
text: str,
delivery_context: Optional[dict] = None,
):
"""Send push message."""
if self._session_invalid:
logger.warning("R93: Connector session invalid - blocking outbound message")
@@ -0,0 +1,345 @@
"""Owned Slack response and media-delivery mixin."""
# ruff: noqa: SIM117, UP006, UP035, UP045 -- preserve frozen behavior/signatures.
from typing import Any, Dict, Optional
from ..reply_visibility import decide_reply_visibility
# mypy: disable-error-code="attr-defined,no-any-return"
class SlackDeliveryMixin:
async def _send_interactive_reply(
self,
*,
channel_id: str,
text: str,
buttons: list[dict],
thread_ts: str = "",
delivery_context: Optional[Dict[str, Any]] = None,
) -> None:
"""Send a Slack Block Kit message with bounded button actions."""
try:
import aiohttp as _aiohttp
except ImportError:
self._adapter_logger().warning(
"aiohttp not available; cannot send Slack interactive reply"
)
return
ctx = dict(delivery_context or {})
if not thread_ts:
thread_ts = str(ctx.get("thread_id", "") or "").strip()
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
str(ctx.get("workspace_id", "") or "").strip()
)
if not bot_token:
self._adapter_logger().warning(
"Slack interactive reply dropped: no workspace token available (workspace=%s)",
workspace_id or "legacy",
)
return
elements: list[dict] = []
for idx, button in enumerate(buttons[:5]):
value = str(button.get("value", "") or "").strip()
if not value:
continue
label = str(button.get("label", "") or "OpenClaw").strip()[:75]
action_id = str(
button.get("action_type")
or button.get("action_id")
or f"openclaw.{idx}"
).strip()[:255]
element: Dict[str, Any] = {
"type": "button",
"text": {"type": "plain_text", "text": label or "OpenClaw"},
"value": value[:2000],
"action_id": action_id or f"openclaw.{idx}",
}
style = self._adapter_style_to_slack(str(button.get("style", "") or ""))
if style:
element["style"] = style
elements.append(element)
if not elements:
if text:
await self._send_reply(
channel_id=channel_id,
text=text,
thread_ts=thread_ts,
delivery_context=ctx,
)
return
payload: Dict[str, Any] = {
"channel": channel_id,
"text": text or "OpenClaw",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (text or "OpenClaw")[:3000],
},
},
{"type": "actions", "elements": elements},
],
}
if thread_ts:
payload["thread_ts"] = thread_ts
headers = {
"Authorization": f"Bearer {bot_token}",
"Content-Type": "application/json; charset=utf-8",
}
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
"https://slack.com/api/chat.postMessage",
json=payload,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status != 200:
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=f"http_{resp.status}",
status_code=resp.status,
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
"interactive": True,
},
)
return
data = await resp.json()
if not data.get("ok"):
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=str(data.get("error", "unknown")),
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
"interactive": True,
},
)
elif installation_id:
self._installation_manager.mark_installation_health(
installation_id,
health_code="ok",
reason="chat_post_message_interactive_ok",
details={"workspace_id": workspace_id},
)
except Exception as e:
self._adapter_logger().warning("Slack interactive reply failed: %s", e)
async def _send_reply(
self,
channel_id: str,
text: str,
thread_ts: str = "",
delivery_context: Optional[Dict[str, Any]] = None,
) -> None:
"""Send a message via Slack Web API (chat.postMessage)."""
ctx = dict(delivery_context or {})
if not thread_ts:
thread_ts = str(ctx.get("thread_id", "") or "").strip()
decision = decide_reply_visibility(
delivery_context=ctx,
platform="slack",
channel_kind=self._adapter_channel_kind(channel_id),
in_thread=bool(thread_ts),
text=text,
)
if decision.suppressed:
self._adapter_logger().info(
"Suppressed Slack reply channel=%s reason=%s",
channel_id,
decision.reason,
)
return
try:
import aiohttp as _aiohttp
except ImportError:
self._adapter_logger().warning(
"aiohttp not available; cannot send Slack reply"
)
return
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
str(ctx.get("workspace_id", "") or "").strip()
)
if not bot_token:
self._adapter_logger().warning(
"Slack reply dropped: no workspace token available (workspace=%s)",
workspace_id or "legacy",
)
return
url = "https://slack.com/api/chat.postMessage"
headers = {
"Authorization": f"Bearer {bot_token}",
"Content-Type": "application/json; charset=utf-8",
}
payload: Dict[str, Any] = {
"channel": channel_id,
"text": text,
}
if thread_ts:
payload["thread_ts"] = thread_ts
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
url,
json=payload,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status != 200:
body = await resp.text()
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=f"http_{resp.status}",
status_code=resp.status,
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
},
)
self._adapter_logger().warning(
f"Slack API error: status={resp.status} body={body[:200]}"
)
else:
data = await resp.json()
if not data.get("ok"):
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=str(data.get("error", "unknown")),
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
},
)
self._adapter_logger().warning(
f"Slack API error: {data.get('error', 'unknown')}"
)
elif installation_id:
self._installation_manager.mark_installation_health(
installation_id,
health_code="ok",
reason="chat_post_message_ok",
details={"workspace_id": workspace_id},
)
except Exception as e:
self._adapter_logger().warning(f"Slack reply failed: {e}")
# ------------------------------------------------------------------
# Platform contract: send_message / send_image
# ------------------------------------------------------------------
async def send_message(
self,
channel_id: str,
text: str,
delivery_context: Optional[Dict[str, Any]] = None,
):
"""Platform contract: send text message."""
await self._send_reply(
channel_id=channel_id,
text=text,
delivery_context=delivery_context,
)
async def send_image(
self,
channel_id: str,
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[Dict[str, Any]] = None,
):
"""Platform contract: send image (Slack files.upload)."""
try:
import aiohttp as _aiohttp
except ImportError:
self._adapter_logger().warning(
"aiohttp not available; cannot upload Slack image"
)
return
ctx = dict(delivery_context or {})
thread_ts = str(ctx.get("thread_id", "") or "").strip()
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
str(ctx.get("workspace_id", "") or "").strip()
)
if not bot_token:
self._adapter_logger().warning(
"Slack image dropped: no workspace token available (workspace=%s)",
workspace_id or "legacy",
)
return
url = "https://slack.com/api/files.upload"
headers = {
"Authorization": f"Bearer {bot_token}",
}
data = _aiohttp.FormData()
data.add_field("file", image_data, filename=filename, content_type="image/png")
data.add_field("channels", channel_id)
if caption:
data.add_field("initial_comment", caption)
if thread_ts:
data.add_field("thread_ts", thread_ts)
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
url,
data=data,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status != 200:
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=f"http_{resp.status}",
status_code=resp.status,
details={
"workspace_id": workspace_id,
"path": "files.upload",
},
)
self._adapter_logger().warning(
f"Slack file upload error: status={resp.status}"
)
else:
resp_data = await resp.json()
if not resp_data.get("ok"):
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=str(resp_data.get("error", "unknown")),
details={
"workspace_id": workspace_id,
"path": "files.upload",
},
)
self._adapter_logger().warning(
f"Slack file upload error: {resp_data.get('error')}"
)
elif installation_id:
self._installation_manager.mark_installation_health(
installation_id,
health_code="ok",
reason="files_upload_ok",
details={"workspace_id": workspace_id},
)
except Exception as e:
self._adapter_logger().warning(f"Slack image upload failed: {e}")
@@ -0,0 +1,497 @@
"""Owned Slack signed-ingress and interaction transaction mixin."""
import json
import time
from typing import Any, Dict, Optional
from urllib.parse import parse_qs
from ..contract import CommandRequest
# ruff: noqa: SIM102, UP006, UP035, UP045 -- preserve frozen behavior/signatures.
# mypy: disable-error-code="attr-defined,no-any-return"
class SlackIngressMixin:
async def handle_event(self, request):
"""POST handler for Slack Events API."""
_, web = self._adapter_import_aiohttp_web()
try:
body_bytes = await request.read()
except Exception:
return self._adapter_make_response(web, status=400, text="Bad request")
# -- Step 1: Signature verification (fail-closed) --
timestamp = ""
signature = ""
if hasattr(request, "headers"):
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
signature = request.headers.get("X-Slack-Signature", "")
if not self._adapter_verify_slack_signature(
signing_secret=self.config.slack_signing_secret or "",
timestamp=timestamp,
body=body_bytes,
signature=signature,
):
self._adapter_logger().warning(
"Slack signature verification failed (rejected)"
)
return self._adapter_make_response(
web, status=401, text="Invalid signature"
)
# -- Step 2: Parse payload --
try:
payload = json.loads(body_bytes)
except json.JSONDecodeError:
return self._adapter_make_response(web, status=400, text="Bad JSON")
# -- Step 3: url_verification challenge (Webhook only) --
if payload.get("type") == "url_verification":
challenge = payload.get("challenge", "")
return self._adapter_make_json_response(web, {"challenge": challenge})
# -- Step 4: Process event --
try:
await self.process_event_payload(payload)
except ValueError:
return self._adapter_make_response(web, status=400, text="Bad Request")
return self._adapter_make_response(web, status=200, text="OK")
async def handle_interaction(self, request):
"""POST handler for Slack Block Kit interactivity callbacks."""
_, web = self._adapter_import_aiohttp_web()
try:
body_bytes = await request.read()
except Exception:
return self._adapter_make_response(web, status=400, text="Bad request")
timestamp = ""
signature = ""
if hasattr(request, "headers"):
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
signature = request.headers.get("X-Slack-Signature", "")
if not self._adapter_verify_slack_signature(
signing_secret=self.config.slack_signing_secret or "",
timestamp=timestamp,
body=body_bytes,
signature=signature,
):
self._adapter_logger().warning(
"Slack interaction signature verification failed (rejected)"
)
return self._adapter_make_response(
web, status=401, text="Invalid signature"
)
parsed = parse_qs(body_bytes.decode("utf-8"), keep_blank_values=True)
raw_payload = (parsed.get("payload") or [""])[0]
if not raw_payload:
return self._adapter_make_response(web, status=400, text="Missing payload")
try:
payload = json.loads(raw_payload)
except json.JSONDecodeError:
return self._adapter_make_response(web, status=400, text="Bad payload")
if not isinstance(payload, dict):
return self._adapter_make_response(web, status=400, text="Bad payload")
try:
routed = await self.process_interaction_payload(payload)
except ValueError:
return self._adapter_make_response(web, status=400, text="Bad Request")
except Exception as exc:
safe_text = self._adapter_safe_external_error_text(
"Slack interaction failed", exc
)
self._adapter_logger().warning("Slack interaction failed: %s", safe_text)
return self._adapter_make_response(web, status=500, text=safe_text)
# Slack requires a fast acknowledgement for interactivity requests.
# Keep the external response bounded; detailed action results are routed
# through the existing reply/deferred-response surfaces.
return self._adapter_make_json_response(
web, {"ok": True, "routed": bool(routed)}
)
async def process_event_payload(self, payload: Dict[str, Any]) -> None:
"""
Shared event processing path for both webhook and socket mode transports.
"""
if payload.get("type") != "event_callback":
return
event = payload.get("event", {})
event_id = payload.get("event_id", "")
event_type = event.get("type", "")
workspace_id = self._installation_manager.extract_workspace_id(payload)
if event_type in ("app_uninstalled", "tokens_revoked", "app_rate_limited"):
if workspace_id:
self._handle_lifecycle_event(workspace_id, event_type)
return
# -- Step 5: Replay / dedupe guard --
if not event_id:
self._adapter_logger().warning("Slack event missing event_id (rejected)")
raise ValueError("Missing event_id")
if not self._replay_guard.check_and_record(event_id):
self._adapter_logger().debug(
f"Slack duplicate event_id={event_id} (accepted, no-op)"
)
return
# -- Step 6: Bot-loop prevention --
# Resolve bot user ID from authorizations or cache.
bot_user_id = self._get_bot_user_id(payload, workspace_id)
sender_id = event.get("user", "")
if sender_id and bot_user_id and sender_id == bot_user_id:
return
if event.get("bot_id"):
return
subtype = event.get("subtype", "")
if subtype and subtype not in ("", "file_share"):
return
# -- Step 7: Event normalization --
text = event.get("text", "").strip()
channel_id = event.get("channel", "")
thread_ts = event.get("thread_ts", "")
message_ts = event.get("ts", "")
if event_type not in ("message", "app_mention"):
return
if not text or not sender_id:
return
# S67: Require mention in group channels.
is_dm = channel_id.startswith("D")
mentioned_bot = event_type == "app_mention" or (
bool(bot_user_id) and f"<@{bot_user_id}>" in text
)
if not is_dm and self.config.slack_require_mention:
if event_type != "app_mention":
if bot_user_id and f"<@{bot_user_id}>" not in text:
return
if bot_user_id:
text = text.replace(f"<@{bot_user_id}>", "").strip()
# -- Step 8: Allowlist checks (S67) --
if self._user_allowlist.entries:
user_result = self._user_allowlist.evaluate(sender_id)
if user_result.decision == "deny":
self._adapter_logger().warning(
f"Slack user {sender_id} denied by allowlist"
)
return
if self._channel_allowlist.entries and channel_id:
chan_result = self._channel_allowlist.evaluate(channel_id)
if chan_result.decision == "deny":
self._adapter_logger().warning(
f"Slack channel {channel_id} denied by allowlist"
)
return
# -- Step 9: Build CommandRequest and route --
req = CommandRequest(
platform="slack",
sender_id=sender_id,
channel_id=channel_id,
username=sender_id,
message_id=event_id,
text=text,
timestamp=float(message_ts) if message_ts else time.time(),
workspace_id=workspace_id,
thread_id=thread_ts
or (message_ts if self.config.slack_reply_in_thread else ""),
)
try:
resp = await self.router.handle(req)
resp_text = getattr(resp, "text", "")
if not isinstance(resp_text, str):
resp_text = str(resp_text) if resp_text is not None else ""
buttons = getattr(resp, "buttons", []) or []
if resp_text or buttons:
if buttons:
await self._send_interactive_reply(
channel_id=channel_id,
text=resp_text or "OpenClaw",
buttons=buttons,
thread_ts=req.thread_id,
delivery_context={
"workspace_id": workspace_id,
"thread_id": req.thread_id,
"channel_kind": self._adapter_channel_kind(channel_id),
"mentioned": mentioned_bot,
},
)
else:
await self._send_reply(
channel_id=channel_id,
text=resp_text,
thread_ts=req.thread_id,
delivery_context={
"workspace_id": workspace_id,
"thread_id": req.thread_id,
"channel_kind": self._adapter_channel_kind(channel_id),
"mentioned": mentioned_bot,
},
)
except Exception as e:
self._adapter_logger().error(
"Slack event handling failed (error_type=%s)", type(e).__name__
)
async def process_interaction_payload(self, payload: Dict[str, Any]) -> bool:
interaction_type = str(payload.get("type", "") or "").strip()
if interaction_type not in self._adapter_interaction_types():
return False
request = self._build_interaction_request(payload)
if request is None:
return False
replay_key = self._interaction_replay_key(payload, request)
if self._interaction_lifecycle is None: # pragma: no cover
if not self._replay_guard.check_and_record(replay_key):
self._adapter_logger().debug(
"Slack duplicate interaction %s (accepted, no-op)", replay_key
)
return False
claim = None
else:
claim = self._interaction_lifecycle.claim(
replay_key,
metadata={
"platform": "slack",
"workspace_id": request.workspace_id,
"interaction_type": str(payload.get("type", "") or ""),
},
)
if claim is not None and not claim.accepted:
self._adapter_logger().debug(
"Slack duplicate interaction %s state=%s code=%s (accepted, no-op)",
replay_key,
claim.record.state,
claim.code,
)
return False
# IMPORTANT: interactive run-like payloads must be routed through the same
# approval semantics as text commands. Untrusted users get approval forced
# before CommandRouter sees the request, avoiding a parallel bypass path.
if request.text.startswith("/run") and not (
self.router._is_admin(request) or self.router._is_trusted(request)
):
request.text = self._adapter_force_approval_command(request.text)
try:
response = await self.router.handle(request)
except Exception:
# IMPORTANT: only failures before router completion are retryable.
# Once router.handle returns, duplicate user actions must not reroute.
if self._interaction_lifecycle is not None:
self._interaction_lifecycle.release_retryable(
replay_key, reason="slack_interaction_failed_before_commit"
)
raise
if self._interaction_lifecycle is not None:
self._interaction_lifecycle.commit_success(replay_key, reason="routed")
response_text = str(getattr(response, "text", "") or "").strip()
response_buttons = getattr(response, "buttons", []) or []
if response_text or response_buttons:
if response_buttons:
await self._send_interactive_reply(
channel_id=request.channel_id,
text=response_text or "Action processed.",
buttons=response_buttons,
thread_ts=request.thread_id,
delivery_context={
"workspace_id": request.workspace_id,
"thread_id": request.thread_id,
},
)
elif response_text:
await self._send_reply(
channel_id=request.channel_id,
text=response_text,
thread_ts=request.thread_id,
delivery_context={
"workspace_id": request.workspace_id,
"thread_id": request.thread_id,
},
)
return True
def _build_interaction_request(
self, payload: Dict[str, Any]
) -> Optional[CommandRequest]:
interaction_type = str(payload.get("type", "") or "").strip()
command_text = self._extract_interaction_command(payload)
if not command_text:
return None
team = payload.get("team") or {}
user = payload.get("user") or {}
container = payload.get("container") or {}
channel = payload.get("channel") or {}
view = payload.get("view") or {}
message = payload.get("message") or {}
action = self._first_action(payload)
workspace_id = self._adapter_first_non_empty(
team.get("id"),
payload.get("team_id"),
(
payload.get("enterprise", {}).get("id")
if isinstance(payload.get("enterprise"), dict)
else ""
),
)
sender_id = self._adapter_first_non_empty(
user.get("id"), payload.get("user_id")
)
channel_id = self._adapter_first_non_empty(
channel.get("id"),
container.get("channel_id"),
payload.get("channel_id"),
)
message_id = self._adapter_first_non_empty(
view.get("id"),
action.get("action_ts"),
container.get("message_ts"),
payload.get("trigger_id"),
f"slack-interaction-{int(time.time())}",
)
thread_id = self._adapter_first_non_empty(
container.get("thread_ts"),
message.get("thread_ts") if isinstance(message, dict) else "",
container.get("message_ts"),
)
if not thread_id and self.config.slack_reply_in_thread:
thread_id = self._adapter_first_non_empty(
container.get("message_ts"), message.get("ts")
)
return CommandRequest(
platform="slack",
sender_id=sender_id,
channel_id=channel_id or sender_id,
username=self._adapter_first_non_empty(
user.get("username"), user.get("name"), sender_id
),
message_id=message_id,
text=command_text,
timestamp=time.time(),
workspace_id=workspace_id,
thread_id=thread_id,
metadata={
"interactive_callback": True,
"interaction_type": interaction_type,
"action_id": self._adapter_first_non_empty(
action.get("action_id"), view.get("callback_id")
),
"response_url": str(payload.get("response_url", "") or ""),
},
)
def _extract_interaction_command(self, payload: Dict[str, Any]) -> str:
interaction_type = str(payload.get("type", "") or "").strip()
if interaction_type == "block_actions":
action = self._first_action(payload)
selected = action.get("selected_option") or {}
value = self._adapter_first_non_empty(
action.get("value"),
selected.get("value") if isinstance(selected, dict) else "",
action.get("action_id"),
)
parsed = self._adapter_json_loads_safe(value)
return self._adapter_first_non_empty(
parsed.get("command"), parsed.get("value"), value
)
if interaction_type == "view_submission":
view = payload.get("view") or {}
private_meta = self._adapter_first_non_empty(view.get("private_metadata"))
parsed = self._adapter_json_loads_safe(private_meta)
if parsed:
return self._adapter_first_non_empty(
parsed.get("command"), parsed.get("value")
)
if private_meta:
return private_meta
state = (view.get("state") or {}).get("values") or {}
return self._extract_command_from_view_state(state)
if interaction_type == "workflow_step_execute":
workflow_step = payload.get("workflow_step") or {}
inputs = workflow_step.get("inputs") or {}
command = inputs.get("command") or {}
if isinstance(command, dict):
return self._adapter_first_non_empty(command.get("value"))
return self._adapter_first_non_empty(workflow_step.get("callback_id"))
return ""
def _extract_command_from_view_state(self, state: Dict[str, Any]) -> str:
if not isinstance(state, dict):
return ""
for block_value in state.values():
if not isinstance(block_value, dict):
continue
for action_value in block_value.values():
if not isinstance(action_value, dict):
continue
candidate = self._adapter_first_non_empty(
action_value.get("value"),
(
(action_value.get("selected_option") or {}).get("value")
if isinstance(action_value.get("selected_option"), dict)
else ""
),
)
parsed = self._adapter_json_loads_safe(candidate)
command = self._adapter_first_non_empty(
parsed.get("command"), parsed.get("value"), candidate
)
if command:
return command
return ""
def _first_action(self, payload: Dict[str, Any]) -> Dict[str, Any]:
actions = payload.get("actions") or []
if isinstance(actions, list) and actions and isinstance(actions[0], dict):
return actions[0]
return {}
def _interaction_replay_key(
self, payload: Dict[str, Any], request: CommandRequest
) -> str:
action = self._first_action(payload)
key_parts = [
"interaction",
str(payload.get("type", "") or ""),
request.workspace_id,
request.sender_id,
request.channel_id,
request.message_id,
str(payload.get("trigger_id", "") or ""),
str(action.get("action_id", "") or ""),
str(action.get("action_ts", "") or ""),
request.text,
]
return ":".join(key_parts)
# ------------------------------------------------------------------
# Slack Web API reply
# ------------------------------------------------------------------
@@ -0,0 +1,171 @@
"""Owned Slack installation, OAuth, and workspace-identity mixin."""
# ruff: noqa: UP006, UP035, UP045 -- preserve frozen facade annotations.
from typing import Any, Dict, Optional, Tuple
# mypy: disable-error-code="attr-defined,has-type,no-any-return"
class SlackInstallationMixin:
async def handle_oauth_install(self, request):
_, web = self._adapter_import_aiohttp_web()
if not self._installation_manager.can_handle_oauth():
return self._adapter_make_response(
web, status=503, text="Slack OAuth not configured"
)
state = self._installation_manager.issue_install_state()
return self._adapter_make_redirect_response(
web, self._installation_manager.build_install_url(state)
)
async def handle_oauth_callback(self, request):
_, web = self._adapter_import_aiohttp_web()
if not self._installation_manager.can_handle_oauth():
return self._adapter_make_response(
web, status=503, text="Slack OAuth not configured"
)
query = getattr(request, "query", {}) or {}
if query.get("error"):
return self._adapter_make_response(
web,
status=400,
text=f"Slack OAuth rejected: {query.get('error')}",
)
state = str(query.get("state", "") or "").strip()
code = str(query.get("code", "") or "").strip()
if not state or not code:
return self._adapter_make_response(
web, status=400, text="Missing OAuth callback fields"
)
if not self._installation_manager.consume_install_state(state):
return self._adapter_make_response(
web, status=400, text="Invalid or replayed OAuth state"
)
try:
payload = await self._installation_manager.exchange_code(code)
installation = self._installation_manager.upsert_from_oauth_payload(payload)
return self._adapter_make_response(
web,
status=200,
text=(
"Slack installation complete for "
f"{installation.workspace_id} ({installation.installation_id})."
),
)
except Exception as exc:
safe_text = self._adapter_safe_external_error_text(
"Slack OAuth processing failed", exc
)
self._adapter_logger().warning("Slack OAuth callback failed: %s", safe_text)
return self._adapter_make_response(
web,
status=502,
text=safe_text,
)
def _get_bot_user_id(self, payload: Dict[str, Any], workspace_id: str) -> str:
candidate = ""
if workspace_id and workspace_id in self._bot_user_ids:
return self._bot_user_ids[workspace_id]
if self._bot_user_id:
return self._bot_user_id
authorizations = payload.get("authorizations", [])
if authorizations and isinstance(authorizations, list):
candidate = str((authorizations[0] or {}).get("user_id", "") or "").strip()
if candidate:
self._bot_user_id = candidate
if workspace_id:
self._bot_user_ids[workspace_id] = candidate
return candidate
if workspace_id:
workspace_resolution, _ = (
self._installation_manager.resolve_workspace_tokens(workspace_id)
)
candidate = self._installation_manager.bot_user_id_for_installation(
workspace_resolution.installation if workspace_resolution.ok else None
)
if candidate:
self._bot_user_ids[workspace_id] = candidate
if self._bot_user_id is None:
self._bot_user_id = candidate
return candidate
def _resolve_workspace_credentials(
self, workspace_id: str
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
workspace_id = str(workspace_id or "").strip()
if workspace_id:
resolution, tokens = self._installation_manager.resolve_workspace_tokens(
workspace_id
)
if resolution.ok and resolution.installation is not None:
bot_token = tokens.get("bot_token")
if bot_token:
self._installation_manager.mark_resolution_success(
resolution.installation.installation_id, workspace_id
)
return (
resolution.installation.installation_id,
bot_token,
workspace_id,
)
self._adapter_logger().warning(
"Slack workspace %s resolved without bot token secret", workspace_id
)
return (
resolution.installation.installation_id,
None,
workspace_id,
)
if (
not self._installation_manager.oauth_enabled
and self.config.slack_bot_token
):
return (None, self.config.slack_bot_token, workspace_id)
self._adapter_logger().warning(
"Slack workspace resolution failed for %s: %s (%s)",
workspace_id,
resolution.reject_reason,
resolution.health_code,
)
return (None, None, workspace_id)
if self.config.slack_bot_token:
return (None, self.config.slack_bot_token, "")
return (None, None, workspace_id)
def _handle_lifecycle_event(self, workspace_id: str, event_type: str) -> None:
installation_id = self._installation_manager.installation_id_for_workspace(
workspace_id
)
try:
if event_type == "app_uninstalled":
self._installation_manager.mark_installation_health(
installation_id,
health_code="revoked",
reason="slack_app_uninstalled",
details={"workspace_id": workspace_id},
)
self._installation_manager.uninstall_installation(
installation_id, reason="slack_app_uninstalled"
)
elif event_type == "tokens_revoked":
self._installation_manager.mark_installation_health(
installation_id,
health_code="invalid_token",
reason="slack_tokens_revoked",
details={"workspace_id": workspace_id},
)
elif event_type == "app_rate_limited":
self._installation_manager.mark_installation_health(
installation_id,
health_code="degraded",
reason="slack_app_rate_limited",
details={"workspace_id": workspace_id},
)
except ValueError:
self._adapter_logger().warning(
"Slack lifecycle event for unbound workspace %s (%s)",
workspace_id,
event_type,
)
@@ -0,0 +1,389 @@
from __future__ import annotations
import json
import logging
import os
import secrets
import threading
import time
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
from connector.config import ConnectorConfig
try:
from services.audit import emit_audit_event
from services.connector_installation_registry import (
ConnectorInstallation,
ConnectorInstallationRegistry,
InstallationResolution,
InstallationStatus,
get_connector_installation_registry,
)
from services.safe_io import (
STANDARD_OUTBOUND_POLICY,
SafeIOHTTPError,
safe_request_json,
)
from services.secret_store import SecretStore, get_secret_store
from services.state_dir import get_state_dir
except ImportError: # pragma: no cover
from services.audit import emit_audit_event # type: ignore
from services.connector_installation_registry import ( # type: ignore
ConnectorInstallation,
ConnectorInstallationRegistry,
InstallationResolution,
InstallationStatus,
get_connector_installation_registry,
)
from services.safe_io import ( # type: ignore
STANDARD_OUTBOUND_POLICY,
SafeIOHTTPError,
safe_request_json,
)
from services.secret_store import SecretStore, get_secret_store # type: ignore
from services.state_dir import get_state_dir # type: ignore
logger = logging.getLogger(__name__)
SLACK_AUTHORIZE_URL = "https://slack.com/oauth/v2/authorize"
SLACK_OAUTH_ACCESS_URL = "https://slack.com/api/oauth.v2.access"
SLACK_OAUTH_STATE_FILE = "slack_oauth_states.json"
_INVALID_TOKEN_ERRORS = frozenset(
{"account_inactive", "invalid_auth", "not_authed", "token_revoked"}
)
_DEGRADED_TOKEN_ERRORS = frozenset({"ratelimited", "request_timeout", "fatal_error"})
class SlackInstallationManager:
def __init__(
self,
config: ConnectorConfig,
*,
registry: Optional[ConnectorInstallationRegistry] = None,
secret_store: Optional[SecretStore] = None,
state_dir: Optional[str] = None,
):
self.config = config
self._state_dir = Path(state_dir or get_state_dir())
self._state_path = self._state_dir / SLACK_OAUTH_STATE_FILE
self._registry = registry or get_connector_installation_registry(
state_dir=str(self._state_dir)
)
self._secret_store = secret_store or get_secret_store(str(self._state_dir))
self._lock = threading.RLock()
self._states: Dict[str, Dict[str, Any]] = {}
self._load_states()
@property
def oauth_enabled(self) -> bool:
return bool(self.config.slack_client_id and self.config.slack_client_secret)
def resolve_redirect_uri(self) -> str:
if self.config.slack_oauth_redirect_uri:
return str(self.config.slack_oauth_redirect_uri).strip()
if self.config.public_base_url:
base = self.config.public_base_url.rstrip("/")
path = self.config.slack_oauth_callback_path or "/slack/oauth/callback"
return f"{base}{path}"
return ""
def can_handle_oauth(self) -> bool:
return self.oauth_enabled and bool(self.resolve_redirect_uri())
def _save_states(self) -> None:
self._state_dir.mkdir(parents=True, exist_ok=True)
temp_path = self._state_path.with_suffix(".tmp")
temp_path.write_text(json.dumps(self._states, indent=2), encoding="utf-8")
os.replace(temp_path, self._state_path)
def _load_states(self) -> None:
if not self._state_path.exists():
return
try:
data = json.loads(self._state_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
self._states = data
except Exception as exc:
logger.warning("Failed to load Slack OAuth state store: %s", exc)
self._states = {}
self._prune_expired_states()
def _prune_expired_states(self) -> None:
now = time.time()
ttl = max(60, int(self.config.slack_oauth_state_ttl_sec or 600))
changed = False
for key, payload in list(self._states.items()):
created_at = float(payload.get("created_at", 0) or 0)
if not created_at or (now - created_at) > ttl:
self._states.pop(key, None)
changed = True
if changed:
self._save_states()
def issue_install_state(self) -> str:
if not self.can_handle_oauth():
raise RuntimeError("Slack OAuth flow not configured")
with self._lock:
self._prune_expired_states()
state = secrets.token_urlsafe(32)
self._states[state] = {"created_at": time.time()}
self._save_states()
return state
def consume_install_state(self, state: str) -> bool:
with self._lock:
self._prune_expired_states()
payload = self._states.pop(str(state or "").strip(), None)
if payload is None:
return False
self._save_states()
return True
def build_install_url(self, state: str) -> str:
params = {
"client_id": self.config.slack_client_id or "",
"scope": ",".join(self.config.slack_oauth_scopes or []),
"redirect_uri": self.resolve_redirect_uri(),
"state": state,
}
return f"{SLACK_AUTHORIZE_URL}?{urlencode(params)}"
async def exchange_code(self, code: str) -> Dict[str, Any]:
payload = {
"client_id": self.config.slack_client_id or "",
"client_secret": self.config.slack_client_secret or "",
"code": str(code or "").strip(),
"redirect_uri": self.resolve_redirect_uri(),
}
try:
return safe_request_json(
method="POST",
url=SLACK_OAUTH_ACCESS_URL,
raw_body=urlencode(payload).encode("utf-8"),
headers={"Accept": "application/json"},
content_type="application/x-www-form-urlencoded",
timeout_sec=15,
allow_hosts={"slack.com"},
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
error_code = "unknown"
try:
body = exc.body or ""
parsed = json.loads(body) if body else {}
if isinstance(parsed, dict):
error_code = str(parsed.get("error", "") or error_code)
except Exception:
pass
raise RuntimeError(
f"slack_oauth_exchange_failed:{exc.status_code}:{error_code}"
) from exc
def _normalize_workspace_id(self, payload: Dict[str, Any]) -> str:
workspace_id = (
(payload.get("team") or {}).get("id")
or payload.get("team_id")
or (
(payload.get("enterprise") or {}).get("id")
if payload.get("enterprise")
else ""
)
)
workspace_id = str(workspace_id or "").strip()
if not workspace_id:
raise ValueError("workspace_id_missing")
return workspace_id
def installation_id_for_workspace(self, workspace_id: str) -> str:
return f"slack:{str(workspace_id or '').strip()}"
def metadata_from_oauth_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
team = dict(payload.get("team", {}) or {})
enterprise = dict(payload.get("enterprise", {}) or {})
authed_user = dict(payload.get("authed_user", {}) or {})
metadata = {
"workspace_name": str(team.get("name", "") or "").strip(),
"enterprise_id": str(enterprise.get("id", "") or "").strip(),
"enterprise_name": str(enterprise.get("name", "") or "").strip(),
"bot_user_id": str(payload.get("bot_user_id", "") or "").strip(),
"app_id": str(payload.get("app_id", "") or "").strip(),
"scope": str(payload.get("scope", "") or "").strip(),
"authed_user_id": str(authed_user.get("id", "") or "").strip(),
"token_type": str(payload.get("token_type", "") or "").strip(),
"transport_mode": self.config.slack_mode,
}
return {key: value for key, value in metadata.items() if value}
def upsert_from_oauth_payload(
self, payload: Dict[str, Any]
) -> ConnectorInstallation:
workspace_id = self._normalize_workspace_id(payload)
installation_id = self.installation_id_for_workspace(workspace_id)
token_values = {"bot_token": str(payload.get("access_token", "") or "").strip()}
if self.config.slack_app_token:
token_values["app_token"] = self.config.slack_app_token
if not token_values["bot_token"]:
raise ValueError("bot_token_missing")
metadata = self.metadata_from_oauth_payload(payload)
existing = self._registry.get_installation(installation_id)
if existing is not None:
rotated = self._registry.rotate_installation_tokens(
installation_id,
token_values,
reason="slack_oauth_reinstall",
)
inst = self._registry.upsert_installation(
platform="slack",
workspace_id=workspace_id,
installation_id=installation_id,
token_refs=rotated.token_refs,
status=rotated.status,
metadata=metadata,
status_reason="slack_oauth_reinstall",
)
else:
inst = self._registry.upsert_installation(
platform="slack",
workspace_id=workspace_id,
installation_id=installation_id,
token_values=token_values,
status=InstallationStatus.CREATED.value,
metadata=metadata,
status_reason="slack_oauth_install",
)
inst = self._registry.activate_installation(
installation_id, reason="slack_oauth_complete"
)
inst = self._registry.update_installation_health(
installation_id,
health_code="ok",
reason="slack_oauth_complete",
details={"workspace_id": workspace_id},
)
emit_audit_event(
action="connector.slack.oauth.install",
target=installation_id,
outcome="allow",
status_code=200,
details={
"workspace_id": workspace_id,
"workspace_name": metadata.get("workspace_name", ""),
"transport_mode": self.config.slack_mode,
},
)
return inst
def extract_workspace_id(self, payload: Dict[str, Any]) -> str:
if isinstance(payload.get("team_id"), str) and payload.get("team_id"):
return str(payload["team_id"]).strip()
team = payload.get("team") or {}
if isinstance(team, dict) and team.get("id"):
return str(team.get("id")).strip()
authorizations = payload.get("authorizations") or []
if isinstance(authorizations, list) and authorizations:
workspace_id = str((authorizations[0] or {}).get("team_id", "")).strip()
if workspace_id:
return workspace_id
event = payload.get("event") or {}
workspace_id = str(event.get("team", "") or "").strip()
return workspace_id
def resolve_workspace_tokens(
self, workspace_id: str
) -> Tuple[InstallationResolution, Dict[str, str]]:
resolution = self._registry.resolve_installation("slack", workspace_id)
if not resolution.ok or resolution.installation is None:
emit_audit_event(
action="connector.slack.resolve",
target=workspace_id or "unknown_workspace",
outcome="deny",
status_code=409,
details={
"workspace_id": workspace_id,
"reject_reason": resolution.reject_reason,
"health_code": resolution.health_code,
},
)
return resolution, {}
tokens: Dict[str, str] = {}
for token_name, ref in resolution.installation.token_refs.items():
secret = self._secret_store.get_secret(
ref, tenant_id=resolution.installation.tenant_id
)
if secret:
tokens[token_name] = secret
return resolution, tokens
def bot_user_id_for_installation(
self, installation: Optional[ConnectorInstallation]
) -> str:
if installation is None:
return ""
return str(
(
installation.metadata.get("bot_user_id", "")
if installation.metadata
else ""
)
or ""
).strip()
def mark_installation_health(
self,
installation_id: str,
*,
health_code: str,
reason: str,
details: Optional[Dict[str, Any]] = None,
) -> None:
self._registry.update_installation_health(
installation_id,
health_code=health_code,
reason=reason,
details=details,
)
def uninstall_installation(self, installation_id: str, *, reason: str) -> None:
self._registry.uninstall_installation(installation_id, reason=reason)
def mark_resolution_success(self, installation_id: str, workspace_id: str) -> None:
self._registry.update_installation_health(
installation_id,
health_code="ok",
reason="workspace_resolved",
details={"workspace_id": workspace_id},
)
def classify_error_health(self, error_code: str, status_code: int = 0) -> str:
normalized = str(error_code or "").strip().lower()
if normalized in _INVALID_TOKEN_ERRORS or status_code in (401, 403):
return "invalid_token"
if (
normalized in _DEGRADED_TOKEN_ERRORS
or status_code == 429
or status_code >= 500
):
return "degraded"
return "degraded"
def mark_api_error(
self,
installation_id: str,
*,
error_code: str,
status_code: int = 0,
details: Optional[Dict[str, Any]] = None,
) -> str:
health_code = self.classify_error_health(error_code, status_code=status_code)
self.mark_installation_health(
installation_id,
health_code=health_code,
reason=error_code or f"http_{status_code}",
details=details,
)
return health_code
+155 -251
View File
@@ -36,12 +36,30 @@ import time
from typing import Any, Dict, Optional
from ..config import ConnectorConfig
from ..contract import CommandRequest, CommandResponse
from ..router import CommandRouter
from ..security_profile import AllowlistPolicy, ReplayGuard
from .slack_delivery_handlers import SlackDeliveryMixin
from .slack_ingress_handlers import SlackIngressMixin
from .slack_installation_handlers import SlackInstallationMixin
from .slack_installation_manager import SlackInstallationManager
try:
from services.connector_replay_lifecycle import ConnectorReplayLifecycle
except ImportError: # pragma: no cover
ConnectorReplayLifecycle = None # type: ignore
logger = logging.getLogger(__name__)
_SLACK_INTERACTION_TYPES = frozenset(
{"block_actions", "view_submission", "workflow_step_execute"}
)
def _slack_channel_kind(channel_id: str) -> str:
if str(channel_id or "").startswith("D"):
return "dm"
return "group"
# -- aiohttp compat layer (same pattern as kakao/whatsapp/wechat) -----------
@@ -90,6 +108,52 @@ def _make_json_response(web_mod, data: dict, *, status: int = 200):
)
def _make_redirect_response(web_mod, url: str):
if web_mod is not None:
raise web_mod.HTTPFound(location=url)
return _CompatResponse(status=302, text=url)
def _safe_external_error_text(default: str, _exc: Exception) -> str:
# IMPORTANT: keep Slack external failures constant. Even "short safe-looking"
# exception text remains scanner-tainted and can re-expose internal detail.
return default
def _json_loads_safe(raw: Any) -> Dict[str, Any]:
if isinstance(raw, dict):
return raw
if not isinstance(raw, str):
return {}
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except (TypeError, ValueError):
return {}
def _first_non_empty(*values: Any) -> str:
for value in values:
text = str(value or "").strip()
if text:
return text
return ""
def _force_approval_command(command_text: str) -> str:
normalized = str(command_text or "").strip()
if normalized.startswith("/run") and "--approval" not in normalized:
return f"{normalized} --approval"
return normalized
def _style_to_slack(style: str) -> str:
normalized = str(style or "").strip().lower()
if normalized in {"primary", "danger"}:
return normalized
return "primary" if normalized in {"approve", "success"} else ""
# -- Slack signature verification -------------------------------------------
# Maximum acceptable clock skew for timestamp validation (5 minutes).
@@ -139,7 +203,11 @@ def verify_slack_signature(
# -- Slack adapter ----------------------------------------------------------
class SlackWebhookServer:
class SlackWebhookServer(
SlackInstallationMixin,
SlackIngressMixin,
SlackDeliveryMixin,
):
"""
F56 -- Slack Events API adapter.
@@ -166,15 +234,78 @@ class SlackWebhookServer:
window_sec=self.REPLAY_WINDOW_SEC,
max_entries=self.NONCE_CACHE_SIZE,
)
if ConnectorReplayLifecycle is None: # pragma: no cover
self._interaction_lifecycle = None
else:
self._interaction_lifecycle = ConnectorReplayLifecycle(
ttl_sec=self.REPLAY_WINDOW_SEC,
max_entries=self.NONCE_CACHE_SIZE,
)
# S67: Allowlists (fail-closed when configured)
self._user_allowlist = AllowlistPolicy(config.slack_allowed_users, strict=False)
self._channel_allowlist = AllowlistPolicy(
config.slack_allowed_channels, strict=False
)
self._installation_manager = SlackInstallationManager(config)
# Bot user ID (resolved on first event or set from config)
self._bot_user_id: Optional[str] = None
self._bot_user_id: Optional[str] = None # type: ignore[assignment]
self._bot_user_ids: Dict[str, str] = {}
# IMPORTANT: resolve facade globals at call time; integration suites and
# minimal-host shims patch these security/protocol seams directly.
@staticmethod
def _adapter_import_aiohttp_web():
return _import_aiohttp_web()
@staticmethod
def _adapter_make_response(*args, **kwargs):
return _make_response(*args, **kwargs)
@staticmethod
def _adapter_make_json_response(*args, **kwargs):
return _make_json_response(*args, **kwargs)
@staticmethod
def _adapter_make_redirect_response(*args, **kwargs):
return _make_redirect_response(*args, **kwargs)
@staticmethod
def _adapter_safe_external_error_text(*args, **kwargs):
return _safe_external_error_text(*args, **kwargs)
@staticmethod
def _adapter_verify_slack_signature(*args, **kwargs):
return verify_slack_signature(*args, **kwargs)
@staticmethod
def _adapter_json_loads_safe(*args, **kwargs):
return _json_loads_safe(*args, **kwargs)
@staticmethod
def _adapter_first_non_empty(*args, **kwargs):
return _first_non_empty(*args, **kwargs)
@staticmethod
def _adapter_force_approval_command(*args, **kwargs):
return _force_approval_command(*args, **kwargs)
@staticmethod
def _adapter_style_to_slack(*args, **kwargs):
return _style_to_slack(*args, **kwargs)
@staticmethod
def _adapter_channel_kind(*args, **kwargs):
return _slack_channel_kind(*args, **kwargs)
@staticmethod
def _adapter_interaction_types():
return _SLACK_INTERACTION_TYPES
@staticmethod
def _adapter_logger():
return logger
# ------------------------------------------------------------------
# Lifecycle
@@ -186,11 +317,19 @@ class SlackWebhookServer:
logger.warning("aiohttp not installed. Skipping Slack adapter.")
return
if not self.config.slack_bot_token or not self.config.slack_signing_secret:
if not self.config.slack_signing_secret:
logger.info(
"Slack adapter disabled "
"(OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN or "
"OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET missing)"
"(OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET missing)"
)
return
if (
not self.config.slack_bot_token
and not self._installation_manager.can_handle_oauth()
):
logger.info(
"Slack adapter disabled "
"(legacy bot token missing and Slack OAuth flow not configured)"
)
return
@@ -202,6 +341,16 @@ class SlackWebhookServer:
self.app = web.Application()
self.app.router.add_post(self.config.slack_webhook_path, self.handle_event)
self.app.router.add_post(
self.config.slack_interactions_path, self.handle_interaction
)
if self._installation_manager.can_handle_oauth():
self.app.router.add_get(
self.config.slack_oauth_install_path, self.handle_oauth_install
)
self.app.router.add_get(
self.config.slack_oauth_callback_path, self.handle_oauth_callback
)
self.runner = web.AppRunner(self.app)
await self.runner.setup()
@@ -219,248 +368,3 @@ class SlackWebhookServer:
# ------------------------------------------------------------------
# Event handler
# ------------------------------------------------------------------
async def handle_event(self, request):
"""POST handler for Slack Events API."""
_, web = _import_aiohttp_web()
try:
body_bytes = await request.read()
except Exception:
return _make_response(web, status=400, text="Bad request")
# -- Step 1: Signature verification (fail-closed) --
timestamp = ""
signature = ""
if hasattr(request, "headers"):
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
signature = request.headers.get("X-Slack-Signature", "")
if not verify_slack_signature(
signing_secret=self.config.slack_signing_secret or "",
timestamp=timestamp,
body=body_bytes,
signature=signature,
):
logger.warning("Slack signature verification failed (rejected)")
return _make_response(web, status=401, text="Invalid signature")
# -- Step 2: Parse payload --
try:
payload = json.loads(body_bytes)
except json.JSONDecodeError:
return _make_response(web, status=400, text="Bad JSON")
# -- Step 3: url_verification challenge (Webhook only) --
if payload.get("type") == "url_verification":
challenge = payload.get("challenge", "")
return _make_json_response(web, {"challenge": challenge})
# -- Step 4: Process event --
try:
await self.process_event_payload(payload)
except ValueError:
return _make_response(web, status=400, text="Bad Request")
return _make_response(web, status=200, text="OK")
async def process_event_payload(self, payload: Dict[str, Any]) -> None:
"""
Shared event processing path for both webhook and socket mode transports.
"""
if payload.get("type") != "event_callback":
return
event = payload.get("event", {})
event_id = payload.get("event_id", "")
event_type = event.get("type", "")
# -- Step 5: Replay / dedupe guard --
if not event_id:
logger.warning("Slack event missing event_id (rejected)")
raise ValueError("Missing event_id")
if not self._replay_guard.check_and_record(event_id):
logger.debug(f"Slack duplicate event_id={event_id} (accepted, no-op)")
return
# -- Step 6: Bot-loop prevention --
# Resolve bot user ID from authorizations or cache.
if self._bot_user_id is None:
auths = payload.get("authorizations", [])
if auths and isinstance(auths, list):
self._bot_user_id = auths[0].get("user_id", "")
sender_id = event.get("user", "")
if sender_id and sender_id == self._bot_user_id:
return
if event.get("bot_id"):
return
subtype = event.get("subtype", "")
if subtype and subtype not in ("", "file_share"):
return
# -- Step 7: Event normalization --
text = event.get("text", "").strip()
channel_id = event.get("channel", "")
thread_ts = event.get("thread_ts", "")
message_ts = event.get("ts", "")
if event_type not in ("message", "app_mention"):
return
if not text or not sender_id:
return
# S67: Require mention in group channels.
is_dm = channel_id.startswith("D")
if not is_dm and self.config.slack_require_mention:
if event_type != "app_mention":
if self._bot_user_id and f"<@{self._bot_user_id}>" not in text:
return
if self._bot_user_id:
text = text.replace(f"<@{self._bot_user_id}>", "").strip()
# -- Step 8: Allowlist checks (S67) --
if self._user_allowlist.entries:
user_result = self._user_allowlist.evaluate(sender_id)
if user_result.decision == "deny":
logger.warning(f"Slack user {sender_id} denied by allowlist")
return
if self._channel_allowlist.entries and channel_id:
chan_result = self._channel_allowlist.evaluate(channel_id)
if chan_result.decision == "deny":
logger.warning(f"Slack channel {channel_id} denied by allowlist")
return
# -- Step 9: Build CommandRequest and route --
req = CommandRequest(
platform="slack",
sender_id=sender_id,
channel_id=channel_id,
username=sender_id,
message_id=event_id,
text=text,
timestamp=float(message_ts) if message_ts else time.time(),
)
try:
resp = await self.router.handle(req)
resp_text = getattr(resp, "text", "")
if not isinstance(resp_text, str):
resp_text = str(resp_text) if resp_text is not None else ""
if resp_text:
await self._send_reply(
channel_id=channel_id,
text=resp_text,
thread_ts=thread_ts
or (message_ts if self.config.slack_reply_in_thread else ""),
)
except Exception as e:
logger.exception(f"Error handling Slack event: {e}")
# ------------------------------------------------------------------
# Slack Web API reply
# ------------------------------------------------------------------
async def _send_reply(
self,
channel_id: str,
text: str,
thread_ts: str = "",
) -> None:
"""Send a message via Slack Web API (chat.postMessage)."""
try:
import aiohttp as _aiohttp
except ImportError:
logger.warning("aiohttp not available; cannot send Slack reply")
return
url = "https://slack.com/api/chat.postMessage"
headers = {
"Authorization": f"Bearer {self.config.slack_bot_token}",
"Content-Type": "application/json; charset=utf-8",
}
payload: Dict[str, Any] = {
"channel": channel_id,
"text": text,
}
if thread_ts:
payload["thread_ts"] = thread_ts
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
url,
json=payload,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status != 200:
body = await resp.text()
logger.warning(
f"Slack API error: status={resp.status} body={body[:200]}"
)
else:
data = await resp.json()
if not data.get("ok"):
logger.warning(
f"Slack API error: {data.get('error', 'unknown')}"
)
except Exception as e:
logger.warning(f"Slack reply failed: {e}")
# ------------------------------------------------------------------
# Platform contract: send_message / send_image
# ------------------------------------------------------------------
async def send_message(self, channel_id: str, text: str):
"""Platform contract: send text message."""
await self._send_reply(channel_id=channel_id, text=text)
async def send_image(
self,
channel_id: str,
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
):
"""Platform contract: send image (Slack files.upload)."""
try:
import aiohttp as _aiohttp
except ImportError:
logger.warning("aiohttp not available; cannot upload Slack image")
return
url = "https://slack.com/api/files.upload"
headers = {
"Authorization": f"Bearer {self.config.slack_bot_token}",
}
data = _aiohttp.FormData()
data.add_field("file", image_data, filename=filename, content_type="image/png")
data.add_field("channels", channel_id)
if caption:
data.add_field("initial_comment", caption)
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
url,
data=data,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status != 200:
logger.warning(f"Slack file upload error: status={resp.status}")
else:
resp_data = await resp.json()
if not resp_data.get("ok"):
logger.warning(
f"Slack file upload error: {resp_data.get('error')}"
)
except Exception as e:
logger.warning(f"Slack image upload failed: {e}")
+175 -13
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,
@@ -182,6 +311,7 @@ class TelegramPolling:
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[dict] = None,
):
"""Send photo via Telegram sendPhoto."""
if not self.session:
@@ -192,6 +322,13 @@ class TelegramPolling:
url = f"{self.base_url}/sendPhoto"
data = aiohttp.FormData()
data.add_field("chat_id", channel_id)
thread_id = self._thread_id_from_context(delivery_context)
if thread_id is not None:
data.add_field("message_thread_id", str(thread_id))
elif delivery_context and delivery_context.get("thread_id"):
await self._send_thread_diagnostic(
channel_id, delivery_context.get("thread_id")
)
if caption:
data.add_field("caption", caption)
@@ -205,15 +342,40 @@ class TelegramPolling:
except Exception as e:
logger.error(f"Telegram send_image error: {e}")
async def send_message(self, channel_id: str, text: str):
async def send_message(
self,
channel_id: str,
text: str,
delivery_context: Optional[dict] = None,
):
"""Send text message."""
if not self.session:
return
decision = decide_reply_visibility(
delivery_context=dict(delivery_context or {}),
platform="telegram",
channel_kind=_telegram_channel_kind(channel_id),
text=text,
)
if decision.suppressed:
logger.info(
"Suppressed Telegram send_message chat=%s reason=%s",
channel_id,
decision.reason,
)
return
# Reuse internal logic logic but public
# Using simplified direct call
url = f"{self.base_url}/sendMessage"
payload = {"chat_id": channel_id, "text": text}
thread_id = self._thread_id_from_context(delivery_context)
if thread_id is not None:
payload["message_thread_id"] = thread_id
elif delivery_context and delivery_context.get("thread_id"):
await self._send_thread_diagnostic(
channel_id, delivery_context.get("thread_id")
)
try:
async with self.session.post(url, json=payload) as r:
if r.status != 200:
+20 -3
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
@@ -680,7 +691,12 @@ class WeChatWebhookServer:
# Outbound: Text (Customer Service Message API)
# ------------------------------------------------------------------
async def send_message(self, recipient_openid: str, text: str):
async def send_message(
self,
recipient_openid: str,
text: str,
delivery_context: Optional[dict] = None,
):
"""
Send text via WeChat Customer Service Message API.
@@ -736,6 +752,7 @@ class WeChatWebhookServer:
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[dict] = None,
):
"""
Send image via WeChat.
+9 -2
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
@@ -296,7 +297,12 @@ class WhatsAppWebhookServer:
# Outbound: Text
# ------------------------------------------------------------------
async def send_message(self, recipient_id: str, text: str):
async def send_message(
self,
recipient_id: str,
text: str,
delivery_context: Optional[dict] = None,
):
"""Send text message via WhatsApp Cloud API."""
if self._session_invalid:
logger.warning("R93: Connector session invalid - blocking outbound")
@@ -350,6 +356,7 @@ class WhatsAppWebhookServer:
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[dict] = None,
):
"""
Send image via WhatsApp using public media URL.
+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
+127 -21
View File
@@ -1,11 +1,12 @@
import asyncio
import logging
import time
from typing import Dict, Optional
from typing import Any, Dict, Optional
from .config import ConnectorConfig
from .contract import Platform
from .openclaw_client import OpenClawClient
from .reply_visibility import decide_reply_visibility
logger = logging.getLogger(__name__)
@@ -26,10 +27,10 @@ class ResultsPoller:
self.platforms = platforms # map "telegram" -> TelegramPolling, etc.
self.queue = (
asyncio.Queue()
) # (prompt_id, platform_name, channel_id, sender_id)
) # (prompt_id, platform_name, channel_id, sender_id, delivery_context)
self.approval_queue = (
asyncio.Queue()
) # (approval_id, platform_name, channel_id, sender_id)
) # (approval_id, platform_name, channel_id, sender_id, delivery_context)
self.active_polls = {} # prompt_id -> task
self.active_approval_polls = {} # approval_id -> task
@@ -55,17 +56,35 @@ class ResultsPoller:
logger.info("ResultsPoller stopped.")
def track_job(
self, prompt_id: str, platform_name: str, channel_id: str, sender_id: str
self,
prompt_id: str,
platform_name: str,
channel_id: str,
sender_id: str,
delivery_context: Optional[Dict[str, Any]] = None,
):
"""Enqueue a job for result monitoring."""
if not prompt_id:
return
logger.info(f"Tracking job {prompt_id} for {platform_name} in {channel_id}")
self.queue.put_nowait((prompt_id, platform_name, channel_id, sender_id))
self.queue.put_nowait(
(
prompt_id,
platform_name,
channel_id,
sender_id,
dict(delivery_context or {}),
)
)
def track_approval(
self, approval_id: str, platform_name: str, channel_id: str, sender_id: str
self,
approval_id: str,
platform_name: str,
channel_id: str,
sender_id: str,
delivery_context: Optional[Dict[str, Any]] = None,
):
if not approval_id:
return
@@ -85,16 +104,28 @@ class ResultsPoller:
f"Tracking approval {approval_id} for {platform_name} in {channel_id}"
)
self.approval_queue.put_nowait(
(approval_id, platform_name, channel_id, sender_id)
(
approval_id,
platform_name,
channel_id,
sender_id,
dict(delivery_context or {}),
)
)
async def _job_consumer(self):
while True:
item = await self.queue.get()
try:
prompt_id, platform_name, channel_id, sender_id = item
prompt_id, platform_name, channel_id, sender_id, delivery_context = item
task = asyncio.create_task(
self._poll_job(prompt_id, platform_name, channel_id, sender_id)
self._poll_job(
prompt_id,
platform_name,
channel_id,
sender_id,
delivery_context,
)
)
self.active_polls[prompt_id] = task
task.add_done_callback(
@@ -107,10 +138,20 @@ class ResultsPoller:
while True:
item = await self.approval_queue.get()
try:
approval_id, platform_name, channel_id, sender_id = item
(
approval_id,
platform_name,
channel_id,
sender_id,
delivery_context,
) = item
task = asyncio.create_task(
self._poll_approval(
approval_id, platform_name, channel_id, sender_id
approval_id,
platform_name,
channel_id,
sender_id,
delivery_context,
)
)
self.active_approval_polls[approval_id] = task
@@ -121,7 +162,12 @@ class ResultsPoller:
self.approval_queue.task_done()
async def _poll_approval(
self, approval_id: str, platform_name: str, channel_id: str, sender_id: str
self,
approval_id: str,
platform_name: str,
channel_id: str,
sender_id: str,
delivery_context: Optional[Dict[str, Any]] = None,
):
start_time = time.time()
delay = 2.0
@@ -137,6 +183,7 @@ class ResultsPoller:
platform_name,
channel_id,
f"❌ Approval {approval_id} {status}.",
delivery_context=delivery_context,
)
return
if status == "approved":
@@ -150,7 +197,11 @@ class ResultsPoller:
)
if prompt_id:
self.track_job(
prompt_id, platform_name, channel_id, sender_id
prompt_id,
platform_name,
channel_id,
sender_id,
delivery_context=delivery_context,
)
return
except Exception as e:
@@ -166,10 +217,16 @@ class ResultsPoller:
platform_name,
channel_id,
f"⚠️ Approval {approval_id} timed out waiting for execution.",
delivery_context=delivery_context,
)
async def _poll_job(
self, prompt_id: str, platform_name: str, channel_id: str, sender_id: str
self,
prompt_id: str,
platform_name: str,
channel_id: str,
sender_id: str,
delivery_context: Optional[Dict[str, Any]] = None,
):
"""Poll history with backoff until complete or timeout."""
start_time = time.time()
@@ -185,7 +242,11 @@ class ResultsPoller:
if prompt_id in data:
job_data = data[prompt_id]
await self._deliver_results(
prompt_id, job_data, platform_name, channel_id
prompt_id,
job_data,
platform_name,
channel_id,
delivery_context=delivery_context,
)
return
except Exception as e:
@@ -203,10 +264,16 @@ class ResultsPoller:
platform_name,
channel_id,
f"⚠️ Job {prompt_id} timed out waiting for results.",
delivery_context=delivery_context,
)
async def _deliver_results(
self, prompt_id: str, job_data: dict, platform_name: str, channel_id: str
self,
prompt_id: str,
job_data: dict,
platform_name: str,
channel_id: str,
delivery_context: Optional[Dict[str, Any]] = None,
):
"""Download images and send to platform."""
outputs = job_data.get("outputs", {})
@@ -216,6 +283,7 @@ class ResultsPoller:
platform_name,
channel_id,
f"✅ Job {prompt_id} finished (No output images).",
delivery_context=delivery_context,
)
return
@@ -231,7 +299,10 @@ class ResultsPoller:
if not images_to_send:
logger.info(f"Job {prompt_id} finished but no images found.")
await self._send_text(
platform_name, channel_id, f"✅ Job {prompt_id} finished (No images)."
platform_name,
channel_id,
f"✅ Job {prompt_id} finished (No images).",
delivery_context=delivery_context,
)
return
@@ -263,23 +334,58 @@ class ResultsPoller:
platform_name,
channel_id,
f"⚠️ Image {filename} skipped (too large).",
delivery_context=delivery_context,
)
continue
# Send with error handling
try:
await platform.send_image(channel_id, content, filename=filename)
await platform.send_image(
channel_id,
content,
filename=filename,
delivery_context=delivery_context,
)
except Exception as e:
logger.error(f"Failed to deliver image to {platform_name}: {e}")
# Fallback text
await self._send_text(
platform_name, channel_id, f"⚠️ Failed to send image: {filename}"
platform_name,
channel_id,
f"⚠️ Failed to send image: {filename}",
delivery_context=delivery_context,
)
async def _send_text(self, platform_name: str, channel_id: str, text: str):
async def _send_text(
self,
platform_name: str,
channel_id: str,
text: str,
*,
delivery_context: Optional[Dict[str, Any]] = None,
):
ctx = dict(delivery_context or {})
decision = decide_reply_visibility(
delivery_context=ctx,
platform=platform_name,
text=text,
)
if decision.suppressed:
# IMPORTANT: a suppressed visible reply is a successful delivery no-op.
logger.info(
"Suppressed connector text reply platform=%s channel=%s reason=%s",
platform_name,
channel_id,
decision.reason,
)
return
platform = self.platforms.get(platform_name)
if platform:
try:
await platform.send_message(channel_id, text)
await platform.send_message(
channel_id,
text,
delivery_context=ctx,
)
except Exception as e:
logger.error(f"Failed to send text to {platform_name}: {e}")
+20 -894
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,879 +51,3 @@ class CommandRouter:
# S44/R97: Semantic Guards
self.semantic_guard = SemanticGuard()
self.command_firewall = CommandFirewall()
async def handle(self, req: CommandRequest) -> CommandResponse:
"""Main dispatch loop."""
text = req.text.strip()
# NOTE: Debug-only raw message logging for troubleshooting parsing issues.
# Enable with OPENCLAW_CONNECTOR_DEBUG=1. May include sensitive user content.
if self.config.debug:
logger.info(
"DEBUG raw message: platform=%s user=%s chat=%s text=%r",
req.platform,
req.sender_id,
req.channel_id,
text,
)
# F32 WP2: Rate limiting
if not self._rate_limiter.is_allowed(str(req.sender_id), str(req.channel_id)):
return CommandResponse(
text="[Rate Limited] Too many requests. Please wait a moment."
)
# F32 WP5: Command length limit
if len(text) > self.config.max_command_length:
return CommandResponse(
text=f"[Error] Command too long ({len(text)} chars). Max: {self.config.max_command_length}."
)
try:
# IMPORTANT (recurring usability bug):
# Do not use `shlex.split()` directly for ChatOps commands that may include natural
# language. In POSIX mode, `shlex` treats apostrophes (`'`) as quote delimiters, so
# common contractions like "She's" trigger "unbalanced quotes" failures.
#
# We therefore only treat *double quotes* (`"`) as quoting characters, so users can
# still do: positive_prompt="a prompt with spaces" while apostrophes remain safe.
lexer = shlex.shlex(text, posix=True)
lexer.whitespace_split = True
lexer.commenters = ""
lexer.quotes = '"'
parts = list(lexer)
except ValueError:
return CommandResponse(
text="[Error] Parsing command arguments failed (unbalanced quotes?)."
)
if not parts:
return CommandResponse(text="Empty command.")
cmd = parts[0].lower()
args = parts[1:]
# Telegram group commands often include the bot username suffix, e.g. `/help@mybot`.
# If we don't strip it, the command won't match our dispatch table and appears "dead"
# even though polling is working.
if (
(req.platform or "").lower() == "telegram"
and cmd.startswith("/")
and "@" in cmd
):
cmd = cmd.split("@", 1)[0]
# Some users type `@bot /help` in group chats. Treat that as a command too.
if cmd.startswith("@") and args and args[0].startswith("/"):
cmd = args[0].lower()
args = args[1:]
# Dispatch Table
handlers = {
("/status", "status"): (self._handle_status, CommandClass.PUBLIC),
("/help", "help", "/start"): (self._handle_help, CommandClass.PUBLIC),
("/run", "run"): (self._handle_run, CommandClass.RUN),
("/interrupt", "interrupt", "/cancel", "cancel", "/stop"): (
self._handle_interrupt,
CommandClass.ADMIN,
), # Global interrupt => admin-only.
("/approvals", "approvals"): (
self._handle_approvals_list,
CommandClass.ADMIN,
),
("/approve", "approve"): (self._handle_approve, CommandClass.ADMIN),
("/reject", "reject"): (self._handle_reject, CommandClass.ADMIN),
("/schedules", "schedules"): (
self._handle_schedules_list,
CommandClass.ADMIN,
),
("/schedule", "schedule"): (
self._handle_schedule_subcommand,
CommandClass.ADMIN,
),
# Phase 3 Introspection
("/history", "history"): (self._handle_history, CommandClass.PUBLIC),
("/trace", "trace"): (self._handle_trace, CommandClass.ADMIN), # Admin only
("/jobs", "jobs", "queue"): (self._handle_jobs, CommandClass.PUBLIC),
# F30: Chat Assistant
("/chat", "chat"): (self._handle_chat, CommandClass.PUBLIC),
}
# Find Handler
handler = None
requires_admin = False
canonical_cmd = cmd # Fallback
for aliases, (func, cmd_class) in handlers.items():
if cmd in aliases:
handler = func
default_class = cmd_class
# R80 Remediation: Use canonical command (first alias) for policy checks
# This prevents "run" vs "/run" bypass issues.
if isinstance(aliases, tuple):
# Convention: first alias is canonical (e.g. "/run")
canonical_cmd = aliases[0]
else:
canonical_cmd = aliases
break
if not handler:
return CommandResponse(
text=f"Unknown command: {cmd}. Type /help for options."
)
# R80: Centralized Authorization Gate
# Pass canonical_cmd to ensure policy matches aliases correctly
if auth_err := self._check_command_authz(canonical_cmd, req, default_class):
return auth_err
# Execute
try:
return await handler(req, args)
except Exception as e:
logger.exception(f"Command execution error {cmd}: {e}")
return CommandResponse(text=f"[Internal Error] {str(e)}")
def _is_admin(self, user_id: str) -> bool:
return str(user_id) in self.config.admin_users
def _check_command_authz(
self, cmd: str, req: CommandRequest, default_class: CommandClass
) -> Optional[CommandResponse]:
"""
R80: Verify command authorization policy.
Returns None if allowed, or CommandResponse(text=error) if denied.
"""
policy = self.config.command_policy
# 1. Resolve Effective Class (Handle per-command overrides)
# Note: 'cmd' here is the canonical parsed command string (lowercase), e.g., "/run" or "run"
# The overrides dict might use "/run" or "run", we should check both or normalize.
# Currently, the router logic normalized `cmd` from input (lines 90-101).
# We'll check exact match against the override key.
eff_class = policy.command_overrides.get(cmd, default_class)
# 2. Check AllowFrom List (Explicit User Allow)
# If an explicit AllowFrom list exists for this class, the user MUST be in it.
# This takes precedence over role logic.
allowed_users = policy.allow_from.get(eff_class)
if allowed_users is not None and len(allowed_users) > 0:
if str(req.sender_id) not in allowed_users:
# If explicit allow-list is active, even admins must be in it?
# Decision: YES, for strict compliance. If you want admins, add them to the list.
# However, for usability, usually admins are implied.
# Let's stick to "Explicit List Wins" for R80 strict mode.
return CommandResponse(
text="[Access Denied] You are not in the allow-list for this command."
)
# If in list, proceed (bypass default role checks? No, usually allows)
return None
# 3. Default Role Logic
if eff_class == CommandClass.ADMIN:
if not self._is_admin(req.sender_id):
return CommandResponse(
text="[Access Denied] This command requires Admin privileges."
)
# PUBLIC and RUN are allowed by default (RUN checks trust internally)
return None
def _is_trusted(self, req: CommandRequest) -> bool:
"""
Trusted users can execute /run immediately.
Untrusted users are routed to approval flow.
"""
if self._is_admin(req.sender_id):
return True
platform = (req.platform or "").lower()
sender_id = str(req.sender_id)
channel_id = str(req.channel_id)
if platform == "telegram":
try:
uid = int(sender_id)
except Exception:
uid = None
try:
cid = int(channel_id)
except Exception:
cid = None
if uid is not None and uid in self.config.telegram_allowed_users:
return True
if cid is not None and cid in self.config.telegram_allowed_chats:
return True
return False
if platform == "discord":
if sender_id in self.config.discord_allowed_users:
return True
if channel_id in self.config.discord_allowed_channels:
return True
return False
if platform == "line":
if sender_id in self.config.line_allowed_users:
return True
if channel_id in self.config.line_allowed_groups:
return True
return False
if platform == "whatsapp":
if sender_id in self.config.whatsapp_allowed_users:
return True
return False
if platform == "wechat":
if sender_id in self.config.wechat_allowed_users:
return True
return False
if platform == "kakao":
if sender_id in self.config.kakao_allowed_users:
return True
return False
if platform == "slack":
if sender_id in self.config.slack_allowed_users:
return True
if channel_id in self.config.slack_allowed_channels:
return True
return False
# Unknown platform: trust only admins
return False
# --- Handlers ---
async def _handle_status(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
health = await self.client.get_health()
queue = await self.client.get_prompt_queue()
# New standardized response handling
health_ok = health.get("ok")
status_icon = "Online" if health_ok else "Offline"
details = []
if health_ok:
data = health.get("data", {})
stats = data.get("stats", {})
details.append(f"Logs: {stats.get('logs_processed', 0)}")
details.append(f"Errors: {stats.get('errors_captured', 0)}")
q_res = queue.get("data", {})
q_rem = q_res.get("exec_info", {}).get("queue_remaining", 0)
details.append(f"Queue: {q_rem}")
else:
details.append(f"Error: {health.get('error')}")
return CommandResponse(
text=f"[{status_icon}] System Status\n"
+ "\n".join(f"- {d}" for d in details)
)
def _require_admin_token_configured(self) -> Optional[CommandResponse]:
"""
F32 WP3: Check if admin token is configured before running admin commands.
Fail-fast with clear error message instead of 403/500 later.
IMPORTANT (recurring CI failure mode):
- Admin-only commands are gated by BOTH:
(1) sender is an admin user, AND
(2) the connector admin token is configured (OPENCLAW_CONNECTOR_ADMIN_TOKEN).
- Unit tests that exercise admin command handlers MUST set `config.admin_token`,
otherwise they will correctly receive the config error response.
"""
if not self.config.admin_token:
return CommandResponse(
text="[Error] Admin token not configured. Set OPENCLAW_CONNECTOR_ADMIN_TOKEN and restart connector."
)
return None
async def _handle_run(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(
text="Usage: /run <template_id> [prompt text] [key=value ...] [--approval]"
)
# Parse flags
explicit_approval = False
clean_args = []
for arg in args:
if arg in ("--require-approval", "--approval", "-a"):
explicit_approval = True
else:
clean_args.append(arg)
if not clean_args:
return CommandResponse(text="Usage: /run <template_id> ...")
template_id = clean_args[0]
inputs: Dict[str, str] = {}
free_text_parts: List[str] = []
for arg in clean_args[1:]:
if "=" in arg:
k, v = arg.split("=", 1)
inputs[k.strip()] = v.strip()
else:
free_text_parts.append(arg)
# If user provided free text without key=value, treat it as the prompt.
# We map it to a best-effort prompt key (prefers template metadata if available).
if free_text_parts:
prompt_key = await self._resolve_prompt_key(template_id)
if prompt_key not in inputs:
inputs[prompt_key] = " ".join(free_text_parts).strip()
elif self.config.debug:
logger.info(
"DEBUG /run free-text ignored (prompt key already set): %s",
prompt_key,
)
# NOTE: Debug-only payload logging for troubleshooting prompt mismatches.
# Enable with OPENCLAW_CONNECTOR_DEBUG=1 to log template_id + inputs.
if self.config.debug:
logger.info(
"DEBUG /run payload: template=%s inputs=%s approval_flag=%s trusted=%s",
template_id,
inputs,
explicit_approval,
self._is_trusted(req),
)
trusted = self._is_trusted(req)
require_approval = explicit_approval or (not trusted)
res = await self.client.submit_job(
template_id, inputs, require_approval=require_approval
)
if res.get("ok"):
data = res.get("data", {})
trace_id = data.get("trace_id", "unknown")
if data.get("pending"):
approval_id = data.get("approval_id", "unknown")
msg = f"[Approval Requested]\nID: {approval_id}\nTrace: {trace_id}"
if "expires_at" in data:
msg += f"\nExpires: {data['expires_at']}"
if self.poller:
# IMPORTANT:
# For untrusted users, approvals are done in the OpenClaw UI.
# We must start tracking the approval_id so we can map
# approval_id -> executed_prompt_id later and auto-deliver images.
self.poller.track_approval(
approval_id, req.platform, req.channel_id, req.sender_id
)
return CommandResponse(text=msg)
else:
prompt_id = data.get("prompt_id", "unknown")
if self.poller:
self.poller.track_job(
prompt_id, req.platform, req.channel_id, req.sender_id
)
return CommandResponse(
text=f"[Job Submitted]\nID: {prompt_id}\nTemplate: {template_id}\nTrace: {trace_id}"
)
else:
err = res.get("error", "Unknown error")
return CommandResponse(text=f"[Submission Failed] Reason: {err}")
async def _resolve_prompt_key(self, template_id: str) -> str:
"""
Best-effort prompt key resolution.
Prefer template metadata (allowed_inputs), then fall back to common names.
"""
meta = await self._get_template_meta(template_id)
allowed = meta.get("allowed_inputs") or []
# If template explicitly declares a single input, use it.
if isinstance(allowed, list) and len(allowed) == 1:
return str(allowed[0])
preferred = ("positive_prompt", "prompt", "text", "positive", "caption")
if isinstance(allowed, list):
for key in preferred:
if key in allowed:
return key
# Default fallback
return "positive_prompt"
async def _get_template_meta(self, template_id: str) -> Dict[str, Any]:
if template_id in self._template_meta_cache:
return self._template_meta_cache[template_id]
try:
res = await self.client.get_templates()
if res.get("ok"):
for item in res.get("templates", []) or []:
if item.get("id") == template_id:
self._template_meta_cache[template_id] = item
return item
except Exception as e:
if self.config.debug:
logger.info(f"DEBUG template meta fetch failed: {e}")
return {}
async def _handle_interrupt(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
# Remediation: Global Interrupt
res = await self.client.interrupt_output()
if res.get("ok"):
return CommandResponse(text="[Stop] Global Interrupt sent to ComfyUI.")
else:
return CommandResponse(text=f"[Stop Failed] {res.get('error')}")
async def _handle_approvals_list(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_approvals()
if not res.get("ok"):
return CommandResponse(
text=f"[Error] Failed to list approvals: {res.get('error')}"
)
items = res.get("items", [])
if not items:
return CommandResponse(text="No pending approvals.")
pending_count = res.get("pending_count")
lines = []
for i in items:
# IMPORTANT (stability): the backend approval schema uses:
# `approval_id`, `template_id`, `status`, `requested_by`, `source`.
# Do not “simplify” these keys to `id/description/requester` unless you also
# update the backend API + all tests. This mismatch previously caused silent
# bad output and brittle regressions.
approval_id = i.get("approval_id") or i.get("id") or "unknown"
template_id = i.get("template_id") or "unknown"
status = i.get("status") or "unknown"
requested_by = i.get("requested_by") or "unknown"
source = i.get("source") or "unknown"
lines.append(
f"- {approval_id} [{status}] template={template_id} by={requested_by} source={source}"
)
header = "Pending Approvals"
if isinstance(pending_count, int):
header += f" ({pending_count})"
return CommandResponse(text=header + ":\n" + "\n".join(lines))
async def _handle_approve(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /approve <id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
# Assuming auto_execute=True by default for chat logic
res = await self.client.approve_request(args[0], auto_execute=True)
if not res.get("ok"):
return CommandResponse(text=f"[Failed] {res.get('error')}")
data = res.get("data", {})
msg = f"[Approved] {args[0]}"
# Phase 4: Show execution result
if "prompt_id" in data:
pid = data["prompt_id"]
msg += f"\nExecuted: {pid}"
if self.poller:
# Approval request might have come from different flow, but usually user invoking /approve
# wants the result. Using current req context is safest assumption for "ChatOps".
self.poller.track_job(pid, req.platform, req.channel_id, req.sender_id)
elif data.get("executed") is False:
msg += "\n(Not Executed)"
if err := data.get("execution_error"):
msg += f"\nError: {err}"
return CommandResponse(text=msg)
async def _handle_reject(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /reject <id> [reason]")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
reason = " ".join(args[1:]) if len(args) > 1 else "Rejected via chat"
res = await self.client.reject_request(args[0], reason)
if not res.get("ok"):
return CommandResponse(text=f"[Failed] {res.get('error')}")
return CommandResponse(text=f"[Rejected] {args[0]}")
async def _handle_schedules_list(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_schedules()
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
scheds = res.get("schedules", [])
if not scheds:
return CommandResponse(text="No schedules found.")
lines = []
for s in scheds:
status = "+" if s.get("enabled") else "-"
lines.append(
f"[{status}] {s.get('id')}: {s.get('cron')} - {s.get('template_id')}"
)
return CommandResponse(text="Schedules:\n" + "\n".join(lines))
async def _handle_schedule_subcommand(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if len(args) < 2:
return CommandResponse(text="Usage: /schedule <run|toggle> <id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
sub = args[0].lower()
sid = args[1]
if sub == "run":
res = await self.client.run_schedule(sid)
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
return CommandResponse(text=f"[Success] Schedule {sid} triggered manually.")
else:
return CommandResponse(text="Not implemented yet.")
async def _handle_help(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
return CommandResponse(
text=(
"OpenClaw Connector\n"
"/status - Check system health and queue\n"
"/run <template> [prompt] [k=v] - Run a generation (trusted users auto-exec; others require approval)\n"
"/stop - Global Interrupt (Admin)\n"
"/history <id> - Job details\n"
"/jobs - Queue summary\n"
"Admin Only:\n"
"/approvals - List pending approvals\n"
"/approve <id>, /reject <id>\n"
"/schedules, /schedule run <id>\n"
"/trace <id> - Execution trace"
)
)
async def _handle_history(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /history <prompt_id>")
res = await self.client.get_history(args[0])
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
# Simple format
data = res.get("data", {})
status = data.get("status", {}).get("status_str", "unknown")
# Assuming backend returns a structure we can summarise
return CommandResponse(
text=f"Job {args[0]}: {status}\nFull details: not implemented in connector view yet."
)
async def _handle_trace(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /trace <prompt_id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_trace(args[0])
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
# Dump trace
sanitized = sanitize_operator_payload(res.get("data"))
return CommandResponse(text=f"Trace {args[0]}: {str(sanitized)[:1000]}...")
async def _handle_jobs(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# Try native /openclaw/jobs first
res = await self.client.get_jobs()
if res.get("ok"):
# Format nice summary
return CommandResponse(
text=f"Default Jobs View: {sanitize_operator_payload(res.get('data'))}"
)
# Fallback: Queue
q = await self.client.get_prompt_queue()
if q.get("ok"):
rem = q.get("data", {}).get("exec_info", {}).get("queue_remaining", "?")
return CommandResponse(text=f"[Fallback] Queue Remaining: {rem}")
return CommandResponse(text="[Error] Could not fetch jobs or queue.")
# -------------------------------------------------------------------------
# F30: Chat LLM Assistant
# -------------------------------------------------------------------------
async def _handle_chat(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
"""
/chat [subcommand] <message>
Subcommands: run, template, status
Default: general chat
Security: Never auto-executes commands. Only suggests command text.
"""
llm = LLMClient(self.client)
if not await llm.is_configured():
return CommandResponse(
text="[Chat Error] LLM not configured. Configure in OpenClaw Settings."
)
# Parse subcommand
if not args:
return CommandResponse(
text="Usage: /chat <message> or /chat run|template|status <request>"
)
subcommand = args[0].lower()
message = " ".join(args[1:]) if len(args) > 1 else ""
trust_level = "TRUSTED" if self._is_trusted(req) else "UNTRUSTED"
if subcommand == "run":
return await self._chat_run(llm, message, trust_level)
elif subcommand == "template":
return await self._chat_template(llm, message)
elif subcommand == "status":
return await self._chat_status(llm)
else:
# General chat: first word is part of message
full_message = " ".join(args)
return await self._chat_general(llm, full_message, trust_level)
async def _chat_general(
self, llm: LLMClient, message: str, trust_level: str
) -> CommandResponse:
"""General chat with assistant."""
# S44: Semantic Guard Evaluation
decision = self.semantic_guard.evaluate_request(message, {"trust": trust_level})
if decision.action == GuardAction.DENY:
return CommandResponse(
text=(
"[Blocked] Request denied by semantic policy "
f"({decision.reason}). {self._policy_kv(decision.to_contract())}"
)
)
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level=trust_level)
response = await llm.chat(system_prompt, message)
# S44: Output Validation + SAFE_REPLY sanitization.
try:
response = self.semantic_guard.validate_output(
response, "general", decision.action
)
except ValueError as e:
return CommandResponse(
text=(
"[Validation Error] Assistant output invalid: "
f"{e}. {self._policy_kv({'code': 'semantic_output_invalid', 'severity': 'medium', 'action': 'deny', 'reason': str(e)})}"
)
)
if decision.action == GuardAction.SAFE_REPLY:
safe_response = (
response
or "I can help with general guidance, but commands are restricted for this request."
)
return CommandResponse(
text=(
f"[Safe Mode] {safe_response}\n\n"
f"(Policy: {self._policy_kv(decision.to_contract())})"
)
)
return CommandResponse(text=response)
async def _chat_run(
self, llm: LLMClient, request: str, trust_level: str
) -> CommandResponse:
"""Suggest a /run command based on user request."""
if not request:
return CommandResponse(
text="Usage: /chat run <description of what you want>"
)
# S44: Semantic Guard Evaluation
decision = self.semantic_guard.evaluate_request(request, {"trust": trust_level})
if decision.action == GuardAction.DENY:
return CommandResponse(
text=(
"[Blocked] Request denied by semantic policy "
f"({decision.reason}). {self._policy_kv(decision.to_contract())}"
)
)
# Force Approval Override based on Risk
force_approval_policy = decision.action == GuardAction.FORCE_APPROVAL
# Get available templates (simplified - could fetch from API)
templates = "txt2img, img2img, upscale (examples)"
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level=trust_level)
user_prompt = f"""User wants to run a generation. Suggest a `/run` command.
Request: {request}
Available templates: {templates}
Trust level: {trust_level}
Remember: {"add --approval flag" if trust_level == "UNTRUSTED" else "no --approval needed"}.
Output only the command in a code block."""
response = await llm.chat(system_prompt, user_prompt)
# S44: Output Structure Validation
try:
response = self.semantic_guard.validate_output(
response, "run", decision.action
)
except ValueError as e:
return CommandResponse(
text=(
"[Validation Error] Assistant output invalid: "
f"{e}. {self._policy_kv({'code': 'semantic_output_invalid', 'severity': 'high', 'action': 'deny', 'reason': str(e)})}"
)
)
# R97: Command Firewall - Extract and Validate
import re
cmd_match = re.search(r"```(?:bash)?\s*(.*?)\s*```", response, re.DOTALL)
raw_cmd = cmd_match.group(1).strip() if cmd_match else response.strip()
# Validate through Firewall
normalized = self.command_firewall.validate_suggestion(raw_cmd)
if not normalized.is_safe:
return CommandResponse(
text=(
"[Safety Block] Assistant suggested unsafe command: "
f"{normalized.safety_reason}. {self._policy_kv(normalized.to_contract())}"
)
)
# R97: Strict /run enforcement (Remediation for Medium Severity)
# CRITICAL: keep this check. /chat run must never emit non-/run commands.
if normalized.command != "/run":
return CommandResponse(
text=(
"[Policy Block] Only /run commands are allowed in this mode. "
f"Got: {normalized.command}. "
f"{self._policy_kv({'code': 'firewall_non_run_command', 'severity': 'high', 'action': 'deny', 'reason': 'non_run_command_in_run_mode'})}"
)
)
# R97/S44: Apply Policy Overrides
# If risk was elevated, ensure --approval is present
if (
force_approval_policy
and "--approval" not in normalized.args
and "approval" not in normalized.flags
):
normalized.args.append("--approval")
final_cmd = normalized.to_string()
# Return as code block for easy copy-paste (or auto-execution UI cues)
if force_approval_policy:
return CommandResponse(
text=(
f"```\n{final_cmd}\n```\n"
f"(Policy: {self._policy_kv(decision.to_contract())})"
)
)
return CommandResponse(text=f"```\n{final_cmd}\n```")
@staticmethod
def _policy_kv(contract: Dict[str, Any]) -> str:
ordered = ("code", "severity", "action", "reason")
parts = []
for key in ordered:
value = contract.get(key)
if value is not None:
parts.append(f"{key}={value}")
return "[" + ", ".join(parts) + "]"
async def _chat_template(self, llm: LLMClient, request: str) -> CommandResponse:
"""Generate a template JSON suggestion."""
if not request:
return CommandResponse(text="Usage: /chat template <description>")
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level="N/A")
user_prompt = f"""Generate a workflow template JSON for this request:
Request: {request}
Output:
1. Suggested filename
2. Template JSON in a code block
Keep it minimal."""
response = await llm.chat(system_prompt, user_prompt)
return CommandResponse(text=response)
async def _chat_status(self, llm: LLMClient) -> CommandResponse:
"""Summarize system status using LLM."""
# Fetch status data
health = await self.client.get_health()
jobs = await self.client.get_jobs()
queue = await self.client.get_prompt_queue()
status_data = {
"health": health.get("data", {}) if health.get("ok") else "unavailable",
"jobs": jobs.get("data", {}) if jobs.get("ok") else "unavailable",
"queue": queue.get("data", {}) if queue.get("ok") else "unavailable",
}
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level="N/A")
user_prompt = CHAT_STATUS_PROMPT.format(status_data=status_data)
response = await llm.chat(system_prompt, user_prompt)
return CommandResponse(text=response)
+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.
@@ -1,19 +1,19 @@
# ADR-0001: Configuration Surface Unification (R139)
# ADR-0001: Configuration Surface Unification
- Status: Accepted
- Date: 2026-03-07
- Owners: OpenClaw maintainers
- Related roadmap item: `R139`
- Related roadmap items: `R139`, phase-2 follow-up completed 2026-03-19
## Context
OpenClaw currently has distributed configuration logic across `config.py`, `services/runtime_config.py`, and selected call sites that still read env vars directly. This increases precedence drift risk and makes behavior harder to reason about.
`R139` requires a phased, backward-compatible unification, not a single destructive rewrite.
The unification work required a phased, backward-compatible rollout rather than a single destructive rewrite.
## Decision
Adopt one authoritative layered model for runtime LLM config resolution, exposed through a unified resolver and consumed by `services/runtime_config.py` compatibility APIs.
Adopt one authoritative layered model for runtime LLM config resolution, exposed through a unified resolver and then through a single effective-config facade consumed by compatibility APIs and downstream readers.
Layer precedence (highest to lowest):
1. `env` (`OPENCLAW_*` first, `MOLTBOT_*` fallback)
@@ -32,6 +32,7 @@ Positive:
- Deterministic precedence and source attribution.
- Reduced duplicated merge logic in primary runtime paths.
- Safer phased migration with compatibility facade intact.
- Shared read seams make parity testing and future deprecation work narrower.
Trade-offs:
- Temporary coexistence of migrated and non-migrated call sites during phased rollout.
@@ -45,8 +46,13 @@ Phase 1 (`R139`):
- Migrate core LLM call sites to stop duplicating env precedence.
- Add precedence/compatibility regression tests.
Phase 2+ (future follow-ups):
- Continue migrating remaining direct env readers where they overlap with runtime config contract.
Phase 2 (completed 2026-03-19):
- Introduced `services/effective_config.py` as the supported effective-config read facade.
- Migrated remaining mixed-path readers touched by the phase onto the shared facade/compatibility shims.
- Added parity coverage so env/runtime/persisted/default precedence is asserted once at the shared seam instead of at each consumer.
Future follow-ups:
- Continue migrating any remaining direct env readers that still overlap with the runtime config contract.
- Remove obsolete adapter/shim code once migration reaches stable completion.
## Rejected Alternatives
@@ -0,0 +1,96 @@
# ADR-0002: Product Boundary And Packaging Contract
- Status: Accepted
- Date: 2026-04-23
- Owners: OpenClaw maintainers
- Related roadmap items: `R160` with follow-up execution in `R161` and `R162`
## Context
OpenClaw started as a ComfyUI-focused node pack, but the repository now also contains:
- embedded HTTP APIs and operator UI surfaces,
- a standalone Remote Admin Console route,
- connector runtime code for multiple chat platforms,
- split control-plane governance for higher-risk deployments.
That evolution made one question increasingly ambiguous: what is the supported identity of this repo/package today, and which parts are first-class versus optional attached subsystems?
Without a boundary decision, future work such as config decomposition, connector extraction, or packaging hygiene is forced to rely on repo intuition instead of an explicit contract.
## Decision
OpenClaw is defined as a **ComfyUI custom node pack** first, with two explicit first-class identities layered on top of that package:
1. **ComfyUI custom node pack**
- This is the primary distribution artifact and runtime anchor.
- `__init__.py` remains the package entrypoint loaded from `custom_nodes/`.
2. **embedded operator platform**
- In-process OpenClaw APIs, runtime/security governance, sidebar UX, and remote admin surfaces are treated as part of the shipped package, not as separate products.
3. **connector-capable control surface**
- Remote chat control is supported through the in-repo connector sidecar, but the connector remains an **optional attached subsystem**, not the primary package artifact.
## Core vs Attached Subsystems
Core to the package:
- custom node pack entrypoint and exported nodes
- embedded API/runtime governance (`/openclaw/*`, route bootstrap, control-plane policy)
- embedded operator UI surfaces (sidebar plus `/openclaw/admin`)
Optional attached subsystem:
- connector sidecar (`python -m connector`) and platform-specific adapters
This means:
- the connector is supported and intentionally in-repo,
- but the repo is **not** currently defined as a connector-first distribution,
- and the repo is **not** currently defined as a standalone generic backend independent of ComfyUI.
## Supported Topologies
Supported:
1. **embedded local/lan**
- OpenClaw runs inside the ComfyUI process as the primary package artifact.
2. **embedded package with split high-risk control plane**
- The same package stays primary, while higher-risk control surfaces are externalized according to the split-mode contract.
3. **embedded package plus optional connector sidecar**
- The connector runs as a companion process that calls the local OpenClaw APIs.
Unsupported as first-class package identities today:
1. **connector-only distribution**
2. **standalone non-ComfyUI backend package**
Those possibilities are future design questions, not current promises. Connector extraction feasibility remains explicitly deferred to `R162`.
## Consequences
Positive:
- future extraction/pruning decisions now have one explicit contract to evaluate against
- docs and contributor discussions can use the same terms instead of mixing "node pack", "server", and "sidecar" loosely
- `R161` and `R162` can narrow their scope around a known boundary instead of re-litigating product identity
Trade-offs:
- the repo still carries multiple execution surfaces inside one codebase
- connector remains intentionally attached even though it is operationally separable
- some public docs must stay careful not to imply standalone server packaging that does not exist yet
## Rejected Alternatives
1. Treat the connector as an equal primary package artifact today
- Rejected because there is no separate connector package/distribution contract yet.
2. Define OpenClaw as a generic standalone backend package
- Rejected because current runtime ownership still assumes a ComfyUI host process.
3. Keep the boundary implicit and rely on contributor convention
- Rejected because packaging and extraction follow-ups now depend on an explicit contract.
@@ -0,0 +1,89 @@
# ADR-0003: Connector Extraction Feasibility And Split-Package Seams
- Status: Accepted
- Date: 2026-04-24
- Owners: OpenClaw maintainers
- Related roadmap items: `R162` with prior boundary decision in `ADR-0002`
## Context
ADR-0002 established that the connector is an **optional attached subsystem**, not the primary published artifact of this repository. The remaining question is whether that attached subsystem should now be extracted into a separately packaged connector or separate repo.
Current code structure still mixes:
- connector platform adapters and runtime
- shared installation/token lifecycle services
- shared callback signing and replay-protection contracts
- backend delivery/result APIs that the connector calls locally
- tenant/config/auth boundaries that remain owned by the core package
That means extraction is no longer a purely packaging question. It is a shared-contract question.
## Decision
OpenClaw adopts a **no-go-for-split-now** decision for connector extraction.
Current recommendation:
1. Keep the connector **in-repo** as an **optional attached subsystem**.
2. Treat a future **optional extra package** as the only plausible next extraction target.
3. Treat both **sidecar-only distribution** and **separate repo / primary connector package** as **no-go now** options.
## Minimum Stable Seams Required Before Any Split
Any future extraction must first stabilize these seam families:
1. **installation registry and token refs**
- workspace/account binding records
- tenant-scoped token-reference ownership
- installation diagnostics and fail-closed resolution
2. **interactive callback security contract**
- signed callback envelopes
- timestamp / replay / idempotency checks
- action-policy mapping and approval downgrade semantics
3. **delivery and result bridge**
- submission/result polling contract
- callback delivery expectations
- backend result payload compatibility
4. **config/auth and tenant boundary**
- connector runtime config contract
- admin token / auth expectations
- tenant header behavior
- server-side secret/state ownership
## Why Separate Packaging Is A No-Go Now
Current blockers are concrete, not theoretical:
- shared services import connector types and connector adapters import shared services, so extraction would currently create unstable bidirectional package seams
- installation/token/state ownership still lives in shared repo services rather than a versioned connector-boundary package
- connector API/client flows still assume in-repo backend evolution instead of a versioned external backend contract
- `services/sidecar` still imports connector config/client modules directly, so even a packaging-only split would not isolate ownership yet
## Consequences
Positive:
- maintainers now have one explicit go/no-go answer instead of repeatedly re-litigating extraction
- future connector extraction work can target named seam families instead of rediscovering coupling ad hoc
- admin diagnostics can expose the same contract to future packaging or release automation
Trade-offs:
- the repo intentionally keeps connector and core package code together for now
- packaging hygiene remains a future concern rather than a solved distribution problem
- extraction pressure is deferred until shared contracts are versionable on their own
## Rejected Alternatives
1. Extract connector into a separate repo now
- Rejected because current coupling would move instability across package boundaries instead of reducing it.
2. Publish connector as a sidecar-only primary distribution now
- Rejected because current operator workflows still assume the embedded OpenClaw package/runtime remains primary.
3. Leave extraction as an undocumented future possibility
- Rejected because future packaging work needs an explicit seam map and a clear no-go baseline.
@@ -0,0 +1,27 @@
# Service Domain Packages
Bootstrap lifecycle, route registration, and effective security posture have explicit
implementation owners:
- `services/bootstrap/lifecycle.py` owns startup phase, outcome, and optional-warmup state.
- `services/bootstrap/registration.py` owns host route registration and retry coordination.
- `services/posture/effective.py` owns the immutable process security-posture snapshot.
The historical modules remain compatibility aliases:
- `services/startup_lifecycle.py`
- `services/route_bootstrap.py`
- `services/effective_security_posture.py`
Each alias maps its module name to the implementation module object. This preserves one
process singleton and keeps existing imports and patch points compatible. Do not replace
these aliases with copied re-exports: copied module globals can diverge from the state used
by implementation functions. Type-checker-only exports may describe the legacy interface,
but they must stay behind `TYPE_CHECKING` and must not become a second runtime owner.
New implementation code should import the domain-owned modules. Existing consumers may
continue to use the compatibility paths. An implementation module must never import its
compatibility alias; the repository dependency policy enforces that direction.
Package initializers are navigation-only. They must not register routes, resolve posture,
start threads, or re-export mutable process state during import.
+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.
+249 -17
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
@@ -27,7 +32,10 @@ OpenClaw now includes a platform-agnostic baseline for multi-workspace connector
- `platform`, `workspace_id`, `installation_id`, `token_refs`, `status`, `updated_at`
- token material is kept in encrypted server-side secret storage; registry and diagnostics expose token references only
- workspace resolution is fail-closed on missing/ambiguous/inactive/stale bindings
- installation diagnostics can also surface stable health states such as `ok`, `invalid_token`, `revoked`, `workspace_unbound`, and `degraded`
- interactive callback contract enforces signed envelope checks, timestamp window, payload-hash validation, replay/idempotency guardrails, and command-policy mapping (`public`/`run`/`admin`) with explicit force-approval outcomes for untrusted `run` callbacks
- connector replay handling acknowledges duplicate committed events as no-ops while allowing retryable failures before delivery commit to be retried
- text reply visibility is resolved through one connector policy for direct-message, shared-chat, thread, internal-delivery, and tool-only contexts; suppressed text is logged/diagnostic and treated as successful no-op delivery
Admin diagnostics APIs:
@@ -35,6 +43,31 @@ Admin diagnostics APIs:
- `GET /openclaw/connector/installations/{installation_id}`
- `GET /openclaw/connector/installations/resolve?platform=<platform>&workspace_id=<workspace_id>`
- `GET /openclaw/connector/installations/audit`
- `GET /openclaw/connector/extraction-contract`
Extraction diagnostics note:
- `/openclaw/connector/extraction-contract` is an admin-only structural metadata route for maintainers and operators. It returns the current packaging recommendation, candidate extraction options, seam families, and blockers, but it does **not** expose live token or installation-state details beyond the existing diagnostics routes above.
- The extraction contract also includes the static service-env SecretRef propagation policy. It is not a live environment dump and does not expose token values.
Slack multi-workspace notes:
- Slack OAuth installs bind one workspace per installation record and persist only encrypted token refs.
- OAuth callback state is single-use and replay/invalid-state callbacks fail closed instead of reusing a prior install session.
- `GET /openclaw/connector/installations/resolve?platform=slack&workspace_id=<team_id>` returns the fail-closed resolution view for a specific Slack workspace.
- `GET /openclaw/connector/installations` diagnostics may include per-install health metadata plus aggregate `health_counts`.
- Slack lifecycle events such as `tokens_revoked`, `app_uninstalled`, and rate-limit degradation update installation health so outbound replies fail closed or degrade predictably for the affected workspace.
- In multi-workspace mode, outbound replies and delayed result deliveries resolve the bot token by workspace binding and keep Slack thread context when replying back to the originating conversation.
- Slack interactive callbacks use the configured interactions path, signature verification, replay/idempotency checks, and connector policy mapping before accepting action payloads.
- Slack text replies honor the shared reply-visibility policy when context metadata is available; channel no-mention or tool-only text can be suppressed while Block Kit/action responses remain deliverable.
Feishu / Lark notes:
- Feishu bindings can be declared with a single app pair or a multi-account `OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON` manifest; each binding resolves to one normalized installation record with account/workspace identity.
- The connector supports both `feishu` and `lark` API domains through one shared binding contract, so region-specific app hosts do not require a different adapter.
- Websocket-mode Feishu deployments still host a callback route so interactive approval cards and command buttons remain available when message ingress itself is long-connection based.
- Feishu callback actions are signed, replay-guarded, tenant-aware, and deduplicated. Untrusted actors pressing run-affecting buttons are downgraded to approval flow instead of executing directly.
- Feishu text replies honor the shared reply-visibility policy when context metadata is available; group no-mention or tool-only text can be suppressed while interactive cards remain deliverable.
### Multi-tenant boundary behavior
@@ -47,13 +80,14 @@ When backend multi-tenant mode is enabled (`OPENCLAW_MULTI_TENANT_ENABLED=1`):
## Supported Platforms
- **Telegram**: Long-polling (instant response).
- **Telegram**: Long-polling (instant response), including forum topic reply context for immediate replies and delayed result delivery.
- **Discord**: Gateway WebSocket (instant response).
- **LINE**: Webhook (requires inbound HTTPS).
- **WhatsApp**: Webhook (requires inbound HTTPS).
- **WeChat Official Account**: Webhook (requires inbound HTTPS).
- **KakaoTalk (Kakao i Open Builder)**: Webhook (requires inbound HTTPS).
- **Slack (Events API)**: Webhook (requires inbound HTTPS).
- **Feishu / Lark**: Webhook or long-connection transport; interactive callbacks require inbound HTTPS for the callback route.
## Setup
@@ -70,7 +104,7 @@ Set the following environment variables (or put them in a `.env` file if you use
- `OPENCLAW_CONNECTOR_URL`: URL of your ComfyUI (default: `http://127.0.0.1:8188`)
- `OPENCLAW_CONNECTOR_DEBUG`: Set to `1` for verbose logs.
- `OPENCLAW_CONNECTOR_ADMIN_USERS`: Comma-separated list of user IDs allowed to run admin commands (for example `/stop`, approvals, schedules). Admin users are also treated as trusted senders for `/run`.
- `OPENCLAW_CONNECTOR_ADMIN_USERS`: Comma-separated list of user IDs allowed to run admin commands (for example `/stop`, `/cancel`, approvals, schedules). Admin users are also treated as trusted senders for `/run`.
- `OPENCLAW_CONNECTOR_ADMIN_TOKEN`: Admin token sent to OpenClaw (`X-OpenClaw-Admin-Token`).
- `OPENCLAW_LOG_TRUNCATE_ON_START`: Optional backend runtime flag. Set `1` to clear `openclaw.log` once at backend startup to avoid stale-history noise in UI log panels.
- `OPENCLAW_MULTI_TENANT_ENABLED`: Optional backend mode toggle. If `1`, connector diagnostics and installation resolution become tenant-scoped.
@@ -82,11 +116,19 @@ Set the following environment variables (or put them in a `.env` file if you use
- If the OpenClaw server has `OPENCLAW_ADMIN_TOKEN` configured, `OPENCLAW_CONNECTOR_ADMIN_TOKEN` must match it or admin calls return HTTP 403.
- Without `OPENCLAW_CONNECTOR_ADMIN_TOKEN`, admin command flows (`/approve`, `/reject`, `/trace`, schedules) are blocked by connector policy before upstream calls.
**SecretRef service environment behavior:**
- Service/sidecar launch helpers may preserve structured env-backed SecretRef metadata for connector credential variables such as platform bot tokens and signing secrets.
- Diagnostics show only the config path, env var name, source, status, and reason. They do not show raw token values from the installing shell.
- Raw secret strings, legacy `secretref-env:<NAME>` markers, unsupported env names, and gateway/admin auth env vars are rejected instead of being written into service metadata.
- Runtime-only auth secrets such as `OPENCLAW_CONNECTOR_ADMIN_TOKEN`, `OPENCLAW_WORKER_TOKEN`, and bridge device tokens must be provided by the runtime environment or a local secret manager rather than persisted through the connector service-env SecretRef boundary.
**Telegram:**
- `OPENCLAW_CONNECTOR_TELEGRAM_TOKEN`: Your Bot Token (from @BotFather).
- `OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_USERS`: Comma-separated list of User IDs (e.g. `123456, 789012`).
- `OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_CHATS`: Comma-separated list of Chat/Group IDs.
- Telegram forum topics are preserved when Telegram provides `message_thread_id`; command replies and delayed result delivery are sent back to the same topic. Manually configured delivery contexts must use numeric topic/thread IDs.
**Discord:**
@@ -145,8 +187,15 @@ Set the following environment variables (or put them in a `.env` file if you use
*(Requires Inbound Connectivity - see below)*
- `OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN`: Bot User OAuth Token (`xoxb-...`).
- `OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN`: Optional legacy single-workspace Bot User OAuth Token (`xoxb-...`). When Slack OAuth is configured, per-workspace tokens are resolved from the installation registry instead.
- `OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET`: Signing Secret (from App Credentials).
- `OPENCLAW_CONNECTOR_SLACK_CLIENT_ID`: OAuth client ID for multi-workspace installation flow.
- `OPENCLAW_CONNECTOR_SLACK_CLIENT_SECRET`: OAuth client secret for multi-workspace installation flow.
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_REDIRECT_URI`: Explicit OAuth callback URL. If omitted, connector derives it from `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL` + callback path.
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_INSTALL_PATH`: Local install route (default `/slack/install`).
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH`: Local callback route (default `/slack/oauth/callback`).
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_SCOPES`: Comma-separated bot scopes used for install URL generation.
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC`: TTL in seconds for single-use OAuth state tokens (default `600`, clamped to `60..3600`).
- `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS`: Comma-separated user IDs (e.g. `U12345, U67890`).
- `OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS`: Comma-separated channel IDs (e.g. `C12345`).
- `OPENCLAW_CONNECTOR_SLACK_BIND`: Host to bind (default `127.0.0.1`).
@@ -155,12 +204,46 @@ Set the following environment variables (or put them in a `.env` file if you use
- `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION`: `true` (default) to require `@Bot` mention in public channels.
- `OPENCLAW_CONNECTOR_SLACK_REPLY_IN_THREAD`: `true` (default) to reply in threads.
**Feishu / Lark:**
*(Long connection or webhook; callback ingress still requires inbound HTTPS if interactive cards are enabled)*
- `OPENCLAW_CONNECTOR_FEISHU_APP_ID`: App ID for the default Feishu/Lark binding.
- `OPENCLAW_CONNECTOR_FEISHU_APP_SECRET`: App secret for the default binding.
- `OPENCLAW_CONNECTOR_FEISHU_VERIFICATION_TOKEN`: Verification token for webhook event ingress.
- `OPENCLAW_CONNECTOR_FEISHU_ENCRYPT_KEY`: Optional encrypt key for encrypted webhook payloads.
- `OPENCLAW_CONNECTOR_FEISHU_ACCOUNT_ID`: Explicit account ID for the default binding.
- `OPENCLAW_CONNECTOR_FEISHU_DEFAULT_ACCOUNT_ID`: Fallback account ID when binding manifest entries omit one.
- `OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_ID`: Workspace or tenant identifier associated with the default binding.
- `OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_NAME`: Human-readable workspace name used in diagnostics.
- `OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON`: JSON list of account bindings for multi-account / multi-workspace setups.
- `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS`: Comma-separated trusted user IDs.
- `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_CHATS`: Comma-separated trusted chat IDs.
- `OPENCLAW_CONNECTOR_FEISHU_BIND`: Host to bind (default `127.0.0.1`).
- `OPENCLAW_CONNECTOR_FEISHU_PORT`: Port (default `8094`).
- `OPENCLAW_CONNECTOR_FEISHU_PATH`: Event ingress route (default `/feishu/events`).
- `OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH`: Interactive callback route (default `/feishu/callback`).
- `OPENCLAW_CONNECTOR_FEISHU_DOMAIN`: API domain selector (`feishu` or `lark`).
- `OPENCLAW_CONNECTOR_FEISHU_MODE`: Transport mode (`websocket` default, or `webhook`).
- `OPENCLAW_CONNECTOR_FEISHU_REQUIRE_MENTION`: Set `false` to allow commands without explicit mention in shared chats.
- `OPENCLAW_CONNECTOR_FEISHU_REPLY_IN_THREAD`: Set `false` to disable reply threading when the source chat supports it.
**Image Delivery:**
- `OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES`: Max completed images delivered per job (default `4`, clamped to `1..16`).
- `OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES`: Per-image delivery cap in bytes (default `10485760`, clamped to `65536..52428800`).
- `OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC`: Result delivery timeout in seconds (default `600`, clamped to `30..3600`).
- `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL`: Public HTTPS URL of your connector (e.g. `https://your-tunnel.example.com`). Required for sending images.
- `OPENCLAW_CONNECTOR_MEDIA_PATH`: URL path for serving temporary media (default `/media`).
- `OPENCLAW_CONNECTOR_MEDIA_TTL_SEC`: Image expiry in seconds (default `300`).
- `OPENCLAW_CONNECTOR_MEDIA_MAX_MB`: Max image size in MB (default `8`).
- `OPENCLAW_CONNECTOR_MEDIA_TTL_SEC`: Image expiry in seconds (default `300`, clamped to `60..86400`).
- `OPENCLAW_CONNECTOR_MEDIA_MAX_MB`: Max image size in MB (default `8`, clamped to `1..64`).
**Connector numeric guardrails:**
- Platform bind ports (`OPENCLAW_CONNECTOR_LINE_PORT`, `...WHATSAPP_PORT`, `...WECHAT_PORT`, `...KAKAO_PORT`, `...SLACK_PORT`, `...FEISHU_PORT`) must stay within `1..65535`; invalid or out-of-range values fall back to the platform default port.
- `OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM`: Per-user connector rate limit (default `10`, clamped to `1..600`).
- `OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM`: Per-channel connector rate limit (default `30`, clamped to `1..600`).
- `OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH`: Max accepted command text length (default `4096`, clamped to `128..32768`).
> **Note:** Media URLs are signed with a secret derived from `OPENCLAW_CONNECTOR_ADMIN_TOKEN` or a random key.
> To ensure URLs remain valid after connector restarts, **you must set `OPENCLAW_CONNECTOR_ADMIN_TOKEN`**.
@@ -168,6 +251,7 @@ Set the following environment variables (or put them in a `.env` file if you use
> WeChat currently supports text-first control. Image/media upload delivery is not implemented in phase 1.
> Kakao currently supports text-first control and quick replies. Rich media delivery is not enabled in the default Kakao webhook flow.
> Slack supports text responses and image uploads (via `files.upload` API).
> Feishu currently supports text replies plus interactive approval/command cards; richer card templates can be added on top of the same signed callback contract.
### Command authorization policy
@@ -205,6 +289,28 @@ OPENCLAW_COMMAND_ALLOW_FROM_RUN=alice_id,ops_bot_id
If a class-level `OPENCLAW_COMMAND_ALLOW_FROM_*` list is set and non-empty, only listed IDs can run that class.
### Authoritative jobs summary
`/jobs` and its `jobs` / `queue` aliases are Admin-class commands. They require both an
authorized connector admin user and a configured `OPENCLAW_CONNECTOR_ADMIN_TOKEN` before
the connector calls `GET /openclaw/jobs`.
The connector validates jobs contract version 1 before rendering any reply:
- output contains aggregate snapshot/page counts plus at most five job IDs and statuses;
- displayed job IDs are capped at 24 characters and the complete reply is capped at 1,000
characters;
- raw job records, prompts, workflows, outputs, errors, tracebacks, tenant identifiers,
and the upstream payload are never sent to the chat LLM or copied into error messages;
- HTTP 401/403 returns a fixed authorization message without fallback;
- only explicit HTTP 501 `jobs_host_contract_unsupported` or HTTP 503
`jobs_backend_unavailable` responses may fall back to a bounded coarse queue count;
- malformed, unknown-version, oversized, or inconsistent success payloads fail to a fixed
content-free message.
Public `/status` remains separate: it can summarize health and the coarse ComfyUI queue,
but it does not fetch or forward the Admin-only jobs snapshot.
### 3. Usage
#### Running the Connector
@@ -252,7 +358,7 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
```bash
OPENCLAW_CONNECTOR_WECHAT_TOKEN=replace-with-your-wechat-token
OPENCLAW_CONNECTOR_WECHAT_APP_ID=wx1234567890abcdef
OPENCLAW_CONNECTOR_WECHAT_APP_ID=replace-with-your-wechat-app-id
OPENCLAW_CONNECTOR_WECHAT_APP_SECRET=replace-with-app-secret
OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS=openid_1,openid_2
OPENCLAW_CONNECTOR_WECHAT_BIND=127.0.0.1
@@ -260,6 +366,9 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
OPENCLAW_CONNECTOR_WECHAT_PATH=/wechat/webhook
```
Use descriptive placeholders for App IDs in docs and examples. Secret scanners can
treat AppID-shaped samples as real credentials even when they are only documentation.
2. Start the connector:
```bash
@@ -295,6 +404,7 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
- The adapter validates WeChat signature on every request and applies replay/timestamp checks.
- Timestamp skew outside policy window is rejected (`403 Stale Request`).
- XML payload parsing is bounded (size/depth/field caps) and fails closed on parser budget violations.
- DTD / ENTITY declarations are rejected before parser entry; the adapter does not attempt to recover from unsafe XML payloads.
- Runtime XML security gate is fail-closed: unsafe/missing parser baseline blocks ingress startup.
- Current command surface is text-first. Unsupported message/event types are ignored with success response.
- Proactive outbound API messaging requires both `OPENCLAW_CONNECTOR_WECHAT_APP_ID` and `OPENCLAW_CONNECTOR_WECHAT_APP_SECRET`.
@@ -373,14 +483,19 @@ Slack uses the Events API webhook mode in OpenClaw. You must expose the endpoint
- `im:history` (DM support)
- `channels:history` (public channel messages)
- `groups:history` (private channel messages)
- Click **Install to Workspace**.
- Copy the **Bot User OAuth Token** (`xoxb-...`).
- For legacy single-workspace mode, click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`).
- For multi-workspace mode, configure a redirect URL and let OpenClaw handle installs through its OAuth routes.
3. **Configure connector environment variables**
```bash
OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN=xoxb-your-token
OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET=your-signing-secret
OPENCLAW_CONNECTOR_SLACK_CLIENT_ID=1234567890.1234567890
OPENCLAW_CONNECTOR_SLACK_CLIENT_SECRET=replace-with-client-secret
OPENCLAW_CONNECTOR_PUBLIC_BASE_URL=https://your-public-host
OPENCLAW_CONNECTOR_SLACK_OAUTH_INSTALL_PATH=/slack/install
OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH=/slack/oauth/callback
OPENCLAW_CONNECTOR_SLACK_INTERACTIONS_PATH=/slack/interactions
OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS=U12345,U67890
OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS=C12345
OPENCLAW_CONNECTOR_SLACK_BIND=127.0.0.1
@@ -392,16 +507,23 @@ Slack uses the Events API webhook mode in OpenClaw. You must expose the endpoint
```
Notes:
- Legacy single-workspace fallback can still set `OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN=xoxb-...`; multi-workspace mode no longer requires that token at startup if OAuth install flow is configured.
- `OPENCLAW_CONNECTOR_ADMIN_TOKEN` must match server `OPENCLAW_ADMIN_TOKEN` if server-side admin token is enabled.
- Slack ingress is fail-closed: invalid/missing signature, stale timestamp, and replayed events are rejected.
- Slack interactive callbacks use the same signing-secret verification and route actions through the connector policy layer before executing run-affecting behavior.
- OAuth callbacks also fail closed on invalid or replayed `state` values.
- External OAuth/install failures intentionally use bounded generic text; inspect connector logs and installation diagnostics for redacted detail instead of expecting raw exception text in the callback response.
4. **Start connector and expose webhook endpoint**
- Start connector: `python -m connector`
- Expose local endpoint to public HTTPS (Cloudflare Tunnel/ngrok/reverse proxy):
- local upstream: `http://127.0.0.1:8095`
- public URL: `https://<public-host>/slack/events`
- interactions URL: `https://<public-host>/slack/interactions`
- install URL: `https://<public-host>/slack/install`
- callback URL: `https://<public-host>/slack/oauth/callback`
5. **Enable Event Subscriptions**
5. **Enable Event Subscriptions and Interactivity**
- Go to **Event Subscriptions** and enable events.
- Set **Request URL** to `https://<public-host>/slack/events`.
- Slack sends `url_verification`; connector responds automatically.
@@ -410,17 +532,23 @@ Slack uses the Events API webhook mode in OpenClaw. You must expose the endpoint
- `message.channels`
- `message.groups`
- `message.im`
- Go to **Interactivity & Shortcuts** and enable interactivity.
- Set **Request URL** to `https://<public-host>/slack/interactions`.
6. **Invite and validate**
- Open `https://<public-host>/slack/install` and complete the workspace install.
- Invite the app to target channels: `/invite @YourBot`.
- In channel: `@YourBot /status` (when `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true`).
- In DM: `/help`.
- For approval or action-capable replies, press a rendered Slack button and confirm the connector logs show a signed interaction accepted or a bounded policy rejection.
- Verify connector logs show signed ingress accepted and replies delivered.
- Verify `GET /openclaw/connector/installations` shows the Slack workspace binding and health state `ok`.
- If you test uninstall/token-revoke scenarios, verify the installation health flips to `revoked` or `invalid_token` and that subsequent replies for that workspace fail closed until reinstalled.
7. **Security checklist before production**
- Keep `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS`/`OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS` restricted.
- Keep `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true` unless intentionally running command-style channels.
- Rotate Slack bot token/signing secret on incident response.
- Rotate Slack signing secret and OAuth client secret on incident response.
- Do not expose connector without HTTPS termination.
#### Slack Socket Mode Setup (Optional)
@@ -437,9 +565,13 @@ Use Socket Mode when you cannot expose a public HTTPS webhook endpoint.
```bash
OPENCLAW_CONNECTOR_SLACK_MODE=socket
OPENCLAW_CONNECTOR_SLACK_APP_TOKEN=xapp-your-token
# Bot token + signing secret are still required for parity and safety checks
OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN=xoxb-your-token
# Signing secret remains required for parity/security checks.
OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET=your-signing-secret
# Either configure legacy single-workspace token...
OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN=xoxb-your-token
# ...or configure multi-workspace OAuth install flow:
OPENCLAW_CONNECTOR_SLACK_CLIENT_ID=1234567890.1234567890
OPENCLAW_CONNECTOR_SLACK_CLIENT_SECRET=replace-with-client-secret
```
3. **Start connector**
@@ -449,6 +581,73 @@ Use Socket Mode when you cannot expose a public HTTPS webhook endpoint.
Notes:
- Socket Mode uses outbound WebSocket, so `OPENCLAW_CONNECTOR_SLACK_BIND`, `OPENCLAW_CONNECTOR_SLACK_PORT`, and `OPENCLAW_CONNECTOR_SLACK_PATH` are ignored in this mode.
- Startup is fail-closed if `OPENCLAW_CONNECTOR_SLACK_APP_TOKEN` is missing or does not start with `xapp-`.
- In multi-workspace mode, outbound replies still resolve the workspace-specific bot token from the installation registry even though the WebSocket connection itself uses the app-level token.
#### Feishu / Lark Setup (Detailed)
Feishu support can run in either long-connection (`websocket`) mode or webhook mode. Long-connection is usually the simpler default for message ingress, but interactive cards still need a reachable callback route if you want approval buttons and other signed actions.
1. **Create the Feishu or Lark app**
- Create a bot app in the Feishu or Lark developer console.
- Record the `App ID` and `App Secret`.
- If you want webhook ingress, also configure the event subscription verification token.
- If encrypted event delivery is enabled, record the encrypt key as well.
2. **Choose transport mode**
- `OPENCLAW_CONNECTOR_FEISHU_MODE=websocket`
- Uses long connection for message ingress.
- Recommended when you do not want to expose the event route publicly.
- `OPENCLAW_CONNECTOR_FEISHU_MODE=webhook`
- Uses HTTPS webhook delivery for messages.
- Requires a public HTTPS route for `OPENCLAW_CONNECTOR_FEISHU_PATH`.
3. **Configure the default binding**
```bash
OPENCLAW_CONNECTOR_FEISHU_APP_ID=cli_xxx
OPENCLAW_CONNECTOR_FEISHU_APP_SECRET=sec_xxx
OPENCLAW_CONNECTOR_FEISHU_ACCOUNT_ID=acct-default
OPENCLAW_CONNECTOR_FEISHU_DEFAULT_ACCOUNT_ID=acct-default
OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_ID=tenant-alpha
OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_NAME="Alpha Workspace"
OPENCLAW_CONNECTOR_FEISHU_DOMAIN=feishu
OPENCLAW_CONNECTOR_FEISHU_MODE=websocket
OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS=ou_xxx,ou_yyy
OPENCLAW_CONNECTOR_FEISHU_ALLOWED_CHATS=oc_xxx,oc_yyy
```
4. **Optional: multi-account binding manifest**
- Use `OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON` when one connector runtime should host more than one Feishu/Lark app or workspace binding.
- Each entry may include:
- `account_id`
- `app_id`
- `app_secret`
- `workspace_id`
- `workspace_name`
- `verification_token`
- `encrypt_key`
- `domain`
- `mode`
5. **Configure interactive callback ingress**
- Set `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL` to your public HTTPS origin.
- Expose `OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH` (default `/feishu/callback`) through your reverse proxy or tunnel.
- In long-connection mode this callback route is still required for interactive approval cards; message ingress transport does not remove callback security requirements.
6. **Start connector**
- `python -m connector`
- Expect logs showing the chosen Feishu mode and callback/event route bindings.
7. **Verify runtime behavior**
- Run `/status` from an allowlisted Feishu/Lark user.
- Run `/approvals` and confirm the reply renders approval buttons as an interactive card.
- Click `Approve` or `Reject` on a test approval and verify the callback succeeds once, then duplicate clicks are deduped.
Notes:
- `OPENCLAW_CONNECTOR_FEISHU_DOMAIN=lark` switches outbound API host behavior without changing the rest of the connector contract.
- Untrusted users can still see bounded command responses, but run-affecting interactive actions are downgraded to approval flow instead of auto-executing.
- Callback signing secrets are resolved from the bound Feishu installation record; diagnostics expose binding state, not raw secret material.
- Callback/event wrapper failures intentionally return bounded external error codes; inspect logs and installation diagnostics for redacted detail instead of expecting stack traces in callback responses.
## Commands
@@ -461,7 +660,8 @@ Notes:
| `/history <id>` | View details of a finished job. |
| `/help` | Show available commands. |
| `/run <template> [k=v] [--approval]` | Submit a job. Use `--approval` to request approval gate instead of creating job immediately. |
| `/stop` | **Global Interrupt**: Stop all running generations. |
| `/stop [job_id ...]` | Stop jobs. With no job IDs, sends an explicit global interrupt. With one or more IDs, requests targeted job cancellation through ComfyUI's jobs API; older single-job hosts may fall back to targeted interrupt. |
| `/cancel [job_id ...]`, `/interrupt [job_id ...]` | Aliases for `/stop` with the same targeted or global behavior. |
**Admin Only:**
*(Requires User ID in `OPENCLAW_CONNECTOR_ADMIN_USERS`)*
@@ -508,6 +708,14 @@ Notes:
- Sender is not in `OPENCLAW_CONNECTOR_ADMIN_USERS`.
- Fix: Add ID to `.env` and restart connector.
- **No visible chat reply after a command**:
- The command may have completed in a context where text-only replies are intentionally suppressed, such as internal delivery, tool-only handling, or a shared chat/channel without an active mention.
- Fix: check connector logs and job/approval state. Approval cards and action buttons should still be delivered when the action requires visible operator input.
- **Duplicate platform event is acknowledged but not executed again**:
- The connector has already committed the action and treats the retry/replay as a successful no-op.
- Fix: check the original event, job, or approval record instead of resending the same action payload. Retry only failures that happened before delivery/action commit.
- **HTTP 403 (Admin Token)**:
- Connector has the right user allowlist, but the upstream OpenClaw server rejected the Admin Token.
- Fix: Ensure `OPENCLAW_CONNECTOR_ADMIN_TOKEN` matches the server's `OPENCLAW_ADMIN_TOKEN`.
@@ -531,3 +739,27 @@ Notes:
- **Slack commands ignored in channels**:
- `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true` and message does not mention the bot.
- Fix: mention bot explicitly (`@Bot /status`) or set `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=false` if policy allows.
- **Slack OAuth callback shows only a generic install failure**:
- This is expected on current builds; callback responses intentionally suppress raw exception text.
- Fix: inspect connector logs plus `GET /openclaw/connector/installations` / `/resolve` diagnostics to find the redacted root cause.
- **I need to know whether the connector is supported as a separate package/repo yet**:
- Current builds intentionally keep the connector in-repo as an optional attached subsystem.
- Fix: review `GET /openclaw/connector/extraction-contract` or [ADR-0003](adr/ADR-0003-connector-extraction-feasibility-and-seams.md) for the current no-split recommendation and the seam blockers that still need to be versioned first.
- **Feishu callback buttons fail with signature or stale-action errors**:
- Callback route is not using the same bound app secret, request arrived too late, or the button payload was replayed.
- Fix: verify binding diagnostics, public callback route, connector clock, and that the same action is not being resent by proxy/retry middleware.
- **Feishu long-connection messages work but card actions do nothing**:
- `OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH` is not exposed publicly, or the callback URL is not routed to the connector bind host/port.
- Fix: expose the callback route over HTTPS even when `OPENCLAW_CONNECTOR_FEISHU_MODE=websocket`.
- **Feishu `/run` action becomes approval instead of executing immediately**:
- Callback actor is untrusted under current allowlist/policy mapping.
- Fix: add the user/chat to `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS` or `_ALLOWED_CHATS`, or keep the approval downgrade as the intended posture.
- **Feishu callback returns a bounded code such as `callback_rejected` or `event_rejected`**:
- This is expected on current builds; callback wrappers intentionally avoid echoing raw exception detail to the caller.
- Fix: inspect connector logs and installation/binding diagnostics for the redacted failure context.
+1 -1
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.
+71 -9
View File
@@ -4,12 +4,46 @@ This document summarizes the current OpenClaw sidebar UI structure and how to ve
## UI Structure
- Entry: `web/openclaw.js` registers the extension and sidebar tab.
- Shell: `web/openclaw_ui.js` renders the header, tab bar, and tab panes.
- Tabs: `web/openclaw_tabs.js` manages tab registration, rendering, and remount safety.
- API: `web/openclaw_api.js` provides a normalized fetch wrapper and OpenClaw endpoints (legacy Moltbot endpoints still work).
- Entry: `web/openclaw.js` registers the extension; host sidebar registration is routed through `web/openclaw_sidebar_registration.js` so current ComfyUI sidebar-store hosts and older frontend facade hosts share one compatibility path.
- Shell: `web/openclaw_ui.js` now acts as the composition root for the sidebar shell and public singleton exports.
- Actions: `web/openclaw_actions.js` owns submit/cancel/retry wiring and guarded action routing for the shell.
- Queue monitor: `web/openclaw_queue_monitor.js` owns queue polling lifecycle and transient banner/status updates used by the shell.
- Event/task polling: admin-console and model/task views consume deterministic delta metadata (`effective_since_seq`, `next_since_seq`, reset/truncation hints) instead of assuming every refresh is a full snapshot.
- Notification center: `web/openclaw_notification_center.js` owns persistent in-app notification storage, dedupe, acknowledge, dismiss, and deep-link behavior.
- Banner runtime: `web/openclaw_banner_manager.js` owns transient banner state and shell-facing banner transitions.
- Tabs: `web/openclaw_tabs.js` manages tab registration, rendering, remount safety, and optional
pending-render disposal before switching panes.
- API: `web/openclaw_api.js` owns normalized transport, session, timeout, retry, and singleton
behavior. Config, generation, resource, model, and event endpoint families live in focused
`web/openclaw_api_*.js` owner modules behind the same public API (legacy Moltbot endpoints still
work).
- Settings: `web/tabs/settings_tab.js` composes status, LLM, secrets, logs, and DOM owner modules.
Its lifecycle owner invalidates stale async generations and clears scheduled work when the tab
is disposed, preventing late responses from mutating a remounted pane.
- Host surface: `web/openclaw_host_surface.js` resolves standalone frontend, legacy fixed-bundle
Desktop, and current managed-install Comfy-Desktop separately, then stamps explicit metadata so
generation-specific behavior stays testable.
- Output refs: `web/openclaw_asset_refs.js` normalizes classic history refs, optional `asset_hash`/`hash` refs when host metadata is present, and current previewable media groups (`images`, `video`, `audio`, `3d`, bounded inline or file-backed `text`) onto one media-aware contract. Allowlisted text files under the host `files` key stay on same-origin `/view` and use a 5-second, 64-KiB streaming, strict textual-MIME/UTF-8 reader with a 4,096-character display cap. HDR `.exr` / `.hdr` image refs show source-preview fallback links instead of normal thumbnails, text reaches the DOM only as literal text, and asset-service-only refs remain explicit fallback states instead of silently auto-fetching `/api/assets`.
- Styles: `web/openclaw.css` provides shared design tokens and component classes.
- Errors: `web/openclaw_utils.js` provides `showError()` / `clearError()` helpers.
- Errors and compatibility helpers: `web/openclaw_utils.js` provides `showError()` / `clearError()` plus runtime legacy-class alias helpers used to keep canonical `openclaw-*` markup compatible with existing `moltbot-*` selectors.
Refactor note:
- `web/openclaw_ui.js` should stay focused on shell composition, shared singleton ownership, and exports.
- New shell behaviors should prefer the extracted action/queue modules unless they truly belong to top-level shell assembly.
- New API methods should be added to the matching route-family owner rather than growing the
transport facade; keep `openclawApi` as the only shared singleton.
- New Settings behavior should stay in the matching status/LLM/secrets/logs/DOM owner and use the
shared generation lifecycle for delayed or asynchronous UI changes.
- New tab markup should use canonical `openclaw-*` classes; legacy `moltbot-*` aliases are generated centrally at runtime instead of being duplicated in each template.
- New host sidebar registration changes should stay in `web/openclaw_sidebar_registration.js` rather than duplicating ComfyUI frontend API detection inside the extension entrypoint.
- Host-sensitive behaviors should consume the shared host-surface helper rather than inferring desktop vs standalone frontend from ad-hoc globals.
- Graph/widget flows should preserve host-shaped promoted-widget source metadata and non-numeric node IDs, including Parameter Lab replay/apply paths.
- Parameter Lab flows should keep scalar/count/byte validation aligned with the backend policy and
use exact request-ID queue receipts; they must not infer prompt ownership from a globally recent
prompt when the host request boundary is unsupported or ambiguous.
- Output preview flows should consume the shared asset-ref normalizer rather than assembling `/view` URLs independently in each tab, treating non-image or HDR media as broken images, or silently widening runtime behavior to direct `/api/assets` fetches.
- Explorer/preflight consumers should treat inventory diagnostics as snapshot-first and surface `snapshot_ts`, `scan_state`, `stale`, and `last_error` instead of blocking the UI on full rescans.
- Explorer/preflight rendering should keep actionable missing-node/model failures separate from suppressed inactive-branch findings returned by the backend.
## Feature Gating (Capabilities)
@@ -25,10 +59,25 @@ This document summarizes the current OpenClaw sidebar UI structure and how to ve
If capabilities are unavailable, the full tab set is registered to surface actionable errors (instead of “missing tabs”).
If `assist_streaming` is unavailable or the stream transport degrades, Planner/Refiner automatically fall back to the existing non-stream request path.
## Host-Surface Contract
- OpenClaw treats standalone `ComfyUI_frontend`, legacy fixed-bundle `desktop`, and current
managed-install `comfy_desktop` as distinct frontend host surfaces.
- The sidebar stamps its resolved host surface and refreshed host-reference metadata at mount time so desktop bundle drift is explicit in diagnostics and regression tests.
- The standalone Remote Admin Console stamps the same host-surface metadata on its document root,
including legacy Desktop `0.9.4`, fixed core `0.22.3`, embedded frontend `1.43.18`, and lagging
parity relative to standalone frontend `1.49.1`. It also exposes current Comfy-Desktop
`1.0.32-rc.1` with `installation_specific` hosted versions. Presence of
`window.__comfyDesktop2` identifies that host generation only; it does not authorize privileged
capability calls or inspect bridge members.
- Graph/widget compatibility code should route through shared host helpers to keep nested-subgraph and promoted-widget behavior aligned with current upstream host semantics, including preserving source metadata and string-shaped node IDs.
## Standalone Remote Admin Console
- Entry route: `GET /openclaw/admin` (legacy `GET /moltbot/admin` still works).
- HTML shell: `web/admin_console.html`
- Runtime app module: `web/admin_console_app.js`
- Runtime API module: `web/admin_console_api.js`
- Purpose: mobile-friendly standalone operations UI for non-sidebar workflows.
- Security model:
- The page itself is a static shell and can render without authentication.
@@ -36,6 +85,7 @@ If `assist_streaming` is unavailable or the stream transport degrades, Planner/R
- Runtime behaviors:
- Dashboard summary + health/config snapshots
- Jobs/Events polling + SSE stream connect/fallback
- Delta-aware polling cursors for events and managed-task refresh loops
- Approvals/Schedules/Triggers control actions
- Config read/partial write and diagnostics access
- Quick Actions (retry/refresh/drill) remain backend-authorized
@@ -47,19 +97,31 @@ If `assist_streaming` is unavailable or the stream transport degrades, Planner/R
3. Clear token and verify write actions fail with explicit auth/policy errors.
4. Connect SSE, then trigger a run; verify event stream updates and fallback polling still works.
5. Confirm there is no blank/overflow breakage on narrow mobile widths.
6. If you are validating desktop parity, confirm the page root resolves the expected host-surface metadata instead of silently defaulting to standalone assumptions.
## Quick Manual Checks
1. Open ComfyUI and confirm OpenClaw appears in the sidebar.
2. Switch between all visible tabs multiple times (and reopen the sidebar if possible) and ensure panes do not go blank.
3. Planner: click **Plan Generation** with minimal input and confirm either live preview/stage updates appear (when streaming is supported) or a readable fallback result/error appears.
4. Refiner: click **Refine Prompts** (with or without image) and confirm either live preview/stage updates appear (when streaming is supported) or a readable fallback result/error appears.
5. Library/Approvals: if backend endpoints are not enabled, confirm the UI shows a clear error state (no crashes).
6. If you simulate/fake a stream failure in dev tools, confirm Planner/Refiner retry through the classic non-stream path without duplicate submits or broken loading state.
3. Confirm the sidebar host-surface metadata resolves correctly for the current environment instead of defaulting silently.
4. Planner: click **Plan Generation** with minimal input and confirm either live preview/stage updates appear (when streaming is supported) or a readable fallback result/error appears.
5. Refiner: click **Refine Prompts** (with or without image) and confirm either live preview/stage updates appear (when streaming is supported) or a readable fallback result/error appears.
6. Jobs: verify output previews still resolve for classic history refs, optional hash-backed refs when host metadata is present, and supported media-aware refs (`images`, `video`, `audio`, `3d`, bounded inline/file-backed `text`); allowlisted text files should show literal bounded content or a deterministic source-link fallback, HDR `.exr` / `.hdr` image refs should render as explicit source-preview fallback links, asset-service-only refs should stay explicit as a bounded fallback state, and repeated polls should not duplicate rows after reconnect/resume.
7. Parameter Lab: verify bounded scalar sweep/compare values queue with an exact request receipt,
and verify unsupported structured values or unknown host queue-event shapes fail visibly without
assigning another prompt's lifecycle.
8. Explorer: verify preflight inventory can show `refreshing` / `stale` / `error` state without freezing the tab while deep scan work continues, and verify inactive-branch suppressed findings render separately from actionable failures.
9. Library/Approvals: if backend endpoints are not enabled, confirm the UI shows a clear error state (no crashes).
10. If you simulate/fake a stream failure in dev tools, confirm Planner/Refiner retry through the classic non-stream path without duplicate submits or broken loading state.
## E2E (Playwright) Checks
- Run: `npm test`
- Tests live in: `tests/e2e/specs/`
- Harness: `tests/e2e/test-harness.html` (mocks ComfyUI core + basic OpenClaw API calls)
- Harness bootstrap now retries one transient `openclaw.js` module-fetch failure before surfacing a hard load error, so CI-only first-request flakiness does not get misreported as a permanent sidebar failure.
- Web helper/self-test harness: `web/tests/e2e-harness.html` (includes frontend helper and wrapper idempotence checks)
- Frontend unit contracts also freeze API exports/signatures, singleton identity, Settings DOM
identities, owner direction, and stale-generation disposal across the decomposed modules.
- Desktop host parity lane: `tests/e2e/specs/desktop_host_parity.spec.js` verifies standalone vs desktop host evidence separately and covers both sidebar and Remote Admin host-sensitive behavior under the shared harness shims.
- When investigating suspected harness flakes locally, prefer `npm run test:stress -- <spec>` so the same shared bootstrap path is exercised repeatedly without changing the default `npm test` contract.
+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.
+203 -18
View File
@@ -1,8 +1,8 @@
openapi: "3.0.3"
info:
title: "ComfyUI-OpenClaw API"
version: "1.0.5"
description: "Generated from docs/release/api_contract.md (R66 baseline)."
version: "1.0.15"
description: "Generated from docs/release/api_contract.md."
servers:
- url: "/openclaw"
description: "Direct OpenClaw prefix"
@@ -22,7 +22,7 @@ paths:
/health:
get:
operationId: "get_health"
summary: "System status, uptime, and dependencies."
summary: "System status, uptime, dependencies, and startup lifecycle diagnostics."
responses:
200:
description: "OK"
@@ -64,7 +64,7 @@ paths:
description: "OK"
x-openclaw-auth: "Observability"
x-openclaw-section: "1.1 Core Observability & System"
description: "Trace payloads redact provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Trace payloads redact provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/trace/{id}"
x-openclaw-auth-tier: "observability"
security:
@@ -87,7 +87,7 @@ paths:
description: "OK"
x-openclaw-auth: "Observability"
x-openclaw-section: "1.1 Core Observability & System"
description: "Event payloads redact provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Event payloads redact provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/events"
x-openclaw-auth-tier: "observability"
security:
@@ -105,7 +105,7 @@ paths:
description: "OK"
x-openclaw-auth: "Observability"
x-openclaw-section: "1.1 Core Observability & System"
description: "SSE event payloads redact provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "SSE event payloads redact provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/events/stream"
x-openclaw-auth-tier: "observability"
security:
@@ -145,16 +145,44 @@ paths:
/jobs:
get:
operationId: "get_jobs"
summary: "List recent jobs (Stub/Not Implemented)."
summary: "List recent jobs through the versioned bounded in-process jobs read model."
responses:
200:
description: "OK"
x-openclaw-auth: "Observability"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.1 Core Observability & System"
x-openclaw-legacy-path: "/moltbot/jobs"
x-openclaw-auth-tier: "observability"
x-openclaw-auth-tier: "admin"
security:
- OpenClawObservabilityToken:
- OpenClawAdminToken:
[]
/preflight:
post:
operationId: "post_preflight"
summary: "Analyze a workflow or API prompt payload for missing nodes/models and portability diagnostics."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.1 Core Observability & System"
x-openclaw-legacy-path: "/moltbot/preflight"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/preflight/inventory:
get:
operationId: "get_preflight_inventory"
summary: "Snapshot-first inventory of nodes/models for operator diagnostics, including refresh-state metadata."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.1 Core Observability & System"
x-openclaw-legacy-path: "/moltbot/preflight/inventory"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/webhook:
post:
@@ -235,7 +263,7 @@ paths:
description: "OK"
x-openclaw-auth: "Admin/Local"
x-openclaw-section: "1.3 Assist, LLM & Chat"
description: "Structured assist payloads preserve final answer fields while redacting provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Structured assist payloads preserve final answer fields while redacting provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/assist/planner"
x-openclaw-auth-tier: "admin"
security:
@@ -253,7 +281,7 @@ paths:
description: "OK"
x-openclaw-auth: "Admin/Local"
x-openclaw-section: "1.3 Assist, LLM & Chat"
description: "Structured assist payloads preserve final answer fields while redacting provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Structured assist payloads preserve final answer fields while redacting provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/assist/refiner"
x-openclaw-auth-tier: "admin"
security:
@@ -271,7 +299,7 @@ paths:
description: "OK"
x-openclaw-auth: "Admin/Local"
x-openclaw-section: "1.3 Assist, LLM & Chat"
description: "Streaming assist final payloads redact provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Streaming assist final payloads redact provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/assist/planner/stream"
x-openclaw-auth-tier: "admin"
security:
@@ -290,7 +318,7 @@ paths:
description: "OK"
x-openclaw-auth: "Admin/Local"
x-openclaw-section: "1.3 Assist, LLM & Chat"
description: "Streaming assist final payloads redact provider reasoning/thinking fields by default. Privileged debug reveal is local-only and opt-in."
description: "Streaming assist final payloads redact provider reasoning/thinking fields and marked internal maintenance/helper content by default. Privileged reasoning reveal is local-only and opt-in."
x-openclaw-legacy-path: "/moltbot/assist/refiner/stream"
x-openclaw-auth-tier: "admin"
security:
@@ -362,6 +390,129 @@ paths:
security:
- OpenClawAdminToken:
[]
/connector/extraction-contract:
get:
operationId: "get_connector_extraction_contract"
summary: "Get the machine-readable connector extraction recommendation, seam families, static service-env SecretRef propagation policy, and current blockers."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3B Connector Installation Diagnostics"
x-openclaw-legacy-path: "/moltbot/connector/extraction-contract"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/models/search:
get:
operationId: "get_models_search"
summary: "Search normalized model entries across managed installs and catalog sources."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C Model Management & Installations"
x-openclaw-legacy-path: "/moltbot/models/search"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/models/downloads:
post:
operationId: "post_models_downloads"
summary: "Create a managed model download task with progress/cancel lifecycle."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C Model Management & Installations"
x-openclaw-legacy-path: "/moltbot/models/downloads"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
get:
operationId: "get_models_downloads"
summary: "List model download tasks with snapshot or delta cursor semantics (`since_seq`)."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C Model Management & Installations"
x-openclaw-legacy-path: "/moltbot/models/downloads"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/models/downloads/{task_id}:
get:
operationId: "get_models_downloads_task_id"
summary: "Get one model download task by id."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C Model Management & Installations"
x-openclaw-legacy-path: "/moltbot/models/downloads/{task_id}"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
parameters:
- name: "task_id"
in: "path"
required: true
schema:
type: "string"
/models/downloads/{task_id}/cancel:
post:
operationId: "post_models_downloads_task_id_cancel"
summary: "Cancel a queued or running model download task."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C Model Management & Installations"
x-openclaw-legacy-path: "/moltbot/models/downloads/{task_id}/cancel"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
parameters:
- name: "task_id"
in: "path"
required: true
schema:
type: "string"
/models/import:
post:
operationId: "post_models_import"
summary: "Import a completed managed download into the bounded install root after provenance and hash verification."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C Model Management & Installations"
x-openclaw-legacy-path: "/moltbot/models/import"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/models/installations:
get:
operationId: "get_models_installations"
summary: "List managed model installations."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C Model Management & Installations"
x-openclaw-legacy-path: "/moltbot/models/installations"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/chat:
post:
operationId: "post_chat"
@@ -370,7 +521,7 @@ paths:
200:
description: "OK"
x-openclaw-auth: "Admin/Local"
x-openclaw-section: "1.3C LLM Management & Chat"
x-openclaw-section: "1.3D LLM Management & Chat"
x-openclaw-legacy-path: "/moltbot/llm/chat"
x-openclaw-auth-tier: "admin"
security:
@@ -384,7 +535,7 @@ paths:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C LLM Management & Chat"
x-openclaw-section: "1.3D LLM Management & Chat"
x-openclaw-legacy-path: "/moltbot/llm/test"
x-openclaw-auth-tier: "admin"
security:
@@ -393,12 +544,12 @@ paths:
/models:
get:
operationId: "get_models"
summary: "List available models from configured provider. Request-time fetch uses the same SSRF contract as saved `base_url` validation, including the explicit insecure override for private-IP/HTTP targets."
summary: "List available models from configured provider. Request-time fetch uses the same SSRF contract as saved `base_url` validation, including scoped private-network allowance and the explicit insecure override for private-IP/HTTP targets."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin"
x-openclaw-section: "1.3C LLM Management & Chat"
x-openclaw-section: "1.3D LLM Management & Chat"
x-openclaw-legacy-path: "/moltbot/llm/models"
x-openclaw-auth-tier: "admin"
security:
@@ -668,6 +819,40 @@ paths:
required: true
schema:
type: "string"
/tools:
get:
operationId: "get_tools"
summary: "List allowed external tools and their declared sandbox metadata."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin Token Required"
x-openclaw-section: "1.5A External Tools"
x-openclaw-legacy-path: "/moltbot/tools"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
/tools/{name}/run:
post:
operationId: "post_tools_name_run"
summary: "Execute a named allowlisted external tool with validated arguments."
responses:
200:
description: "OK"
x-openclaw-auth: "Admin Token Required"
x-openclaw-section: "1.5A External Tools"
x-openclaw-legacy-path: "/moltbot/tools/{name}/run"
x-openclaw-auth-tier: "admin"
security:
- OpenClawAdminToken:
[]
parameters:
- name: "name"
in: "path"
required: true
schema:
type: "string"
/bridge/health:
get:
operationId: "get_bridge_health"
+132 -11
View File
@@ -1,12 +1,12 @@
# OpenClaw API Contract (v1)
> **Status**: normative
> **Version**: 1.0.5
> **Date**: 2026-03-12
> **Version**: 1.0.15
> **Date**: 2026-07-10
This document defines the public API contract for OpenClaw. It serves as the authoritative baseline for client compatibility and breaking change policies.
## 0. Tenant Boundary Context (S49)
## 0. Tenant Boundary Context
Default behavior remains single-tenant compatible (`tenant_id=default`).
@@ -37,7 +37,7 @@ All new integrations should use the `/openclaw/` prefix. Use of `/moltbot/` is d
| Method | Path | Legacy Path | Auth | Description |
| :--- | :--- | :--- | :--- | :--- |
| `GET` | `/health` | `/moltbot/health` | None | System status, uptime, and dependencies. |
| `GET` | `/health` | `/moltbot/health` | None | System status, uptime, dependencies, and startup lifecycle diagnostics. |
| `GET` | `/capabilities` | `/moltbot/capabilities` | None | Feature flags and supported extensions (includes optional UX/runtime features such as assist streaming support). |
| `GET` | `/logs/tail` | `/moltbot/logs/tail` | Observability | Tail recent log lines (rate-limited). |
| `GET` | `/trace/{prompt_id}` | `/moltbot/trace/{id}` | Observability | Get execution trace by prompt ID. |
@@ -45,11 +45,32 @@ All new integrations should use the `/openclaw/` prefix. Use of `/moltbot/` is d
| `GET` | `/events/stream` | `/moltbot/events/stream` | Observability | SSE stream of job lifecycle events with resume support. |
| `GET` | `/config` | `/moltbot/config` | Observability | Read-only view of sanitized provider config. |
| `PUT` | `/config` | `/moltbot/config` | Admin | Update system configuration. |
| `GET` | `/jobs` | `/moltbot/jobs` | Observability | List recent jobs (Stub/Not Implemented). |
| `GET` | `/jobs` | `/moltbot/jobs` | Admin | List recent jobs through the versioned bounded in-process jobs read model. |
| `POST` | `/preflight` | `/moltbot/preflight` | Admin | Analyze a workflow or API prompt payload for missing nodes/models and portability diagnostics. |
| `GET` | `/preflight/inventory` | `/moltbot/preflight/inventory` | Admin | Snapshot-first inventory of nodes/models for operator diagnostics, including refresh-state metadata. |
Jobs list contract:
- `GET /openclaw/jobs` and its browser/legacy aliases are Admin-only and return
`contract_version: 1` from the bounded in-process ComfyUI jobs adapter.
- Supported query fields are `status`, `workflow_id`, `sort_by`, `sort_order`, `limit`,
and `offset`. Status values are `pending`, `in_progress`, `completed`, `failed`, and
`cancelled`; sorting supports `created_at` or `execution_duration` with `asc`/`desc`.
- The default/maximum page sizes are 50/200 and the source/offset window is capped at
10,000. Successful responses contain only `ok`, `contract_version`, `jobs`,
`pagination`, `source`, and `scan` at the top level.
- Job summaries allow only `id`, `status`, bounded priority/timestamps, `outputs_count`,
and bounded `workflow_id`. `preview_output` is never included, and list responses never
include raw prompts, workflows, execution errors, tracebacks, current inputs/outputs,
tenant/client/trace identifiers, reasoning, or internal content.
- An authoritative empty snapshot is HTTP 200 with `jobs: []`. Missing host helpers use
HTTP 501 `jobs_host_contract_unsupported`; unavailable or malformed snapshots use HTTP
503 `jobs_backend_unavailable`. These failures are never converted into empty success.
Reasoning-content redaction contract:
- operator-visible trace and events payloads strip provider reasoning / thinking-like fields by default
- operator-visible trace and events payloads strip provider reasoning / thinking-like fields and explicitly marked internal maintenance/helper content by default
- audit event payload/meta fields follow the same internal-content and reasoning-like redaction boundary before retention
- privileged reveal is opt-in only and requires:
- request header `X-OpenClaw-Debug-Reveal-Reasoning: 1` or query `debug_reasoning=1`
- server-side enablement via `OPENCLAW_DEBUG_REASONING_REVEAL=1`
@@ -58,6 +79,33 @@ Reasoning-content redaction contract:
- non-hardened runtime profile
- deployment profile `local` or `lan`
- clients MUST treat reveal behavior as debug-only and MUST NOT depend on reasoning payload presence in normal operation
- explicitly marked internal maintenance/helper content has no public or debug reveal path
Inventory diagnostics contract:
- `/preflight/inventory` is snapshot-first and may return before deep scan work finishes
- clients SHOULD treat `snapshot_ts`, `scan_state`, `stale`, and `last_error` as first-class diagnostics fields rather than assuming a blocking full-rescan model
Preflight workflow diagnostics contract:
- `POST /openclaw/preflight` accepts both API prompt dictionaries and frontend workflow JSON when supplied by operator tooling
- response summaries distinguish actionable `missing_nodes` / `missing_models` from `suppressed_missing_nodes` / `suppressed_missing_models`
- suppressed findings represent muted or bypassed root nodes or subgraph branches when the submitted workflow shape provides enough frontend ancestry metadata
- clients SHOULD display suppressed findings as informational context rather than blocking workflow readiness
History and output-ref contract:
- history/output consumers SHOULD treat the normalized output-ref contract as media-aware
- current previewable output groups are `images`, `video`, `audio`, `3d`, bounded inline `text`, and allowlisted file-backed text refs from the host `files` key
- file-like refs that can be represented through `/view` remain on the bounded `/history` + `/view` preview path
- file-backed text admission is limited to `.txt`, `.md`, `.markdown`, `.json`, `.csv`, `.yaml`, `.yml`, `.xml`, and `.log`; clients MUST build the URL from normalized filename/subfolder/type fields rather than trust a history-provided URL
- browser text previews MUST remain same-origin `/view` GET requests, reject redirects and active/ambiguous MIME types, use strict UTF-8, stream at most 64 KiB within 5 seconds, display at most 4,096 characters, and degrade to a source link when safe streaming is unavailable
- fetched text MUST be inserted as literal text; HTML, Markdown, SVG/XML, ANSI, or script interpretation is not part of this contract
- `asset_hash` / `hash` metadata is optional because current ComfyUI host asset hashing is opt-in through `--enable-asset-hashing`; clients MUST NOT require hashes for normal filename-backed previews
- refs with `asset_hash` or `hash` values, when host metadata provides them, preview through `/view?filename=blake3:...`
- refs that only expose upstream asset-service identifiers remain explicit `asset_api_required` states; clients MUST NOT silently infer direct `/api/assets` fetching from that marker
- HDR `.exr` / `.hdr` image refs remain image refs but should be rendered as explicit source-preview fallback links unless the client implements a safe HDR-specific viewer
- legacy callback/image-only consumers may continue using image-only extraction paths; non-image media refs should be rendered as explicit fallback/link/text surfaces unless the client implements a safe media-specific renderer
### 1.2 Webhooks & Triggers
@@ -70,6 +118,13 @@ Reasoning-content redaction contract:
| `POST` | `/webhook/validate` | `/moltbot/webhook/validate` | Webhook Secret | Dry-run validation of webhook payload. |
| `POST` | `/triggers/fire` | `/moltbot/triggers/fire` | Admin | Fire an ad-hoc workflow trigger from external system. |
ComfyUI prompt submission interoperability:
- OpenClaw-generated ComfyUI `/prompt` payloads include `extra_data.comfy_usage_source = "comfyui-openclaw"` when the caller has not supplied a value
- caller-provided `extra_data.comfy_usage_source` is preserved
- attribution is a stable product identifier and MUST NOT include prompt text, tenant ids, trace ids, URLs, tokens, or secrets
- existing `extra_data.openclaw` and legacy `extra_data.moltbot` metadata remain caller-owned except for OpenClaw tenant metadata insertion under `extra_data.openclaw.tenant_id`
### 1.3 Assist, LLM & Chat
**Assist Base Path**: `/openclaw/assist/`
@@ -84,8 +139,9 @@ Reasoning-content redaction contract:
Assist payload redaction contract:
- structured assist responses preserve final operator-visible answer fields but strip provider reasoning / chain-of-thought style fields by default
- structured assist responses preserve final operator-visible answer fields but strip provider reasoning / chain-of-thought style fields and explicitly marked internal maintenance/helper content by default
- when the privileged reveal gate is allowed, debug reasoning is exposed only in a separate debug payload and not merged back into the normal structured answer fields
- explicitly marked internal maintenance/helper content is not exposed by the privileged reasoning reveal gate
### 1.3B Connector Installation Diagnostics
@@ -98,8 +154,38 @@ Assist payload redaction contract:
| `GET` | `/connector/installations/{installation_id}` | `/moltbot/connector/installations/{installation_id}` | Admin | Get one redacted connector installation record. |
| `GET` | `/connector/installations/resolve` | `/moltbot/connector/installations/resolve` | Admin | Run fail-closed workspace resolution diagnostics (`platform`, `workspace_id`). |
| `GET` | `/connector/installations/audit` | `/moltbot/connector/installations/audit` | Admin | List installation lifecycle audit evidence (redacted). |
| `GET` | `/connector/extraction-contract` | `/moltbot/connector/extraction-contract` | Admin | Get the machine-readable connector extraction recommendation, seam families, static service-env SecretRef propagation policy, and current blockers. |
### 1.3C LLM Management & Chat
Connector diagnostics contract notes:
- installation records may expose operator-safe health metadata under `installation.metadata.health` (for example `ok`, `invalid_token`, `revoked`, `degraded`) without exposing token material
- `/connector/installations` diagnostics may include aggregate `health_counts` in addition to lifecycle `status_counts`
- `/connector/installations/resolve` may expose a stable `health_code` alongside the legacy `reject_reason` so clients can distinguish `workspace_unbound` vs token-health failures without parsing status text
- `/connector/extraction-contract` is structural packaging metadata and static service-env SecretRef policy only; clients MUST NOT treat it as a live installation-health, live environment dump, or token-status feed
### 1.3C Model Management & Installations
**Base Path**: `/openclaw/`
**Auth**: Admin Token Required
| Method | Path | Legacy Path | Auth | Description |
| :--- | :--- | :--- | :--- | :--- |
| `GET` | `/models/search` | `/moltbot/models/search` | Admin | Search normalized model entries across managed installs and catalog sources. |
| `POST` | `/models/downloads` | `/moltbot/models/downloads` | Admin | Create a managed model download task with progress/cancel lifecycle. |
| `GET` | `/models/downloads` | `/moltbot/models/downloads` | Admin | List model download tasks with snapshot or delta cursor semantics (`since_seq`). |
| `GET` | `/models/downloads/{task_id}` | `/moltbot/models/downloads/{task_id}` | Admin | Get one model download task by id. |
| `POST` | `/models/downloads/{task_id}/cancel` | `/moltbot/models/downloads/{task_id}/cancel` | Admin | Cancel a queued or running model download task. |
| `POST` | `/models/import` | `/moltbot/models/import` | Admin | Import a completed managed download into the bounded install root after provenance and hash verification. |
| `GET` | `/models/installations` | `/moltbot/models/installations` | Admin | List managed model installations. |
Model-manager contract notes:
- `/models/downloads` supports `since_seq` cursor polling and may return deterministic delta metadata (`requested_since_seq`, `effective_since_seq`, `next_since_seq`, truncation/reset hints) alongside the task list
- `model_type` values SHOULD use current ComfyUI folder keys where applicable, including `text_encoders`, `diffusion_models`, `clip_vision`, `style_models`, `upscale_models`, `vae_approx`, `gligen`, `latent_upscale_models`, `hypernetworks`, `photomaker`, `model_patches`, `audio_encoders`, `background_removal`, `frame_interpolation`, `geometry_estimation`, `optical_flow`, and `detection`
- legacy aliases such as `ckpt`, `checkpoints`, `loras`, `controlnets`, `clip`, `text_encoder`, `unet`, `diffusion_model`, `upscale_model`, `latent_upscale_model`, `hypernetwork`, `model_patch`, and `audio_encoder` are normalized before filtering or import destination resolution
- current ComfyUI folder keys that are not managed model-file destinations fail closed for download creation: `configs` (configuration YAML), `diffusers` (folder-valued trees), `classifiers` (extensionless classifier artifacts), `custom_nodes` (executable plugin code), and `datasets` (user-managed training data)
- download creation requires structured provenance metadata (`publisher`, `license`, `source_url`) and a 64-char `expected_sha256`
- import keeps fail-closed destination/filename validation and re-checks the staged file hash before activation
### 1.3D LLM Management & Chat
**LLM Base Path**: `/openclaw/llm/`
@@ -107,7 +193,7 @@ Assist payload redaction contract:
| :--- | :--- | :--- | :--- | :--- |
| `POST` | `/chat` | `/moltbot/llm/chat` | Admin/Local | Unified chat interface for assistant interactions. |
| `POST` | `/test` | `/moltbot/llm/test` | Admin | Test LLM connectivity and configuration. |
| `GET` | `/models` | `/moltbot/llm/models` | Admin | List available models from configured provider. Request-time fetch uses the same SSRF contract as saved `base_url` validation, including the explicit insecure override for private-IP/HTTP targets. |
| `GET` | `/models` | `/moltbot/llm/models` | Admin | List available models from configured provider. Request-time fetch uses the same SSRF contract as saved `base_url` validation, including scoped private-network allowance and the explicit insecure override for private-IP/HTTP targets. |
### 1.4 Templates & Assets
@@ -144,6 +230,36 @@ Assist payload redaction contract:
| `POST` | `/approvals/{id}/approve` | Approve a pending request. |
| `POST` | `/approvals/{id}/reject` | Reject a pending request. |
Schedule `delivery` is normalized before persistence. Supported fields are
`platform`, `target_id` (legacy aliases such as `channel_id` are accepted),
`thread_id` (aliases such as `thread_ts`, `topic_id`, and `message_thread_id`
are accepted), `workspace_id`, `account_id`, `mode`, and `failure_alert`.
Omitting `delivery` on update preserves the existing target, `delivery: null`
clears it, and `{"enabled": false}` or `{"mode": "none"}` records explicit
no-delivery. Invalid delivery targets are rejected before persistence with
bounded codes: `delivery_malformed`, `delivery_ambiguous`, or
`delivery_unsupported`.
### 1.5A External Tools
**OpenClaw path prefix**: `/openclaw/`
**Legacy Base Path**: `/moltbot/`
**Auth**: Admin Token Required
**Feature flag**: `OPENCLAW_ENABLE_EXTERNAL_TOOLS=true`
| Method | Path | Legacy Path | Auth | Description |
| :--- | :--- | :--- | :--- | :--- |
| `GET` | `/tools` | `/moltbot/tools` | Admin Token Required | List allowed external tools and their declared sandbox metadata. |
| `POST` | `/tools/{name}/run` | `/moltbot/tools/{name}/run` | Admin Token Required | Execute a named allowlisted external tool with validated arguments. |
Tool execution contract notes:
- tools are disabled unless `OPENCLAW_ENABLE_EXTERNAL_TOOLS` is truthy
- tool definitions load from package-owned `data/tools_allowlist.json` unless `OPENCLAW_TOOLS_CONFIG_PATH` is set
- in public/split high-risk surfaces, tool execution can be blocked by the surface guard even when the feature flag is enabled
- execution responses preserve the current HTTP payload shape: failed runs return `ok=false`, `tool`, `error`, redacted `output`, `exit_code`, and `duration_ms`
- the service-level tool runner classifies common local failures with stable diagnostics such as `sandbox_runtime_unavailable`, `interpreter_missing`, `timeout`, and `workspace_violation`; clients should still follow this API document for the current HTTP response shape
### 1.6 Bridge (Sidecar)
**Base Path**: `/bridge/`
@@ -190,6 +306,7 @@ All JSON responses (success or error) share a common structure:
| `413` | Payload Too Large | Input size exceeds `OPENCLAW_MAX_RENDERED_WORKFLOW_BYTES` or similar limits. |
| `429` | Too Many Requests | Rate limit or Execution Budget exceeded. |
| `500` | Internal Error | Unhandled server exception. |
| `501` | Not Implemented | Required current-host contract is unavailable (for example `jobs_host_contract_unsupported`). |
| `503` | Unavailable | Feature disabled or service not wired. |
Tenant-boundary error notes:
@@ -209,7 +326,7 @@ Tenant-boundary error notes:
- `error`
- `keepalive`
- Clients MUST treat `final` as the source of truth for structured assist results. `delta` preview text is best-effort and may be truncated or differ from the final parsed payload.
- Event-stream and polling payloads redact provider reasoning / thinking traces by default; reveal is debug-only and gated by the same privileged local-debug contract used by trace/assist surfaces.
- Event-stream and polling payloads redact provider reasoning / thinking traces and explicitly marked internal maintenance/helper content by default; reasoning reveal is debug-only and gated by the same privileged local-debug contract used by trace/assist surfaces.
- Clients SHOULD gracefully fall back to non-streaming assist endpoints when streaming capability is absent or streaming transport fails.
### 2.4 Pagination & Scan Diagnostics (Management Query Contract)
@@ -219,6 +336,10 @@ Tenant-boundary error notes:
- normalized limit/offset/cursor values
- stale/future cursor resets
- bounded scan truncation or malformed-record skips
- `GET /openclaw/preflight/inventory` may return snapshot-state diagnostics so clients/operators can detect:
- whether the response came from a previous cached snapshot
- whether a background refresh is currently running
- whether the current snapshot is stale or the last deep scan failed
- Backend/runtime errors outside pagination normalization are still surfaced explicitly (not silently swallowed).
---
@@ -236,7 +357,7 @@ These limits are contractual and strictly enforced. Clients MUST handle `413` an
| **Payload Size** | Rendered workflow | 512KB | `OPENCLAW_MAX_RENDERED_WORKFLOW_BYTES` |
| **Webhook Body** | Raw JSON body | 10MB | `MAX_BODY_SIZE` (internal constant) |
| **Trigger Inputs** | Input variables | 32KB | Hardcoded in `api/triggers.py` |
| **Log Tail** | Max lines | 500 | Hardcoded in `api/routes.py` |
| **Log Tail** | Max lines | 500 | Hardcoded in `api/route_handlers.py` |
---
+46 -2
View File
@@ -8,9 +8,15 @@ All pull requests must pass the repository SOP gate before merge.
| --- | --- | --- |
| Secret detection | `pre-commit run detect-secrets --all-files` | Prevent secret leakage |
| Pre-commit hooks | `pre-commit run --all-files --show-diff-on-failure` | Enforce formatting and static checks |
| Backend unit tests | `python scripts/run_unittests.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json` | Validate backend behavior and skip governance |
| Production dependency boundary | `python scripts/verify_production_dependencies.py` | Parse tracked production imports without importing modules; block ownership, direction, cycle, and dynamic-import drift |
| Frontend dependency audit | `npm ci` then `npm audit --audit-level=high` | Reconcile the lockfile and fail on high/critical vulnerabilities across production and development dependencies |
| Backend dependency audit | `pip-audit -r requirements.txt` | Audit declared Python project dependencies without scanning unrelated CI runner/toolchain packages |
| GitHub CodeQL analysis | `.github/workflows/codeql.yml` | Run repository-native static security analysis for Python, JavaScript/TypeScript, and GitHub Actions on push, pull request, and weekly schedule |
| Coverage governance | `python scripts/verify_quality_governance.py` | Fail closed on coverage-policy, mutation-threshold, SOP-guidance, and survivor-allowlist drift |
| Test debt governance | `python scripts/verify_test_debt_governance.py` | Fail closed on stale or under-documented skip-policy / mutation allowlist debt entries |
| Backend unit coverage gate | `python scripts/run_backend_coverage.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json --coverage-json .tmp/coverage/backend_unit_coverage.json` | Validate backend behavior, skip governance, and the active coverage floor from the shared local/CI artifact path |
| Adversarial gate | `python scripts/run_adversarial_gate.py --profile auto --seed 42` | Enforce adaptive fuzz/mutation verification with smoke=>extended escalation on high-risk diffs |
| Frontend E2E | `npm test` | Validate UI and frontend/backend integration |
| Contract tests | `python -m pytest tests/contract -v` | Validate API/config contracts |
## Public MAE Hard-Guarantee Suites
@@ -29,3 +35,41 @@ If a change intentionally modifies contract behavior:
1. Update affected tests and docs in the same PR.
2. Record the behavior change and migration impact in release notes.
3. Keep security-path tests on triple-assert semantics (status + machine code + audit signal).
## Governance Baseline
- Coverage governance is part of the standard gate, not an optional reporting step.
- Dependency-audit governance is part of CI parity:
- Node audit must cover production and development dependencies because build and test tooling is part of the acceptance trust boundary.
- A separate production-only audit may be retained as a runtime-boundary readback, but it is not a substitute for the full blocking audit.
- Python audit must stay scoped to `requirements.txt`; env-wide bare `pip-audit` is out of contract because it can fail on tool-only transient packages that are not part of the repo dependency surface.
- GitHub Actions workflow files are part of the security boundary:
- workflows using `GITHUB_TOKEN` must declare explicit least-privilege `permissions:` instead of relying on repository defaults
- missing or broadened workflow token scope should be treated as CI-policy drift, not an acceptable implementation shortcut
- CodeQL analysis must stay versioned in `.github/workflows/codeql.yml`; do not rely on UI-only default-setup drift for the repository baseline
- CodeQL rollout remains visibility-first until the active backlog is burned down; treat new workflow findings as triage input, not an automatic merge blocker, unless the gating policy is explicitly tightened in roadmap/docs
- `pyproject.toml` must keep:
- `fail_under >= 55.0`
- `show_missing = true`
- `skip_covered = true`
- staged coverage ratchet policy (`tests/coverage_governance_policy.json`) is the source of truth for:
- current enforced floor
- next planned ratchet target
- hotspot families and any temporary exceptions
- `fail_under` must match the current stage floor declared in `tests/coverage_governance_policy.json`; do not ratchet the floor by editing `pyproject.toml` alone.
- Coverage hotspot review should use:
- `python scripts/report_coverage_governance.py --coverage-json <path-to-coverage.json>`
- release-cycle promotion evidence must be retained in:
- `tests/coverage_promotion_reviews.json`
- ratchet-55 reviews must contain consecutive release boundaries, immutable commit and
full-suite artifact identity, every required hotspot percentage, and owned regression suites
- backend coverage gate should use:
- `python scripts/run_backend_coverage.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json --coverage-json .tmp/coverage/backend_unit_coverage.json`
- Test debt governance remains fail-closed:
- no-skip modules in `tests/skip_policy.json` must keep explicit metadata (`reason` + `review_after`) and point at live test modules
- mutation survivor allowlist entries must carry `review_after` dates and point at live repo files
- review dates in the past are governance debt, not advisory comments
- Mutation governance remains adaptive:
- smoke profile threshold: `20.0%`
- extended profile threshold: `80.0%`
- Known equivalent mutation survivors must stay explicitly allowlisted in `tests/mutation_survivor_allowlist.json`; drift is a gate failure, not a warning.
@@ -0,0 +1,80 @@
# CodeQL and Secret Scanning Policy Reference
Date: 2026-04-08
Scope: Repository planning guidance for GitHub-native security scanning policy during and after the residual alert wave.
Current status:
- the repository now uses the committed advanced CodeQL workflow as its authoritative scanner baseline
- GitHub `Code scanning` and `Secret scanning` were both brought back to `0` open findings during the `S91` closeout
## 1. Why CodeQL Belongs in GitHub Actions
This repository has a large security surface:
- Python backend services
- JavaScript frontend and test helpers
- GitHub Actions workflows
- connector ingress paths
- filesystem and model-management flows
The residual GitHub findings demonstrate that static security analysis is catching issues that ordinary local happy-path tests do not reliably surface.
The correct home for CodeQL in this repository is the GitHub Actions security-validation layer, not the mandatory local development loop.
## 2. Local vs Remote Validation Boundary
Local validation should focus on:
- targeted regressions for the changed bug surface
- repo-local contract tests
- the smallest credible transaction seam for each fix
GitHub-hosted validation should own:
- CodeQL scans
- code-scanning alert lifecycle
- long-running static dataflow analysis
- alert triage over the default branch
- secret-scanning closure workflow when a finding is confirmed to be historical or non-live
## 3. Recommended CodeQL Rollout Model
Recommended rollout order:
1. enable repository-native CodeQL in GitHub Actions
2. start in visibility/baseline mode
3. review new findings against changed files first
4. graduate to stricter gating only after the backlog is reduced
Recommended initial policy:
- languages: Python, JavaScript/TypeScript, GitHub Actions
- query suite: begin with standard security queries; expand only if runtime/cost remains acceptable
- gating: report-only at first, then fail on new high-severity findings after baseline stabilization
## 4. Secret Scanning Policy
Secret-scanning findings must be handled differently from CodeQL findings:
- removing or editing repository content does not automatically guarantee closure
- historical example values can continue to alert
- closure requires provenance review
Before closing a secret-scanning alert:
1. determine whether the value was ever real or was always an example / placeholder
2. confirm whether any rotation or revocation is required
3. avoid copying raw secret material into planning docs, issue text, or commit messages
4. record the closure rationale in planning and implementation evidence
## 5. Residual-Wave Review Checklist
For each remaining GitHub Security family:
- identify whether the finding is a true vulnerability, a scanner-visible dangerous pattern, or a probable false positive
- prefer code changes that make the safe boundary obvious to both humans and scanners
- add a hot-spot comment at the fix point when regression risk is high
- add targeted regression coverage if a local seam exists
- verify GitHub rescans after push
- dismiss only as a last resort, with recorded rationale
+50 -11
View File
@@ -3,35 +3,74 @@
```openclaw-compat-matrix-meta
{
"anchors": {
"comfyui": "unknown",
"comfyui_frontend": "unknown",
"desktop": "unknown"
"comfy_desktop": "1.0.32-rc.1 (85e28b7a / v1.0.32-rc.1-3-g85e28b7)",
"comfyui": "9cf91339 (v0.29.0-12-g9cf91339 / pyproject 0.29.0)",
"comfyui_frontend": "1.49.1 (4b3866b838 / v1.49.1-19-g4b3866b838)",
"desktop": "0.9.4 (core 0.22.3 / frontend 1.43.18)"
},
"evidence": {
"evidence_id": "compat-matrix-20260225",
"updated_at": "2026-02-25T00:00:00+00:00",
"updated_by": "manual"
"evidence_id": "compat-matrix-refresh-20260731",
"updated_at": "2026-07-31T04:03:00+08:00",
"updated_by": "host-reference-alignment"
},
"last_validated_date": "2026-02-25",
"matrix_version": "v0.2.1",
"host_surfaces": {
"comfy_desktop": {
"anchor_key": "comfy_desktop",
"core_version": null,
"frontend_version": null,
"generation": "managed_install",
"hosted_version_mode": "installation_specific"
},
"desktop": {
"anchor_key": "desktop",
"core_version": "0.22.3",
"frontend_version": "1.43.18",
"generation": "legacy_fixed_bundle",
"hosted_version_mode": "fixed"
}
},
"last_validated_date": "2026-07-30",
"matrix_version": "v0.2.9",
"policy": {
"max_age_days": 45,
"warn_age_days": 30
},
"schema_version": 1
"schema_version": 2
}
```
This document outlines the validated environments for ComfyUI-OpenClaw `v0.2.1` (M1 Release).
This document tracks the current reference anchors and validated environments for the active ComfyUI-OpenClaw branch.
## Core Dependencies
| Component | Validated Range | Best Effort / Experimental | Notes |
| :--- | :--- | :--- | :--- |
| **ComfyUI** | v0.2.2+ | v0.1.x | Recommend latest release |
| **ComfyUI** | `9cf91339` reference anchor (`v0.29.0-12-g9cf91339`; `pyproject.toml` version `0.29.0`) | Older tagged snapshots | Current local upstream reference repo snapshot used for compatibility review |
| **ComfyUI Frontend** | `1.49.1` reference anchor (`4b3866b838`; `v1.49.1-19-g4b3866b838`) | Minor drift around the anchor | Sidebar extension contract remains compatible; prefer the current sidebar store API with deprecated facade fallback |
| **Legacy Desktop** | `0.9.4 (core 0.22.3 / frontend 1.43.18)` reference anchor | Legacy fixed bundle may lag standalone frontend | Preserve the recorded fixed-bundle contract for existing parity coverage |
| **Current Comfy-Desktop** | `1.0.32-rc.1` reference anchor (`85e28b7a`; `v1.0.32-rc.1-3-g85e28b7`) | Hosted component versions vary by installation | Treat the managed-install generation separately; do not infer fixed core/frontend versions from the application release |
| **Python** | 3.10, 3.11, 3.12 | 3.9 | 3.13 not yet validated |
| **Torch** | 2.1.2+ | 1.13+ | CUDA 11.8/12.1 verified |
## Host-Surface Notes
- **ComfyUI host runtime**: current bootstrap assumptions remain aligned with upstream `PromptServer` startup and route registration flow, including `/api`-prefixed canonical API routing.
- **Frontend host surface**: current sidebar integration contract remains compatible with the standalone frontend reference anchor, while inactive subgraph diagnostics and promoted-widget behavior remain regression-sensitive seams.
- **Legacy Desktop host surface**: Desktop `0.9.4` embeds frontend `1.43.18`, which lags the standalone frontend `1.49.1` reference. Validate this fixed bundle against its own anchor.
- **Current Comfy-Desktop host surface**: application `1.0.32-rc.1` is a managed-install generation. Its hosted ComfyUI and frontend versions are `installation_specific`; the application anchor must not be cross-wired into fixed hosted-version claims.
## Residual Host-Contract Decisions
- **SaveImage output refs**: OpenClaw consumes runtime `/history` output refs and does not infer graph-rewrite behavior from output-node socket shape. `SaveImage` output sockets are allowed to exist without changing the normalized output-ref contract.
- **3D output refs**: `Load3DAdvanced` and related 3D preview refs remain media-aware output refs. File-like refs and optional hash-backed 3D refs stay on the bounded `/view` preview contract; clients without a 3D renderer should show an explicit fallback/link surface.
- **HDR image output refs**: `.exr` and `.hdr` image refs stay on the bounded `/view` source-preview contract but render as explicit fallback/link surfaces unless a client implements a safe HDR-specific viewer.
- **File-backed text output refs**: allowlisted text files under the host `files` output key normalize to text refs on the existing `/view` route. Job Monitor uses same-origin, redirect-free, strict MIME/UTF-8 streaming with fixed 5-second, 64-KiB transfer, and 4,096-character display limits; failures remain source-link fallbacks and content is never interpreted as HTML or Markdown.
- **Promoted widget source scope and structured widgets**: OpenClaw graph helpers preserve host-shaped promoted-widget source metadata and keep non-numeric node IDs stable. Backend preflight remains a conservative model-key whitelist; structured `COLORS` / `BOUNDING_BOXES` inputs and frontend source metadata are not treated as model references, and OpenClaw does not claim full host frontend active-scope parity without a richer graph-instance contract.
- **Asset dimensions and grouped assets**: typed width/height metadata and grouped multi-download behavior are host-frontend display/download concerns. They do not change OpenClaw fetch routing, and asset-service-only identifiers remain explicit `asset_api_required` states rather than implicit `/api/assets` fetches.
- **Asset loader paths and model tags**: current host asset metadata may expose `loader_path`; model uploads require `model_type:<folder_name>` tags, advertised by `/features.supports_model_type_tags`. OpenClaw does not upload through or directly consume `/api/assets`, so these schema facts do not change the existing `/history` + `/view` contract.
- **Sidebar registration**: prefer the current `sidebarTab.registerSidebarTab` host API and retain the deprecated `extensionManager.registerSidebarTab` fallback for older or desktop-embedded frontend hosts.
- **Node runtime policy**: the standalone ComfyUI frontend development workspace currently declares `node >=25 <26` and `pnpm >=11.3`, but OpenClaw keeps its package engine at `>=18.0.0` because this custom-node package runs its own Playwright/Vitest harness and does not build the host frontend workspace. OpenClaw acceptance remains governed by `tests/TEST_SOP.md` and `tests/E2E_TESTING_SOP.md`, which require Node.js 18+ and CI-parity validation on the project test harness.
## Operating Systems
| OS | Status | CI Validation | Notes |
+67 -9
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
@@ -131,22 +135,75 @@ Controls the `connector` sidecar process and outbound delivery.
| `OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN` | Slack | Bot OAuth token (`xoxb-*`). |
| `OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET` | Slack | Ingress signature secret. |
| `OPENCLAW_CONNECTOR_SLACK_APP_TOKEN` | Slack | Optional Socket Mode app token (`xapp-*`). |
| `OPENCLAW_CONNECTOR_SLACK_CLIENT_ID` | Slack | OAuth client ID for multi-workspace installation flow. |
| `OPENCLAW_CONNECTOR_SLACK_CLIENT_SECRET` | Slack | OAuth client secret for multi-workspace installation flow. |
| `OPENCLAW_CONNECTOR_SLACK_OAUTH_REDIRECT_URI` | Slack | Explicit OAuth callback URL. Falls back to `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL + OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH` when omitted. |
| `OPENCLAW_CONNECTOR_SLACK_OAUTH_INSTALL_PATH` | Slack | Local install route path (default `/slack/install`). |
| `OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH` | Slack | Local OAuth callback route path (default `/slack/oauth/callback`). |
| `OPENCLAW_CONNECTOR_SLACK_OAUTH_SCOPES` | Slack | Comma-separated bot scopes for install URL generation. |
| `OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC` | Slack | TTL for single-use OAuth state tokens (default `600`, clamped to `60..3600`). |
| `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS` | Slack | Comma-separated trusted user IDs. |
| `OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS` | Slack | Comma-separated trusted channel IDs. |
| `OPENCLAW_CONNECTOR_FEISHU_APP_ID` | Feishu/Lark | App ID for the default binding. |
| `OPENCLAW_CONNECTOR_FEISHU_APP_SECRET` | Feishu/Lark | App secret for the default binding. |
| `OPENCLAW_CONNECTOR_FEISHU_VERIFICATION_TOKEN` | Feishu/Lark | Verification token for webhook event ingress. |
| `OPENCLAW_CONNECTOR_FEISHU_ENCRYPT_KEY` | Feishu/Lark | Optional encrypt key for encrypted webhook payloads. |
| `OPENCLAW_CONNECTOR_FEISHU_ACCOUNT_ID` | Feishu/Lark | Explicit account ID for the default binding. |
| `OPENCLAW_CONNECTOR_FEISHU_DEFAULT_ACCOUNT_ID` | Feishu/Lark | Fallback account ID for multi-binding setups. |
| `OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_ID` | Feishu/Lark | Workspace or tenant identifier associated with the binding. |
| `OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_NAME` | Feishu/Lark | Human-readable workspace name surfaced in diagnostics. |
| `OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON` | Feishu/Lark | JSON list of account/workspace binding records for multi-account setups. |
| `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS` | Feishu/Lark | Comma-separated trusted user IDs. |
| `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_CHATS` | Feishu/Lark | Comma-separated trusted chat IDs. |
| `OPENCLAW_CONNECTOR_FEISHU_DOMAIN` | Feishu/Lark | API domain selector (`feishu` or `lark`). |
| `OPENCLAW_CONNECTOR_FEISHU_MODE` | Feishu/Lark | Transport mode (`websocket` or `webhook`). |
| `OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH` | Feishu/Lark | Local interactive callback route path (default `/feishu/callback`). |
| `OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM` | Core | Per-user connector rate limit (default `10`, clamped to `1..600`). |
| `OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM` | Core | Per-channel connector rate limit (default `30`, clamped to `1..600`). |
| `OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH` | Core | Max accepted connector command text length (default `4096`, clamped to `128..32768`). |
Connector posture rules:
- In strict posture (`OPENCLAW_DEPLOYMENT_PROFILE=public` or `OPENCLAW_RUNTIME_PROFILE=hardened`), active connector platforms without allowlist coverage are fail-closed.
- Public deployment profile check surfaces this as `DP-PUBLIC-009`.
- Connector reply visibility is policy-driven and does not introduce new secret/config knobs: text-only silent/internal/tool-only/no-mention replies can be suppressed by context, while approval cards and action buttons remain deliverable.
- Connector replay handling treats duplicate committed events as successful no-ops and allows retry only for failures before action/delivery commit.
- Slack multi-workspace installs persist only encrypted token refs in `connector_installations.json`; raw bot/app tokens remain in encrypted secret storage and must not appear in diagnostics or exported config surfaces.
- Feishu/Lark bindings persist normalized installation identity plus secret references only; app secrets and callback signing material must stay in encrypted/local secret storage and must not appear in diagnostics or exported config surfaces.
- Connector service-env propagation preserves only structured env-backed SecretRef metadata for supported connector credential variables. It reports secret-blind status/reason fields and rejects raw secrets, legacy marker strings, unsupported env names, missing envs, and runtime-only auth tokens such as admin, worker, and bridge tokens. Raw token values must not be written into diagnostics or service metadata.
- Connector bind-port envs (`OPENCLAW_CONNECTOR_LINE_PORT`, `...WHATSAPP_PORT`, `...WECHAT_PORT`, `...KAKAO_PORT`, `...SLACK_PORT`, `...FEISHU_PORT`) must stay within `1..65535`; invalid or out-of-range values fall back to the documented platform defaults instead of crashing startup.
**Delivery & Media:**
| Variable | Description |
| :--- | :--- |
| `OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC` | Timeout (sec) for delivering results to chat (default: `600`). |
| `OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC` | Timeout (sec) for delivering results to chat (default `600`, clamped to `30..3600`). |
| `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL` | Public base URL for serving images to LINE/Webhooks. |
| `OPENCLAW_CONNECTOR_MEDIA_PATH` | Local directory for staging media files. |
| `OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES` | Max completed images delivered per job (default `4`, clamped to `1..16`). |
| `OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES` | Per-image delivery cap in bytes (default `10485760`, clamped to `65536..52428800`). |
| `OPENCLAW_CONNECTOR_MEDIA_TTL_SEC` | Media expiry in seconds (default `300`, clamped to `60..86400`). |
| `OPENCLAW_CONNECTOR_MEDIA_MAX_MB` | Max staged media size in MB (default `8`, clamped to `1..64`). |
### 2.5 Execution Budgets & Limits
### 2.5 External Tools and Runtime Hygiene
External tool execution is opt-in and admin-gated. Package-owned defaults, runtime state, and local validation artifacts are separate ownership classes.
| Variable | Default | Description |
| :--- | :--- | :--- |
| `OPENCLAW_ENABLE_EXTERNAL_TOOLS` | `false` | Enables `/openclaw/tools` and `/openclaw/tools/{name}/run`. Keep disabled unless a reviewed deployment needs allowlisted external CLI execution. |
| `OPENCLAW_TOOLS_CONFIG_PATH` | package `data/tools_allowlist.json` | Explicit path to a custom tools allowlist. If unset, OpenClaw uses the package-owned shipped allowlist rather than a state-dir shadow file. |
| `OPENCLAW_TOOL_SANDBOX_RUNTIME_AVAILABLE` | `1` | Runtime availability marker used by hardened tool execution diagnostics. In hardened mode, `0` fails closed before tool execution. |
| `OPENCLAW_TOOL_SANDBOX_DIR` | `{state_dir}/tool_sandbox` | Optional override for external-tool scratch/temp workspace. Legacy alias: `MOLTBOT_TOOL_SANDBOX_DIR`. |
Ownership rules:
- package resources such as the default tool allowlist are read from the installed custom-node pack
- state-owned runtime cache/sandbox paths live under `OPENCLAW_STATE_DIR` unless explicitly overridden
- repo-local generated folders such as `.tmp/`, `.venv/`, and `node_modules/` are local tooling artifacts, not runtime state
- OpenClaw does not automatically repair, migrate, or delete runtime dependency caches
- tool execution results classify common local failures with deterministic diagnostics such as `sandbox_runtime_unavailable`, `interpreter_missing`, `timeout`, and `workspace_violation`
### 2.6 Execution Budgets & Limits
Contractual limits to prevent resource exhaustion.
@@ -160,12 +217,13 @@ Contractual limits to prevent resource exhaustion.
| `OPENCLAW_MAX_INFLIGHT_SUBMITS_PER_TENANT` | `1` | Per-tenant concurrent submit cap (applies when multi-tenant mode is enabled). |
| `OPENCLAW_MAX_RENDERED_WORKFLOW_BYTES` | `524288` | Max size (bytes) of a rendered workflow JSON (512KB). |
### 2.6 Runtime & Diagnostics
### 2.7 Runtime & Diagnostics
| Variable | Description |
| :--- | :--- |
| `OPENCLAW_STATE_DIR` | Directory for persistent state (DBs, history, logs). Default: `ComfyUI/user/default/openclaw` |
| `OPENCLAW_STATE_DIR` | Directory for persistent state (DBs, history, logs, runtime cache). Defaults to the platform user-data directory, such as `%LOCALAPPDATA%\comfyui-openclaw\`, `~/Library/Application Support/comfyui-openclaw/`, or `~/.local/share/comfyui-openclaw/`. |
| `OPENCLAW_LOG_TRUNCATE_ON_START` | Set `1` to truncate active log file (`openclaw.log`) once at process startup before new handlers write records. |
| `OPENCLAW_STARTUP_WARMUP_TIMEOUT_SEC` | Optional timeout for non-blocking startup warmups. Warmup timeout degrades health diagnostics but does not block required route startup. |
| `OPENCLAW_DIAGNOSTICS` | Comma-separated list of subsystems to enable debug logging for (e.g. `webhook.*,templates`). Safe-redacted. |
| `OPENCLAW_CONNECTOR_DEBUG` | Set `1` to enable verbose debug logging in Connector. |
+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.
+66 -10
View File
@@ -49,8 +49,45 @@ interface BannerStatus {
- **Monitoring**: Polls `/health` every 10s.
- **Triggers**: Checks `stats.observability.total_dropped > 0`.
- **Display**: Simple DOM injection of `.moltbot-banner`.
- **Limitations**: No connectivity state handling, no 'info'/'success' states, simplistic dedupe.
- **Display**: Simple DOM injection of canonical `.openclaw-banner` markup; legacy `.moltbot-banner` compatibility selectors remain available through centralized runtime aliasing.
- **Connectivity posture**: Queue-monitor disconnect warnings should tolerate initial sidebar/bootstrap races and only escalate after bounded repeated failure or post-healthy disconnect evidence, so transient startup misses do not become durable incident noise.
- **Limitations**: No 'info'/'success' states, simplistic dedupe.
## 1.1 Notification Center (F66)
Persistent operator notifications are the durable counterpart to transient banners and toasts.
### Schema (TypeScript)
```typescript
interface NotificationEntry {
id: string;
severity: BannerSeverity;
message: string;
source: string;
created_at: string;
updated_at: string;
count: number;
acknowledged_at?: string | null;
dismissed_at?: string | null;
action?: {
label: string;
type: 'url' | 'tab' | 'action';
payload: string;
};
metadata?: Record<string, unknown>;
}
```
### F66 Baseline
- Warning/error banners and selected operator toasts are mirrored into the in-app notification center.
- Entries are deduplicated by source-specific keys and persisted in local storage across reloads.
- `Dismiss` hides an entry from the active list without deleting the historical record from storage.
- `Acknowledge` clears unread state while keeping the entry visible.
- Notification `message` / `source` fields are treated as untrusted text at the render sink and must stay escaped before DOM insertion; notification content is not a supported HTML surface.
- Sources with jump targets should attach a tab/action deep link so operators can navigate directly to the affected surface.
- Canonical `openclaw-*` DOM/class ownership should be authored once in the shell/templates; any retained `moltbot-*` class compatibility must come from shared runtime alias helpers instead of duplicated markup.
## 2. Context Actions (F51)
@@ -85,7 +122,7 @@ interface ContextAction {
}
```
## 3. Parameter Lab (F52)
## 3. Parameter Lab
Contracts for bounded parameter sweeps and experiment orchestration.
@@ -98,20 +135,39 @@ Contracts for bounded parameter sweeps and experiment orchestration.
{
"node_id": "10",
"widget_name": "cfg",
"values": [6.0, 7.0, 8.0]
"values": [6.0, 7.0, 8.0],
"strategy": "grid"
},
{
"node_id": "3",
"node_id": "loader-alpha",
"widget_name": "seed",
"strategy": "random",
"count": 3
"values": [41, 42]
}
],
"max_runs": 20,
"batch_size": 1
]
}
```
Contract notes:
- `node_id` is a string-preserving host graph identifier. It may be numeric text such as `"10"` or a non-numeric host ID, and clients must not coerce it to a number when storing, comparing, or replaying experiment parameters.
- Experiment parameter keys such as `"10.cfg"` are display/storage keys derived from the original `node_id` plus `widget_name`; they are not a separate numeric node contract.
- Sweep values are limited to bounded strings, booleans, integers, and finite numbers. Null,
arrays, objects, non-finite numbers, overlong strings, presentation-ambiguous duplicates, and
unsupported strategies fail validation instead of being coerced.
- Sweep creation supports `grid` strategy only. The backend policy is authoritative and limits a
request to 5 MiB, workflow text to 4 MiB, eight dimensions, 50 values per dimension, and 50
generated combinations. Compare creation accepts at most eight scalar items.
### Queue ownership receipt
- The coordinator observes the host's reviewed `promptQueueing` and `promptQueued` request
boundaries, correlating their integer `requestId` and `batchCount` fields.
- It writes a transient UUID receipt only into the matching serialized workflow and returns the
exact `promptId` / `requestId` pair used to route bounded lifecycle event metadata.
- Unsupported event APIs, malformed or missing boundaries, pre-existing unobserved host queue
activity, receipt collisions, timeouts, and ambiguous batch ownership fail explicitly. There is
no fallback to a globally recent prompt ID.
### Experiment Result Schema (JSON)
```json

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