385 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
rookiestar28 a0d37c371e bump version to v0.7.0 2026-03-14 18:51:14 +08:00
rookiestar28 4911537536 docs: update readme 2026-03-14 18:49:23 +08:00
rookiestar28 f1eac335ef chore: auto-regenerate OpenAPI spec in pre-commit 2026-03-14 18:30:50 +08:00
rookiestar28 1ef822810f docs: regenerate OpenAPI spec 2026-03-14 18:01:12 +08:00
rookiestar28 9473a9e2e2 docs: clarify LLM SSRF override parity 2026-03-14 17:58:54 +08:00
rookiestar28 2b0dd5d9a4 fix: honor insecure LLM base URL override at request time 2026-03-14 17:46:10 +08:00
rookiestar28 5b187b2d05 fix: clarify private-host LLM SSRF contract 2026-03-14 03:06:23 +08:00
rookiestar28 0a9d0429ec fix: catch pre-commit drift before push 2026-03-14 02:42:37 +08:00
rookiestar28 9904ecea49 feat: implement R141 snapshot inventory indexing 2026-03-13 23:42:15 +08:00
rookiestar28 d641c04bde chore: apply auto formated file 2026-03-12 18:46:00 +08:00
rookiestar28 91423bc320 chore: guard generated OpenAPI spec sync in pre-commit and pre-push 2026-03-12 18:43:49 +08:00
rookiestar28 069635a1f7 fix: regenerate OpenAPI spec from S72 contract 2026-03-12 04:05:16 +08:00
rookiestar28 a5a795d6be docs: document S72 reasoning redaction contract 2026-03-12 03:54:13 +08:00
rookiestar28 f70e01145e chore: apply auto reformatted files 2026-03-12 03:40:35 +08:00
rookiestar28 1d971df5fc docs: update the last update block of readme 2026-03-12 03:30:24 +08:00
rookiestar28 a62d0f9349 Implement S72 reasoning redaction gates 2026-03-12 02:55:10 +08:00
rookiestar28 f65f374a71 Complete F65 resume recovery validation 2026-03-11 00:20:31 +08:00
rookiestar28 1fdd856c7f docs: update readme 2026-03-08 20:54:36 +08:00
rookiestar28 9ffdcee190 feat:add F64 model manager frontend tab 2026-03-08 19:55:07 +08:00
rookiestar28 6d89b42021 chore: add icon to Parameter Lab 2026-03-08 19:52:51 +08:00
rookiestar28 5c0a65b869 feat: implement F54 model manager task flow 2026-03-08 18:03:09 +08:00
rookiestar28 1880eb8bbd feat: implement F53 workflow rewrite recipe library 2026-03-08 17:29:49 +08:00
rookiestar28 81d18b9185 docs: complete R69 frontend migration decision 2026-03-08 16:48:49 +08:00
rookiestar28 2fb1207a1a test: add adaptive adversarial gate with hotspot strictness 2026-03-08 16:43:56 +08:00
rookiestar28 c4dd5f5276 test: harden access-control mutation hotspots 2026-03-08 16:18:26 +08:00
rookiestar28 b86ae18ea2 bump version to v0.6.8 2026-03-07 03:23:51 +08:00
rookiestar28 9a0d554bcb chore: apply auto format 2026-03-07 03:19:05 +08:00
rookiestar28 28db66d4c0 Merge branch 'dev' 2026-03-07 03:16:41 +08:00
rookiestar28 cdf2b0b5f6 docs: align README and security contracts with latest implementation 2026-03-07 03:12:22 +08:00
rookiestar28 44ef61ca10 feat: complete S49 multi-tenant boundary model 2026-03-07 02:59:04 +08:00
rookiestar28 e0bfe64530 feat(security): add optional 1password secret provider chain 2026-03-07 02:18:11 +08:00
rookiestar28 911399d9cc feat: close S48/S9 advisory surfacing and telemetry opt-out 2026-03-07 01:46:47 +08:00
rookiestar28 9c68aefb38 Merge branch 'dev' 2026-03-07 01:26:35 +08:00
rookiestar28 ecc173780a feat: complete R139 config surface unification phase 1 2026-03-07 00:54:15 +08:00
rookiestar28 f135051859 test(e2e): stabilize r38 streaming loading assertion 2026-03-05 04:16:49 +08:00
rookiestar28 0368ce8356 chore: apply pre-push auto-format 2026-03-05 04:02:50 +08:00
rookiestar28 cce635789c docs: align security and API docs with connector/planner contracts 2026-03-05 03:57:56 +08:00
rookiestar28 3415591b24 R126/R127: add connector installation and callback contracts 2026-03-05 03:16:50 +08:00
rookiestar28 bf84b90f08 F62: externalize planner profiles and prompt registry 2026-03-05 02:49:33 +08:00
rookiestar28 7277575289 S70: harden SSRF pinning regression coverage 2026-03-05 02:26:30 +08:00
rookiestar28 450e978a9c F63/R138/R140: add frontend quality baseline and coverage 2026-03-05 02:12:09 +08:00
rookiestar28 807a78e76f R137/S71: single-emit audit wrappers and fail-closed connector allowlists 2026-03-01 16:31:20 +08:00
rookiestar28 6d0359a6a7 docs: move security and release checklist into docs and update cross-links 2026-03-01 03:35:54 +08:00
rookiestar28 87d99bbafe bump version to v0.6.5 2026-03-01 03:28:39 +08:00
rookiestar28 44ceaf1196 R136/S69: fail-closed bootstrap gate propagation and public shared-surface boundary guardrail 2026-03-01 03:04:57 +08:00
rookiestar28 714a5efc5d docs(security): align no-origin CSRF override guidance across README and deployment docs 2026-03-01 02:38:21 +08:00
rookiestar28 3b69b0c358 feat(s68): surface no-origin override posture in startup and doctor 2026-03-01 01:42:06 +08:00
rookiestar28 df03ecb173 fix(r135): make audit hash-chain writes atomic with lock 2026-03-01 01:35:59 +08:00
rookiestar28 ea38c96432 refactor(r134): remove unreachable startup-gate branch after fatal raise 2026-03-01 01:30:02 +08:00
rookiestar28 80ae3a83a0 refactor(r133): unify image encoding helper and converge node class names 2026-03-01 01:15:40 +08:00
rookiestar28 9aef5eb2d4 refactor(r132): harden JSON object extraction via stdlib decoder 2026-03-01 01:07:52 +08:00
rookiestar28 bbc2bc10b8 refactor(r130): split bootstrap/llm failover internals with compat shims 2026-03-01 00:56:50 +08:00
rookiestar28 8f45f6364a fix(r131): harden provider adapter error contract and retry-after handling 2026-03-01 00:34:38 +08:00
rookiestar28 6bfcf37508 added new standalone remote Admin Console and bump to v0.6.1 2026-02-28 22:58:16 +08:00
rookiestar28 9d4965c5ac feat(logging): add startup log truncation env flag and sync docs 2026-02-28 22:52:07 +08:00
rookiestar28 06a933dcd0 update docs 2026-02-28 21:57:30 +08:00
rookiestar28 0de70c2188 feat: add F61 standalone remote admin console 2026-02-28 20:38:09 +08:00
rookiestar28 2e788cb434 chore: apply pre-commit formatting to R129 test files 2026-02-28 20:30:20 +08:00
rookiestar28 9c72671cef chore: align ignore patterns with lowercase roadmap/reference mirrors 2026-02-28 20:14:56 +08:00
rookiestar28 ba64113a37 feat: implement R129 executor lane split with IO callback isolation 2026-02-28 20:13:15 +08:00
rookiestar28 4f95dd0bca fix: hot-reload LLM client config across assist/vision/composer paths and harden audit import fallback 2026-02-26 21:20:48 +08:00
rookiestar28 218356c29d chore: update pics in readme 2026-02-26 21:19:43 +08:00
rookiestar28 5c914b2068 bump version to v0.6.0 (and apply some pre-commit formatting collateral) 2026-02-26 16:47:51 +08:00
rookiestar28 3671be8b99 feat: implement R67/R65/R66 lifecycle consistency, structured logging, and OpenAPI generation 2026-02-26 16:28:01 +08:00
rookiestar28 4651bbb5ff chore: apply pre-commit formatting collateral 2026-02-26 16:27:31 +08:00
rookiestar28 d1a0b460e1 feat: implement R38/R96 streaming assist UX and docs full update 2026-02-26 15:43:21 +08:00
rookiestar28 f6b8b2e0e9 feat: implement R90/R95 governance and query contracts 2026-02-26 12:48:03 +08:00
rookiestar28 28126613e2 feat: implement R119 drills and S66 guardrails 2026-02-26 03:34:37 +08:00
rookiestar28 6785ad7150 refactor: close R55/R64 helper consolidation 2026-02-26 03:15:30 +08:00
rookiestar28 63b95086db feat(assist): add F25 Phase B automation payload composer endpoint with validated trigger/webhook draft generation 2026-02-24 03:09:04 +08:00
rookiestar28 ceb282085e feat(ui): implement R38-lite shared request lifecycle with staged loading, cancel-safe retries, and E2E coverage 2026-02-24 02:37:31 +08:00
rookiestar28 b3a0f7d2ad feat(scheduler): implement R92 delegated mode, anti-skip cadence invariants, and compute-error safeguards 2026-02-24 02:20:05 +08:00
rookiestar28 8c8d65e982 Merge branch 'dev' 2026-02-24 01:50:10 +08:00
tcedison777 13aa463286 Remove low-risk Moltbot aliases and add configurable Playwright E2E port 2026-02-24 01:23:33 +08:00
rookiestar28 8033500e50 feat(r128): complete OpenClaw naming unification phase 3 with legacy telemetry, canonical UI selectors, and e2e alignment 2026-02-21 00:19:12 +08:00
rookiestar28 a99f2aa673 feat(r128): complete OpenClaw naming convergence phase 2 with legacy bridge parity and hardened mutation gate coverage 2026-02-20 23:10:18 +08:00
rookiestar28 c35fe05d3e fix(ci): harden R113 mutation gate with critical note on access-control test suite breadth 2026-02-20 21:27:16 +08:00
rookiestar28 4c0ac67b37 refactor(web): unify OpenClaw naming across UI/API/session while preserving Moltbot compatibility aliases 2026-02-20 04:25:23 +08:00
rookiestar28 4f593471c4 chore: file modified 2026-02-20 03:40:53 +08:00
rookiestar28 361b28e227 feat(parameter-lab): complete F60 dynamic dimension picker with safe rendering guards, E2E coverage, and roadmap/record closeout 2026-02-20 03:13:51 +08:00
rookiestar28 68e7d06133 feat(slack): finalize secure Events API integration with no-skip R124/R125 gates and docs/roadmap sync 2026-02-19 23:22:21 +08:00
rookiestar28 f3fadd6d8a feat(slack): finalize secure Events API integration with no-skip R124/R125 gates and docs/roadmap sync 2026-02-19 20:45:18 +08:00
rookiestar28 a88a3320f8 docs: align deployment/runbook/env-var docs with current S56/S62+SSRF posture and replace sidecar.md with a deprecated stub 2026-02-19 18:16:33 +08:00
rookiestar28 20cacd9399 fix(ci): harden R118 mutation gate with explicit module baseline, non-zero fail semantics, and stale-report/bytecode guards 2026-02-19 17:09:58 +08:00
rookiestar28 e6adcacadf post-wave-e: finalize bundle C with adversarial gate parity, dual-lane retry partition, and SOP/pre-push alignment 2026-02-19 16:45:12 +08:00
rookiestar28 db40c0c538 add R123 real-backend model-list SSRF parity lane and enforce no-skip CI/SOP/full-gate checks 2026-02-19 15:22:48 +08:00
rookiestar28 06bca8df03 fix(ssrf): restore local LLM loopback egress and unify pre/post outbound validation 2026-02-19 14:36:06 +08:00
rookiestar28 d5063c2169 feat(security): close post-wave E bundle B with S65 safe_io egress convergence and R120 CI/local preflight parity gates 2026-02-19 14:08:17 +08:00
rookiestar28 242bd98db4 close S64/R116 with explicit route-plane governance and invariant-gated MAE validation 2026-02-19 03:03:03 +08:00
rookiestar28 b948852eaa test(ci): add R122 real-backend low-mock E2E lane with no-skip CI gate and SOP coverage 2026-02-19 02:37:16 +08:00
rookiestar28 90319ffc8d enforce MAE no-skip CI gates and add key/token lifecycle rotation-revocation-DR SOP 2026-02-19 00:41:47 +08:00
rookiestar28 3376f44d33 fix(ci-tests): install cryptography in CI/pre-push, add requirements.txt baseline, and harden no-crypto fallback for registry signature + secret store tests 2026-02-19 00:10:00 +08:00
rookiestar28 93439ff095 complete wave e bundle c: deliver signed policy posture, security telemetry, deterministic fuzz harness, mutation baseline, and sop-validated closeout 2026-02-18 23:55:27 +08:00
rookiestar28 0f487a72dd feat(security): complete Wave E Bundle B (R108/R109/S43/R89) with matrix contracts, threat-intel gating, and resilience verification 2026-02-18 22:46:22 +08:00
rookiestar28 5cbfcbdb46 feat(wave-e): complete Bundle A (S56/R105/R68/R107) and enforce fail-closed startup profile gate 2026-02-18 21:59:56 +08:00
rookiestar28 9486c358bb feat(bundle-b): close S58-S61 integration gaps and finalize Wave D Bundle B with full SOP gate pass 2026-02-18 21:23:40 +08:00
rookiestar28 8d19fab12a feat(security): close S62/R106/F55/S57 control-plane split baseline with external adapter hardening and encrypted secrets-at-rest 2026-02-18 15:55:59 +08:00
rookiestar28 4c91d90abc fix(security-doctor): honor OPENCLAW_CALLBACK_ALLOW_HOSTS with legacy fallback and add SSRF env-key regression tests 2026-02-18 05:17:36 +08:00
rookiestar28 9554d2a614 docs(readme): add deployment profile guide and self-check CLI 2026-02-18 04:55:39 +08:00
rookiestar28 866489329e feat(wave-c): finalize operator guidance/context actions/parameter-lab compare and sync README with Wave A-B-C hardening 2026-02-18 03:01:20 +08:00
rookiestar28 db7fe477f6 feat(security): close out Wave B hardening with mTLS binding, pack integrity guards, DoS lifecycle controls, and release provenance verification 2026-02-18 01:23:02 +08:00
rookiestar28 2520f7426a fix(wave-a): close R91/R94/R93/S50/S51 with strict durable idempotency, connector auth-invalidation lock, and outbound policy enforcement 2026-02-18 00:14:02 +08:00
rookiestar28 5927ad54aa feat(security): close C1 posture-report contract with S45 parity and schema-drift regression coverage 2026-02-17 21:29:14 +08:00
rookiestar28 1a40255501 docs(readme): add sidebar modules matrix and TOC link for all 10 OpenClaw tabs 2026-02-17 20:36:00 +08:00
rookiestar28 13e4d4ca21 fix(frontend): harden OpenClaw sidebar imports and E2E core-script mocking to prevent masked path regressions 2026-02-17 20:23:38 +08:00
rookiestar28 67f9dc317a security: finalize R99/S47 closeout with deny-path audit coverage, sandbox FS guards, and full SOP validation 2026-02-17 20:03:34 +08:00
rookiestar28 ace9e63af5 feat(security): deliver S46 WP3 RBAC baseline with regression tests and legacy auth-contract safeguards 2026-02-17 18:54:32 +08:00
rookiestar28 0632361e6a feat(security): complete S45 startup exposure hard-stop and sync roadmap/implementation evidence 2026-02-17 18:17:50 +08:00
rookiestar28 d20a4f7818 feat(security): complete endpoint inventory drift guard, align roadmap progress, and document latest hardening updates 2026-02-17 17:05:13 +08:00
rookiestar28 4a3c8d3a38 fix: close contract-first remediation gaps with winner-route integrity, strict winner safety gate, tab-action regression fix, and full TEST_SOP verification 2026-02-17 16:10:22 +08:00
rookiestar28 3b880ca98b feat(connector): finalize S44/R97 semantic guardrails with structured policy contracts and chat firewall enforcement 2026-02-17 01:35:28 +08:00
rookiestar28 1769546648 feat(operator-ux): close F49/F51/F52/F50 remainder gaps and sync compare/auth/history contracts 2026-02-17 00:55:33 +08:00
rookiestar28 df91b68e3a feat(operator-ux): harden F52 lab routes and align F49/F51/F52/F50 baseline progress 2026-02-17 00:13:38 +08:00
rookiestar28 606c5d76e5 Merge remote-tracking branch 'origin/main' 2026-02-16 23:15:17 +08:00
rookiestar28 7a2bde5b2e fix(security): fail closed when transform process runner is unavailable and harden doctor diagnostics 2026-02-16 22:43:12 +08:00
rookiestar28 4e26f88a0e docs: additional security patch content 2026-02-16 06:34:22 +08:00
rookiestar28 d7ef0278bd Merge pull request #4 from BentWorks/fix/cve-packs-api-input-validation
fix(security): input validation in pack API route handlers
2026-02-16 06:17:40 +08:00
rookiestar28 7e9f4985cf fix: remove leftover merge marker in pack registry 2026-02-16 06:12:57 +08:00
BentleyandClaude Opus 4.6 c796dde48e fix(security): add path traversal protection to install_pack metadata
The `install_pack()` method read `name` and `version` fields from the
`pack.json` inside an uploaded zip archive and used them unsanitized to
construct the installation directory path. A malicious zip could contain
`"name": "../../arbitrary/path"` in its metadata, causing files to be
written outside the intended packs directory.

This patch adds:
- A `_validate_pack_segment()` helper that rejects empty values,
  `.`/`..`, and any characters outside `[a-zA-Z0-9._-]`.
- `resolve_under_root()` from `safe_io` as a second-layer defense to
  ensure the resolved path stays within `packs_dir`.
- Regression tests in `test_packs.py` covering traversal via zip
  metadata name, version, and dotdot segments, plus a positive test
  confirming valid packs still install correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 06:10:11 +08:00
rookiestar28 2049af4ae8 Merge remote-tracking branch 'origin/main' 2026-02-16 06:10:04 +08:00
rookiestar28 5be595738f Merge pull request #2 from BentWorks/fix/cve-pack-registry-path-traversal-uninstall
fix(security): path traversal in uninstall_pack and get_pack_path
2026-02-16 06:03:06 +08:00
BentleyandClaude Opus 4.6 3acdbfba83 fix(security): add input validation to pack API route handlers
The `delete_pack_handler` and `export_pack_handler` in `api/packs.py`
passed user-controlled URL route parameters (`name`, `version`) directly
to `PackRegistry` methods without validation. An attacker with admin
access could supply path traversal sequences to delete arbitrary
directories or read arbitrary paths.

This patch adds:
- `_is_safe_pack_segment()` validator at the API layer rejecting empty
  values, `.`/`..`, slashes, backslashes, and characters outside
  `[a-zA-Z0-9._-]`.
- Validation calls in both `delete_pack_handler` and
  `export_pack_handler` before any registry interaction.
- Sanitization of the `Content-Disposition` header filename to prevent
  HTTP response header injection.
- Regression tests in `test_packs_integrity.py` covering traversal
  rejection in both delete and export handlers, plus unit tests for the
  segment validator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 14:52:24 -05:00
BentleyandClaude Opus 4.6 788ab32293 fix(security): add path traversal protection to uninstall_pack and get_pack_path
The `uninstall_pack()` and `get_pack_path()` methods in PackRegistry
accepted unsanitized `name` and `version` parameters, concatenating
them directly into filesystem paths via `os.path.join()`. An attacker
with admin access could supply traversal sequences like `../../etc` to
read or delete arbitrary directories via `shutil.rmtree()`.

This patch adds:
- A `_validate_pack_segment()` helper that rejects empty values,
  `.`/`..`, and any characters outside `[a-zA-Z0-9._-]`.
- `resolve_under_root()` from `safe_io` as a second-layer defense to
  ensure the resolved path stays within `packs_dir`.
- Regression tests in `test_packs.py` covering traversal via name,
  version, and dotdot segments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 14:45:32 -05:00
rookiestar28 b8a5ef7875 feat: complete S42/R86/R87 hardening wave and sync roadmap with partial F48 status 2026-02-15 21:18:26 +08:00
rookiestar28 3e6f4c0e4c fix(bridge): harden handshake imports for ComfyUI loader and add import-regression test 2026-02-15 18:02:34 +08:00
rookiestar28 f7b2c42cdb docs: add advanced runtime/security setup guides, update README TOC and bridge docs 2026-02-15 17:40:07 +08:00
rookiestar28 ca2552fe15 docs: modify readme 2026-02-15 17:12:13 +08:00
rookiestar28 9d86dff86a feat: add runtime profile startup hardening, module boot boundaries, and bridge handshake compatibility 2026-02-15 16:51:59 +08:00
rookiestar28 e23580bbac docs(connector): add comprehensive KakaoTalk setup guide and troubleshooting 2026-02-15 15:08:10 +08:00
rookiestar28 f3cd94204e Strengthen WeChat/KakaoTalk robustness and security, bump to v0.3.0 2026-02-14 04:27:07 +08:00
698 changed files with 129118 additions and 9852 deletions
+196 -21
View File
@@ -6,6 +6,12 @@ on:
branches:
- main
- master
workflow_dispatch:
schedule:
- cron: '0 3 * * *' # nightly 03:00 UTC for adversarial-extended
permissions:
contents: read
jobs:
@@ -17,14 +23,24 @@ jobs:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install import deps
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m pip install numpy pillow
- name: R120 preflight
run: |
python scripts/preflight_check.py --strict
- name: Import smoke test
env:
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_smoke
@@ -40,16 +56,26 @@ jobs:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install preflight deps
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
- name: R120 preflight
run: |
python scripts/preflight_check.py --strict
- name: Install Node deps
run: |
npm install
npm ci
- name: Install Playwright browsers
run: |
npx playwright install chromium
@@ -62,22 +88,81 @@ jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install test deps
run: |
python -m pip install --upgrade pip
# Keep aligned with local pre-push/full-test scripts.
# aiohttp is required by multiple unit-test import paths.
python -m pip install numpy pillow aiohttp
# CRITICAL: Python 3.10 coverage reads pyproject.toml only when the
# TOML extra is present; do not downgrade this back to plain coverage.
python -m pip install -r requirements.txt
python -m pip install -r requirements-quality.txt
python -m pip install numpy pillow aiohttp "coverage[toml]"
- name: R120 preflight
run: |
python scripts/preflight_check.py --strict
- name: Static-analysis policy
run: |
python scripts/verify_static_analysis_policy.py
- name: Run MAE hard-guarantee suites
env:
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_mae
run: |
# CRITICAL: keep these as explicit CI gates so public MAE route-plane
# classification/regression drift cannot silently bypass full discovery.
python scripts/run_unittests.py --module tests.test_s60_mae_route_segmentation --enforce-skip-policy tests/skip_policy.json --max-skipped 0
python scripts/run_unittests.py --module tests.test_s60_routes_startup_gate --enforce-skip-policy tests/skip_policy.json --max-skipped 0
python scripts/run_unittests.py --module tests.security.test_endpoint_drift --enforce-skip-policy tests/skip_policy.json --max-skipped 0
- name: Run unit tests
env:
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_unit
run: |
python scripts/run_unittests.py --start-dir tests --pattern "test_*.py"
python scripts/run_backend_coverage.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json --coverage-json .tmp/coverage/backend_unit_coverage.json
- name: Coverage hotspot report
run: |
python scripts/report_coverage_governance.py --coverage-json .tmp/coverage/backend_unit_coverage.json
backend-e2e-real:
name: Backend E2E (real-backend lane, low-mock)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install test deps
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m pip install numpy pillow aiohttp
- name: R120 preflight
run: |
python scripts/preflight_check.py --strict
- name: Run real-backend lane
env:
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_backend_e2e_real
run: |
# CRITICAL: this lane must stay low-mock and exercise real aiohttp request flow.
python scripts/run_unittests.py --module tests.test_r122_real_backend_lane --enforce-skip-policy tests/skip_policy.json --max-skipped 0
# R123: model-list loopback/private-IP SSRF parity must remain no-skip.
python scripts/run_unittests.py --module tests.test_r123_real_backend_model_list_lane --enforce-skip-policy tests/skip_policy.json --max-skipped 0
contract-tests:
name: Contract Tests (R52)
@@ -87,14 +172,24 @@ jobs:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install test deps
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m pip install numpy pillow aiohttp pytest-asyncio
- name: R120 preflight
run: |
python scripts/preflight_check.py --strict
- name: Run contract tests
run: |
python -m pytest tests/contract -v
@@ -103,19 +198,99 @@ jobs:
name: Security Audit (S23)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Frontend Audit (npm)
run: |
# Audit only production dependencies, ignore dev
npm audit --production
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Frontend Audit (npm)
run: |
# Development tooling is part of the build/test trust boundary.
npm ci
npm audit --audit-level=high
- name: Install backend deps
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
- name: Install pip-audit
run: pip install pip-audit
- name: Backend Audit (pip)
run: pip-audit
run: |
# IMPORTANT: audit declared project deps, not the whole CI tool environment.
# Env-wide `pip-audit` also scans pip-audit's own transient dependencies and
# can fail on toolchain-only packages that are outside the repo dependency contract.
pip-audit -r requirements.txt
adversarial-smoke:
name: Adversarial Gate (adaptive)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install test deps
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m pip install numpy pillow aiohttp
- name: R120 preflight
run: |
python scripts/preflight_check.py --strict
- name: R118 adversarial adaptive (auto profile)
env:
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_adversarial
OPENCLAW_DIFF_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
OPENCLAW_DIFF_HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
run: |
python scripts/run_adversarial_gate.py --profile auto --seed 42 --artifact-dir .tmp/adversarial
- name: Upload adversarial artifacts
if: always()
uses: actions/upload-artifact@v6
with:
name: adversarial-adaptive-manifest
path: .tmp/adversarial/
retention-days: 30
adversarial-extended:
name: Adversarial Gate (extended, nightly/manual)
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Supply-chain hardening check
run: |
python scripts/check_supply_chain_hardening.py
- name: Install test deps
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m pip install numpy pillow aiohttp
- name: R120 preflight
run: |
python scripts/preflight_check.py --strict
- name: R118 adversarial extended
env:
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_adversarial_ext
run: |
python scripts/run_adversarial_gate.py --profile extended --artifact-dir .tmp/adversarial-extended
- name: Upload adversarial artifacts
if: always()
uses: actions/upload-artifact@v6
with:
name: adversarial-extended-manifest
path: .tmp/adversarial-extended/
retention-days: 90
+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"
+58 -22
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/
AGENT_CONTEXT.md
ROADMAP.md
AGEN*.md
scripts/sync_split.py
tests_output.txt
node_modules/
playwright-report/
test-results/
playwright/.cache/
openclaw_state/
.tmp/
*.log
moltbot_state/
test_output.txt
test_auth_out.txt
connector_state.json*
*.swp
# Agent/local project exclusions
.pla*/
reference/
REFERENCE/
.reference/
ROA*.md
roa*.md
AG*.md
# Secrets and local environment
.env
.env.*
*.env
!.env.example
!.env.sample
# Python
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
.se*/
.mypy_cache/
.ruff_cache/
.coverage
coverage.xml
htmlcov/
.venv/
.venv-*/
venv/
ENV/
# Node / frontend tests
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
playwright-report/
test-results/
coverage/
# Build, cache, temp, and local tool output
dist/
build/
.cache/
.tmp/
tmp/
temp/
*.log
# OS and editor files
.DS_Store
Thumbs.db
.vscode/
.idea/
*.swp
+28
View File
@@ -29,6 +29,34 @@ repos:
language: python
pass_filenames: false
always_run: true
- id: regen-openapi-spec
name: regenerate OpenAPI spec (staged)
entry: python -B scripts/regenerate_openapi_if_needed.py --staged
language: python
pass_filenames: false
always_run: true
- id: guard-openapi-sync
name: guard generated OpenAPI sync (staged)
entry: python -B scripts/check_openapi_sync.py --staged
language: python
pass_filenames: false
always_run: true
- id: static-analysis-policy
name: incremental Ruff/Mypy static-analysis policy
# Keep isolated pins aligned with requirements-quality.txt and policy JSON.
entry: python -B scripts/verify_static_analysis_policy.py
language: python
additional_dependencies:
- ruff==0.15.20
- mypy==2.2.0
pass_filenames: false
always_run: true
- id: production-dependency-boundary
name: production dependency boundary contract
entry: python -B scripts/verify_production_dependencies.py
language: python
pass_filenames: false
always_run: true
# Secret detection
- repo: https://github.com/Yelp/detect-secrets
+656 -322
View File
File diff suppressed because it is too large Load Diff
-79
View File
@@ -1,79 +0,0 @@
# Release Checklist (DoD)
This document contains the authoritative checklist for releasing **ComfyUI-OpenClaw**.
A release candidate must pass **Gate A** to be considered for Public Release v1.
If the deployment enables remote control or bridge features, it must also pass **Gate B**.
> [!IMPORTANT]
> The validation workflow in `tests/TEST_SOP.md` is **mandatory** for all releases.
---
## Gate A: Public Release v1 Baseline (Required)
**Goal**: Safe-by-default for internet-exposed deployments (assuming they follow the deployment recipes in `docs/deploy/`).
### 1. Security & configuration
- [ ] **Admin Boundary**: `OPENCLAW_CONNECTOR_ADMIN_TOKEN` is required for sensitive operations if remote access is enabled.
- [ ] **Webhooks**: Listening webhooks (Discord/Line/Telegram) are disabled unless their respective tokens are configured (`OPENCLAW_CONNECTOR_DISCORD_TOKEN`, etc.).
- [ ] **Observability**: `/openclaw/logs/tail` and `/openclaw/config` require `OPENCLAW_OBSERVABILITY_TOKEN` (legacy: `MOLTBOT_OBSERVABILITY_TOKEN`) if accessed remotely, or are loopback-only.
- [ ] **SSRF**: LLM `base_url` defaults to known providers. Custom URLs require `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1` or explicit allowlist.
- [ ] **Budgets**: `OPENCLAW_MAX_INFLIGHT_SUBMITS_TOTAL` (concurrency) and `OPENCLAW_MAX_RENDERED_WORKFLOW_BYTES` (payloads) are enforced.
- [ ] **Contracts**: API endpoints match `docs/release/api_contract.md`; Configuration follows `docs/release/config_secrets_contract.md`.
### 2. Documentation & Recipes
- [ ] **Deployment**: `docs/deploy/` contains recipes for:
- [ ] Local-only (Default)
- [ ] Tailscale (Recommended Remote)
- [ ] LAN (Restricted)
- [ ] **Security**: `SECURITY.md` is up-to-date and linked from README.
- [ ] **Feature Flags**: `docs/release/feature_flags.md` accurately reflects the codebase defaults.
### 3. Validation (Must Pass)
Run the full regression suite:
```bash
# 1. Secret Scanning
pre-commit run detect-secrets --all-files
# 2. Lint & Formatting
pre-commit run --all-files --show-diff-on-failure
# 3. Backend Unit Tests
MOLTBOT_STATE_DIR="$(pwd)/moltbot_state/_local_unit" python -m unittest discover -s tests -p "test_*.py" -v
# 4. Frontend E2E (Unit/Integration)
# Ensure Node 18+
node -v
npm test
```
---
## Gate B: Bridge / Remote Control Safety (Conditional)
**Goal**: Safe operation when `OPENCLAW_BRIDGE_ENABLED=1` or remote commands are active.
- [ ] **Explicit Enable**: Bridge features are off unless `OPENCLAW_BRIDGE_ENABLED=1` is set.
- [ ] **Auth**: Bridge endpoints require `OPENCLAW_BRIDGE_TOKEN` (or device pairing).
- [ ] **CSRF**: State-changing endpoints (admin/bridge) enforce Origin checks or require Token on loopback.
- [ ] **Callback Safety**: Delivery targets are validated against DNS/IP allowlists (no internal network access).
- [ ] **DoD**: Operator docs include "Red Lines" (never expose Bridge port directly to internet without auth).
---
## Release Metadata
- [ ] **Version**: `pyproject.toml` version matches git tag.
- [ ] **Changelog**: Updated `CHANGELOG.md` (if present) with user-facing changes.
- [ ] **Migration**: If config/storage schema changed, explicit migration notes are in `docs/migration/` (Optional).
---
## Sign-off
- [ ] **Gate A Passed**: (Date/Initials)
- [ ] **Gate B Passed** (if applicable): (Date/Initials)
-111
View File
@@ -1,111 +0,0 @@
# Security Policy
## Supported Versions
Only the latest version of ComfyUI-OpenClaw is supported for security updates.
| Version | Supported |
| ------- | ------------------ |
| Latest | :white_check_mark: |
| < 0.2.0 | :x: |
## Reporting a Vulnerability
Please report security vulnerabilities by creating a **private** issue on GitHub if possible, or contact the maintainers directly. Do not open public issues for sensitive security flaws.
---
# Safe Deployment Guide (S18)
OpenClaw is a powerful extension that interacts with LLMs and the filesystem (via ComfyUI). **By default, it is designed for local (localhost) use.** Exposing it to the public internet requires careful configuration.
## ⚠️ Warning
**Do NOT expose your ComfyUI instance directly to the public internet** (e.g., port forwarding 8188) without a secure reverse proxy or VPN.
## Recommended Deployment
1. **Localhost (Default)**: Use on your own machine. No extra config needed.
2. **VPN / Tailscale**: Best for private remote access.
3. **SSH Tunnel**: `ssh -L 8188:localhost:8188 user@remote`
## Reverse Proxy Setup (Advanced)
If you must expose OpenClaw via a reverse proxy (Nginx, Caddy, Cloudflare Tunnel), you MUST configure the following:
### 1. Observability Access Control (S14)
Logs (`/openclaw/logs/tail`) and Config (`/openclaw/config`) are restricted to loopback clients by default. (Legacy `/moltbot/*` endpoints are also supported.) To allow remote access via proxy, set a secure token:
```bash
export OPENCLAW_OBSERVABILITY_TOKEN="your-secure-random-token-here"
# Legacy compatibility (optional):
# export MOLTBOT_OBSERVABILITY_TOKEN="your-secure-random-token-here"
```
Then configure your proxy or client to send the header `X-OpenClaw-Obs-Token: your-secure-random-token-here` (legacy: `X-Moltbot-Obs-Token`).
### 2. Trusted Proxies (S6)
If using a reverse proxy, OpenClaw needs to know the *real* client IP for rate limiting enforcement.
Configure your proxy (e.g., Nginx) to send `X-Forwarded-For`. Then tell Moltbot to trust your proxy's IP:
```bash
export MOLTBOT_TRUST_X_FORWARDED_FOR=1
# Comma-separated list of trusted proxy IPs or CIDRs
export MOLTBOT_TRUSTED_PROXIES="127.0.0.1,10.0.0.0/8"
```
New names (preferred):
```bash
export OPENCLAW_TRUST_X_FORWARDED_FOR=1
export OPENCLAW_TRUSTED_PROXIES="127.0.0.1,10.0.0.0/8"
```
### 3. SSRF Protection (S16)
OpenClaw validates custom LLM `base_url` settings to prevent Server-Side Request Forgery (SSRF).
* **Default**: Only known providers (OpenAI, Anthropic, etc.) and Localhost (Ollama) are allowed.
* **Custom URLs**: Must be explicitly enabled:
```bash
export OPENCLAW_ALLOW_CUSTOM_BASE_URL=1
# Legacy compatibility (optional):
# export MOLTBOT_ALLOW_CUSTOM_BASE_URL=1
```
Even when enabled, private IPs (LAN) are blocked by default. To allow insecure/LAN base URLs (risky):
```bash
export OPENCLAW_ALLOW_INSECURE_BASE_URL=1
# Legacy compatibility (optional):
# export MOLTBOT_ALLOW_INSECURE_BASE_URL=1
```
### 4. Rate Limiting (S17)
OpenClaw enforces internal rate limits even if you don't.
* Webhooks: 30/min
* Logs: 60/min
* Admin: 20/min
* Admin: 20/min
### 5. Sidecar Bridge (S19)
OpenClaw supports a "Sidecar Bridge" (F10) for safe interaction with external bots (Discord/Slack).
* **Default**: **DISABLED**.
* **Enable**: Set `OPENCLAW_BRIDGE_ENABLED=1` (legacy `MOLTBOT_BRIDGE_ENABLED=1`).
* **Authentication**: Requires `OPENCLAW_BRIDGE_DEVICE_TOKEN` (legacy `MOLTBOT_BRIDGE_DEVICE_TOKEN`) (shared secret).
* **Network**: Bridge endpoints (`/bridge/*`) are sensitive. **Do not expose to public internet.** Use a private network (Tailscale) or restrict access via reverse proxy.
* **SSRF**: Callback delivery blocks internal IPs. To allow specific external callback hosts, set `OPENCLAW_BRIDGE_CALLBACK_HOST_ALLOWLIST` (legacy: `MOLTBOT_BRIDGE_CALLBACK_HOST_ALLOWLIST`).
## Security Checklist
* [ ] **Authentication**: Your reverse proxy should handle general auth (Basic Auth, OAuth).
* [ ] **HTTPS**: Always use TLS/SSL.
* [ ] **Tokens**: Set `OPENCLAW_OBSERVABILITY_TOKEN` and `OPENCLAW_ADMIN_TOKEN` (for config writes). (Legacy `MOLTBOT_*` vars still work.)
* [ ] **Isolation**: Don't run as root.
+42 -252
View File
@@ -2,21 +2,23 @@ import os
import sys
# Ensure this custom node root is on sys.path (ComfyUI loads modules by path, not package)
_MOLTBOT_ROOT = os.path.dirname(os.path.abspath(__file__))
if _MOLTBOT_ROOT not in sys.path:
sys.path.insert(0, _MOLTBOT_ROOT)
_OPENCLAW_ROOT = os.path.dirname(os.path.abspath(__file__))
if _OPENCLAW_ROOT not in sys.path:
sys.path.insert(0, _OPENCLAW_ROOT)
if __package__:
from .nodes.batch_variants import MoltbotBatchVariants
from .nodes.image_to_prompt import MoltbotImageToPrompt
from .nodes.prompt_planner import MoltbotPromptPlanner
from .nodes.prompt_refiner import MoltbotPromptRefiner
from .nodes.batch_variants import OpenClawBatchVariants
from .nodes.image_to_prompt import OpenClawImageToPrompt
from .nodes.portability_contract import NODE_PORTABILITY_MAPPINGS
from .nodes.prompt_planner import OpenClawPromptPlanner
from .nodes.prompt_refiner import OpenClawPromptRefiner
NODE_CLASS_MAPPINGS = {
"MoltbotPromptPlanner": MoltbotPromptPlanner,
"MoltbotBatchVariants": MoltbotBatchVariants,
"MoltbotImageToPrompt": MoltbotImageToPrompt,
"MoltbotPromptRefiner": MoltbotPromptRefiner,
# IMPORTANT: keep legacy mapping keys stable for existing workflows.
"MoltbotPromptPlanner": OpenClawPromptPlanner,
"MoltbotBatchVariants": OpenClawBatchVariants,
"MoltbotImageToPrompt": OpenClawImageToPrompt,
"MoltbotPromptRefiner": OpenClawPromptRefiner,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -26,257 +28,45 @@ if __package__:
"MoltbotPromptRefiner": "openclaw: Prompt Refiner",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
__all__ = [
"NODE_CLASS_MAPPINGS",
"NODE_DISPLAY_NAME_MAPPINGS",
"NODE_PORTABILITY_MAPPINGS",
"WEB_DIRECTORY",
]
else:
# Allow test collection to proceed without crashing on relative imports
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
__all__ = ["WEB_DIRECTORY"]
NODE_PORTABILITY_MAPPINGS = {}
__all__ = ["NODE_PORTABILITY_MAPPINGS", "WEB_DIRECTORY"]
WEB_DIRECTORY = "./web"
# Register API routes (observability endpoints)
_routes_registered = False
import logging
import threading
import time
def _bootstrap_openclaw_routes() -> None:
# IMPORTANT: keep entrypoint thin; heavy startup orchestration lives in
# services.route_bootstrap to prevent __init__.py from regressing into a
# large mixed-responsibility module again.
try:
if __package__:
from .services.route_bootstrap import register_routes_once
else:
from services.route_bootstrap import register_routes_once
except Exception as exc:
try:
if __package__:
from .services.startup_lifecycle import mark_bootstrap_import_failed
else:
from services.startup_lifecycle import mark_bootstrap_import_failed
def _register_routes_once():
global _routes_registered
if _routes_registered:
mark_bootstrap_import_failed(exc)
except Exception:
# IMPORTANT: diagnostics must not mask the original compatibility fallback.
pass
return
# R23: Register Plugins
# This needs to happen regardless of PromptServer availability, as plugins might register other things.
try:
from .services.plugins.builtin import register_all
register_all()
except Exception as e:
logging.getLogger("ComfyUI-OpenClaw").error(f"Failed to register plugins: {e}")
def _do_full_registration(server):
"""Register all Moltbot routes including Bridge and Scheduler."""
from .api.approvals import register_approval_routes
from .api.bridge import BridgeHandlers
from .api.presets import register_preset_routes
from .api.routes import register_routes
from .api.schedules import register_schedule_routes
from .api.triggers import register_trigger_routes
from .services.access_control import require_admin_token
from .services.plugins.async_bridge import run_async_in_sync_context
from .services.queue_submit import submit_prompt
from .services.templates import get_template_service
register_routes(server)
register_preset_routes(server.app)
# R4: Register schedule CRUD routes
register_schedule_routes(server.app, require_admin_token_fn=require_admin_token)
# Bridge: Adapt functional submit_prompt to service interface
class QueueSubmitService:
def submit(self, job_req):
tmpl_svc = get_template_service()
workflow = tmpl_svc.render_template(job_req.template_id, job_req.inputs)
async def _do_submit():
return await submit_prompt(
workflow,
client_id=job_req.session_id or "bridge",
extra_data={
"openclaw": {"trace_id": job_req.trace_id},
# Legacy key kept for existing tooling that expects this blob.
"moltbot": {"trace_id": job_req.trace_id},
},
source="bridge",
trace_id=job_req.trace_id,
)
return run_async_in_sync_context(_do_submit())
bridge_handlers = BridgeHandlers(submit_service=QueueSubmitService())
# Dual registration for bridge (R26)
if hasattr(server.app.router, "add_post"): # Redundant check but safe
# Legacy
server.app.router.add_post(
"/moltbot/bridge/submit", bridge_handlers.submit_handler
)
server.app.router.add_post(
"/moltbot/bridge/deliver", bridge_handlers.deliver_handler
)
server.app.router.add_get(
"/moltbot/bridge/health", bridge_handlers.health_handler
)
# New prefix
server.app.router.add_post(
"/openclaw/bridge/submit", bridge_handlers.submit_handler
)
server.app.router.add_post(
"/openclaw/bridge/deliver", bridge_handlers.deliver_handler
)
server.app.router.add_get(
"/openclaw/bridge/health", bridge_handlers.health_handler
)
# /api Prefixed
try:
server.app.router.add_post(
"/api/moltbot/bridge/submit", bridge_handlers.submit_handler
)
server.app.router.add_post(
"/api/moltbot/bridge/deliver", bridge_handlers.deliver_handler
)
server.app.router.add_get(
"/api/moltbot/bridge/health", bridge_handlers.health_handler
)
server.app.router.add_post(
"/api/openclaw/bridge/submit", bridge_handlers.submit_handler
)
server.app.router.add_post(
"/api/openclaw/bridge/deliver", bridge_handlers.deliver_handler
)
server.app.router.add_get(
"/api/openclaw/bridge/health", bridge_handlers.health_handler
)
except RuntimeError:
pass
# Shared submit function for scheduler and triggers
async def unified_submit_fn(
template_id,
inputs,
trace_id,
idempotency_key,
delivery=None,
source="unknown",
):
"""Submit function for scheduler and trigger-triggered runs."""
# NOTE: Use IdempotencyStore API (check_and_record/update_prompt_id).
# Avoid legacy get_store/get/set usage; wrong API here breaks route registration at runtime.
from .services.idempotency_store import IdempotencyStore
from .services.queue_submit import submit_prompt
from .services.templates import get_template_service
# Check idempotency
store = IdempotencyStore()
is_dup, existing_prompt_id = store.check_and_record(idempotency_key)
if is_dup:
return {"prompt_id": existing_prompt_id, "deduped": True}
# Render template
tmpl_svc = get_template_service()
workflow = tmpl_svc.render_template(template_id, inputs)
# Submit
result = await submit_prompt(
workflow,
extra_data={
"openclaw": {"trace_id": trace_id, "source": "automation"},
"moltbot": {"trace_id": trace_id, "source": "automation"},
},
source=source,
trace_id=trace_id,
)
# Store for dedupe
if result.get("prompt_id"):
store.update_prompt_id(idempotency_key, result["prompt_id"])
return result
# R4: Start scheduler daemon
from .services.scheduler.runner import get_scheduler_runner, start_scheduler
runner = get_scheduler_runner()
runner._submit_fn = unified_submit_fn
start_scheduler()
# F6: Register trigger routes
register_trigger_routes(
server.app,
require_admin_token_fn=require_admin_token,
submit_fn=unified_submit_fn,
)
# S7: Register approval routes (with execution capability)
register_approval_routes(
server.app,
require_admin_token_fn=require_admin_token,
submit_fn=unified_submit_fn,
)
def start_registration_retry_loop():
"""
R25: Background loop to ensure routes are registered even if PromptServer is slow to init.
Attempts 10 times with exponential backoff.
"""
def _retry_worker():
global _routes_registered
attempts = 0
max_attempts = 10
delay = 2.0
logger = logging.getLogger("ComfyUI-OpenClaw")
while not _routes_registered and attempts < max_attempts:
try:
ps_mod = sys.modules.get("server")
PromptServer = (
getattr(ps_mod, "PromptServer", None) if ps_mod else None
)
if (
PromptServer
and getattr(PromptServer, "instance", None) is not None
):
_do_full_registration(PromptServer.instance)
_routes_registered = True
logger.info(
f"Routes registered successfully on attempt {attempts + 1}"
)
return
logger.debug(
f"PromptServer.instance not ready (attempt {attempts + 1})"
)
except Exception as e:
logger.exception(
f"Error registering routes (attempt {attempts + 1})"
)
time.sleep(delay)
delay = min(delay * 1.5, 30)
attempts += 1
if not _routes_registered:
logger.error(
f"Failed to register routes after {max_attempts} attempts. API endpoints unavailable."
)
t = threading.Thread(
target=_retry_worker, name="openclaw-route-retry", daemon=True
)
t.start()
# Initial attempt + Start background retry
try:
ps_mod = sys.modules.get("server")
PromptServer = getattr(ps_mod, "PromptServer", None) if ps_mod else None
if PromptServer and getattr(PromptServer, "instance", None) is not None:
_do_full_registration(PromptServer.instance)
_routes_registered = True
logging.getLogger("ComfyUI-OpenClaw").info(
"Routes registered successfully on initial attempt."
)
else:
logging.getLogger("ComfyUI-OpenClaw").info(
"PromptServer not ready, starting background registration retry loop..."
)
start_registration_retry_loop()
except Exception as e:
logging.getLogger("ComfyUI-OpenClaw").exception("Route registration failed")
register_routes_once()
_register_routes_once()
_bootstrap_openclaw_routes()
+295 -70
View File
@@ -3,22 +3,39 @@ Approval API Endpoints (S7/F12).
REST endpoints for managing approval requests.
"""
from __future__ import annotations
import logging
from typing import Callable, Optional
from aiohttp import web
try:
from ..services.access_control import resolve_token_info
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.approvals.models import ApprovalStatus
from ..services.approvals.service import get_approval_service
from ..services.audit import emit_audit_event
from ..services.management_query import bounded_scan_collect, normalize_limit_offset
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
from ..services.webhook_auth import AuthError
except ImportError:
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
from services.access_control import resolve_token_info # type: ignore
from services.aiohttp_compat import import_aiohttp_web
from services.approvals.models import ApprovalStatus
from services.approvals.service import get_approval_service
from services.audit import emit_audit_event # type: ignore
from services.management_query import ( # type: ignore
bounded_scan_collect,
normalize_limit_offset,
)
from services.tenant_context import ( # type: ignore
TenantBoundaryError,
request_tenant_scope,
)
from services.webhook_auth import AuthError
logger = logging.getLogger("ComfyUI-OpenClaw.api.approvals")
web = import_aiohttp_web()
class ApprovalHandlers:
@@ -51,19 +68,64 @@ class ApprovalHandlers:
if not allowed:
raise AuthError(error or "Unauthorized")
def _audit(
self,
*,
request: web.Request,
action: str,
target: str,
outcome: str,
status_code: int,
details: Optional[dict] = None,
) -> None:
try:
token_info = resolve_token_info(request)
except Exception:
token_info = None
emit_audit_event(
action=action,
target=target,
outcome=outcome,
token_info=token_info,
status_code=status_code,
details=details or {},
request=request,
)
async def list_approvals(self, request: web.Request) -> web.Response:
"""GET /moltbot/approvals - List approval requests."""
try:
await self._check_auth(request)
except AuthError as e:
self._audit(
request=request,
action="approvals.list",
target="approvals",
outcome="deny",
status_code=403,
details={"reason": str(e)},
)
return web.json_response({"error": str(e)}, status=403)
except Exception:
self._audit(
request=request,
action="approvals.list",
target="approvals",
outcome="deny",
status_code=403,
details={"reason": "unauthorized"},
)
return web.json_response({"error": "Unauthorized"}, status=403)
# Parse query params
status_filter = request.query.get("status")
limit = int(request.query.get("limit", "100"))
offset = int(request.query.get("offset", "0"))
page = normalize_limit_offset(
request.query,
default_limit=100,
max_limit=500,
default_offset=0,
max_offset=5000,
)
# Validate and convert status
status = None
@@ -75,45 +137,125 @@ class ApprovalHandlers:
{"error": f"Invalid status: {status_filter}"}, status=400
)
# Get approvals
approvals = self._service.list_all(
status=status,
limit=min(limit, 500),
offset=offset,
)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
# Get approvals
# R95: bounded scan window protects API serialization path and keeps
# malformed-record behavior deterministic without swallowing service errors.
scan_cap = max(page.offset + page.limit + 200, page.limit * 10)
approvals = self._service.list_all(
status=status,
limit=min(scan_cap, 5000),
offset=0,
tenant_id=tenant.tenant_id,
)
page_result = bounded_scan_collect(
approvals,
skip=page.offset,
take=page.limit,
scan_cap=min(scan_cap, 5000),
serializer=lambda a: a.to_dict(),
)
return web.json_response(
{
"approvals": [a.to_dict() for a in approvals],
"count": len(approvals),
"pending_count": self._service.count_pending(),
}
)
return web.json_response(
{
"tenant_id": tenant.tenant_id,
"approvals": page_result.items,
"count": len(page_result.items),
"pending_count": self._service.count_pending(
tenant_id=tenant.tenant_id
),
"pagination": {
"limit": page.limit,
"offset": page.offset,
"warnings": page.warnings,
},
"scan": page_result.to_dict(),
}
)
except TenantBoundaryError as exc:
return web.json_response(
{"error": exc.code, "message": str(exc)},
status=403,
)
async def get_approval(self, request: web.Request) -> web.Response:
"""GET /moltbot/approvals/{approval_id} - Get a single approval."""
try:
await self._check_auth(request)
except AuthError as e:
self._audit(
request=request,
action="approvals.get",
target=request.match_info.get("approval_id", ""),
outcome="deny",
status_code=403,
details={"reason": str(e)},
)
return web.json_response({"error": str(e)}, status=403)
except Exception:
self._audit(
request=request,
action="approvals.get",
target=request.match_info.get("approval_id", ""),
outcome="deny",
status_code=403,
details={"reason": "unauthorized"},
)
return web.json_response({"error": "Unauthorized"}, status=403)
approval_id = request.match_info.get("approval_id", "")
approval = self._service.get(approval_id)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
approval = self._service.get(approval_id, tenant_id=tenant.tenant_id)
if not approval:
return web.json_response({"error": "Approval not found"}, status=404)
if not approval:
return web.json_response(
{"error": "Approval not found"}, status=404
)
return web.json_response({"approval": approval.to_dict()})
return web.json_response(
{"tenant_id": tenant.tenant_id, "approval": approval.to_dict()}
)
except TenantBoundaryError as exc:
return web.json_response(
{"error": exc.code, "message": str(exc)},
status=403,
)
async def approve_request(self, request: web.Request) -> web.Response:
"""POST /moltbot/approvals/{approval_id}/approve - Approve and execute request."""
try:
await self._check_auth(request)
except AuthError as e:
self._audit(
request=request,
action="approvals.approve",
target=request.match_info.get("approval_id", ""),
outcome="deny",
status_code=403,
details={"reason": str(e)},
)
return web.json_response({"error": str(e)}, status=403)
except Exception:
self._audit(
request=request,
action="approvals.approve",
target=request.match_info.get("approval_id", ""),
outcome="deny",
status_code=403,
details={"reason": "unauthorized"},
)
return web.json_response({"error": "Unauthorized"}, status=403)
approval_id = request.match_info.get("approval_id", "")
@@ -128,60 +270,97 @@ class ApprovalHandlers:
except Exception:
pass # No body is fine
token_info = resolve_token_info(request)
try:
# First approve the request
approval = self._service.approve(approval_id, actor=actor)
logger.info(f"Approved request: {approval_id}")
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
# First approve the request
approval = self._service.approve(
approval_id, actor=actor, tenant_id=tenant.tenant_id
)
logger.info(f"Approved request: {approval_id}")
result = {
"approved": True,
"approval": approval.to_dict(),
}
result = {
"tenant_id": tenant.tenant_id,
"approved": True,
"approval": approval.to_dict(),
}
# Execute if requested and submit_fn is available
if auto_execute and self._submit_fn:
try:
from .triggers import execute_approved_trigger
# Execute if requested and submit_fn is available
if auto_execute and self._submit_fn:
try:
from .triggers import execute_approved_trigger
exec_result = await execute_approved_trigger(
approval_id=approval_id,
submit_fn=self._submit_fn,
)
exec_result = await execute_approved_trigger(
approval_id=approval_id,
submit_fn=self._submit_fn,
)
result["executed"] = True
result["prompt_id"] = exec_result.get("prompt_id")
result["trace_id"] = exec_result.get("trace_id")
result["executed"] = True
result["prompt_id"] = exec_result.get("prompt_id")
result["trace_id"] = exec_result.get("trace_id")
if result.get("prompt_id"):
# NOTE: Persist executed_prompt_id so connector can deliver results
# after UI approvals. Do not remove without updating connector.
try:
self._service.record_execution(
approval_id,
prompt_id=result.get("prompt_id"),
trace_id=result.get("trace_id"),
actor=actor,
)
except Exception as record_error:
logger.error(
"Failed to record approval execution metadata: "
f"{record_error}"
)
if result.get("prompt_id"):
# NOTE: Persist executed_prompt_id so connector can deliver results
# after UI approvals. Do not remove without updating connector.
try:
self._service.record_execution(
approval_id,
prompt_id=result.get("prompt_id"),
trace_id=result.get("trace_id"),
actor=actor,
tenant_id=tenant.tenant_id,
)
except Exception as record_error:
logger.error(
"Failed to record approval execution metadata: "
f"{record_error}"
)
logger.info(
f"Executed approved trigger: {approval_id} -> {result.get('prompt_id')}"
)
logger.info(
f"Executed approved trigger: {approval_id} -> {result.get('prompt_id')}"
)
except Exception as exec_error:
logger.error(f"Failed to execute approved trigger: {exec_error}")
except Exception as exec_error:
logger.error(
f"Failed to execute approved trigger: {exec_error}"
)
result["executed"] = False
result["execution_error"] = str(exec_error)
else:
result["executed"] = False
result["execution_error"] = str(exec_error)
else:
result["executed"] = False
return web.json_response(result)
self._audit(
request=request,
action="approvals.approve",
target=approval_id,
outcome="allow",
status_code=200,
details={
"tenant_id": tenant.tenant_id,
"executed": result.get("executed", False),
"actor": actor,
},
)
return web.json_response(result)
except TenantBoundaryError as exc:
return web.json_response(
{"error": exc.code, "message": str(exc)},
status=403,
)
except ValueError as e:
self._audit(
request=request,
action="approvals.approve",
target=approval_id,
outcome="error",
status_code=400,
details={"error": str(e), "actor": actor},
)
return web.json_response({"error": str(e)}, status=400)
async def reject_request(self, request: web.Request) -> web.Response:
@@ -189,8 +368,24 @@ class ApprovalHandlers:
try:
await self._check_auth(request)
except AuthError as e:
self._audit(
request=request,
action="approvals.reject",
target=request.match_info.get("approval_id", ""),
outcome="deny",
status_code=403,
details={"reason": str(e)},
)
return web.json_response({"error": str(e)}, status=403)
except Exception:
self._audit(
request=request,
action="approvals.reject",
target=request.match_info.get("approval_id", ""),
outcome="deny",
status_code=403,
details={"reason": "unauthorized"},
)
return web.json_response({"error": "Unauthorized"}, status=403)
approval_id = request.match_info.get("approval_id", "")
@@ -203,18 +398,48 @@ class ApprovalHandlers:
except Exception:
pass
token_info = resolve_token_info(request)
try:
approval = self._service.reject(approval_id, actor=actor)
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
approval = self._service.reject(
approval_id, actor=actor, tenant_id=tenant.tenant_id
)
logger.info(f"Rejected request: {approval_id}")
logger.info(f"Rejected request: {approval_id}")
self._audit(
request=request,
action="approvals.reject",
target=approval_id,
outcome="allow",
status_code=200,
details={"tenant_id": tenant.tenant_id, "actor": actor},
)
return web.json_response(
{
"tenant_id": tenant.tenant_id,
"rejected": True,
"approval": approval.to_dict(),
}
)
except TenantBoundaryError as exc:
return web.json_response(
{
"rejected": True,
"approval": approval.to_dict(),
}
{"error": exc.code, "message": str(exc)},
status=403,
)
except ValueError as e:
self._audit(
request=request,
action="approvals.reject",
target=approval_id,
outcome="error",
status_code=400,
details={"error": str(e), "actor": actor},
)
return web.json_response({"error": str(e)}, status=400)
+572 -68
View File
@@ -1,103 +1,168 @@
import logging
from __future__ import annotations
from aiohttp import web
import asyncio
import contextlib
import json
import logging
from typing import Any, Dict, Optional
try:
from ..services.access_control import require_admin_token
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.async_utils import run_in_thread
from ..services.automation_composer import AutomationComposerService
from ..services.planner import PlannerService
from ..services.rate_limit import check_rate_limit
from ..services.planner_registry import get_planner_registry
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.reasoning_redaction import (
audit_reasoning_reveal,
resolve_reasoning_reveal,
sanitize_operator_payload,
)
from ..services.refiner import RefinerService
except ImportError:
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
from services.access_control import require_admin_token
from services.aiohttp_compat import import_aiohttp_web
from services.async_utils import run_in_thread
from services.automation_composer import AutomationComposerService
from services.planner import PlannerService
from services.rate_limit import check_rate_limit
from services.planner_registry import get_planner_registry
from services.rate_limit import build_rate_limit_response, check_rate_limit
from services.reasoning_redaction import (
audit_reasoning_reveal,
resolve_reasoning_reveal,
sanitize_operator_payload,
)
from services.refiner import RefinerService
# R98: Endpoint Metadata
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
else:
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.assist")
web = import_aiohttp_web()
# Payload size limits (character count for strings, base64 length for images)
MAX_REQUIREMENTS_LEN = 8000
MAX_STYLE_LEN = 2000
MAX_IMAGE_B64_LEN = 5 * 1024 * 1024 # ~5MB base64 string length
MAX_STREAM_DELTA_CHARS = 256
MAX_STREAM_PREVIEW_CHARS = 16_000
STREAM_KEEPALIVE_SEC = 1.0
def _planner_profiles_payload() -> Dict[str, Any]:
registry = get_planner_registry()
return {
"profiles": [
{
"id": profile.id,
"label": profile.label,
"description": profile.description,
"version": profile.version,
}
for profile in registry.list_profiles()
],
"default_profile": registry.get_default_profile_id(),
}
class AssistHandlers:
def __init__(self):
self.planner = PlannerService()
self.refiner = RefinerService()
self.composer = AutomationComposerService()
async def planner_handler(self, request):
"""
POST /openclaw/assist/planner (legacy: /moltbot/assist/planner)
JSON: { profile, requirements, style_directives, seed }
"""
# Security: Admin Token required
authorized, err_msg = require_admin_token(request)
async def _require_admin_and_rate_limit(
self, request: web.Request
) -> Optional[web.Response]:
authorized, _err_msg = require_admin_token(request)
if not authorized:
return web.json_response({"error": "Unauthorized"}, status=401)
# Security: Rate Limit
if not check_rate_limit(request, "admin"):
return web.json_response({"error": "Rate limit exceeded"}, status=429)
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=False,
)
return None
async def _parse_json_body(
self, request: web.Request
) -> tuple[Optional[dict], Optional[web.Response]]:
try:
data = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
return None, web.json_response({"error": "Invalid JSON"}, status=400)
if not isinstance(data, dict):
return None, web.json_response(
{"error": "JSON object required"}, status=400
)
return data, None
profile = data.get("profile", "SDXL-v1")
def _validate_planner_payload(
self, data: dict
) -> tuple[Optional[dict], Optional[web.Response]]:
registry = get_planner_registry()
profile = data.get("profile", registry.get_default_profile_id())
requirements = data.get("requirements", "")
style = data.get("style_directives", "")
seed = data.get("seed", 0)
# Security: Payload size clamps
if not isinstance(profile, str):
return None, web.json_response(
{"error": "profile must be string"}, status=400
)
if not registry.get_profile(profile):
return None, web.json_response(
{"error": f"Unknown profile: {profile}"}, status=400
)
if not isinstance(requirements, str):
return None, web.json_response(
{"error": "requirements must be string"}, status=400
)
if not isinstance(style, str):
return None, web.json_response(
{"error": "style_directives must be string"}, status=400
)
if len(requirements) > MAX_REQUIREMENTS_LEN:
return web.json_response(
return None, web.json_response(
{"error": f"requirements exceeds {MAX_REQUIREMENTS_LEN} chars"},
status=400,
)
if len(style) > MAX_STYLE_LEN:
return web.json_response(
return None, web.json_response(
{"error": f"style_directives exceeds {MAX_STYLE_LEN} chars"}, status=400
)
try:
# Run sync LLM call in thread pool to avoid blocking event loop
pos, neg, params = await run_in_thread(
self.planner.plan_generation, profile, requirements, style, seed
)
return web.json_response(
{"positive": pos, "negative": neg, "params": params}
)
except Exception as e:
logger.exception("Planner API failed")
return web.json_response({"error": "Internal server error"}, status=500)
async def refiner_handler(self, request):
"""
POST /openclaw/assist/refiner (legacy: /moltbot/assist/refiner)
JSON: { image_b64, orig_positive, orig_negative, issue, params_json, goal }
"""
# Security checks
authorized, err_msg = require_admin_token(request)
if not authorized:
return web.json_response({"error": "Unauthorized"}, status=401)
# Security: Rate Limit
if not check_rate_limit(request, "admin"):
return web.json_response({"error": "Rate limit exceeded"}, status=429)
try:
data = await request.json()
seed = int(seed)
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
seed = 0
return {
"profile": profile,
"requirements": requirements,
"style_directives": style,
"seed": seed,
}, None
# Extract payload (aligned with service signature)
def _validate_refiner_payload(
self, data: dict
) -> tuple[Optional[dict], Optional[web.Response]]:
image_b64 = data.get("image_b64", "")
orig_pos = data.get("orig_positive", "")
orig_neg = data.get("orig_negative", "")
@@ -105,37 +170,476 @@ class AssistHandlers:
params_json = data.get("params_json", "{}")
goal = data.get("goal", "Fix issues")
# Validation & Size clamps
if not image_b64:
return web.json_response({"error": "image_b64 required"}, status=400)
if not isinstance(image_b64, str) or not image_b64:
return None, web.json_response({"error": "image_b64 required"}, status=400)
if len(image_b64) > MAX_IMAGE_B64_LEN:
return web.json_response(
return None, web.json_response(
{"error": f"image_b64 exceeds {MAX_IMAGE_B64_LEN // 1024 // 1024}MB"},
status=400,
)
for key, value in (
("orig_positive", orig_pos),
("orig_negative", orig_neg),
("issue", issue),
("params_json", params_json),
("goal", goal),
):
if not isinstance(value, str):
return None, web.json_response(
{"error": f"{key} must be string"}, status=400
)
if len(orig_pos) > MAX_REQUIREMENTS_LEN or len(orig_neg) > MAX_REQUIREMENTS_LEN:
return web.json_response({"error": "Prompt too long"}, status=400)
return None, web.json_response({"error": "Prompt too long"}, status=400)
return {
"image_b64": image_b64,
"orig_positive": orig_pos,
"orig_negative": orig_neg,
"issue": issue,
"params_json": params_json,
"goal": goal,
}, None
def _finalize_operator_payload(
self,
request: web.Request,
*,
target: str,
payload: Dict[str, Any],
service: Any,
) -> Dict[str, Any]:
admin_allowed, _ = require_admin_token(request)
reveal = resolve_reasoning_reveal(request, admin_authorized=admin_allowed)
audit_reasoning_reveal(request, target=target, decision=reveal)
final_payload = sanitize_operator_payload(payload)
consume_debug = getattr(service, "consume_last_reasoning_debug", None)
reasoning_debug = consume_debug() if callable(consume_debug) else None
if reveal["allowed"] and reasoning_debug not in (None, {}, []):
final_payload = dict(final_payload)
final_payload["debug"] = {"reasoning": reasoning_debug}
return final_payload
@staticmethod
def _sse_frame(event: str, payload: Dict[str, Any]) -> bytes:
return (
f"event: {event}\n"
f"data: {json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}\n\n"
).encode("utf-8")
async def _write_sse_event(
self, response: web.StreamResponse, event: str, payload: Dict[str, Any]
) -> bool:
try:
await response.write(self._sse_frame(event, payload))
return True
except (ConnectionError, RuntimeError):
return False
async def _assist_stream_session(
self,
request: web.Request,
*,
kind: str,
worker_fn,
worker_kwargs: Dict[str, Any],
) -> web.StreamResponse:
response = web.StreamResponse(
status=200,
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
await response.prepare(request)
loop = asyncio.get_running_loop()
queue: asyncio.Queue = asyncio.Queue()
preview_chars = 0
def emit(event: str, payload: Dict[str, Any]) -> None:
try:
loop.call_soon_threadsafe(queue.put_nowait, (event, payload))
except RuntimeError:
pass
def on_text_delta(delta: str) -> None:
nonlocal preview_chars
if not isinstance(delta, str) or not delta:
return
remaining = MAX_STREAM_PREVIEW_CHARS - preview_chars
if remaining <= 0:
return
clipped = delta[: min(remaining, MAX_STREAM_DELTA_CHARS)]
if not clipped:
return
preview_chars += len(clipped)
emit("delta", {"text": clipped, "preview_chars": preview_chars})
async def runner() -> None:
emit("ready", {"ok": True, "kind": kind, "mode": "sse"})
emit(
"stage", {"phase": "dispatch", "message": "Dispatching assist request"}
)
try:
call_kwargs = dict(worker_kwargs)
call_kwargs["on_text_delta"] = on_text_delta
result = await run_in_thread(worker_fn, **call_kwargs)
emit(
"stage",
{"phase": "finalize", "message": "Parsing and validating output"},
)
if kind == "planner":
pos, neg, params = result
final_payload = {
"positive": pos,
"negative": neg,
"params": params,
}
elif kind == "refiner":
new_pos, new_neg, patch, rationale = result
final_payload = {
"refined_positive": new_pos,
"refined_negative": new_neg,
"param_patch": patch,
"rationale": rationale,
}
else:
final_payload = {"result": result}
service = (
self.planner
if kind == "planner"
else self.refiner if kind == "refiner" else self.composer
)
final_payload = self._finalize_operator_payload(
request,
target=f"assist.{kind}.stream",
payload=final_payload,
service=service,
)
emit(
"final",
{
"ok": True,
"kind": kind,
"result": final_payload,
"streaming": {
"preview_chars": preview_chars,
"preview_truncated": preview_chars
>= MAX_STREAM_PREVIEW_CHARS,
},
},
)
except Exception as e:
logger.exception("Assist streaming API failed (%s)", kind)
emit(
"error",
{"ok": False, "kind": kind, "error": "Internal server error"},
)
finally:
emit("__done__", {})
runner_task = asyncio.create_task(runner())
try:
while True:
try:
event, payload = await asyncio.wait_for(
queue.get(), timeout=STREAM_KEEPALIVE_SEC
)
except asyncio.TimeoutError:
if runner_task.done():
break
if not await self._write_sse_event(
response, "keepalive", {"ok": True}
):
break
continue
if event == "__done__":
break
if not await self._write_sse_event(response, event, payload):
break
finally:
if not runner_task.done():
runner_task.cancel()
with contextlib.suppress(BaseException):
await runner_task
return response
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="List planner profiles",
description="Returns Prompt Planner profiles from the active registry.",
audit="assist.planner_profiles",
plane=RoutePlane.ADMIN,
)
async def planner_profiles_handler(self, request):
auth_resp = await self._require_admin_and_rate_limit(request)
if auth_resp:
return auth_resp
return web.json_response(_planner_profiles_payload())
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Run planner",
description="Generate prompts from requirements via LLM.",
audit="assist.planner",
plane=RoutePlane.ADMIN,
)
async def planner_handler(self, request):
"""
POST /openclaw/assist/planner (legacy: /moltbot/assist/planner)
JSON: { profile, requirements, style_directives, seed }
"""
# Security: Admin Token required
auth_resp = await self._require_admin_and_rate_limit(request)
if auth_resp:
return auth_resp
data, error_resp = await self._parse_json_body(request)
if error_resp:
return error_resp
assert data is not None
payload, payload_err = self._validate_planner_payload(data)
if payload_err:
return payload_err
assert payload is not None
try:
# Run sync LLM call in thread pool to avoid blocking event loop
pos, neg, params = await run_in_thread(
self.planner.plan_generation,
payload["profile"],
payload["requirements"],
payload["style_directives"],
payload["seed"],
)
return web.json_response(
self._finalize_operator_payload(
request,
target="assist.planner",
payload={"positive": pos, "negative": neg, "params": params},
service=self.planner,
)
)
except Exception as e:
logger.exception("Planner API failed")
return web.json_response({"error": "Internal server error"}, status=500)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Run refiner",
description="Refine prompt/parameters based on feedback.",
audit="assist.refiner",
plane=RoutePlane.ADMIN,
)
async def refiner_handler(self, request):
"""
POST /openclaw/assist/refiner (legacy: /moltbot/assist/refiner)
JSON: { image_b64, orig_positive, orig_negative, issue, params_json, goal }
"""
# Security checks
auth_resp = await self._require_admin_and_rate_limit(request)
if auth_resp:
return auth_resp
data, error_resp = await self._parse_json_body(request)
if error_resp:
return error_resp
assert data is not None
payload, payload_err = self._validate_refiner_payload(data)
if payload_err:
return payload_err
assert payload is not None
try:
# Run sync LLM call in thread pool
new_pos, new_neg, patch, rationale = await run_in_thread(
self.refiner.refine_prompt,
image_b64=image_b64,
orig_positive=orig_pos,
orig_negative=orig_neg,
issue=issue,
params_json=params_json,
goal=goal,
**payload,
)
return web.json_response(
{
"refined_positive": new_pos,
"refined_negative": new_neg,
"param_patch": patch,
"rationale": rationale,
}
self._finalize_operator_payload(
request,
target="assist.refiner",
payload={
"refined_positive": new_pos,
"refined_negative": new_neg,
"param_patch": patch,
"rationale": rationale,
},
service=self.refiner,
)
)
except Exception as e:
logger.exception("Refiner API failed")
return web.json_response({"error": "Internal server error"}, status=500)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Run planner (streaming)",
description="Generate prompts from requirements via LLM with SSE-style incremental updates.",
audit="assist.planner.stream",
plane=RoutePlane.ADMIN,
)
async def planner_stream_handler(self, request):
auth_resp = await self._require_admin_and_rate_limit(request)
if auth_resp:
return auth_resp
data, error_resp = await self._parse_json_body(request)
if error_resp:
return error_resp
assert data is not None
payload, payload_err = self._validate_planner_payload(data)
if payload_err:
return payload_err
assert payload is not None
return await self._assist_stream_session(
request,
kind="planner",
worker_fn=self.planner.plan_generation,
worker_kwargs=payload,
)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Run refiner (streaming)",
description="Refine prompt/parameters with SSE-style incremental updates.",
audit="assist.refiner.stream",
plane=RoutePlane.ADMIN,
)
async def refiner_stream_handler(self, request):
auth_resp = await self._require_admin_and_rate_limit(request)
if auth_resp:
return auth_resp
data, error_resp = await self._parse_json_body(request)
if error_resp:
return error_resp
assert data is not None
payload, payload_err = self._validate_refiner_payload(data)
if payload_err:
return payload_err
assert payload is not None
return await self._assist_stream_session(
request,
kind="refiner",
worker_fn=self.refiner.refine_prompt,
worker_kwargs=payload,
)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Compose automation payload",
description="Generate-only automation payload draft for trigger/webhook endpoints.",
audit="assist.compose",
plane=RoutePlane.ADMIN,
)
async def compose_handler(self, request):
"""
POST /openclaw/assist/automation/compose (legacy: /moltbot/assist/automation/compose)
JSON:
{
kind: "trigger" | "webhook",
template_id: str,
intent: str,
inputs_hint?: object,
profile_id?: str,
require_approval?: bool,
trace_id?: str,
callback?: object
}
"""
authorized, err_msg = require_admin_token(request)
if not authorized:
return web.json_response({"error": "Unauthorized"}, status=401)
if not check_rate_limit(request, "admin"):
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=False,
)
try:
data = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
kind = data.get("kind")
template_id = data.get("template_id")
intent = data.get("intent")
inputs_hint = data.get("inputs_hint", {})
profile_id = data.get("profile_id")
require_approval = data.get("require_approval")
trace_id = data.get("trace_id")
callback = data.get("callback")
if not isinstance(kind, str) or kind.strip().lower() not in {
"trigger",
"webhook",
}:
return web.json_response(
{"error": "kind must be 'trigger' or 'webhook'"}, status=400
)
if not isinstance(template_id, str) or not template_id.strip():
return web.json_response({"error": "template_id is required"}, status=400)
if not isinstance(intent, str) or not intent.strip():
return web.json_response({"error": "intent is required"}, status=400)
if len(intent) > MAX_REQUIREMENTS_LEN:
return web.json_response(
{"error": f"intent exceeds {MAX_REQUIREMENTS_LEN} chars"}, status=400
)
if not isinstance(inputs_hint, dict):
return web.json_response(
{"error": "inputs_hint must be object"}, status=400
)
if profile_id is not None and not isinstance(profile_id, str):
return web.json_response({"error": "profile_id must be string"}, status=400)
if require_approval is not None and not isinstance(require_approval, bool):
return web.json_response(
{"error": "require_approval must be boolean"}, status=400
)
if trace_id is not None and not isinstance(trace_id, str):
return web.json_response({"error": "trace_id must be string"}, status=400)
if callback is not None and not isinstance(callback, dict):
return web.json_response({"error": "callback must be object"}, status=400)
try:
result = await run_in_thread(
self.composer.compose_payload,
kind=kind,
template_id=template_id,
intent=intent,
inputs_hint=inputs_hint,
profile_id=profile_id,
require_approval=require_approval,
trace_id=trace_id,
callback=callback,
)
return web.json_response(
self._finalize_operator_payload(
request,
target="assist.compose",
payload={"ok": True, **result},
service=self.composer,
)
)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception:
logger.exception("Automation compose API failed")
return web.json_response({"error": "Internal server error"}, status=500)
+438 -27
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import asyncio
import logging
import time
from types import SimpleNamespace
from typing import Any, Dict, Optional
try:
@@ -17,9 +18,15 @@ except ModuleNotFoundError: # pragma: no cover (optional for unit tests)
try:
from ..services.async_utils import run_in_thread
from ..services.cache import TTLCache
from ..services.audit import emit_audit_event
# CRITICAL: handshake verifier must be imported in package mode;
# missing this causes NameError at runtime on /bridge/handshake.
from ..services.bridge_handshake import verify_handshake
from ..services.execution_budgets import BudgetExceededError
from ..services.rate_limit import check_rate_limit
from ..services.idempotency_store import IdempotencyStore
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.redaction import stable_redaction_tag
from ..services.sidecar.auth import is_bridge_enabled, require_bridge_auth
from ..services.sidecar.bridge_contract import (
BRIDGE_ENDPOINTS,
@@ -33,9 +40,12 @@ try:
except ImportError:
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
from services.async_utils import run_in_thread
from services.cache import TTLCache
from services.audit import emit_audit_event
from services.bridge_handshake import verify_handshake
from services.execution_budgets import BudgetExceededError
from services.rate_limit import check_rate_limit
from services.idempotency_store import IdempotencyStore
from services.rate_limit import build_rate_limit_response, check_rate_limit
from services.redaction import stable_redaction_tag
from services.sidecar.auth import is_bridge_enabled, require_bridge_auth
from services.sidecar.bridge_contract import (
BRIDGE_ENDPOINTS,
@@ -47,6 +57,22 @@ except ImportError:
from services.trace import get_effective_trace_id
from services.trace_store import trace_store
# R98: Endpoint Metadata
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
else:
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.bridge")
# Payload limits
@@ -58,6 +84,10 @@ MAX_FILES_COUNT = 10
_startup_time = time.time()
def _bridge_sensitive_tag(value: Optional[str], *, label: str) -> str:
return stable_redaction_tag(value, label=label)
class BridgeHandlers:
"""Handlers for bridge API endpoints."""
@@ -70,10 +100,9 @@ class BridgeHandlers:
self.submit_service = submit_service
self.delivery_router = delivery_router
# R22: Bounded Idempotency Store
# Key: idempotency_key -> Response Dict
self._idempotency_store = TTLCache[dict](
max_size=1000, ttl_sec=86400
) # 24h retention
# S50: Durable Idempotency Backend
self._idempotency_store = IdempotencyStore()
# F46: Worker job queue (in-memory stub, production would use persistent store)
self._worker_job_queue: list = []
# F46: Worker result store
@@ -81,6 +110,43 @@ class BridgeHandlers:
# F46: Worker heartbeats
self._worker_heartbeats: dict = {}
def _bridge_token(self, device_id: Optional[str], scope: Optional[str] = None):
scopes = {scope} if scope else set()
return SimpleNamespace(
token_id=f"bridge:{_bridge_sensitive_tag(device_id, label='device')}",
role="bridge",
scopes=scopes,
)
def _audit(
self,
*,
request: web.Request,
action: str,
target: str,
outcome: str,
status_code: int,
device_id: Optional[str] = None,
scope: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
) -> None:
emit_audit_event(
action=action,
target=target,
outcome=outcome,
token_info=self._bridge_token(device_id, scope),
status_code=status_code,
details=details or {},
request=request,
)
@endpoint_metadata(
auth=AuthTier.PUBLIC, # Guarded by is_bridge_enabled internally, effectively public if enabled
risk=RiskTier.LOW,
summary="Bridge health",
description="Returns bridge health status.",
plane=RoutePlane.USER,
)
async def health_handler(self, request: web.Request) -> web.Response:
"""
GET /bridge/health
@@ -114,26 +180,109 @@ class BridgeHandlers:
}
)
@endpoint_metadata(
auth=AuthTier.PUBLIC,
risk=RiskTier.LOW,
summary="Bridge handshake",
description="Negotiate protocol version.",
plane=RoutePlane.USER,
)
async def handshake_handler(self, request: web.Request) -> web.Response:
"""
POST /bridge/handshake
Negotiate protocol version compatibility.
"""
try:
data = await request.json()
client_version = int(data.get("version", 0))
except (ValueError, TypeError, Exception):
return web.json_response({"error": "Invalid version format"}, status=400)
ok, msg, meta = verify_handshake(client_version)
status_code = 200 if ok else 409 # 409 Conflict for version mismatch
return web.json_response(
{
"ok": ok,
"message": msg,
"metadata": meta,
},
status=status_code,
)
@endpoint_metadata(
auth=AuthTier.BRIDGE,
risk=RiskTier.HIGH,
summary="Bridge submit",
description="Submit a job via sidecar bridge.",
audit="bridge.submit",
plane=RoutePlane.INTERNAL,
)
async def submit_handler(self, request: web.Request) -> web.Response:
"""
POST /bridge/submit
Submit a job via sidecar bridge.
"""
# S62: Block webhook execution in public+split mode
try:
# CRITICAL: package-relative import must stay first in ComfyUI runtime.
from ..services.surface_guard import check_surface
except ImportError:
from services.surface_guard import check_surface # type: ignore
blocked = check_surface("webhook_execute", request)
if blocked:
return blocked
# Auth check
is_valid, error_resp, device_id = require_bridge_auth(
request, BridgeScope.JOB_SUBMIT
)
if not is_valid:
self._audit(
request=request,
action="bridge.submit",
target="bridge.submit",
outcome="deny",
status_code=getattr(error_resp, "status", 403),
details={"reason": "auth_failed"},
)
return error_resp
# Rate limit
if not check_rate_limit(request, "bridge"):
return web.json_response({"error": "Rate limit exceeded"}, status=429)
self._audit(
request=request,
action="bridge.submit",
target="bridge.submit",
outcome="deny",
status_code=429,
device_id=device_id,
scope=BridgeScope.JOB_SUBMIT.value,
details={"reason": "rate_limit"},
)
return build_rate_limit_response(
request,
"bridge",
web_module=web,
error="Rate limit exceeded",
include_ok=False,
)
# Parse payload
try:
data = await request.json()
except Exception:
self._audit(
request=request,
action="bridge.submit",
target="bridge.submit",
outcome="deny",
status_code=400,
device_id=device_id,
scope=BridgeScope.JOB_SUBMIT.value,
details={"reason": "invalid_json"},
)
return web.json_response({"error": "Invalid JSON"}, status=400)
# R25: Trace context
@@ -145,8 +294,28 @@ class BridgeHandlers:
idempotency_key = data.get("idempotency_key")
if not template_id:
self._audit(
request=request,
action="bridge.submit",
target="bridge.submit",
outcome="deny",
status_code=400,
device_id=device_id,
scope=BridgeScope.JOB_SUBMIT.value,
details={"reason": "missing_template_id"},
)
return web.json_response({"error": "template_id required"}, status=400)
if not idempotency_key:
self._audit(
request=request,
action="bridge.submit",
target="bridge.submit",
outcome="deny",
status_code=400,
device_id=device_id,
scope=BridgeScope.JOB_SUBMIT.value,
details={"reason": "missing_idempotency_key"},
)
return web.json_response({"error": "idempotency_key required"}, status=400)
# Payload size check
@@ -154,15 +323,38 @@ class BridgeHandlers:
inputs_size = len(json.dumps(inputs))
if inputs_size > MAX_INPUTS_SIZE:
self._audit(
request=request,
action="bridge.submit",
target="bridge.submit",
outcome="deny",
status_code=400,
device_id=device_id,
scope=BridgeScope.JOB_SUBMIT.value,
details={"reason": "inputs_too_large", "size": inputs_size},
)
return web.json_response(
{"error": f"inputs exceeds {MAX_INPUTS_SIZE // 1024}KB"}, status=400
)
# Idempotency check
cached = self._idempotency_store.get(idempotency_key)
if cached:
logger.info(f"Duplicate bridge submit suppressed: {idempotency_key}")
return web.json_response(cached)
# Idempotency check (S50 Durable)
store_key = f"bridge:{idempotency_key}"
is_dup, existing_pid = self._idempotency_store.check_and_record(
store_key, ttl=86400
)
if is_dup:
logger.info(f"Duplicate bridge submit suppressed: {store_key}")
return web.json_response(
{
"ok": True,
"deduped": True,
"prompt_id": existing_pid,
"trace_id": trace_id,
"status": "queued",
"message": "Duplicate request suppressed",
}
)
# Build request object
job_request = BridgeJobRequest(
@@ -207,13 +399,34 @@ class BridgeHandlers:
except Exception:
pass
# Cache response
self._idempotency_store.put(idempotency_key, response_data)
# Update durable store with prompt_id
if prompt_id:
self._idempotency_store.update_prompt_id(store_key, prompt_id)
self._audit(
request=request,
action="bridge.submit",
target=template_id,
outcome="allow",
status_code=200,
device_id=device_id,
scope=BridgeScope.JOB_SUBMIT.value,
details={"prompt_id": prompt_id, "trace_id": trace_id},
)
return web.json_response(response_data)
except BudgetExceededError as e:
logger.warning(f"Bridge submit denied by execution budget: {e}")
self._audit(
request=request,
action="bridge.submit",
target=template_id,
outcome="deny",
status_code=429,
device_id=device_id,
scope=BridgeScope.JOB_SUBMIT.value,
details={"reason": "budget_exceeded", "error": str(e)},
)
return web.json_response(
{"error": "budget_exceeded", "detail": str(e)},
status=429,
@@ -221,28 +434,90 @@ class BridgeHandlers:
)
except Exception as e:
logger.exception("Bridge submit failed")
self._audit(
request=request,
action="bridge.submit",
target=template_id or "bridge.submit",
outcome="error",
status_code=500,
device_id=device_id,
scope=BridgeScope.JOB_SUBMIT.value,
details={"error": str(e)},
)
return web.json_response({"error": "Internal server error"}, status=500)
@endpoint_metadata(
auth=AuthTier.BRIDGE,
risk=RiskTier.HIGH,
summary="Bridge deliver",
description="Request outbound delivery via sidecar.",
audit="bridge.deliver",
plane=RoutePlane.INTERNAL,
)
async def deliver_handler(self, request: web.Request) -> web.Response:
"""
POST /bridge/deliver
Request outbound delivery via sidecar.
"""
# S62: Block callback egress in public+split mode
try:
# CRITICAL: package-relative import must stay first in ComfyUI runtime.
from ..services.surface_guard import check_surface
except ImportError:
from services.surface_guard import check_surface # type: ignore
blocked = check_surface("callback_egress", request)
if blocked:
return blocked
# Auth check
is_valid, error_resp, device_id = require_bridge_auth(
request, BridgeScope.DELIVERY
)
if not is_valid:
self._audit(
request=request,
action="bridge.deliver",
target="bridge.deliver",
outcome="deny",
status_code=getattr(error_resp, "status", 403),
details={"reason": "auth_failed"},
)
return error_resp
# Rate limit
if not check_rate_limit(request, "bridge"):
return web.json_response({"error": "Rate limit exceeded"}, status=429)
self._audit(
request=request,
action="bridge.deliver",
target="bridge.deliver",
outcome="deny",
status_code=429,
device_id=device_id,
scope=BridgeScope.DELIVERY.value,
details={"reason": "rate_limit"},
)
return build_rate_limit_response(
request,
"bridge",
web_module=web,
error="Rate limit exceeded",
include_ok=False,
)
# Parse payload
try:
data = await request.json()
except Exception:
self._audit(
request=request,
action="bridge.deliver",
target="bridge.deliver",
outcome="deny",
status_code=400,
device_id=device_id,
scope=BridgeScope.DELIVERY.value,
details={"reason": "invalid_json"},
)
return web.json_response({"error": "Invalid JSON"}, status=400)
# R25: Trace context
@@ -255,16 +530,56 @@ class BridgeHandlers:
files = data.get("files", [])
if not target:
self._audit(
request=request,
action="bridge.deliver",
target="bridge.deliver",
outcome="deny",
status_code=400,
device_id=device_id,
scope=BridgeScope.DELIVERY.value,
details={"reason": "missing_target"},
)
return web.json_response({"error": "target required"}, status=400)
if not idempotency_key:
self._audit(
request=request,
action="bridge.deliver",
target=target or "bridge.deliver",
outcome="deny",
status_code=400,
device_id=device_id,
scope=BridgeScope.DELIVERY.value,
details={"reason": "missing_idempotency_key"},
)
return web.json_response({"error": "idempotency_key required"}, status=400)
# Payload size checks
if len(text) > MAX_TEXT_LENGTH:
self._audit(
request=request,
action="bridge.deliver",
target=target,
outcome="deny",
status_code=400,
device_id=device_id,
scope=BridgeScope.DELIVERY.value,
details={"reason": "text_too_large", "length": len(text)},
)
return web.json_response(
{"error": f"text exceeds {MAX_TEXT_LENGTH} chars"}, status=400
)
if len(files) > MAX_FILES_COUNT:
self._audit(
request=request,
action="bridge.deliver",
target=target,
outcome="deny",
status_code=400,
device_id=device_id,
scope=BridgeScope.DELIVERY.value,
details={"reason": "too_many_files", "count": len(files)},
)
return web.json_response(
{"error": f"files exceeds {MAX_FILES_COUNT}"}, status=400
)
@@ -286,6 +601,17 @@ class BridgeHandlers:
logger.warning("BridgeHandlers.delivery_router not wired")
success = True
self._audit(
request=request,
action="bridge.deliver",
target=target,
outcome="allow" if success else "error",
status_code=200 if success else 500,
device_id=device_id,
scope=BridgeScope.DELIVERY.value,
details={"trace_id": trace_id},
)
return web.json_response(
{
"ok": success,
@@ -295,12 +621,29 @@ class BridgeHandlers:
except Exception as e:
logger.exception("Bridge deliver failed")
self._audit(
request=request,
action="bridge.deliver",
target=target if "target" in locals() else "bridge.deliver",
outcome="error",
status_code=500,
device_id=device_id,
scope=BridgeScope.DELIVERY.value,
details={"error": str(e)},
)
return web.json_response({"error": "Internal server error"}, status=500)
# ------------------------------------------------------------------
# F46 — Worker-facing endpoints
# ------------------------------------------------------------------
@endpoint_metadata(
auth=AuthTier.BRIDGE,
risk=RiskTier.LOW,
summary="Worker poll",
description="Worker polls for pending jobs.",
plane=RoutePlane.INTERNAL,
)
async def worker_poll_handler(self, request: web.Request) -> web.Response:
"""
GET /bridge/worker/poll
@@ -331,6 +674,14 @@ class BridgeHandlers:
return web.json_response({"jobs": jobs})
@endpoint_metadata(
auth=AuthTier.BRIDGE,
risk=RiskTier.MEDIUM,
summary="Worker result",
description="Worker submits completed job result.",
audit="bridge.worker.result",
plane=RoutePlane.INTERNAL,
)
async def worker_result_handler(self, request: web.Request) -> web.Response:
"""
POST /bridge/worker/result/{job_id}
@@ -340,41 +691,98 @@ class BridgeHandlers:
request, BridgeScope.JOB_SUBMIT
)
if not is_valid:
self._audit(
request=request,
action="bridge.worker.result",
target="bridge.worker.result",
outcome="deny",
status_code=getattr(error_resp, "status", 403),
details={"reason": "auth_failed"},
)
return error_resp
job_id = request.match_info.get("job_id", "")
if not job_id:
self._audit(
request=request,
action="bridge.worker.result",
target="bridge.worker.result",
outcome="deny",
status_code=400,
device_id=device_id,
scope=BridgeScope.JOB_SUBMIT.value,
details={"reason": "missing_job_id"},
)
return web.json_response({"error": "job_id required"}, status=400)
# Idempotency check
# S50: Durable idempotency check for worker result ingress.
# IMPORTANT: use check_and_record (durable path), do not rely on TTLCache-style get/put.
idempotency_key = request.headers.get("X-Idempotency-Key", "")
if idempotency_key:
cached = self._idempotency_store.get(f"wr:{idempotency_key}")
if cached:
logger.info(f"Duplicate worker result suppressed: {idempotency_key}")
return web.json_response(cached)
store_key = f"wr:{idempotency_key}"
is_dup, _ = self._idempotency_store.check_and_record(store_key, ttl=86400)
if is_dup:
logger.info(
"Duplicate worker result suppressed for %s",
_bridge_sensitive_tag(idempotency_key, label="idem"),
)
return web.json_response(
{
"ok": True,
"job_id": job_id,
"status": "accepted",
"deduped": True,
}
)
try:
data = await request.json()
except Exception:
self._audit(
request=request,
action="bridge.worker.result",
target=job_id,
outcome="deny",
status_code=400,
device_id=device_id,
scope=BridgeScope.JOB_SUBMIT.value,
details={"reason": "invalid_json"},
)
return web.json_response({"error": "Invalid JSON"}, status=400)
# Store result
self._worker_results[job_id] = {
"status": data.get("status", "completed"),
"outputs": data.get("outputs", {}),
"worker_id": device_id,
# IMPORTANT: keep worker identity redacted in cached bridge state.
"worker_id": _bridge_sensitive_tag(device_id, label="device"),
"timestamp": time.time(),
}
response_data = {"ok": True, "job_id": job_id, "status": "accepted"}
if idempotency_key:
self._idempotency_store.put(f"wr:{idempotency_key}", response_data)
logger.info(f"F46: Worker result accepted for job={job_id} from={device_id}")
self._audit(
request=request,
action="bridge.worker.result",
target=job_id,
outcome="allow",
status_code=201,
device_id=device_id,
scope=BridgeScope.JOB_SUBMIT.value,
details={"status": data.get("status", "completed")},
)
# IMPORTANT: keep this success log constant. Request-auth CodeQL still treats
# the surrounding handler scope as credential-tainted even when job_id is benign.
logger.info("F46: Worker result accepted.")
return web.json_response(response_data, status=201)
@endpoint_metadata(
auth=AuthTier.BRIDGE,
risk=RiskTier.LOW,
summary="Worker heartbeat",
description="Worker reports its status.",
plane=RoutePlane.INTERNAL,
)
async def worker_heartbeat_handler(self, request: web.Request) -> web.Response:
"""
POST /bridge/worker/heartbeat
@@ -413,6 +821,9 @@ def register_bridge_routes(
app.router.add_get(BRIDGE_ENDPOINTS["health"]["path"], handlers.health_handler)
app.router.add_post(BRIDGE_ENDPOINTS["submit"]["path"], handlers.submit_handler)
app.router.add_post(BRIDGE_ENDPOINTS["deliver"]["path"], handlers.deliver_handler)
app.router.add_post(
BRIDGE_ENDPOINTS["handshake"]["path"], handlers.handshake_handler
)
# F46: Worker-facing endpoints
app.router.add_get(
+32 -3
View File
@@ -3,14 +3,43 @@ Capabilities API Handler (R19).
GET /openclaw/capabilities (legacy: /moltbot/capabilities)
"""
from aiohttp import web
from __future__ import annotations
try:
# Import discipline
if __package__ and "." in __package__:
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.capabilities import get_capabilities
except ImportError:
else:
from services.aiohttp_compat import import_aiohttp_web
from services.capabilities import get_capabilities
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
else:
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
web = import_aiohttp_web()
@endpoint_metadata(
auth=AuthTier.PUBLIC,
risk=RiskTier.LOW,
summary="Get capabilities",
description="Returns API version and feature flags.",
audit="capabilities.list",
plane=RoutePlane.USER,
)
async def capabilities_handler(request: web.Request) -> web.Response:
"""
GET /openclaw/capabilities (legacy: /moltbot/capabilities)
+72 -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,10 +35,30 @@ else: # pragma: no cover (test-only import mode)
get_checkpoint,
list_checkpoints,
)
from services.rate_limit import check_rate_limit # type: ignore
from services.rate_limit import ( # type: ignore
build_rate_limit_payload,
check_rate_limit,
)
from services.request_ip import get_client_ip # type: ignore
# R98: Endpoint Metadata
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
else:
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.checkpoints")
@@ -74,13 +94,29 @@ def _deny_remote_admin_if_needed(request: web.Request) -> web.Response | None:
)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="List checkpoints",
description="List available workflow checkpoints.",
audit="checkpoints.list",
plane=RoutePlane.ADMIN,
)
async def list_checkpoints_handler(request: web.Request) -> web.Response:
"""GET /openclaw/checkpoints"""
if web is None:
raise RuntimeError("aiohttp not available")
if not check_rate_limit(request, "admin"):
return _json_resp({"ok": False, "error": "rate_limit_exceeded"}, 429)
return _json_resp(
build_rate_limit_payload(
request,
"admin",
error="rate_limit_exceeded",
include_ok=True,
),
429,
)
allowed, error = require_admin_token(request)
if not allowed:
@@ -97,13 +133,29 @@ async def list_checkpoints_handler(request: web.Request) -> web.Response:
return _json_resp({"ok": False, "error": str(e)}, 500)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Create checkpoint",
description="Create a new workflow checkpoint.",
audit="checkpoints.create",
plane=RoutePlane.ADMIN,
)
async def create_checkpoint_handler(request: web.Request) -> web.Response:
"""POST /openclaw/checkpoints"""
if web is None:
raise RuntimeError("aiohttp not available")
if not check_rate_limit(request, "admin"):
return _json_resp({"ok": False, "error": "rate_limit_exceeded"}, 429)
return _json_resp(
build_rate_limit_payload(
request,
"admin",
error="rate_limit_exceeded",
include_ok=True,
),
429,
)
# Body Size Check
if request.content_length and request.content_length > MAX_BODY_SIZE:
@@ -142,6 +194,14 @@ async def create_checkpoint_handler(request: web.Request) -> web.Response:
return _json_resp({"ok": False, "error": str(e)}, 500)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="Get checkpoint",
description="Retrieve specific checkpoint details.",
audit="checkpoints.get",
plane=RoutePlane.ADMIN,
)
async def get_checkpoint_handler(request: web.Request) -> web.Response:
"""GET /openclaw/checkpoints/{id}"""
if web is None:
@@ -168,6 +228,14 @@ async def get_checkpoint_handler(request: web.Request) -> web.Response:
return _json_resp({"ok": False, "error": str(e)}, 500)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Delete checkpoint",
description="Delete a workflow checkpoint.",
audit="checkpoints.delete",
plane=RoutePlane.ADMIN,
)
async def delete_checkpoint_handler(request: web.Request) -> web.Response:
"""DELETE /openclaw/checkpoints/{id}"""
if web is None:
+285 -751
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
)
+268
View File
@@ -0,0 +1,268 @@
from __future__ import annotations
import json
import logging
from typing import Optional
try:
from aiohttp import web
except ImportError: # pragma: no cover
class _MockResponse:
def __init__(self, payload: dict, status: int = 200):
self.status = status
self.body = json.dumps(payload).encode("utf-8")
class _MockWeb:
class Request:
pass
@staticmethod
def json_response(payload: dict, status: int = 200):
return _MockResponse(payload, status=status)
web = _MockWeb() # type: ignore
if __package__ and "." in __package__:
from ..services.access_control import require_admin_token, resolve_token_info
from ..services.connector_extraction_contract import (
get_connector_extraction_contract,
)
from ..services.connector_installation_registry import (
get_connector_installation_registry,
)
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
else: # pragma: no cover
from services.access_control import require_admin_token # type: ignore
from services.access_control import resolve_token_info # type: ignore
from services.connector_extraction_contract import ( # type: ignore
get_connector_extraction_contract,
)
from services.connector_installation_registry import ( # type: ignore
get_connector_installation_registry,
)
from services.rate_limit import ( # type: ignore
build_rate_limit_response,
check_rate_limit,
)
from services.tenant_context import ( # type: ignore
TenantBoundaryError,
request_tenant_scope,
)
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
else:
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.connector_contracts")
def _require_admin(request) -> Optional[web.Response]:
if not check_rate_limit(request, "admin"):
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
allowed, err = require_admin_token(request)
if not allowed:
return web.json_response(
{"ok": False, "error": err or "Unauthorized"}, status=403
)
return None
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="List connector installations",
description="Returns redacted multi-workspace connector installation diagnostics.",
audit="connector.installations.list",
plane=RoutePlane.ADMIN,
)
async def connector_installations_list_handler(request):
if (guard := _require_admin(request)) is not None:
return guard
registry = get_connector_installation_registry()
platform = request.query.get("platform")
workspace_id = request.query.get("workspace_id")
status = request.query.get("status")
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
installations = registry.list_installations(
platform=platform,
tenant_id=tenant.tenant_id,
workspace_id=workspace_id,
status=status,
)
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"installations": [inst.to_public_dict() for inst in installations],
"diagnostics": registry.diagnostics(tenant_id=tenant.tenant_id),
}
)
except TenantBoundaryError as exc:
return web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)},
status=403,
)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Get connector installation",
description="Returns a single redacted connector installation record.",
audit="connector.installations.get",
plane=RoutePlane.ADMIN,
)
async def connector_installation_get_handler(request):
if (guard := _require_admin(request)) is not None:
return guard
installation_id = request.match_info.get("installation_id", "")
registry = get_connector_installation_registry()
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
installation = registry.get_installation(
installation_id, tenant_id=tenant.tenant_id
)
if installation is None:
return web.json_response(
{"ok": False, "error": "not_found"}, status=404
)
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"installation": installation.to_public_dict(),
}
)
except TenantBoundaryError as exc:
return web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)},
status=403,
)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Resolve connector installation",
description="Runs fail-closed workspace resolution for diagnostics without exposing token material.",
audit="connector.installations.resolve",
plane=RoutePlane.ADMIN,
)
async def connector_installation_resolve_handler(request):
if (guard := _require_admin(request)) is not None:
return guard
platform = (request.query.get("platform") or "").strip()
workspace_id = (request.query.get("workspace_id") or "").strip()
if not platform or not workspace_id:
return web.json_response(
{"ok": False, "error": "platform and workspace_id are required"},
status=400,
)
registry = get_connector_installation_registry()
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
resolution = registry.resolve_installation(
platform, workspace_id, tenant_id=tenant.tenant_id
)
status_code = 200 if resolution.ok else 409
return web.json_response(
{
"ok": resolution.ok,
"tenant_id": tenant.tenant_id,
"resolution": resolution.to_public_dict(),
},
status=status_code,
)
except TenantBoundaryError as exc:
return web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)},
status=403,
)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Connector installation audit",
description="Returns installation lifecycle audit evidence.",
audit="connector.installations.audit",
plane=RoutePlane.ADMIN,
)
async def connector_installation_audit_handler(request):
if (guard := _require_admin(request)) is not None:
return guard
registry = get_connector_installation_registry()
installation_id = request.query.get("installation_id")
try:
limit = int(request.query.get("limit") or 100)
except Exception:
limit = 100
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"events": registry.get_audit_trail(
installation_id=installation_id,
tenant_id=tenant.tenant_id,
limit=limit,
),
}
)
except TenantBoundaryError as exc:
return web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)},
status=403,
)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="Connector extraction contract",
description="Returns the machine-readable connector extraction feasibility contract.",
audit="connector.extraction_contract.get",
plane=RoutePlane.ADMIN,
)
async def connector_extraction_contract_handler(request):
if (guard := _require_admin(request)) is not None:
return guard
return web.json_response(
{
"ok": True,
"contract": get_connector_extraction_contract(),
}
)
+191 -31
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import asyncio
import json
import logging
from inspect import signature
from typing import Any, Dict
try:
@@ -22,15 +23,34 @@ except ModuleNotFoundError: # pragma: no cover
web = None # type: ignore
if __package__ and "." in __package__:
from ..services.access_control import require_observability_access
from ..services.access_control import (
require_admin_token,
require_observability_access,
)
from ..services.job_events import get_job_event_store
from ..services.management_query import normalize_cursor_limit
from ..services.metrics import metrics
from ..services.rate_limit import check_rate_limit
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.reasoning_redaction import (
audit_reasoning_reveal,
resolve_reasoning_reveal,
)
else: # pragma: no cover
from services.access_control import require_observability_access # type: ignore
from services.access_control import ( # type: ignore
require_admin_token,
require_observability_access,
)
from services.job_events import get_job_event_store # type: ignore
from services.management_query import normalize_cursor_limit # type: ignore
from services.metrics import metrics # type: ignore
from services.rate_limit import check_rate_limit # type: ignore
from services.rate_limit import ( # type: ignore
build_rate_limit_response,
check_rate_limit,
)
from services.reasoning_redaction import ( # type: ignore
audit_reasoning_reveal,
resolve_reasoning_reveal,
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.events")
@@ -40,6 +60,48 @@ SSE_KEEPALIVE_SEC = 15
SSE_MAX_DURATION_SEC = 300 # 5 minutes
def _call_event_serializer(
event: Any,
method_name: str,
*,
include_reasoning: bool,
) -> Any:
"""Use enhanced serializers when supported, but stay compatible with old test doubles."""
serializer = getattr(event, method_name)
try:
params = signature(serializer).parameters
except (TypeError, ValueError):
params = {}
if "include_reasoning" in params:
return serializer(include_reasoning=include_reasoning)
return serializer()
# R98: Endpoint Metadata
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
else:
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
@endpoint_metadata(
auth=AuthTier.OBSERVABILITY,
risk=RiskTier.LOW,
summary="Stream job events",
description="SSE endpoint for job lifecycle events.",
audit="events.stream",
plane=RoutePlane.ADMIN,
)
async def events_stream_handler(request: web.Request) -> web.StreamResponse:
"""
GET /openclaw/events/stream
@@ -53,16 +115,21 @@ async def events_stream_handler(request: web.Request) -> web.StreamResponse:
# Rate limit
if not check_rate_limit(request, "events"):
return web.json_response(
{"ok": False, "error": "rate_limit_exceeded"},
status=429,
headers={"Retry-After": "60"},
return build_rate_limit_response(
request,
"events",
web_module=web,
error="rate_limit_exceeded",
include_ok=True,
)
# Access control (same as logs/tail)
allowed, error = require_observability_access(request)
if not allowed:
return web.json_response({"ok": False, "error": error}, status=403)
admin_allowed, _ = require_admin_token(request)
reveal = resolve_reasoning_reveal(request, admin_authorized=admin_allowed)
audit_reasoning_reveal(request, target="events.stream", decision=reveal)
store = get_job_event_store()
@@ -112,7 +179,13 @@ async def events_stream_handler(request: web.Request) -> web.StreamResponse:
if events:
for evt in events:
await response.write(evt.to_sse().encode("utf-8"))
await response.write(
_call_event_serializer(
evt,
"to_sse",
include_reasoning=reveal["allowed"],
).encode("utf-8")
)
last_seq = evt.seq
else:
# Send keep-alive header only if interval exceeded
@@ -132,6 +205,14 @@ async def events_stream_handler(request: web.Request) -> web.StreamResponse:
return response
@endpoint_metadata(
auth=AuthTier.OBSERVABILITY,
risk=RiskTier.LOW,
summary="Poll job events",
description="JSON polling fallback for job events.",
audit="events.poll",
plane=RoutePlane.ADMIN,
)
async def events_poll_handler(request: web.Request) -> web.Response:
"""
GET /openclaw/events
@@ -147,42 +228,121 @@ async def events_poll_handler(request: web.Request) -> web.Response:
# Rate limit
if not check_rate_limit(request, "events"):
return web.json_response(
{"ok": False, "error": "rate_limit_exceeded"},
status=429,
headers={"Retry-After": "60"},
return build_rate_limit_response(
request,
"events",
web_module=web,
error="rate_limit_exceeded",
include_ok=True,
)
# Access control
allowed, error = require_observability_access(request)
if not allowed:
return web.json_response({"ok": False, "error": error}, status=403)
admin_allowed, _ = require_admin_token(request)
reveal = resolve_reasoning_reveal(request, admin_authorized=admin_allowed)
audit_reasoning_reveal(request, target="events.poll", decision=reveal)
store = get_job_event_store()
# Parse query params
try:
since = int(request.query.get("since", "0"))
except ValueError:
since = 0
# R95: deterministic pagination normalization + bounded scan diagnostics
prompt_id = request.query.get("prompt_id")
try:
limit = max(1, min(int(request.query.get("limit", "50")), 200))
except ValueError:
limit = 50
events = store.events_since(
last_seq=since,
limit=limit,
prompt_id=prompt_id,
page = normalize_cursor_limit(
request.query,
cursor_key="since",
default_cursor=0,
min_cursor=0,
default_limit=50,
max_limit=200,
)
since_requested = int(page.cursor or 0)
latest_seq = store.latest_seq()
cursor_status = "ok"
since_effective = since_requested
if since_requested > latest_seq:
cursor_status = "future_cursor_reset"
since_effective = latest_seq
page.warnings.append(
{
"code": "R95_STALE_CURSOR_FUTURE",
"field": "since",
"raw": str(since_requested),
"normalized": since_effective,
}
)
scan_cap = max(page.limit * 10, 500)
events, scan = store.events_since_bounded(
last_seq=since_effective,
limit=page.limit,
prompt_id=prompt_id,
scan_cap=scan_cap,
)
earliest_retained = scan.get("earliest_retained_seq")
if (
isinstance(earliest_retained, int)
and since_effective != 0
and since_effective < (earliest_retained - 1)
):
cursor_status = "stale_cursor_reset"
since_effective = max(0, earliest_retained - 1)
page.warnings.append(
{
"code": "R95_STALE_CURSOR_RESET",
"field": "since",
"raw": str(since_requested),
"normalized": since_effective,
}
)
events, scan = store.events_since_bounded(
last_seq=since_effective,
limit=page.limit,
prompt_id=prompt_id,
scan_cap=scan_cap,
)
return web.json_response(
{
"ok": True,
"events": [e.to_dict() for e in events],
"latest_seq": store.latest_seq(),
"events": [
_call_event_serializer(
e,
"to_dict",
include_reasoning=reveal["allowed"],
)
for e in events
],
"latest_seq": latest_seq,
"pagination": {
"limit": page.limit,
"since_requested": since_requested,
"since_effective": since_effective,
"cursor_status": cursor_status,
"warnings": page.warnings,
},
"delta": {
"cursor_key": "since",
"requested_since_seq": since_requested,
"effective_since_seq": since_effective,
"next_since_seq": (events[-1].seq if events else since_effective),
"latest_seq": latest_seq,
"earliest_retained_seq": scan.get("earliest_retained_seq"),
"latest_retained_seq": scan.get("latest_retained_seq"),
"cursor_status": cursor_status,
"snapshot": since_requested == 0,
"truncated": bool(
scan.get("truncated")
or (
events
and isinstance(scan.get("latest_retained_seq"), int)
and int(scan.get("latest_retained_seq")) > int(events[-1].seq)
)
),
"warnings": page.warnings,
},
"scan": scan,
}
)
+331
View File
@@ -0,0 +1,331 @@
"""
F54 model manager API handlers.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
try:
from ..services.access_control import require_admin_token, resolve_token_info
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
from ..services.management_query import normalize_cursor_limit
from ..services.model_manager import ModelManagerError, model_manager
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
except ImportError: # pragma: no cover
from services.access_control import ( # type: ignore
require_admin_token,
resolve_token_info,
)
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.endpoint_manifest import ( # type: ignore
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
from services.management_query import normalize_cursor_limit # type: ignore
from services.model_manager import ModelManagerError, model_manager # type: ignore
from services.tenant_context import ( # type: ignore
TenantBoundaryError,
request_tenant_scope,
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.model_manager")
web = import_aiohttp_web()
def _json(data: Dict[str, Any], status: int = 200) -> web.Response:
return web.json_response(data, status=status)
def _require_admin(request: web.Request) -> Optional[web.Response]:
ok, error = require_admin_token(request)
if ok:
return None
return _json({"ok": False, "error": error or "unauthorized"}, 403)
def _parse_int(raw: Any, default: int, minimum: int, maximum: int) -> int:
try:
value = int(str(raw).strip())
except Exception:
return default
return max(minimum, min(maximum, value))
def _parse_optional_bool(raw: Optional[str]) -> Optional[bool]:
if raw is None:
return None
text = str(raw).strip().lower()
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off"}:
return False
return None
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="Search models",
description="Search normalized model entries across managed installs and catalog sources.",
audit="models.search",
plane=RoutePlane.ADMIN,
)
async def model_search_handler(request: web.Request) -> web.Response:
deny = _require_admin(request)
if deny:
return deny
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
result = model_manager.search_models(
query=request.query.get("q", ""),
source=request.query.get("source", ""),
model_type=request.query.get("model_type", ""),
installed=_parse_optional_bool(request.query.get("installed")),
limit=_parse_int(request.query.get("limit"), 50, 1, 200),
offset=_parse_int(request.query.get("offset"), 0, 0, 10_000),
tenant_id=tenant.tenant_id,
)
return _json({"ok": True, **result})
except TenantBoundaryError as exc:
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.HIGH,
summary="Create model download task",
description="Create a managed model download task with progress/cancel lifecycle.",
audit="models.download.create",
plane=RoutePlane.ADMIN,
)
async def model_download_create_handler(request: web.Request) -> web.Response:
deny = _require_admin(request)
if deny:
return deny
try:
payload = await request.json()
except Exception:
return _json({"ok": False, "error": "invalid_json"}, 400)
if not isinstance(payload, dict):
return _json({"ok": False, "error": "invalid_payload"}, 400)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
task = model_manager.create_download_task(
model_id=payload.get("model_id") or payload.get("id") or "",
name=payload.get("name") or "",
model_type=payload.get("model_type") or "",
source=payload.get("source") or "",
source_label=payload.get("source_label") or "",
download_url=payload.get("download_url") or "",
expected_sha256=payload.get("expected_sha256") or "",
provenance=payload.get("provenance") or {},
destination_subdir=payload.get("destination_subdir"),
filename=payload.get("filename"),
tenant_id=tenant.tenant_id,
)
return _json({"ok": True, "task": task}, 201)
except TenantBoundaryError as exc:
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
except ModelManagerError as exc:
return _json({"ok": False, "error": exc.code, "detail": exc.detail}, exc.status)
except Exception as exc:
logger.exception("Failed to create model download task")
return _json({"ok": False, "error": "internal_error", "detail": str(exc)}, 500)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="List model download tasks",
description="List model download task states.",
audit="models.download.list",
plane=RoutePlane.ADMIN,
)
async def model_download_list_handler(request: web.Request) -> web.Response:
deny = _require_admin(request)
if deny:
return deny
token_info = resolve_token_info(request)
since_seq = None
delta_warnings = []
if "since_seq" in request.query:
page = normalize_cursor_limit(
request.query,
cursor_key="since_seq",
default_cursor=0,
min_cursor=0,
default_limit=100,
max_limit=200,
)
since_seq = int(page.cursor or 0)
delta_warnings = list(page.warnings)
limit = page.limit
else:
limit = _parse_int(request.query.get("limit"), 100, 1, 200)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
result = model_manager.list_download_tasks(
tenant_id=tenant.tenant_id,
state=request.query.get("state", ""),
limit=limit,
offset=_parse_int(request.query.get("offset"), 0, 0, 10_000),
since_seq=since_seq,
)
if since_seq is not None:
result.setdefault("pagination", {})["warnings"] = delta_warnings
if "delta" in result:
result["delta"]["warnings"] = delta_warnings
return _json({"ok": True, **result})
except TenantBoundaryError as exc:
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="Get model download task",
description="Get one model download task by task id.",
audit="models.download.get",
plane=RoutePlane.ADMIN,
)
async def model_download_get_handler(request: web.Request) -> web.Response:
deny = _require_admin(request)
if deny:
return deny
task_id = request.match_info.get("task_id")
if not task_id:
return _json({"ok": False, "error": "missing_task_id"}, 400)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
task = model_manager.get_download_task(task_id, tenant_id=tenant.tenant_id)
return _json({"ok": True, "task": task})
except TenantBoundaryError as exc:
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
except ModelManagerError as exc:
return _json({"ok": False, "error": exc.code, "detail": exc.detail}, exc.status)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.HIGH,
summary="Cancel model download task",
description="Cancel a queued/running model download task.",
audit="models.download.cancel",
plane=RoutePlane.ADMIN,
)
async def model_download_cancel_handler(request: web.Request) -> web.Response:
deny = _require_admin(request)
if deny:
return deny
task_id = request.match_info.get("task_id")
if not task_id:
return _json({"ok": False, "error": "missing_task_id"}, 400)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
task = model_manager.cancel_download_task(
task_id, tenant_id=tenant.tenant_id
)
return _json({"ok": True, "task": task})
except TenantBoundaryError as exc:
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
except ModelManagerError as exc:
return _json({"ok": False, "error": exc.code, "detail": exc.detail}, exc.status)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.HIGH,
summary="Import downloaded model",
description="Activate/import a completed model download with policy checks.",
audit="models.import",
plane=RoutePlane.ADMIN,
)
async def model_import_handler(request: web.Request) -> web.Response:
deny = _require_admin(request)
if deny:
return deny
try:
payload = await request.json()
except Exception:
return _json({"ok": False, "error": "invalid_json"}, 400)
if not isinstance(payload, dict):
return _json({"ok": False, "error": "invalid_payload"}, 400)
task_id = str(payload.get("task_id") or "").strip()
if not task_id:
return _json({"ok": False, "error": "missing_task_id"}, 400)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
rec = model_manager.import_downloaded_model(
task_id=task_id,
tenant_id=tenant.tenant_id,
destination_subdir=payload.get("destination_subdir"),
filename=payload.get("filename"),
tags=(
payload.get("tags")
if isinstance(payload.get("tags"), list)
else None
),
)
return _json({"ok": True, "installation": rec})
except TenantBoundaryError as exc:
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
except ModelManagerError as exc:
return _json({"ok": False, "error": exc.code, "detail": exc.detail}, exc.status)
except Exception as exc:
logger.exception("Failed to import model download")
return _json({"ok": False, "error": "internal_error", "detail": str(exc)}, 500)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="List installed models",
description="List managed model installations.",
audit="models.installations.list",
plane=RoutePlane.ADMIN,
)
async def model_installations_list_handler(request: web.Request) -> web.Response:
deny = _require_admin(request)
if deny:
return deny
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
result = model_manager.list_installations(
tenant_id=tenant.tenant_id,
model_type=request.query.get("model_type", ""),
limit=_parse_int(request.query.get("limit"), 100, 1, 200),
offset=_parse_int(request.query.get("offset"), 0, 0, 10_000),
)
return _json({"ok": True, **result})
except TenantBoundaryError as exc:
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
+70 -1
View File
@@ -32,6 +32,7 @@ except ImportError:
web = MockWeb()
import os
import re
import shutil
import tempfile
@@ -45,6 +46,32 @@ else:
from services.packs.pack_archive import PackArchive, PackError
from services.packs.pack_registry import PackRegistry
# R98: Endpoint Metadata
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
else:
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
# Strict pattern for pack name/version URL route parameters.
_SAFE_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
def _is_safe_pack_segment(value: str) -> bool:
"""Check if a pack name or version segment is safe for filesystem use."""
if not value or value in (".", ".."):
return False
return bool(_SAFE_SEGMENT_RE.match(value))
if web:
@@ -70,6 +97,14 @@ class PacksHandlers:
def __init__(self, state_dir: str):
self.registry = PackRegistry(state_dir)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="List packs",
description="List installed packs.",
audit="packs.list",
plane=RoutePlane.ADMIN,
)
async def list_packs_handler(self, request: web.Request) -> web.Response:
"""GET /packs - List installed packs."""
if getattr(web, "_IS_MOCKWEB", False) is True:
@@ -95,6 +130,14 @@ class PacksHandlers:
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.HIGH,
summary="Import pack",
description="Install pack from zip upload.",
audit="packs.import",
plane=RoutePlane.ADMIN,
)
async def import_pack_handler(self, request: web.Request) -> web.Response:
"""POST /packs/import - Install pack from zip upload."""
if getattr(web, "_IS_MOCKWEB", False) is True:
@@ -139,6 +182,14 @@ class PacksHandlers:
if os.path.exists(temp_path):
os.remove(temp_path)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.HIGH,
summary="Delete pack",
description="Uninstall pack.",
audit="packs.delete",
plane=RoutePlane.ADMIN,
)
async def delete_pack_handler(self, request: web.Request) -> web.Response:
"""DELETE /packs/{name}/{version} - Uninstall pack."""
if getattr(web, "_IS_MOCKWEB", False) is True:
@@ -155,6 +206,11 @@ class PacksHandlers:
{"ok": False, "error": "Missing name/version"}, status=400
)
if not _is_safe_pack_segment(name) or not _is_safe_pack_segment(version):
return web.json_response(
{"ok": False, "error": "Invalid name or version format"}, status=400
)
try:
success = self.registry.uninstall_pack(name, version)
if success:
@@ -166,6 +222,14 @@ class PacksHandlers:
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Export pack",
description="Download pack zip.",
audit="packs.export",
plane=RoutePlane.ADMIN,
)
async def export_pack_handler(self, request: web.Request) -> web.Response:
"""GET /packs/export/{name}/{version} - Download pack zip."""
if getattr(web, "_IS_MOCKWEB", False) is True:
@@ -182,6 +246,11 @@ class PacksHandlers:
{"ok": False, "error": "Missing name/version"}, status=400
)
if not _is_safe_pack_segment(name) or not _is_safe_pack_segment(version):
return web.json_response(
{"ok": False, "error": "Invalid name or version format"}, status=400
)
pack_path = self.registry.get_pack_path(name, version)
if not pack_path:
return web.json_response(
@@ -206,7 +275,7 @@ class PacksHandlers:
return CleanupFileResponse(
temp_zip,
headers={
"Content-Disposition": f'attachment; filename="{name}-{version}.zip"',
"Content-Disposition": f'attachment; filename="{name.replace(chr(34), "")}-{version.replace(chr(34), "")}.zip"',
"Content-Type": "application/zip",
},
)
+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)
+66 -11
View File
@@ -20,22 +20,45 @@ if __package__ and "." in __package__:
from ..models.schemas import MAX_BODY_SIZE
from ..services.access_control import is_loopback, require_admin_token
from ..services.preflight import (
_get_model_inventory,
_get_node_class_mappings,
get_model_inventory_snapshot,
run_preflight_check,
)
from ..services.rate_limit import check_rate_limit
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.request_ip import get_client_ip
from ..services.workflow_portability import get_workflow_portability_contract
else: # pragma: no cover (test-only import mode)
from models.schemas import MAX_BODY_SIZE # type: ignore
from services.access_control import is_loopback, require_admin_token # type: ignore
from services.preflight import ( # type: ignore
_get_model_inventory,
_get_node_class_mappings,
get_model_inventory_snapshot,
run_preflight_check,
)
from services.rate_limit import check_rate_limit # type: ignore
from services.rate_limit import ( # type: ignore
build_rate_limit_response,
check_rate_limit,
)
from services.request_ip import get_client_ip # type: ignore
from services.workflow_portability import ( # type: ignore
get_workflow_portability_contract,
)
# R98: Endpoint Metadata
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
else:
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.preflight")
@@ -68,6 +91,14 @@ def _deny_remote_admin_if_needed(request: web.Request) -> web.Response | None:
)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW, # Read-only analysis
summary="Run preflight check",
description="Analyze workflow JSON for missing nodes and models.",
audit="preflight.analyze",
plane=RoutePlane.ADMIN,
)
async def preflight_handler(request: web.Request) -> web.Response:
"""
POST /openclaw/preflight
@@ -78,8 +109,12 @@ async def preflight_handler(request: web.Request) -> web.Response:
# Rate limit: admin-grade endpoint (inventory leak + CPU cost)
if not check_rate_limit(request, "admin"):
return web.json_response(
{"ok": False, "error": "rate_limit_exceeded"}, status=429
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="rate_limit_exceeded",
include_ok=True,
)
# Body Size Check
@@ -134,6 +169,14 @@ async def preflight_handler(request: web.Request) -> web.Response:
)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="Get inventory",
description="Returns a snapshot of available nodes and models.",
audit="preflight.inventory",
plane=RoutePlane.ADMIN,
)
async def inventory_handler(request: web.Request) -> web.Response:
"""
GET /openclaw/preflight/inventory
@@ -144,8 +187,12 @@ async def inventory_handler(request: web.Request) -> web.Response:
# Rate Limit
if not check_rate_limit(request, "admin"):
return web.json_response(
{"ok": False, "error": "rate_limit_exceeded"}, status=429
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="rate_limit_exceeded",
include_ok=True,
)
# Admin boundary (localhost convenience mode if no token configured)
@@ -163,11 +210,19 @@ async def inventory_handler(request: web.Request) -> web.Response:
nodes_map = _get_node_class_mappings()
node_classes = sorted(list(nodes_map.keys()))
# Models
models_map = _get_model_inventory()
inventory_snapshot = get_model_inventory_snapshot()
return web.json_response(
{"ok": True, "nodes": node_classes, "models": models_map}
{
"ok": True,
"nodes": node_classes,
"models": inventory_snapshot["models"],
"portability_contract": get_workflow_portability_contract(),
"snapshot_ts": inventory_snapshot["snapshot_ts"],
"scan_state": inventory_snapshot["scan_state"],
"stale": inventory_snapshot["stale"],
"last_error": inventory_snapshot["last_error"],
}
)
except Exception as e:
logger.exception("Inventory fetch failed")
+192 -63
View File
@@ -8,22 +8,57 @@ import os
import time
from typing import Optional
from aiohttp import web
if __package__ and "." in __package__:
from ..services.import_fallback import import_attrs_dual
else:
from services.import_fallback import import_attrs_dual # type: ignore
try:
from ..services.access_control import require_admin_token
from ..services.presets import Preset, preset_store
except ImportError:
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
from services.access_control import require_admin_token
from services.presets import Preset, preset_store
(require_admin_token, resolve_token_info) = import_attrs_dual(
__package__,
"..services.access_control",
"services.access_control",
("require_admin_token", "resolve_token_info"),
)
(import_aiohttp_web,) = import_attrs_dual(
__package__,
"..services.aiohttp_compat",
"services.aiohttp_compat",
("import_aiohttp_web",),
)
(AuthTier, RiskTier, RoutePlane, endpoint_metadata) = import_attrs_dual(
__package__,
"..services.endpoint_manifest",
"services.endpoint_manifest",
("AuthTier", "RiskTier", "RoutePlane", "endpoint_metadata"),
)
(Preset, preset_store) = import_attrs_dual(
__package__,
"..services.presets",
"services.presets",
("Preset", "preset_store"),
)
(TenantBoundaryError, request_tenant_scope) = import_attrs_dual(
__package__,
"..services.tenant_context",
"services.tenant_context",
("TenantBoundaryError", "request_tenant_scope"),
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.presets")
web = import_aiohttp_web()
class PresetHandlers:
"""Handlers for preset API."""
@endpoint_metadata(
auth=AuthTier.PUBLIC, # Conditionally public
risk=RiskTier.LOW,
summary="List presets",
description="List available presets (dynamic auth).",
audit="presets.list",
plane=RoutePlane.USER,
)
async def list_presets(self, request: web.Request) -> web.Response:
"""
GET /moltbot/presets
@@ -53,10 +88,33 @@ class PresetHandlers:
category = request.query.get("category")
tag = request.query.get("tag")
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
presets = preset_store.list_presets(
category=category,
tag=tag,
tenant_id=tenant.tenant_id,
)
return web.json_response([p.to_dict() for p in presets])
except TenantBoundaryError as exc:
return web.json_response(
{"error": exc.code, "message": str(exc)},
status=403,
)
presets = preset_store.list_presets(category=category, tag=tag)
return web.json_response([p.to_dict() for p in presets])
@endpoint_metadata(
auth=AuthTier.PUBLIC, # Conditionally public
risk=RiskTier.LOW,
summary="Get preset",
description="Get preset details (dynamic auth).",
audit="presets.get",
plane=RoutePlane.USER,
)
async def get_preset(self, request: web.Request) -> web.Response:
"""GET /moltbot/presets/{preset_id}"""
# Milestone B: Auth Check
@@ -80,12 +138,32 @@ class PresetHandlers:
if not preset_id:
return web.json_response({"error": "Missing ID"}, status=400)
preset = preset_store.get_preset(preset_id)
if not preset:
return web.json_response({"error": "Not Found"}, status=404)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
preset = preset_store.get_preset(preset_id, tenant_id=tenant.tenant_id)
if not preset:
return web.json_response({"error": "Not Found"}, status=404)
return web.json_response(preset.to_dict())
return web.json_response(preset.to_dict())
except TenantBoundaryError as exc:
return web.json_response(
{"error": exc.code, "message": str(exc)},
status=403,
)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Create preset",
description="Create a new preset.",
audit="presets.create",
plane=RoutePlane.ADMIN,
)
async def create_preset(self, request: web.Request) -> web.Response:
"""POST /moltbot/presets"""
allowed, error = require_admin_token(request)
@@ -102,32 +180,52 @@ class PresetHandlers:
if not name or not content:
return web.json_response({"error": "Name and Content required"}, status=400)
token_info = resolve_token_info(request)
try:
# Create object
preset = Preset.new(
name=data["name"],
content=data["content"],
category=data.get("category", "general"),
tags=data.get("tags", []),
)
# Milestone E: Schema Validation
try:
preset.validate_content()
except ValueError as e:
return web.json_response(
{"error": f"Validation Error: {str(e)}"}, status=400
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
# Create object
preset = Preset.new(
name=data["name"],
content=data["content"],
category=data.get("category", "general"),
tags=data.get("tags", []),
)
preset.tenant_id = tenant.tenant_id
# Save
preset_store.save_preset(preset)
logger.info(f"Created preset {preset.id} ({preset.name})")
# Milestone E: Schema Validation
try:
preset.validate_content()
except ValueError as e:
return web.json_response(
{"error": f"Validation Error: {str(e)}"}, status=400
)
return web.json_response(preset.to_dict(), status=201)
# Save
preset_store.save_preset(preset)
logger.info(f"Created preset {preset.id} ({preset.name})")
return web.json_response(preset.to_dict(), status=201)
except TenantBoundaryError as exc:
return web.json_response(
{"error": exc.code, "message": str(exc)},
status=403,
)
except Exception as e:
logger.error(f"Failed to create preset: {e}")
return web.json_response({"error": str(e)}, status=500)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Update preset",
description="Update an existing preset.",
audit="presets.update",
plane=RoutePlane.ADMIN,
)
async def update_preset(self, request: web.Request) -> web.Response:
"""PUT /moltbot/presets/{preset_id}"""
allowed, error = require_admin_token(request)
@@ -138,38 +236,58 @@ class PresetHandlers:
if not preset_id:
return web.json_response({"error": "Missing ID"}, status=400)
preset = preset_store.get_preset(preset_id)
if not preset:
return web.json_response({"error": "Not Found"}, status=404)
token_info = resolve_token_info(request)
try:
data = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
preset = preset_store.get_preset(preset_id, tenant_id=tenant.tenant_id)
if not preset:
return web.json_response({"error": "Not Found"}, status=404)
# Update fields
if "name" in data:
preset.name = data["name"]
if "content" in data:
preset.content = data["content"]
if "category" in data:
preset.category = data["category"]
if "tags" in data:
preset.tags = data["tags"]
try:
data = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
# Milestone E: Schema Validation
try:
preset.validate_content()
except ValueError as e:
# Update fields
if "name" in data:
preset.name = data["name"]
if "content" in data:
preset.content = data["content"]
if "category" in data:
preset.category = data["category"]
if "tags" in data:
preset.tags = data["tags"]
# Milestone E: Schema Validation
try:
preset.validate_content()
except ValueError as e:
return web.json_response(
{"error": f"Validation Error: {str(e)}"}, status=400
)
preset.updated_at = time.time()
preset_store.save_preset(preset)
return web.json_response(preset.to_dict())
except TenantBoundaryError as exc:
return web.json_response(
{"error": f"Validation Error: {str(e)}"}, status=400
{"error": exc.code, "message": str(exc)},
status=403,
)
preset.updated_at = time.time()
preset_store.save_preset(preset)
return web.json_response(preset.to_dict())
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.HIGH,
summary="Delete preset",
description="Delete a preset.",
audit="presets.delete",
plane=RoutePlane.ADMIN,
)
async def delete_preset(self, request: web.Request) -> web.Response:
"""DELETE /moltbot/presets/{preset_id}"""
allowed, error = require_admin_token(request)
@@ -180,10 +298,21 @@ class PresetHandlers:
if not preset_id:
return web.json_response({"error": "Missing ID"}, status=400)
if preset_store.delete_preset(preset_id):
return web.json_response({"ok": True})
else:
return web.json_response({"error": "Not Found or Failed"}, status=404)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
if preset_store.delete_preset(preset_id, tenant_id=tenant.tenant_id):
return web.json_response({"ok": True})
return web.json_response({"error": "Not Found or Failed"}, status=404)
except TenantBoundaryError as exc:
return web.json_response(
{"error": exc.code, "message": str(exc)},
status=403,
)
def register_preset_routes(app: web.Application):
+61
View File
@@ -0,0 +1,61 @@
"""
F61 Remote Admin Console static page handler.
Serves a standalone mobile-friendly admin UI for remote operators.
"""
from __future__ import annotations
from pathlib import Path
if __package__ and "." in __package__:
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
else: # pragma: no cover (test-only import mode)
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.endpoint_manifest import ( # type: ignore
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
web = import_aiohttp_web()
# CRITICAL: resolve HTML path relative to this module's package root.
# Do not switch to cwd-based resolution; ComfyUI may launch from arbitrary directories.
def _admin_console_html_path() -> Path:
return Path(__file__).resolve().parents[1] / "web" / "admin_console.html"
@endpoint_metadata(
auth=AuthTier.PUBLIC,
risk=RiskTier.LOW,
summary="Remote admin console page",
description="Serves the standalone remote admin console HTML shell.",
audit="admin.console.page",
plane=RoutePlane.USER,
)
async def remote_admin_page_handler(request: web.Request) -> web.Response:
path = _admin_console_html_path()
if not path.exists():
return web.json_response(
{
"ok": False,
"error": "remote_admin_console_not_found",
"path": str(path),
},
status=500,
)
html = path.read_text(encoding="utf-8")
return web.Response(
text=html,
content_type="text/html",
headers={"Cache-Control": "no-store"},
)
+400
View File
@@ -0,0 +1,400 @@
"""
F53 rewrite recipe API handlers.
Admin-only workflow:
- Recipe CRUD
- Dry-run preview with structured diff
- Guarded apply with rollback snapshot on failure
"""
from __future__ import annotations
import logging
import time
from typing import Any, Dict
try:
from ..services.access_control import require_admin_token, resolve_token_info
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
from ..services.rewrite_recipes import (
RecipeApplyError,
RecipeValidationError,
RewriteConstraints,
RewriteOperation,
RewriteRecipe,
dry_run_recipe,
guarded_apply_recipe,
rewrite_recipe_store,
)
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
except ImportError:
from services.access_control import ( # type: ignore
require_admin_token,
resolve_token_info,
)
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.endpoint_manifest import ( # type: ignore
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
from services.rewrite_recipes import ( # type: ignore
RecipeApplyError,
RecipeValidationError,
RewriteConstraints,
RewriteOperation,
RewriteRecipe,
dry_run_recipe,
guarded_apply_recipe,
rewrite_recipe_store,
)
from services.tenant_context import ( # type: ignore
TenantBoundaryError,
request_tenant_scope,
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.rewrite_recipes")
web = import_aiohttp_web()
def _json(data: Dict[str, Any], status: int = 200) -> web.Response:
return web.json_response(data, status=status)
def _require_admin(request: web.Request) -> web.Response | None:
allowed, error = require_admin_token(request)
if not allowed:
return _json({"ok": False, "error": error or "unauthorized"}, 403)
return None
def _build_recipe_from_payload(
payload: Dict[str, Any], existing: RewriteRecipe | None = None
) -> RewriteRecipe:
if existing is None:
return RewriteRecipe.new(
name=payload.get("name") or "",
prompt_template=payload.get("prompt_template") or "",
description=payload.get("description") or "",
tags=payload.get("tags") or [],
operations=payload.get("operations") or [],
constraints=payload.get("constraints") or {},
tenant_id=payload.get("tenant_id") or "default",
)
if "name" in payload:
existing.name = payload["name"] or ""
if "prompt_template" in payload:
existing.prompt_template = payload.get("prompt_template") or ""
if "description" in payload:
existing.description = payload.get("description") or ""
if "tags" in payload:
existing.tags = payload.get("tags") or []
if "operations" in payload:
existing.operations = [
RewriteOperation.from_dict(item)
for item in (payload.get("operations") or [])
]
if "constraints" in payload:
existing.constraints = RewriteConstraints.from_dict(
payload.get("constraints") or {}
)
existing.updated_at = time.time()
existing.validate()
return existing
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="List rewrite recipes",
description="List workflow rewrite recipes.",
audit="rewrite_recipes.list",
plane=RoutePlane.ADMIN,
)
async def rewrite_recipes_list_handler(request: web.Request) -> web.Response:
deny = _require_admin(request)
if deny:
return deny
tag = request.query.get("tag")
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
recipes = rewrite_recipe_store.list_recipes(
tag=tag,
tenant_id=tenant.tenant_id,
)
return _json({"ok": True, "recipes": [item.to_dict() for item in recipes]})
except TenantBoundaryError as exc:
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="Get rewrite recipe",
description="Get a single workflow rewrite recipe.",
audit="rewrite_recipes.get",
plane=RoutePlane.ADMIN,
)
async def rewrite_recipe_get_handler(request: web.Request) -> web.Response:
deny = _require_admin(request)
if deny:
return deny
recipe_id = request.match_info.get("recipe_id")
if not recipe_id:
return _json({"ok": False, "error": "missing_id"}, 400)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
recipe = rewrite_recipe_store.get_recipe(
recipe_id, tenant_id=tenant.tenant_id
)
if recipe is None:
return _json({"ok": False, "error": "not_found"}, 404)
return _json({"ok": True, "recipe": recipe.to_dict()})
except TenantBoundaryError as exc:
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Create rewrite recipe",
description="Create a workflow rewrite recipe.",
audit="rewrite_recipes.create",
plane=RoutePlane.ADMIN,
)
async def rewrite_recipe_create_handler(request: web.Request) -> web.Response:
deny = _require_admin(request)
if deny:
return deny
try:
payload = await request.json()
except Exception:
return _json({"ok": False, "error": "invalid_json"}, 400)
if not isinstance(payload, dict):
return _json({"ok": False, "error": "invalid_payload"}, 400)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
payload["tenant_id"] = tenant.tenant_id
recipe = _build_recipe_from_payload(payload)
rewrite_recipe_store.save_recipe(recipe)
return _json({"ok": True, "recipe": recipe.to_dict()}, 201)
except TenantBoundaryError as exc:
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
except RecipeValidationError as exc:
return _json({"ok": False, "error": exc.code, "detail": exc.detail}, 400)
except Exception as exc:
logger.exception("Failed to create rewrite recipe")
return _json({"ok": False, "error": "internal_error", "detail": str(exc)}, 500)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Update rewrite recipe",
description="Update an existing workflow rewrite recipe.",
audit="rewrite_recipes.update",
plane=RoutePlane.ADMIN,
)
async def rewrite_recipe_update_handler(request: web.Request) -> web.Response:
deny = _require_admin(request)
if deny:
return deny
recipe_id = request.match_info.get("recipe_id")
if not recipe_id:
return _json({"ok": False, "error": "missing_id"}, 400)
try:
payload = await request.json()
except Exception:
return _json({"ok": False, "error": "invalid_json"}, 400)
if not isinstance(payload, dict):
return _json({"ok": False, "error": "invalid_payload"}, 400)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
recipe = rewrite_recipe_store.get_recipe(
recipe_id, tenant_id=tenant.tenant_id
)
if recipe is None:
return _json({"ok": False, "error": "not_found"}, 404)
updated = _build_recipe_from_payload(payload, existing=recipe)
rewrite_recipe_store.save_recipe(updated)
return _json({"ok": True, "recipe": updated.to_dict()})
except TenantBoundaryError as exc:
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
except RecipeValidationError as exc:
return _json({"ok": False, "error": exc.code, "detail": exc.detail}, 400)
except Exception as exc:
logger.exception("Failed to update rewrite recipe")
return _json({"ok": False, "error": "internal_error", "detail": str(exc)}, 500)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.HIGH,
summary="Delete rewrite recipe",
description="Delete a workflow rewrite recipe.",
audit="rewrite_recipes.delete",
plane=RoutePlane.ADMIN,
)
async def rewrite_recipe_delete_handler(request: web.Request) -> web.Response:
deny = _require_admin(request)
if deny:
return deny
recipe_id = request.match_info.get("recipe_id")
if not recipe_id:
return _json({"ok": False, "error": "missing_id"}, 400)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
if not rewrite_recipe_store.delete_recipe(
recipe_id, tenant_id=tenant.tenant_id
):
return _json({"ok": False, "error": "not_found"}, 404)
return _json({"ok": True})
except TenantBoundaryError as exc:
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Dry-run rewrite recipe",
description="Preview rewrite result and structured diff without applying changes.",
audit="rewrite_recipes.dry_run",
plane=RoutePlane.ADMIN,
)
async def rewrite_recipe_dry_run_handler(request: web.Request) -> web.Response:
deny = _require_admin(request)
if deny:
return deny
recipe_id = request.match_info.get("recipe_id")
if not recipe_id:
return _json({"ok": False, "error": "missing_id"}, 400)
try:
payload = await request.json()
except Exception:
return _json({"ok": False, "error": "invalid_json"}, 400)
workflow = payload.get("workflow") if isinstance(payload, dict) else None
inputs = payload.get("inputs", {}) if isinstance(payload, dict) else {}
if not isinstance(workflow, dict):
return _json({"ok": False, "error": "missing_workflow"}, 400)
if not isinstance(inputs, dict):
return _json({"ok": False, "error": "invalid_inputs"}, 400)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
recipe = rewrite_recipe_store.get_recipe(
recipe_id, tenant_id=tenant.tenant_id
)
if recipe is None:
return _json({"ok": False, "error": "not_found"}, 404)
result = dry_run_recipe(recipe, workflow=workflow, inputs=inputs)
return _json({"ok": True, **result})
except TenantBoundaryError as exc:
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
except RecipeValidationError as exc:
return _json({"ok": False, "error": exc.code, "detail": exc.detail}, 400)
except Exception as exc:
logger.exception("Rewrite dry-run failed")
return _json({"ok": False, "error": "internal_error", "detail": str(exc)}, 500)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.HIGH,
summary="Apply rewrite recipe",
description="Guarded apply with validation and rollback snapshot on failure.",
audit="rewrite_recipes.apply",
plane=RoutePlane.ADMIN,
)
async def rewrite_recipe_apply_handler(request: web.Request) -> web.Response:
deny = _require_admin(request)
if deny:
return deny
recipe_id = request.match_info.get("recipe_id")
if not recipe_id:
return _json({"ok": False, "error": "missing_id"}, 400)
try:
payload = await request.json()
except Exception:
return _json({"ok": False, "error": "invalid_json"}, 400)
workflow = payload.get("workflow") if isinstance(payload, dict) else None
inputs = payload.get("inputs", {}) if isinstance(payload, dict) else {}
confirm = bool(payload.get("confirm")) if isinstance(payload, dict) else False
if not isinstance(workflow, dict):
return _json({"ok": False, "error": "missing_workflow"}, 400)
if not isinstance(inputs, dict):
return _json({"ok": False, "error": "invalid_inputs"}, 400)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
recipe = rewrite_recipe_store.get_recipe(
recipe_id, tenant_id=tenant.tenant_id
)
if recipe is None:
return _json({"ok": False, "error": "not_found"}, 404)
result = guarded_apply_recipe(
recipe,
workflow=workflow,
inputs=inputs,
confirm=confirm,
)
return _json({"ok": True, **result})
except TenantBoundaryError as exc:
return _json({"ok": False, "error": exc.code, "detail": str(exc)}, 403)
except RecipeApplyError as exc:
return _json(
{
"ok": False,
"error": exc.code,
"detail": exc.detail,
"rollback_snapshot": exc.rollback_snapshot,
},
400,
)
except Exception as exc:
logger.exception("Rewrite apply failed")
return _json({"ok": False, "error": "internal_error", "detail": str(exc)}, 500)
+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,
),
)
+645 -509
View File
File diff suppressed because it is too large Load Diff
+60 -15
View File
@@ -3,15 +3,11 @@ Scheduler CRUD API (R4).
REST endpoints for managing persistent schedules.
"""
from __future__ import annotations
import logging
from typing import Optional
from aiohttp import web
from ..services.scheduler.models import Schedule, TriggerType
from ..services.scheduler.storage import get_schedule_store
from ..services.templates import is_template_allowed
# Import discipline:
# - ComfyUI runtime: package-relative imports only (prevents collisions with other custom nodes).
# - Unit tests: allow top-level fallbacks.
@@ -19,11 +15,48 @@ from ..services.templates import is_template_allowed
# IMPORTANT: Avoid a broad `try/except ImportError` here. Falling back to `services.*` in ComfyUI
# can silently import another pack's module and break auth/approval semantics.
if __package__ and "." in __package__:
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.scheduler.delivery_contract import (
DeliveryContractError,
normalize_schedule_delivery,
)
from ..services.scheduler.models import Schedule, TriggerType
from ..services.scheduler.storage import get_schedule_store
from ..services.templates import is_template_allowed
from ..services.webhook_auth import AuthError
else: # pragma: no cover (test-only import mode)
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.scheduler.delivery_contract import ( # type: ignore
DeliveryContractError,
normalize_schedule_delivery,
)
from services.scheduler.models import Schedule, TriggerType # type: ignore
from services.scheduler.storage import get_schedule_store # type: ignore
from services.templates import is_template_allowed # type: ignore
from services.webhook_auth import AuthError # type: ignore
logger = logging.getLogger("ComfyUI-OpenClaw.api.schedules")
web = import_aiohttp_web()
def _delivery_error_response(exc: DeliveryContractError) -> web.Response:
return web.json_response({"error": str(exc), "code": exc.code}, status=400)
def _get_scheduler_runner():
if __package__ and "." in __package__:
from ..services.scheduler.runner import get_scheduler_runner
else: # pragma: no cover (test-only import mode)
from services.scheduler.runner import get_scheduler_runner # type: ignore
return get_scheduler_runner()
def _get_run_history():
if __package__ and "." in __package__:
from ..services.scheduler.history import get_run_history
else: # pragma: no cover (test-only import mode)
from services.scheduler.history import get_run_history # type: ignore
return get_run_history()
class ScheduleHandlers:
@@ -136,6 +169,8 @@ class ScheduleHandlers:
timezone=data.get("timezone", "local"),
enabled=data.get("enabled", True),
)
except DeliveryContractError as e:
return _delivery_error_response(e)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
@@ -188,7 +223,10 @@ class ScheduleHandlers:
if "inputs" in data:
existing.inputs = data["inputs"]
if "delivery" in data:
existing.delivery = data["delivery"]
try:
existing.delivery = normalize_schedule_delivery(data["delivery"])
except DeliveryContractError as e:
return _delivery_error_response(e)
if "timezone" in data:
existing.timezone = data["timezone"]
if "enabled" in data:
@@ -197,6 +235,8 @@ class ScheduleHandlers:
# Re-validate
try:
existing.validate()
except DeliveryContractError as e:
return _delivery_error_response(e)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
@@ -278,9 +318,18 @@ class ScheduleHandlers:
# Trigger immediate execution via scheduler runner
import time
from ..services.scheduler.runner import get_scheduler_runner
runner = _get_scheduler_runner()
if runner.is_execution_delegated():
# IMPORTANT: in public+split mode, embedded scheduler execution must remain blocked.
return web.json_response(
{
"error": "Scheduler execution is delegated to external control plane",
"code": "scheduler_delegated",
"remediation": "Use external scheduler control plane in split mode.",
},
status=503,
)
runner = get_scheduler_runner()
try:
runner._execute_schedule(schedule, time.time())
return web.json_response(
@@ -309,9 +358,7 @@ class ScheduleHandlers:
if not self._store.get(schedule_id):
return web.json_response({"error": "Schedule not found"}, status=404)
from ..services.scheduler.history import get_run_history
history = get_run_history()
history = _get_run_history()
limit = int(request.query.get("limit", "100"))
offset = int(request.query.get("offset", "0"))
status = request.query.get("status")
@@ -340,9 +387,7 @@ class ScheduleHandlers:
except Exception:
return web.json_response({"error": "Unauthorized"}, status=403)
from ..services.scheduler.history import get_run_history
history = get_run_history()
history = _get_run_history()
limit = int(request.query.get("limit", "100"))
offset = int(request.query.get("offset", "0"))
status = request.query.get("status")
+148 -18
View File
@@ -15,38 +15,61 @@ Endpoints:
- DELETE /openclaw/secrets/{provider}: Clear provider secret
"""
from __future__ import annotations
import logging
import os
from typing import Optional
from aiohttp import web
# Import discipline:
# - ComfyUI runtime: package-relative imports only.
# - Unit tests: allow top-level fallbacks.
if __package__ and "." in __package__:
from ..models.schemas import MAX_BODY_SIZE
from ..services.access_control import require_admin_token
from ..services.audit import audit_secret_delete, audit_secret_write
from ..services.access_control import require_admin_token, resolve_token_info
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.audit import emit_audit_event
from ..services.csrf_protection import require_same_origin_if_no_token
from ..services.metrics import metrics
from ..services.rate_limit import check_rate_limit
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.request_ip import get_client_ip
from ..services.runtime_config import get_admin_token, is_loopback_client
from ..services.secret_store import get_secret_store
else: # pragma: no cover (test-only import mode)
from models.schemas import MAX_BODY_SIZE # type: ignore
from services.access_control import require_admin_token # type: ignore
from services.audit import audit_secret_delete # type: ignore
from services.audit import audit_secret_write
from services.access_control import resolve_token_info # type: ignore
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.audit import emit_audit_event # type: ignore
from services.csrf_protection import require_same_origin_if_no_token # type: ignore
from services.metrics import metrics # type: ignore
from services.rate_limit import check_rate_limit # type: ignore
from services.rate_limit import ( # type: ignore
build_rate_limit_response,
check_rate_limit,
)
from services.request_ip import get_client_ip # type: ignore
from services.runtime_config import get_admin_token # type: ignore
from services.runtime_config import is_loopback_client
from services.secret_store import get_secret_store # type: ignore
web = import_aiohttp_web()
# R98: Endpoint Metadata
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
else:
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.secrets")
_TRUTHY = ("1", "true", "yes", "on")
@@ -62,6 +85,15 @@ def _deny_if_remote_admin_not_allowed(request: web.Request) -> Optional[web.Resp
return None
remote = request.remote or ""
if not is_loopback_client(remote):
# R99: audit deny path
emit_audit_event(
action="secrets.access",
target="secrets",
outcome="deny",
status_code=403,
details={"reason": "remote_admin_denied", "remote": remote},
request=request,
)
return web.json_response(
{
"ok": False,
@@ -76,6 +108,15 @@ def _deny_if_remote_admin_not_allowed(request: web.Request) -> Optional[web.Resp
def _require_admin(request: web.Request) -> Optional[web.Response]:
allowed, error = require_admin_token(request)
if not allowed:
# R99: audit deny path
emit_audit_event(
action="secrets.access",
target="secrets",
outcome="deny",
status_code=403,
details={"reason": error or "admin_token_required"},
request=request,
)
return web.json_response(
{"ok": False, "error": error or "Unauthorized"}, status=403
)
@@ -86,13 +127,23 @@ def _rate_limit_admin(request: web.Request) -> Optional[web.Response]:
if check_rate_limit(request, "admin"):
return None
metrics.increment("rate_limit_exceeded")
return web.json_response(
{"ok": False, "error": "rate_limit_exceeded"},
status=429,
headers={"Retry-After": "60"},
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="rate_limit_exceeded",
include_ok=True,
)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="Get secret status",
description="Returns secret configuration status (NO ACTUAL VALUES).",
audit="secrets.status",
plane=RoutePlane.ADMIN,
)
async def secrets_status_handler(request: web.Request) -> web.Response:
"""
GET /openclaw/secrets/status
@@ -128,6 +179,14 @@ async def secrets_status_handler(request: web.Request) -> web.Response:
)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.HIGH,
summary="Write secret",
description="Save API key to server store.",
audit="secrets.write",
plane=RoutePlane.ADMIN,
)
async def secrets_put_handler(request: web.Request) -> web.Response:
"""
PUT /openclaw/secrets
@@ -142,6 +201,16 @@ async def secrets_put_handler(request: web.Request) -> web.Response:
- CSRF-protected (same-origin if no token)
- Request body NEVER logged
"""
# S62: Block secrets write in public+split mode
try:
# CRITICAL: package-relative import must stay first in ComfyUI runtime.
from ..services.surface_guard import check_surface
except ImportError:
from services.surface_guard import check_surface # type: ignore
blocked = check_surface("secrets_write", request)
if blocked:
return blocked
# S26+: CSRF protection for convenience mode
admin_token_configured = bool(get_admin_token())
resp = require_same_origin_if_no_token(request, admin_token_configured)
@@ -156,6 +225,8 @@ async def secrets_put_handler(request: web.Request) -> web.Response:
if resp:
return resp
token_info = resolve_token_info(request)
resp = _rate_limit_admin(request)
if resp:
return resp
@@ -219,20 +290,43 @@ async def secrets_put_handler(request: web.Request) -> web.Response:
# Never log the secret value
logger.info(f"S25: Saved secret for provider '{provider}' via UI")
# S26+: Audit event (no secrets)
audit_secret_write(actor_ip, provider, ok=True)
emit_audit_event(
action="secrets.write",
target=provider,
outcome="allow",
token_info=token_info,
status_code=200,
details={"actor_ip": actor_ip},
request=request,
)
return web.json_response(
{"ok": True, "message": f"Secret saved for provider '{provider}'"}
)
except Exception as e:
logger.error(f"S25: Failed to save secret: {e}")
audit_secret_write(actor_ip, provider, ok=False, error=str(e))
emit_audit_event(
action="secrets.write",
target=provider,
outcome="error",
token_info=token_info,
status_code=500,
details={"actor_ip": actor_ip, "error": str(e)},
request=request,
)
return web.json_response(
{"ok": False, "error": "save_failed", "message": str(e)}, status=500
)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.HIGH,
summary="Delete secret",
description="Clear provider secret.",
audit="secrets.delete",
plane=RoutePlane.ADMIN,
)
async def secrets_delete_handler(request: web.Request) -> web.Response:
"""
DELETE /openclaw/secrets/{provider}
@@ -244,6 +338,16 @@ async def secrets_delete_handler(request: web.Request) -> web.Response:
- Rate-limited
- CSRF-protected (same-origin if no token)
"""
# S62: Block secrets write in public+split mode
try:
# CRITICAL: package-relative import must stay first in ComfyUI runtime.
from ..services.surface_guard import check_surface
except ImportError:
from services.surface_guard import check_surface # type: ignore
blocked = check_surface("secrets_write", request)
if blocked:
return blocked
# S26+: CSRF protection for convenience mode
admin_token_configured = bool(get_admin_token())
resp = require_same_origin_if_no_token(request, admin_token_configured)
@@ -258,6 +362,8 @@ async def secrets_delete_handler(request: web.Request) -> web.Response:
if resp:
return resp
token_info = resolve_token_info(request)
resp = _rate_limit_admin(request)
if resp:
return resp
@@ -279,12 +385,28 @@ async def secrets_delete_handler(request: web.Request) -> web.Response:
if removed:
logger.info(f"S25: Cleared secret for provider '{provider}' via UI")
audit_secret_delete(actor_ip, provider, ok=True)
emit_audit_event(
action="secrets.delete",
target=provider,
outcome="allow",
token_info=token_info,
status_code=200,
details={"actor_ip": actor_ip},
request=request,
)
return web.json_response(
{"ok": True, "message": f"Secret cleared for provider '{provider}'"}
)
else:
audit_secret_delete(actor_ip, provider, ok=False, error="not_found")
emit_audit_event(
action="secrets.delete",
target=provider,
outcome="deny",
token_info=token_info,
status_code=404,
details={"actor_ip": actor_ip, "reason": "not_found"},
request=request,
)
return web.json_response(
{
"ok": False,
@@ -295,7 +417,15 @@ async def secrets_delete_handler(request: web.Request) -> web.Response:
)
except Exception as e:
logger.error(f"S25: Failed to clear secret: {e}")
audit_secret_delete(actor_ip, provider, ok=False, error=str(e))
emit_audit_event(
action="secrets.delete",
target=provider,
outcome="error",
token_info=token_info,
status_code=500,
details={"actor_ip": actor_ip, "error": str(e)},
request=request,
)
return web.json_response(
{"ok": False, "error": "clear_failed", "message": str(e)}, status=500
)
+35 -4
View File
@@ -41,16 +41,43 @@ except ImportError: # pragma: no cover
if __package__ and "." in __package__:
from ..services.access_control import require_admin_token
from ..services.rate_limit import check_rate_limit
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.security_doctor import run_security_doctor
else: # pragma: no cover (test-only)
from services.access_control import require_admin_token # type: ignore
from services.rate_limit import check_rate_limit # type: ignore
from services.rate_limit import ( # type: ignore
build_rate_limit_response,
check_rate_limit,
)
from services.security_doctor import run_security_doctor # type: ignore
# R98: Endpoint Metadata
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
else:
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.security_doctor")
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.HIGH, # Can perform remediation actions
summary="Security Doctor",
description="Run security posture diagnostics and remediation.",
audit="security.doctor",
plane=RoutePlane.ADMIN,
)
async def security_doctor_handler(request: web.Request) -> web.Response:
"""
GET /openclaw/security/doctor
@@ -66,8 +93,12 @@ async def security_doctor_handler(request: web.Request) -> web.Response:
# S17: Rate limit
if not check_rate_limit(request, "admin"):
return web.json_response(
{"ok": False, "error": "Rate limit exceeded"}, status=429
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
# Admin boundary
+82 -33
View File
@@ -18,13 +18,43 @@ except ImportError: # pragma: no cover (optional for unit tests)
# - ComfyUI runtime: package-relative imports only (prevents collisions with other custom nodes).
# - Unit tests: allow top-level fallbacks.
if __package__ and "." in __package__:
from ..services.access_control import require_observability_access
from ..services.rate_limit import check_rate_limit
from ..services.access_control import (
require_observability_access,
resolve_token_info,
)
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.templates import get_template_service
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
else: # pragma: no cover (test-only import mode)
from services.access_control import require_observability_access # type: ignore
from services.rate_limit import check_rate_limit # type: ignore
from services.access_control import ( # type: ignore
require_observability_access,
resolve_token_info,
)
from services.rate_limit import ( # type: ignore
build_rate_limit_response,
check_rate_limit,
)
from services.templates import get_template_service # type: ignore
from services.tenant_context import ( # type: ignore
TenantBoundaryError,
request_tenant_scope,
)
# R98: Endpoint Metadata
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
else:
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.templates")
@@ -52,6 +82,14 @@ def _ensure_templates_api_deps_ready() -> tuple[bool, str | None]:
return True, None
@endpoint_metadata(
auth=AuthTier.OBSERVABILITY, # Actually guarded by require_observability_access (token/loopback)
risk=RiskTier.LOW,
summary="List templates",
description="Returns templates visible to the backend.",
audit="templates.list",
plane=RoutePlane.ADMIN,
)
async def templates_list_handler(request: web.Request) -> web.Response:
"""
GET /openclaw/templates (legacy: /moltbot/templates)
@@ -73,41 +111,52 @@ async def templates_list_handler(request: web.Request) -> web.Response:
# Reuse the admin bucket to avoid unbounded enumeration from remote callers.
if not check_rate_limit(request, "admin"):
return web.json_response(
{"ok": False, "error": "Rate limit exceeded"},
status=429,
headers={"Retry-After": "60"},
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
try:
svc = get_template_service()
items = []
# Prefer runtime discovery (file-based templates) so operators don't need
# to maintain a separate allowlist file.
for template_id in svc.get_debug_info().get("discovered_template_ids", []): # type: ignore[call-arg]
cfg = svc.get_template_config(template_id) # type: ignore[arg-type]
if cfg is None:
continue
items.append(
{
"id": template_id,
"allowed_inputs": list(cfg.allowed_inputs or []),
"defaults": dict(cfg.defaults or {}),
}
)
items.sort(key=lambda x: x["id"])
resp: dict = {"ok": True, "templates": items, "count": len(items)}
token_info = resolve_token_info(request)
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
):
items = []
# Prefer runtime discovery (file-based templates) so operators don't need
# to maintain a separate allowlist file.
for template_id in svc.get_debug_info().get("discovered_template_ids", []): # type: ignore[call-arg]
cfg = svc.get_template_config(template_id) # type: ignore[arg-type]
if cfg is None:
continue
items.append(
{
"id": template_id,
"allowed_inputs": list(cfg.allowed_inputs or []),
"defaults": dict(cfg.defaults or {}),
}
)
items.sort(key=lambda x: x["id"])
resp: dict = {"ok": True, "templates": items, "count": len(items)}
# Optional diagnostics. This reveals absolute paths, so keep it opt-in.
debug = request.query.get("debug", "").strip() in ("1", "true", "yes")
if debug:
try:
resp["debug"] = svc.get_debug_info() # type: ignore[attr-defined]
except Exception:
# If TemplateService interface changes, don't break the endpoint.
resp["debug"] = {"error": "debug_info_unavailable"}
# Optional diagnostics. This reveals absolute paths, so keep it opt-in.
debug = request.query.get("debug", "").strip() in ("1", "true", "yes")
if debug:
try:
resp["debug"] = svc.get_debug_info() # type: ignore[attr-defined]
except Exception:
# If TemplateService interface changes, don't break the endpoint.
resp["debug"] = {"error": "debug_info_unavailable"}
return web.json_response(resp)
return web.json_response(resp)
except TenantBoundaryError as e:
return web.json_response(
{"ok": False, "error": e.code, "message": str(e)},
status=403,
)
except Exception as e:
logger.exception("Failed to list templates")
return web.json_response({"ok": False, "error": str(e)}, status=500)
+132 -7
View File
@@ -3,21 +3,65 @@ S12: API Handlers for External Tools.
Protected by Admin Token and Feature Flag.
"""
from __future__ import annotations
import json
import logging
from aiohttp import web
if __package__ and "." in __package__:
from ..services.import_fallback import import_attrs_dual
else:
from services.import_fallback import import_attrs_dual # type: ignore
try:
from ..services.access_control import require_admin_token
from ..services.tool_runner import get_tool_runner, is_tools_enabled
except ImportError:
from services.access_control import require_admin_token
from services.tool_runner import get_tool_runner, is_tools_enabled
(require_admin_token, resolve_token_info) = import_attrs_dual(
__package__,
"..services.access_control",
"services.access_control",
("require_admin_token", "resolve_token_info"),
)
(import_aiohttp_web,) = import_attrs_dual(
__package__,
"..services.aiohttp_compat",
"services.aiohttp_compat",
("import_aiohttp_web",),
)
(emit_audit_event,) = import_attrs_dual(
__package__,
"..services.audit",
"services.audit",
("emit_audit_event",),
)
(get_tool_runner, is_tools_enabled) = import_attrs_dual(
__package__,
"..services.tool_runner",
"services.tool_runner",
("get_tool_runner", "is_tools_enabled"),
)
(AuthTier, RiskTier, RoutePlane, endpoint_metadata) = import_attrs_dual(
__package__,
"..services.endpoint_manifest",
"services.endpoint_manifest",
("AuthTier", "RiskTier", "RoutePlane", "endpoint_metadata"),
)
(check_surface,) = import_attrs_dual(
__package__,
"..services.surface_guard",
"services.surface_guard",
("check_surface",),
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.tools")
web = import_aiohttp_web()
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="List tools",
description="List allowed external tools.",
audit="tools.list",
plane=RoutePlane.ADMIN,
)
async def tools_list_handler(request: web.Request) -> web.Response:
"""
GET /openclaw/tools
@@ -41,6 +85,14 @@ async def tools_list_handler(request: web.Request) -> web.Response:
return web.json_response({"ok": True, "tools": tools})
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.HIGH,
summary="Run tool",
description="Execute an external tool.",
audit="tools.run",
plane=RoutePlane.ADMIN,
)
async def tools_run_handler(request: web.Request) -> web.Response:
"""
POST /openclaw/tools/{name}/run
@@ -48,18 +100,51 @@ async def tools_run_handler(request: web.Request) -> web.Response:
Body: {"args": {"arg1": "val1", ...}}
Requires: Admin Token.
"""
# S62: Block tool execution in public+split mode
blocked = check_surface("tool_execution", request)
if blocked:
return blocked
if not is_tools_enabled():
emit_audit_event(
action="tools.run",
target="tools",
outcome="deny",
status_code=404,
details={"reason": "tools_disabled"},
request=request,
)
return web.json_response(
{"ok": False, "error": "External tooling is disabled."}, status=404
)
token_info = resolve_token_info(request)
# Admin check
allowed, error = require_admin_token(request)
if not allowed:
emit_audit_event(
action="tools.run",
target="tools",
outcome="deny",
token_info=token_info,
status_code=403,
details={"reason": error or "unauthorized"},
request=request,
)
return web.json_response({"ok": False, "error": error}, status=403)
tool_name = request.match_info.get("name")
if not tool_name:
emit_audit_event(
action="tools.run",
target="unknown",
outcome="deny",
token_info=token_info,
status_code=400,
details={"reason": "missing_tool_name"},
request=request,
)
return web.json_response(
{"ok": False, "error": "Tool name required"}, status=400
)
@@ -67,12 +152,30 @@ async def tools_run_handler(request: web.Request) -> web.Response:
try:
body = await request.json()
except json.JSONDecodeError:
emit_audit_event(
action="tools.run",
target=tool_name,
outcome="deny",
token_info=token_info,
status_code=400,
details={"reason": "invalid_json"},
request=request,
)
return web.json_response(
{"ok": False, "error": "Invalid JSON body"}, status=400
)
args = body.get("args", {})
if not isinstance(args, dict):
emit_audit_event(
action="tools.run",
target=tool_name,
outcome="deny",
token_info=token_info,
status_code=400,
details={"reason": "invalid_args_shape"},
request=request,
)
return web.json_response(
{"ok": False, "error": "'args' must be a dictionary"}, status=400
)
@@ -81,6 +184,19 @@ async def tools_run_handler(request: web.Request) -> web.Response:
result = runner.execute_tool(tool_name, args)
if not result.success:
emit_audit_event(
action="tools.run",
target=tool_name,
outcome="error",
token_info=token_info,
status_code=500 if result.error else 400,
details={
"exit_code": result.exit_code,
"error": result.error,
"duration_ms": result.duration_ms,
},
request=request,
)
return web.json_response(
{
"ok": False,
@@ -93,6 +209,15 @@ async def tools_run_handler(request: web.Request) -> web.Response:
status=500 if result.error else 400,
) # 500 for runtime error, 400 for validation?
emit_audit_event(
action="tools.run",
target=tool_name,
outcome="allow",
token_info=token_info,
status_code=200,
details={"duration_ms": result.duration_ms},
request=request,
)
return web.json_response(
{
"ok": True,
+25 -2
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,17 +23,32 @@ from aiohttp import web
# can silently import the WRONG module (another custom node or ComfyUI-adjacent package), causing
# template allowlists to appear "missing" even when `data/templates/manifest.json` is correct.
if __package__ and "." in __package__:
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
from ..services.execution_budgets import BudgetExceededError
from ..services.templates import is_template_allowed
from ..services.trace import generate_trace_id
from ..services.webhook_auth import AuthError
else: # pragma: no cover (test-only import mode)
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.endpoint_manifest import ( # type: ignore
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
from services.execution_budgets import BudgetExceededError # type: ignore
from services.templates import is_template_allowed # type: ignore
from services.trace import generate_trace_id # type: ignore
from services.webhook_auth import AuthError # type: ignore
logger = logging.getLogger("ComfyUI-OpenClaw.api.triggers")
web = import_aiohttp_web()
# Default: require approval for external triggers (secure-by-default)
REQUIRE_APPROVAL_DEFAULT = (
@@ -76,6 +91,14 @@ class TriggerHandlers:
if not allowed:
raise AuthError(error or "Unauthorized")
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.HIGH,
summary="Fire trigger",
description="Fire an ad-hoc workflow trigger.",
audit="triggers.fire",
plane=RoutePlane.ADMIN,
)
async def fire_trigger(self, request: web.Request) -> web.Response:
"""
POST /moltbot/triggers/fire
+78 -29
View File
@@ -9,39 +9,87 @@ POST /moltbot/webhook
- Returns normalized internal request
"""
from __future__ import annotations
import json
import logging
from aiohttp import web
if __package__ and "." in __package__:
from ..services.import_fallback import import_attrs_dual
else:
from services.import_fallback import import_attrs_dual # type: ignore
try:
from .errors import APIError, ErrorCode, create_error_response
except ImportError:
# Build-time / Test fallback
from api.errors import APIError, ErrorCode, create_error_response
try:
from ..models.schemas import MAX_BODY_SIZE, WebhookJobRequest
from ..services.metrics import metrics
from ..services.rate_limit import check_rate_limit
from ..services.trace import get_effective_trace_id
from ..services.webhook_auth import get_auth_summary, require_auth
except ImportError:
from models.schemas import MAX_BODY_SIZE, WebhookJobRequest
from services.metrics import metrics
from services.rate_limit import check_rate_limit
from services.trace import get_effective_trace_id
from services.webhook_auth import get_auth_summary, require_auth
try:
from ..services.diagnostics_flags import diagnostics
except ImportError:
from services.diagnostics_flags import diagnostics
(APIError, ErrorCode, create_error_response) = import_attrs_dual(
__package__,
".errors",
"api.errors",
("APIError", "ErrorCode", "create_error_response"),
)
(
MAX_BODY_SIZE,
WebhookJobRequest,
) = import_attrs_dual(
__package__,
"..models.schemas",
"models.schemas",
("MAX_BODY_SIZE", "WebhookJobRequest"),
)
(import_aiohttp_web,) = import_attrs_dual(
__package__,
"..services.aiohttp_compat",
"services.aiohttp_compat",
("import_aiohttp_web",),
)
(metrics,) = import_attrs_dual(
__package__,
"..services.metrics",
"services.metrics",
("metrics",),
)
(build_rate_limit_response, check_rate_limit) = import_attrs_dual(
__package__,
"..services.rate_limit",
"services.rate_limit",
("build_rate_limit_response", "check_rate_limit"),
)
(get_effective_trace_id,) = import_attrs_dual(
__package__,
"..services.trace",
"services.trace",
("get_effective_trace_id",),
)
(get_auth_summary, require_auth) = import_attrs_dual(
__package__,
"..services.webhook_auth",
"services.webhook_auth",
("get_auth_summary", "require_auth"),
)
(diagnostics,) = import_attrs_dual(
__package__,
"..services.diagnostics_flags",
"services.diagnostics_flags",
("diagnostics",),
)
(AuthTier, RiskTier, RoutePlane, endpoint_metadata) = import_attrs_dual(
__package__,
"..services.endpoint_manifest",
"services.endpoint_manifest",
("AuthTier", "RiskTier", "RoutePlane", "endpoint_metadata"),
)
# R46: Scoped logger for safe-by-default redaction
logger = diagnostics.get_logger("ComfyUI-OpenClaw.api.webhook", "webhook")
web = import_aiohttp_web()
@endpoint_metadata(
auth=AuthTier.WEBHOOK,
risk=RiskTier.HIGH,
summary="Webhook submit",
description="Authenticated endpoint for external job requests (legacy pipeline).",
audit="webhook.submit.legacy",
plane=RoutePlane.EXTERNAL,
)
async def webhook_handler(request: web.Request) -> web.Response:
"""
POST /moltbot/webhook
@@ -51,11 +99,12 @@ async def webhook_handler(request: web.Request) -> web.Response:
# S17: Rate Limit
if not check_rate_limit(request, "webhook"):
metrics.inc("webhook_denied")
return create_error_response(
message="Rate limit exceeded",
code=ErrorCode.RATE_LIMIT_EXCEEDED,
status=429,
detail={"retry_after": "60"},
return build_rate_limit_response(
request,
"webhook",
web_module=web,
error="Rate limit exceeded",
include_ok=True,
)
try:
+64 -9
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,27 +15,36 @@ from aiohttp import web
# import another pack's top-level `services` module and break allowlists/auth in surprising ways.
if __package__ and "." in __package__:
from ..models.schemas import MAX_BODY_SIZE, WebhookJobRequest
from ..services.aiohttp_compat import import_aiohttp_web
from ..services.callback_delivery import start_callback_watch
from ..services.execution_budgets import BudgetExceededError
from ..services.idempotency_store import IdempotencyStore
from ..services.job_events import JobEventType, get_job_event_store
from ..services.metrics import metrics
from ..services.queue_submit import submit_prompt
from ..services.rate_limit import check_rate_limit
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.templates import get_template_service
from ..services.trace import get_effective_trace_id
from ..services.trace_store import trace_store
from ..services.webhook_auth import require_auth
from ..services.webhook_mapping import apply_mapping, resolve_profile # F40
from ..services.webhook_mapping import ( # F40/S59
apply_mapping,
resolve_profile,
validate_canonical_schema,
)
else: # pragma: no cover (test-only import mode)
from models.schemas import MAX_BODY_SIZE, WebhookJobRequest
from services.aiohttp_compat import import_aiohttp_web # type: ignore
from services.callback_delivery import start_callback_watch # type: ignore
from services.execution_budgets import BudgetExceededError # type: ignore
from services.idempotency_store import IdempotencyStore # type: ignore
from services.job_events import JobEventType, get_job_event_store # type: ignore
from services.metrics import metrics # type: ignore
from services.queue_submit import submit_prompt # type: ignore
from services.rate_limit import check_rate_limit # type: ignore
from services.rate_limit import ( # type: ignore
build_rate_limit_response,
check_rate_limit,
)
from services.templates import get_template_service # type: ignore
from services.trace import get_effective_trace_id # type: ignore
from services.trace_store import trace_store # type: ignore
@@ -45,9 +52,27 @@ else: # pragma: no cover (test-only import mode)
from services.webhook_mapping import ( # F40 # type: ignore
apply_mapping,
resolve_profile,
validate_canonical_schema,
)
# R98: Endpoint Metadata
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
else:
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.webhook_submit")
web = import_aiohttp_web()
def safe_error_response(status: int, error: str, detail: str = "") -> web.Response:
@@ -57,6 +82,14 @@ def safe_error_response(status: int, error: str, detail: str = "") -> web.Respon
return web.json_response(body, status=status)
@endpoint_metadata(
auth=AuthTier.WEBHOOK,
risk=RiskTier.HIGH,
summary="Webhook submit",
description="Authenticated endpoint for external job requests (modern pipeline).",
audit="webhook.submit",
plane=RoutePlane.EXTERNAL,
)
async def webhook_submit_handler(request: web.Request) -> web.Response:
"""
POST /moltbot/webhook/submit
@@ -68,13 +101,25 @@ async def webhook_submit_handler(request: web.Request) -> web.Response:
5. Render Template (F5)
6. Submit to Queue (F5)
"""
# S62: Block webhook execution in public+split mode
try:
# CRITICAL: package-relative import must stay first in ComfyUI runtime.
from ..services.surface_guard import check_surface
except ImportError:
from services.surface_guard import check_surface # type: ignore
blocked = check_surface("webhook_execute", request)
if blocked:
return blocked
# S17: Rate Limit
if not check_rate_limit(request, "webhook"):
metrics.inc("webhook_denied")
return web.json_response(
{"ok": False, "error": "rate_limit_exceeded"},
status=429,
headers={"Retry-After": "60"},
return build_rate_limit_response(
request,
"webhook",
web_module=web,
error="rate_limit_exceeded",
include_ok=True,
)
try:
@@ -157,6 +202,16 @@ async def webhook_submit_handler(request: web.Request) -> web.Response:
return safe_error_response(400, "mapping_error", str(e))
# Validate against schema
# S59: enforce canonical post-map schema gate before typed parsing.
canonical_ok, canonical_errors = validate_canonical_schema(data)
if not canonical_ok:
metrics.inc("webhook_denied")
return safe_error_response(
400,
"validation_error",
"; ".join(canonical_errors),
)
try:
job_request = WebhookJobRequest.from_dict(data)
normalized = job_request.to_normalized()
+35 -6
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
@@ -60,6 +63,22 @@ else: # pragma: no cover (test-only import mode)
resolve_profile,
)
# R98: Endpoint Metadata
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
else:
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.webhook_validate")
PLACEHOLDER_PATTERN = re.compile(r"\{\{[^{}]+\}\}")
@@ -72,6 +91,14 @@ def _safe_error_response(status: int, error: str, detail: str = "") -> web.Respo
return web.json_response(body, status=status)
@endpoint_metadata(
auth=AuthTier.WEBHOOK,
risk=RiskTier.LOW,
summary="Webhook validate",
description="Dry-run validation for webhook requests.",
audit="webhook.validate",
plane=RoutePlane.EXTERNAL,
)
async def webhook_validate_handler(request: web.Request) -> web.Response:
"""
POST /openclaw/webhook/validate (legacy: /moltbot/webhook/validate)
@@ -82,10 +109,12 @@ async def webhook_validate_handler(request: web.Request) -> web.Response:
# S17: Rate limit (same bucket as submit)
if not check_rate_limit(request, "webhook"):
metrics.inc("webhook_denied")
return web.json_response(
{"ok": False, "error": "rate_limit_exceeded"},
status=429,
headers={"Retry-After": "60"},
return build_rate_limit_response(
request,
"webhook",
web_module=web,
error="rate_limit_exceeded",
include_ok=True,
)
# S2: Content-Type + body size
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+169 -49
View File
@@ -5,27 +5,68 @@ import time
from logging.handlers import RotatingFileHandler
from typing import Optional
# R139: centralized env-alias helpers for config surface compatibility.
try:
from .services.config_layers import (
GENERIC_LLM_API_KEY_ENV_KEYS,
get_first_present_env,
)
from .services.effective_config import get_effective_llm_api_key
except Exception:
try:
from services.config_layers import ( # type: ignore
GENERIC_LLM_API_KEY_ENV_KEYS,
get_first_present_env,
)
from services.effective_config import get_effective_llm_api_key # type: ignore
except Exception:
GENERIC_LLM_API_KEY_ENV_KEYS = (
"OPENCLAW_LLM_API_KEY",
"MOLTBOT_LLM_API_KEY",
"CLAWDBOT_LLM_API_KEY",
)
def get_first_present_env(keys, *, env=None): # type: ignore
env_map = env or os.environ
for key in keys:
if key in env_map:
return env_map.get(key)
return None
def get_effective_llm_api_key(provider=None, tenant_id=None): # type: ignore
return get_first_present_env(GENERIC_LLM_API_KEY_ENV_KEYS)
# Pack metadata
PACK_NAME = "ComfyUI-OpenClaw"
PACK_START_TIME = time.time()
def _read_pyproject_version() -> Optional[str]:
"""
Read version from pyproject.toml ([project].version) as the single source of truth.
def _extract_toml_section(text: str, header: str) -> Optional[str]:
pattern = re.compile(
rf"(?ms)^\ufeff?\[{re.escape(header)}\]\s*$\n(?P<body>.*?)(?=^\[|\Z)"
)
match = pattern.search(text)
if not match:
return None
return match.group("body")
Uses a lightweight regex parse to avoid non-stdlib TOML dependencies.
"""
try:
pack_dir = os.path.dirname(os.path.abspath(__file__))
pyproject_path = os.path.join(pack_dir, "pyproject.toml")
if not os.path.exists(pyproject_path):
return None
text = ""
with open(pyproject_path, "r", encoding="utf-8") as f:
text = f.read()
# Prefer stdlib TOML parser if available (Python 3.11+), then fallback to regex.
def _extract_toml_string_assignment(section_text: str, key: str) -> Optional[str]:
match = re.search(
rf"(?m)^\s*{re.escape(key)}\s*=\s*['\"]([^'\"]+)['\"]\s*$",
section_text,
)
if not match:
return None
value = match.group(1).strip()
return value or None
def _parse_pyproject_version_text(
text: str, *, prefer_tomllib: bool = True
) -> Optional[str]:
if prefer_tomllib:
try:
from tomllib import loads as _toml_loads # type: ignore
except Exception:
@@ -40,50 +81,129 @@ def _read_pyproject_version() -> Optional[str]:
except Exception:
pass
# Find the [project] section and parse `version = "..."` within it.
# IMPORTANT: tolerate BOM/CRLF so the UI version does not silently fall back to 0.1.0.
# This is intentionally conservative to avoid false matches in other sections.
m = re.search(
r"(?ms)^\ufeff?\\[project\\]\\s*(?:[^\\[]*?)^version\\s*=\\s*[\"']([^\"']+)[\"']\\s*$",
text,
)
if not m:
# IMPORTANT: keep this fallback section-bounded.
# Matching any `version = ...` outside `[project]` silently reports the wrong build.
project_section = _extract_toml_section(text, "project")
if project_section is None:
return None
return _extract_toml_string_assignment(project_section, "version")
def _read_pyproject_version_from_path(
pyproject_path: os.PathLike[str] | str, *, prefer_tomllib: bool = True
) -> Optional[str]:
"""
Read version from pyproject.toml ([project].version) as the single source of truth.
Uses a lightweight regex parse to avoid non-stdlib TOML dependencies.
"""
try:
pyproject_path = os.fspath(pyproject_path)
if not os.path.exists(pyproject_path):
return None
ver = (m.group(1) or "").strip()
return ver or None
with open(pyproject_path, "r", encoding="utf-8") as f:
text = f.read()
return _parse_pyproject_version_text(
text,
prefer_tomllib=prefer_tomllib,
)
except Exception:
return None
def _read_pyproject_version() -> Optional[str]:
pack_dir = os.path.dirname(os.path.abspath(__file__))
return _read_pyproject_version_from_path(os.path.join(pack_dir, "pyproject.toml"))
# Version: single source of truth is pyproject.toml (line 4 in this repo).
PACK_VERSION = _read_pyproject_version() or "0.1.0"
# Environment variable for the API key
ENV_API_KEY = "OPENCLAW_LLM_API_KEY"
LEGACY_ENV_API_KEY = "MOLTBOT_LLM_API_KEY"
LEGACY2_ENV_API_KEY = "CLAWDBOT_LLM_API_KEY"
ENV_API_KEY = GENERIC_LLM_API_KEY_ENV_KEYS[0]
LEGACY_ENV_API_KEY = GENERIC_LLM_API_KEY_ENV_KEYS[1]
LEGACY2_ENV_API_KEY = GENERIC_LLM_API_KEY_ENV_KEYS[2]
# Data directory (R11: use portable state directory)
try:
# Prefer package-relative import (ComfyUI loads custom nodes by file loader)
from .services.state_dir import get_log_path, get_state_dir # type: ignore
from .services.state_dir import ( # type: ignore
get_log_path,
get_state_dir,
peek_log_path,
peek_state_dir,
)
except Exception:
try:
# Fallback for unit tests / direct sys.path imports
from services.state_dir import get_log_path, get_state_dir
from services.state_dir import (
get_log_path,
get_state_dir,
peek_log_path,
peek_state_dir,
)
except Exception:
get_state_dir = None
get_log_path = None
peek_state_dir = None
peek_log_path = None
if get_state_dir and get_log_path:
DATA_DIR = get_state_dir()
LOG_FILE = get_log_path()
if peek_state_dir and peek_log_path:
DATA_DIR = peek_state_dir()
LOG_FILE = peek_log_path()
else:
# Last-resort fallback during early import or if state_dir is unavailable
PACK_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(PACK_DIR, "data")
LOG_FILE = os.path.join(DATA_DIR, "openclaw.log")
_IMPORT_DATA_DIR = DATA_DIR
_IMPORT_LOG_FILE = LOG_FILE
# IMPORTANT: startup log truncation must run once per process.
# Multiple module-level loggers call setup_logger(); repeated truncation would
# erase fresh logs emitted after the first logger initialization.
_LOG_TRUNCATE_APPLIED = False
def _is_env_enabled(*keys: str) -> bool:
for key in keys:
val = (os.environ.get(key) or "").strip().lower()
if val in {"1", "true", "yes", "on"}:
return True
return False
def _maybe_truncate_log_on_start(logger: logging.Logger) -> None:
global _LOG_TRUNCATE_APPLIED
if _LOG_TRUNCATE_APPLIED:
return
if not _is_env_enabled(
"OPENCLAW_LOG_TRUNCATE_ON_START", "MOLTBOT_LOG_TRUNCATE_ON_START"
):
return
try:
os.makedirs(DATA_DIR, exist_ok=True)
with open(LOG_FILE, "w", encoding="utf-8"):
pass
logger.info(
f"Startup log truncation applied for {LOG_FILE} "
"(OPENCLAW_LOG_TRUNCATE_ON_START=1)"
)
_LOG_TRUNCATE_APPLIED = True
except Exception as e:
logger.warning(f"Failed to truncate startup log file {LOG_FILE}: {e}")
def _ensure_log_targets() -> tuple[str, str]:
global DATA_DIR, LOG_FILE
if DATA_DIR != _IMPORT_DATA_DIR or LOG_FILE != _IMPORT_LOG_FILE:
return DATA_DIR, LOG_FILE
if get_state_dir and get_log_path:
DATA_DIR = get_state_dir()
LOG_FILE = get_log_path()
return DATA_DIR, LOG_FILE
class RedactedFormatter(logging.Formatter):
"""
@@ -104,21 +224,13 @@ class RedactedFormatter(logging.Formatter):
def get_api_key() -> Optional[str]:
"""
Retrieves the LLM API key from environment variables.
Retrieves the effective LLM API key via the unified config facade.
Preference order:
1) OPENCLAW_LLM_API_KEY
2) (legacy) MOLTBOT_LLM_API_KEY
3) (legacy) CLAWDBOT_LLM_API_KEY
This keeps logger redaction aligned with the same provider/key resolution
path used by runtime consumers.
"""
# Respect explicit empty string overrides by checking env var presence.
if ENV_API_KEY in os.environ:
return os.environ.get(ENV_API_KEY) or None
if LEGACY_ENV_API_KEY in os.environ:
return os.environ.get(LEGACY_ENV_API_KEY) or None
if LEGACY2_ENV_API_KEY in os.environ:
return os.environ.get(LEGACY2_ENV_API_KEY) or None
return None
value = get_effective_llm_api_key()
return value or None
def setup_logger(name: str = "ComfyUI-OpenClaw") -> logging.Logger:
@@ -127,9 +239,17 @@ def setup_logger(name: str = "ComfyUI-OpenClaw") -> logging.Logger:
Includes both console and file handlers with rotation.
"""
logger = logging.getLogger(name)
# CRITICAL: keep propagate disabled.
# If this is changed to True, ComfyUI/root handlers re-emit the same record,
# and terminal output regresses to duplicated spam:
# [openclaw.LLMClient] WARNING: ...
# No API key found for provider ...
logger.propagate = False
# Only add handler if not already added to avoid duplicates on reload
if not logger.handlers:
data_dir, log_file = _ensure_log_targets()
_maybe_truncate_log_on_start(logger)
api_key = get_api_key()
sensitive = [api_key] if api_key else []
formatter = RedactedFormatter(
@@ -143,9 +263,9 @@ def setup_logger(name: str = "ComfyUI-OpenClaw") -> logging.Logger:
# File handler with rotation (5MB, 3 backups)
try:
os.makedirs(DATA_DIR, exist_ok=True)
os.makedirs(data_dir, exist_ok=True)
file_handler = RotatingFileHandler(
LOG_FILE,
log_file,
maxBytes=5 * 1024 * 1024, # 5MB
backupCount=3,
encoding="utf-8",
@@ -161,5 +281,5 @@ def setup_logger(name: str = "ComfyUI-OpenClaw") -> logging.Logger:
return logger
# Global config accessor if needed
logger = setup_logger()
# Global logger handle for compatibility; actual handler bootstrap is lazy.
logger = logging.getLogger("ComfyUI-OpenClaw")
+114 -1
View File
@@ -10,8 +10,12 @@ import sys
from .config import load_config
from .openclaw_client import OpenClawClient
from .platforms.discord_gateway import DiscordGateway
from .platforms.feishu_installation_manager import FeishuInstallationManager
from .platforms.feishu_long_connection import FeishuLongConnectionClient
from .platforms.feishu_webhook import FeishuWebhookServer
from .platforms.kakao_webhook import KakaoWebhookServer
from .platforms.line_webhook import LINEWebhookServer
from .platforms.slack_webhook import SlackWebhookServer
from .platforms.telegram_polling import TelegramPolling
from .platforms.wechat_webhook import WeChatWebhookServer
from .platforms.whatsapp_webhook import WhatsAppWebhookServer
@@ -42,6 +46,8 @@ def _print_security_banner(config):
or config.whatsapp_allowed_users
or config.wechat_allowed_users
or config.kakao_allowed_users
or config.slack_allowed_users
or config.feishu_allowed_users
)
has_admins = bool(config.admin_users)
@@ -104,6 +110,9 @@ async def main():
whatsapp_server = None
wechat_server = None
kakao_server = None
slack_server = None
feishu_server = None
feishu_long_clients = []
# 3. Platforms
if config.telegram_bot_token:
@@ -171,15 +180,113 @@ async def main():
else:
logger.info("Kakao adapter disabled.")
if config.slack_bot_token and config.slack_signing_secret:
if config.slack_mode == "socket":
# CRITICAL: Socket Mode must remain explicit opt-in; do not auto-fallback
# from webhook mode or security/ingress assumptions can drift silently.
from .platforms.slack_socket_mode import SlackSocketModeClient
slack_server = SlackSocketModeClient(config, router)
elif config.slack_mode == "events":
slack_server = SlackWebhookServer(config, router)
else:
logger.error(
"Invalid OPENCLAW_CONNECTOR_SLACK_MODE=%r. Expected 'events' or 'socket'.",
config.slack_mode,
)
slack_server = None
logger.error("Slack adapter startup aborted (fail-closed).")
# Keep connector alive for other platforms; Slack remains disabled.
# If Slack is the only platform, global no-platform guard below will exit.
pass
if slack_server is None:
logger.warning("Slack adapter disabled due to invalid mode config.")
else:
platforms["slack"] = slack_server
await slack_server.start()
if not tasks:
tasks.append(asyncio.create_task(asyncio.sleep(3600 * 24 * 365)))
elif config.slack_bot_token:
logger.warning("Slack configured but Signing Secret missing. Skipping.")
else:
logger.info("Slack not configured (OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN missing)")
if config.feishu_bindings_json or (
config.feishu_app_id and config.feishu_app_secret
):
try:
feishu_installation_manager = FeishuInstallationManager(config)
except Exception as exc:
logger.error("Feishu adapter startup aborted (fail-closed): %s", exc)
feishu_installation_manager = None
if config.feishu_mode == "webhook":
if feishu_installation_manager is not None:
feishu_server = FeishuWebhookServer(
config,
router,
installation_manager=feishu_installation_manager,
)
elif config.feishu_mode == "websocket":
# CRITICAL: Feishu long-connection remains explicit opt-in; do not
# auto-fallback across transports or ingress verification can drift.
if feishu_installation_manager is not None:
feishu_server = FeishuWebhookServer(
config,
router,
installation_manager=feishu_installation_manager,
)
binding_configs = feishu_installation_manager.binding_configs()
if not binding_configs:
binding_configs = [config]
for binding_config in binding_configs:
feishu_long_clients.append(
FeishuLongConnectionClient(
binding_config,
router,
installation_manager=feishu_installation_manager,
bound_account_id=str(
binding_config.feishu_account_id or ""
).strip(),
)
)
else:
logger.error(
"Invalid OPENCLAW_CONNECTOR_FEISHU_MODE=%r. Expected 'websocket' or 'webhook'.",
config.feishu_mode,
)
feishu_server = None
logger.error("Feishu adapter startup aborted (fail-closed).")
if feishu_server is None and not feishu_long_clients:
logger.warning("Feishu adapter disabled due to invalid mode config.")
else:
platforms["feishu"] = feishu_server or feishu_long_clients[0]
if feishu_server is not None:
await feishu_server.start()
for feishu_long_client in feishu_long_clients:
await feishu_long_client.start()
if not tasks:
tasks.append(asyncio.create_task(asyncio.sleep(3600 * 24 * 365)))
elif config.feishu_app_id:
logger.warning("Feishu configured but App Secret missing. Skipping.")
else:
logger.info("Feishu not configured (OPENCLAW_CONNECTOR_FEISHU_APP_ID missing)")
if (
not tasks
and not line_server
and not whatsapp_server
and not wechat_server
and not kakao_server
and not slack_server
and not feishu_server
and not feishu_long_clients
):
logger.error(
"No platforms configured! Set TELEGRAM_TOKEN, DISCORD_TOKEN, LINE_SECRET, WHATSAPP_ACCESS_TOKEN, WECHAT_TOKEN or KAKAO_ENABLED."
"No platforms configured! Set TELEGRAM_TOKEN, DISCORD_TOKEN, "
"LINE_SECRET, WHATSAPP_ACCESS_TOKEN, WECHAT_TOKEN, "
"KAKAO_ENABLED, SLACK_BOT_TOKEN or FEISHU_APP_ID."
)
await client.close()
return
@@ -206,6 +313,12 @@ async def main():
await wechat_server.stop()
if kakao_server:
await kakao_server.stop()
if slack_server:
await slack_server.stop()
for feishu_long_client in feishu_long_clients:
await feishu_long_client.stop()
if feishu_server:
await feishu_server.stop()
if poller:
await poller.stop()
await client.close()
View File
+190
View File
@@ -0,0 +1,190 @@
"""
R97 Command Firewall.
Implements the runtime safety layer for connector chat:
- Canonical command parsing (assistant output -> internal structure).
- Allowlist/Denylist validation for flags and values.
- Normalized safe rendering (internal structure -> user-facing command string).
"""
import logging
import re
import shlex
from dataclasses import dataclass, field
from typing import Dict, List
logger = logging.getLogger(__name__)
_DANGEROUS_PATTERNS = (r";", r"`", r"\$\(", r"\|")
_VALID_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
@dataclass
class NormalizedCommand:
command: str
args: List[str] = field(default_factory=list)
flags: Dict[str, str] = field(default_factory=dict)
is_safe: bool = False
safety_reason: str = "unvalidated"
code: str = "unvalidated"
severity: str = "medium"
action: str = "deny"
def to_string(self) -> str:
"""Render deterministic safe command string."""
parts = [self.command]
# Canonical flag order
for k in sorted(self.flags.keys()):
v = self.flags[k]
# Simple quoting heuristic
if " " in v or not v:
v = f'"{v}"'
parts.append(f"{k}={v}")
# Positional args
parts.extend(self.args)
return " ".join(parts)
def to_contract(self) -> Dict[str, str]:
return {
"code": self.code,
"severity": self.severity,
"action": self.action,
"reason": self.safety_reason,
}
class CommandFirewall:
"""
Validates and normalizes assistant-generated command suggestions.
"""
def __init__(self):
# TODO: Load policy from config
self.allowed_commands = {"/run", "/status", "/help", "/jobs"}
self.unsafe_pattern_deny = set(_DANGEROUS_PATTERNS)
def validate_suggestion(self, raw_suggestion: str) -> NormalizedCommand:
"""
Parse and validate a raw command string from LLM output.
Returns a NormalizedCommand object marked safe or unsafe.
"""
clean_text = raw_suggestion.strip()
# 0. Pre-parsing unsafe pattern check (Denylist)
for pattern in self.unsafe_pattern_deny:
if re.search(pattern, clean_text):
return NormalizedCommand(
command="error",
is_safe=False,
safety_reason=f"unsafe_pattern_detected: {pattern}",
code="firewall_unsafe_pattern",
severity="high",
action="deny",
)
# 1. Basic Parse
try:
lexer = shlex.shlex(clean_text, posix=True)
lexer.whitespace_split = True
lexer.quotes = '"' # strict double quotes per router contract
parts = list(lexer)
except ValueError as e:
return NormalizedCommand(
command="error",
is_safe=False,
safety_reason=f"parse_error: {str(e)}",
code="firewall_parse_error",
severity="medium",
action="deny",
)
if not parts:
return NormalizedCommand(
command="",
is_safe=False,
safety_reason="empty_command",
code="firewall_empty_command",
severity="medium",
action="deny",
)
cmd = parts[0].lower()
# 2. Allowlist Check
if cmd not in self.allowed_commands:
return NormalizedCommand(
command=cmd,
is_safe=False,
safety_reason=f"command_not_allowed: {cmd}",
code="firewall_command_not_allowed",
severity="high",
action="deny",
)
# 3. Argument Parsing & Normalization
args = parts[1:]
clean_args = []
flags = {}
for arg in args:
if arg.startswith("-"):
if "=" in arg and not arg.startswith("--"):
k, v = arg.split("=", 1)
if not _VALID_KEY_RE.match(k):
return NormalizedCommand(
command=cmd,
is_safe=False,
safety_reason=f"invalid_key: {k}",
code="firewall_invalid_key",
severity="medium",
action="deny",
)
if len(v) > 1000:
return NormalizedCommand(
command=cmd,
is_safe=False,
safety_reason=f"value_too_long: {k}",
code="firewall_value_too_long",
severity="medium",
action="deny",
)
flags[k] = v
elif arg.startswith("--"):
clean_args.append(arg)
else:
clean_args.append(arg)
elif "=" in arg:
k, v = arg.split("=", 1)
if not _VALID_KEY_RE.match(k):
return NormalizedCommand(
command=cmd,
is_safe=False,
safety_reason=f"invalid_key: {k}",
code="firewall_invalid_key",
severity="medium",
action="deny",
)
if len(v) > 1000:
return NormalizedCommand(
command=cmd,
is_safe=False,
safety_reason=f"value_too_long: {k}",
code="firewall_value_too_long",
severity="medium",
action="deny",
)
flags[k] = v
else:
clean_args.append(arg)
return NormalizedCommand(
command=cmd,
args=clean_args,
flags=flags,
is_safe=True,
safety_reason="valid",
code="firewall_allow",
severity="info",
action="allow",
)
+404 -45
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,29 +223,87 @@ class ConnectorConfig:
wechat_encoding_aes_key: Optional[str] = None # R82: AES encrypted mode
wechat_allowed_users: List[str] = field(default_factory=list)
wechat_bind_host: str = "127.0.0.1"
wechat_bind_port: int = 8097
wechat_bind_port: int = DEFAULT_WECHAT_BIND_PORT
wechat_webhook_path: str = "/wechat/webhook"
# KakaoTalk (F44 Phase A)
kakao_enabled: bool = False
kakao_bind_host: str = "127.0.0.1"
kakao_bind_port: int = 8096
kakao_bind_port: int = DEFAULT_KAKAO_BIND_PORT
kakao_webhook_path: str = "/kakao/webhook"
kakao_allowed_users: List[str] = field(default_factory=list)
# Slack (F56 / S67)
slack_bot_token: Optional[str] = None
slack_signing_secret: Optional[str] = None
slack_allowed_users: List[str] = field(default_factory=list)
slack_allowed_channels: List[str] = field(default_factory=list)
slack_bind_host: str = "127.0.0.1"
slack_bind_port: int = DEFAULT_SLACK_BIND_PORT
slack_webhook_path: str = "/slack/events"
slack_interactions_path: str = "/slack/interactions"
slack_require_mention: bool = True
slack_reply_in_thread: bool = True
slack_mode: str = "events" # F57: events | socket
slack_app_token: Optional[str] = None # F57: required in socket mode (xapp-...)
slack_client_id: Optional[str] = None
slack_client_secret: Optional[str] = None
slack_oauth_redirect_uri: Optional[str] = None
slack_oauth_install_path: str = "/slack/install"
slack_oauth_callback_path: str = "/slack/oauth/callback"
slack_oauth_scopes: List[str] = field(
default_factory=lambda: [
"app_mentions:read",
"channels:history",
"chat:write",
"files:write",
"groups:history",
"im:history",
"mpim:history",
]
)
slack_oauth_state_ttl_sec: int = DEFAULT_SLACK_OAUTH_STATE_TTL_SEC
# Feishu / Lark (F67)
feishu_app_id: Optional[str] = None
feishu_app_secret: Optional[str] = None
feishu_verification_token: Optional[str] = None
feishu_encrypt_key: Optional[str] = None
feishu_account_id: Optional[str] = None
feishu_default_account_id: Optional[str] = None
feishu_workspace_id: Optional[str] = None
feishu_workspace_name: Optional[str] = None
feishu_bindings_json: Optional[str] = None
feishu_allowed_users: List[str] = field(default_factory=list)
feishu_allowed_chats: List[str] = field(default_factory=list)
feishu_bind_host: str = "127.0.0.1"
feishu_bind_port: int = DEFAULT_FEISHU_BIND_PORT
feishu_webhook_path: str = "/feishu/events"
feishu_callback_path: str = "/feishu/callback"
feishu_domain: str = "feishu" # feishu | lark
feishu_mode: str = "websocket" # websocket | webhook
feishu_require_mention: bool = True
feishu_reply_in_thread: bool = True
# Privileged Access (ID match across platforms; Telegram Int vs Discord Str handled by router)
admin_users: List[str] = field(default_factory=list)
# Media Host (F33)
public_base_url: Optional[str] = None
media_path: str = "/media"
media_ttl_sec: int = 300
media_max_mb: int = 8
media_ttl_sec: int = DEFAULT_MEDIA_TTL_SEC
media_max_mb: int = DEFAULT_MEDIA_MAX_MB
# Security (F32)
rate_limit_user_rpm: int = 10 # Requests per minute per user
rate_limit_channel_rpm: int = 30 # Requests per minute per channel
max_command_length: int = 4096 # Max characters in a single command
rate_limit_user_rpm: int = (
DEFAULT_RATE_LIMIT_USER_RPM # Requests per minute per user
)
rate_limit_channel_rpm: int = (
DEFAULT_RATE_LIMIT_CHANNEL_RPM # Requests per minute per channel
)
max_command_length: int = (
DEFAULT_MAX_COMMAND_LENGTH # Max characters in a single command
)
llm_max_tokens_per_request: int = 1024 # LLM token budget
# R80: Command Auth Policy
@@ -112,6 +313,16 @@ class ConnectorConfig:
debug: bool = False
state_path: Optional[str] = None
def __repr__(self):
"""R117: redact secret/token/key fields in logs and debug output."""
d = self.__dict__.copy()
for k in d:
if "token" in k or "secret" in k or "key" in k:
if d[k]:
d[k] = "***REDACTED***"
fields = ", ".join(f"{k}={v!r}" for k, v in d.items())
return f"{self.__class__.__name__}({fields})"
def load_config() -> ConnectorConfig:
"""Load configuration from environment variables."""
@@ -125,14 +336,23 @@ def load_config() -> ConnectorConfig:
cfg.state_path = os.environ.get("OPENCLAW_CONNECTOR_STATE_PATH")
# Delivery
cfg.delivery_max_images = int(
os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES", "4")
cfg.delivery_max_images = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES",
default=DEFAULT_DELIVERY_MAX_IMAGES,
minimum=MIN_DELIVERY_MAX_IMAGES,
maximum=MAX_DELIVERY_MAX_IMAGES,
)
cfg.delivery_max_bytes = int(
os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES", str(10 * 1024 * 1024))
cfg.delivery_max_bytes = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES",
default=DEFAULT_DELIVERY_MAX_BYTES,
minimum=MIN_DELIVERY_MAX_BYTES,
maximum=MAX_DELIVERY_MAX_BYTES,
)
cfg.delivery_timeout_sec = int(
os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC", "600")
cfg.delivery_timeout_sec = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC",
default=DEFAULT_DELIVERY_TIMEOUT_SEC,
minimum=MIN_DELIVERY_TIMEOUT_SEC,
maximum=MAX_DELIVERY_TIMEOUT_SEC,
)
# Telegram
@@ -168,9 +388,13 @@ def load_config() -> ConnectorConfig:
cfg.line_allowed_groups = [u.strip() for u in l_groups.split(",") if u.strip()]
cfg.line_bind_host = os.environ.get("OPENCLAW_CONNECTOR_LINE_BIND", "127.0.0.1")
if l_port := os.environ.get("OPENCLAW_CONNECTOR_LINE_PORT"):
if l_port.isdigit():
cfg.line_bind_port = int(l_port)
cfg.line_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_LINE_PORT",
default=DEFAULT_LINE_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.line_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_LINE_PATH", "/line/webhook"
)
@@ -193,9 +417,13 @@ def load_config() -> ConnectorConfig:
cfg.whatsapp_bind_host = os.environ.get(
"OPENCLAW_CONNECTOR_WHATSAPP_BIND", "127.0.0.1"
)
if wa_port := os.environ.get("OPENCLAW_CONNECTOR_WHATSAPP_PORT"):
if wa_port.isdigit():
cfg.whatsapp_bind_port = int(wa_port)
cfg.whatsapp_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_WHATSAPP_PORT",
default=DEFAULT_WHATSAPP_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.whatsapp_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_WHATSAPP_PATH", "/whatsapp/webhook"
)
@@ -210,9 +438,13 @@ def load_config() -> ConnectorConfig:
if wc_users := os.environ.get("OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS"):
cfg.wechat_allowed_users = [u.strip() for u in wc_users.split(",") if u.strip()]
cfg.wechat_bind_host = os.environ.get("OPENCLAW_CONNECTOR_WECHAT_BIND", "127.0.0.1")
if wc_port := os.environ.get("OPENCLAW_CONNECTOR_WECHAT_PORT"):
if wc_port.isdigit():
cfg.wechat_bind_port = int(wc_port)
cfg.wechat_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_WECHAT_PORT",
default=DEFAULT_WECHAT_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.wechat_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_WECHAT_PATH", "/wechat/webhook"
)
@@ -222,39 +454,166 @@ def load_config() -> ConnectorConfig:
cfg.kakao_enabled = True
cfg.kakao_bind_host = os.environ.get("OPENCLAW_CONNECTOR_KAKAO_BIND", "127.0.0.1")
if kp := os.environ.get("OPENCLAW_CONNECTOR_KAKAO_PORT"):
if kp.isdigit():
cfg.kakao_bind_port = int(kp)
cfg.kakao_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_KAKAO_PORT",
default=DEFAULT_KAKAO_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.kakao_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_KAKAO_PATH", "/kakao/webhook"
)
if ku := os.environ.get("OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS"):
cfg.kakao_allowed_users = [u.strip() for u in ku.split(",") if u.strip()]
# Slack (F56 / S67)
cfg.slack_bot_token = os.environ.get("OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN")
cfg.slack_signing_secret = os.environ.get("OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET")
if su := os.environ.get("OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS"):
cfg.slack_allowed_users = [u.strip() for u in su.split(",") if u.strip()]
if sc := os.environ.get("OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS"):
cfg.slack_allowed_channels = [u.strip() for u in sc.split(",") if u.strip()]
cfg.slack_bind_host = os.environ.get("OPENCLAW_CONNECTOR_SLACK_BIND", "127.0.0.1")
cfg.slack_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_SLACK_PORT",
default=DEFAULT_SLACK_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.slack_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_SLACK_PATH", "/slack/events"
)
cfg.slack_interactions_path = os.environ.get(
"OPENCLAW_CONNECTOR_SLACK_INTERACTIONS_PATH", "/slack/interactions"
)
if (
os.environ.get("OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION", "").lower()
== "false"
):
cfg.slack_require_mention = False
if (
os.environ.get("OPENCLAW_CONNECTOR_SLACK_REPLY_IN_THREAD", "").lower()
== "false"
):
cfg.slack_reply_in_thread = False
cfg.slack_mode = os.environ.get("OPENCLAW_CONNECTOR_SLACK_MODE", "events").lower()
cfg.slack_app_token = os.environ.get("OPENCLAW_CONNECTOR_SLACK_APP_TOKEN")
cfg.slack_client_id = os.environ.get("OPENCLAW_CONNECTOR_SLACK_CLIENT_ID")
cfg.slack_client_secret = os.environ.get("OPENCLAW_CONNECTOR_SLACK_CLIENT_SECRET")
cfg.slack_oauth_redirect_uri = os.environ.get(
"OPENCLAW_CONNECTOR_SLACK_OAUTH_REDIRECT_URI"
)
cfg.slack_oauth_install_path = os.environ.get(
"OPENCLAW_CONNECTOR_SLACK_OAUTH_INSTALL_PATH", "/slack/install"
)
cfg.slack_oauth_callback_path = os.environ.get(
"OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH", "/slack/oauth/callback"
)
if slack_scopes := os.environ.get("OPENCLAW_CONNECTOR_SLACK_OAUTH_SCOPES"):
parsed_scopes = [
scope.strip() for scope in slack_scopes.split(",") if scope.strip()
]
if parsed_scopes:
cfg.slack_oauth_scopes = parsed_scopes
cfg.slack_oauth_state_ttl_sec = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC",
default=DEFAULT_SLACK_OAUTH_STATE_TTL_SEC,
minimum=MIN_SLACK_OAUTH_STATE_TTL_SEC,
maximum=MAX_SLACK_OAUTH_STATE_TTL_SEC,
)
# Feishu / Lark (F67)
cfg.feishu_app_id = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_APP_ID")
cfg.feishu_app_secret = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_APP_SECRET")
cfg.feishu_verification_token = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_VERIFICATION_TOKEN"
)
cfg.feishu_encrypt_key = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_ENCRYPT_KEY")
cfg.feishu_account_id = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_ACCOUNT_ID")
cfg.feishu_default_account_id = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_DEFAULT_ACCOUNT_ID"
)
cfg.feishu_workspace_id = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_ID")
cfg.feishu_workspace_name = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_NAME"
)
cfg.feishu_bindings_json = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON")
if fu := os.environ.get("OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS"):
cfg.feishu_allowed_users = [u.strip() for u in fu.split(",") if u.strip()]
if fc := os.environ.get("OPENCLAW_CONNECTOR_FEISHU_ALLOWED_CHATS"):
cfg.feishu_allowed_chats = [u.strip() for u in fc.split(",") if u.strip()]
cfg.feishu_bind_host = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_BIND", "127.0.0.1")
cfg.feishu_bind_port = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_FEISHU_PORT",
default=DEFAULT_FEISHU_BIND_PORT,
minimum=MIN_BIND_PORT,
maximum=MAX_BIND_PORT,
clamp=False,
)
cfg.feishu_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_PATH", "/feishu/events"
)
cfg.feishu_callback_path = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH", "/feishu/callback"
)
cfg.feishu_domain = (
os.environ.get("OPENCLAW_CONNECTOR_FEISHU_DOMAIN", "feishu").strip() or "feishu"
)
cfg.feishu_mode = os.environ.get(
"OPENCLAW_CONNECTOR_FEISHU_MODE", "websocket"
).lower()
if (
os.environ.get("OPENCLAW_CONNECTOR_FEISHU_REQUIRE_MENTION", "").lower()
== "false"
):
cfg.feishu_require_mention = False
if (
os.environ.get("OPENCLAW_CONNECTOR_FEISHU_REPLY_IN_THREAD", "").lower()
== "false"
):
cfg.feishu_reply_in_thread = False
# Admin
if admins := os.environ.get("OPENCLAW_CONNECTOR_ADMIN_USERS"):
cfg.admin_users = [u.strip() for u in admins.split(",") if u.strip()]
# Security (F32)
if rpm := os.environ.get("OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM"):
if rpm.isdigit():
cfg.rate_limit_user_rpm = int(rpm)
if rpm := os.environ.get("OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM"):
if rpm.isdigit():
cfg.rate_limit_channel_rpm = int(rpm)
if max_len := os.environ.get("OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH"):
if max_len.isdigit():
cfg.max_command_length = int(max_len)
cfg.rate_limit_user_rpm = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM",
default=DEFAULT_RATE_LIMIT_USER_RPM,
minimum=MIN_RATE_LIMIT_RPM,
maximum=MAX_RATE_LIMIT_RPM,
)
cfg.rate_limit_channel_rpm = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM",
default=DEFAULT_RATE_LIMIT_CHANNEL_RPM,
minimum=MIN_RATE_LIMIT_RPM,
maximum=MAX_RATE_LIMIT_RPM,
)
cfg.max_command_length = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH",
default=DEFAULT_MAX_COMMAND_LENGTH,
minimum=MIN_MAX_COMMAND_LENGTH,
maximum=MAX_MAX_COMMAND_LENGTH,
)
# Media Host (F33)
cfg.public_base_url = os.environ.get("OPENCLAW_CONNECTOR_PUBLIC_BASE_URL")
cfg.media_path = os.environ.get("OPENCLAW_CONNECTOR_MEDIA_PATH", "/media")
if ttl := os.environ.get("OPENCLAW_CONNECTOR_MEDIA_TTL_SEC"):
if ttl.isdigit():
cfg.media_ttl_sec = int(ttl)
if mb := os.environ.get("OPENCLAW_CONNECTOR_MEDIA_MAX_MB"):
if mb.isdigit():
cfg.media_max_mb = int(mb)
cfg.media_ttl_sec = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_MEDIA_TTL_SEC",
default=DEFAULT_MEDIA_TTL_SEC,
minimum=MIN_MEDIA_TTL_SEC,
maximum=MAX_MEDIA_TTL_SEC,
)
cfg.media_max_mb = _load_bounded_int_env(
"OPENCLAW_CONNECTOR_MEDIA_MAX_MB",
default=DEFAULT_MEDIA_MAX_MB,
minimum=MIN_MEDIA_MAX_MB,
maximum=MAX_MEDIA_MAX_MB,
)
# R80: Command Auth Policy
import json
+11 -2
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"
View File
+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")
+48 -2
View File
@@ -13,8 +13,10 @@ from typing import Optional
from ..config import ConnectorConfig
from ..contract import CommandRequest, CommandResponse
from ..media_response import build_connector_media_response
from ..router import CommandRouter
from ..security_profile import AllowlistPolicy, ReplayGuard, verify_hmac_signature
from ..transport_contract import RelayResponseClassifier
logger = logging.getLogger(__name__)
@@ -46,6 +48,7 @@ class LINEWebhookServer:
self.runner = None
self.site = None
self.session = None
self._session_invalid = False # R93: Track session validity
# S32: shared replay guard (replaces inline F32 nonce cache)
self._replay_guard = ReplayGuard(
@@ -84,6 +87,7 @@ class LINEWebhookServer:
f"Starting LINE Webhook on {self.config.line_bind_host}:{self.config.line_bind_port}{self.config.line_webhook_path}"
)
self.session = aiohttp.ClientSession()
self._session_invalid = False # Reset on start
self.app = web.Application()
self.app.router.add_post(self.config.line_webhook_path, self.handle_webhook)
@@ -172,7 +176,7 @@ class LINEWebhookServer:
if not path:
return web.Response(status=404, text="Media Not Found or Expired")
return web.FileResponse(path)
return build_connector_media_response(web, path)
async def _process_event(self, event: dict):
"""Convert LINE event to CommandRequest and route."""
@@ -236,6 +240,10 @@ class LINEWebhookServer:
async def _reply_message(self, reply_token: str, text: str):
"""Send reply via LINE Messaging API."""
if self._session_invalid:
logger.warning("R93: Connector session invalid - blocking outbound")
return
aiohttp, _ = _import_aiohttp_web()
if aiohttp is None:
raise RuntimeError("aiohttp not available")
@@ -257,6 +265,13 @@ class LINEWebhookServer:
# Remediation: Use persistent session
try:
async with self.session.post(url, headers=headers, json=body) as resp:
if RelayResponseClassifier.is_auth_invalid(resp.status):
self._session_invalid = True
logger.error(
f"R93: Auth Invalid (LINE {resp.status}) - Locking session"
)
return
if resp.status == 429: # Check Rate Limit first
logger.warning("LINE API Rate Limit Hit")
elif resp.status != 200:
@@ -272,10 +287,15 @@ class LINEWebhookServer:
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[dict] = None,
):
"""
Send image via LINE using public URL.
"""
if self._session_invalid:
logger.warning("R93: Connector session invalid - blocking outbound image")
return
if not self.config.public_base_url:
logger.warning("LINE send_image: No public_base_url configured.")
text = (
@@ -314,6 +334,9 @@ class LINEWebhookServer:
self, channel_id: str, url: str, preview_url: Optional[str] = None
):
"""Low-level push image."""
if self._session_invalid:
return
aiohttp, _ = _import_aiohttp_web()
if not self.session:
return
@@ -336,12 +359,28 @@ class LINEWebhookServer:
}
async with self.session.post(api_url, headers=headers, json=body) as resp:
if RelayResponseClassifier.is_auth_invalid(resp.status):
self._session_invalid = True
logger.error(
f"R93: Auth Invalid (LINE {resp.status}) - Locking session"
)
return
if resp.status != 200:
err = await resp.text()
logger.error(f"LINE image push failed: {resp.status} {err}")
async def send_message(self, channel_id: str, text: str):
async def send_message(
self,
channel_id: str,
text: str,
delivery_context: Optional[dict] = None,
):
"""Send push message."""
if self._session_invalid:
logger.warning("R93: Connector session invalid - blocking outbound message")
return
aiohttp, _ = _import_aiohttp_web()
if not aiohttp or not self.session:
return
@@ -359,6 +398,13 @@ class LINEWebhookServer:
try:
async with self.session.post(url, headers=headers, json=body) as resp:
if RelayResponseClassifier.is_auth_invalid(resp.status):
self._session_invalid = True
logger.error(
f"R93: Auth Invalid (LINE {resp.status}) - Locking session"
)
return
if resp.status != 200:
err = await resp.text()
logger.error(f"LINE send_message failed: {resp.status} {err}")
@@ -0,0 +1,345 @@
"""Owned Slack response and media-delivery mixin."""
# ruff: noqa: SIM117, UP006, UP035, UP045 -- preserve frozen behavior/signatures.
from typing import Any, Dict, Optional
from ..reply_visibility import decide_reply_visibility
# mypy: disable-error-code="attr-defined,no-any-return"
class SlackDeliveryMixin:
async def _send_interactive_reply(
self,
*,
channel_id: str,
text: str,
buttons: list[dict],
thread_ts: str = "",
delivery_context: Optional[Dict[str, Any]] = None,
) -> None:
"""Send a Slack Block Kit message with bounded button actions."""
try:
import aiohttp as _aiohttp
except ImportError:
self._adapter_logger().warning(
"aiohttp not available; cannot send Slack interactive reply"
)
return
ctx = dict(delivery_context or {})
if not thread_ts:
thread_ts = str(ctx.get("thread_id", "") or "").strip()
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
str(ctx.get("workspace_id", "") or "").strip()
)
if not bot_token:
self._adapter_logger().warning(
"Slack interactive reply dropped: no workspace token available (workspace=%s)",
workspace_id or "legacy",
)
return
elements: list[dict] = []
for idx, button in enumerate(buttons[:5]):
value = str(button.get("value", "") or "").strip()
if not value:
continue
label = str(button.get("label", "") or "OpenClaw").strip()[:75]
action_id = str(
button.get("action_type")
or button.get("action_id")
or f"openclaw.{idx}"
).strip()[:255]
element: Dict[str, Any] = {
"type": "button",
"text": {"type": "plain_text", "text": label or "OpenClaw"},
"value": value[:2000],
"action_id": action_id or f"openclaw.{idx}",
}
style = self._adapter_style_to_slack(str(button.get("style", "") or ""))
if style:
element["style"] = style
elements.append(element)
if not elements:
if text:
await self._send_reply(
channel_id=channel_id,
text=text,
thread_ts=thread_ts,
delivery_context=ctx,
)
return
payload: Dict[str, Any] = {
"channel": channel_id,
"text": text or "OpenClaw",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (text or "OpenClaw")[:3000],
},
},
{"type": "actions", "elements": elements},
],
}
if thread_ts:
payload["thread_ts"] = thread_ts
headers = {
"Authorization": f"Bearer {bot_token}",
"Content-Type": "application/json; charset=utf-8",
}
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
"https://slack.com/api/chat.postMessage",
json=payload,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status != 200:
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=f"http_{resp.status}",
status_code=resp.status,
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
"interactive": True,
},
)
return
data = await resp.json()
if not data.get("ok"):
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=str(data.get("error", "unknown")),
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
"interactive": True,
},
)
elif installation_id:
self._installation_manager.mark_installation_health(
installation_id,
health_code="ok",
reason="chat_post_message_interactive_ok",
details={"workspace_id": workspace_id},
)
except Exception as e:
self._adapter_logger().warning("Slack interactive reply failed: %s", e)
async def _send_reply(
self,
channel_id: str,
text: str,
thread_ts: str = "",
delivery_context: Optional[Dict[str, Any]] = None,
) -> None:
"""Send a message via Slack Web API (chat.postMessage)."""
ctx = dict(delivery_context or {})
if not thread_ts:
thread_ts = str(ctx.get("thread_id", "") or "").strip()
decision = decide_reply_visibility(
delivery_context=ctx,
platform="slack",
channel_kind=self._adapter_channel_kind(channel_id),
in_thread=bool(thread_ts),
text=text,
)
if decision.suppressed:
self._adapter_logger().info(
"Suppressed Slack reply channel=%s reason=%s",
channel_id,
decision.reason,
)
return
try:
import aiohttp as _aiohttp
except ImportError:
self._adapter_logger().warning(
"aiohttp not available; cannot send Slack reply"
)
return
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
str(ctx.get("workspace_id", "") or "").strip()
)
if not bot_token:
self._adapter_logger().warning(
"Slack reply dropped: no workspace token available (workspace=%s)",
workspace_id or "legacy",
)
return
url = "https://slack.com/api/chat.postMessage"
headers = {
"Authorization": f"Bearer {bot_token}",
"Content-Type": "application/json; charset=utf-8",
}
payload: Dict[str, Any] = {
"channel": channel_id,
"text": text,
}
if thread_ts:
payload["thread_ts"] = thread_ts
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
url,
json=payload,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status != 200:
body = await resp.text()
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=f"http_{resp.status}",
status_code=resp.status,
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
},
)
self._adapter_logger().warning(
f"Slack API error: status={resp.status} body={body[:200]}"
)
else:
data = await resp.json()
if not data.get("ok"):
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=str(data.get("error", "unknown")),
details={
"workspace_id": workspace_id,
"path": "chat.postMessage",
},
)
self._adapter_logger().warning(
f"Slack API error: {data.get('error', 'unknown')}"
)
elif installation_id:
self._installation_manager.mark_installation_health(
installation_id,
health_code="ok",
reason="chat_post_message_ok",
details={"workspace_id": workspace_id},
)
except Exception as e:
self._adapter_logger().warning(f"Slack reply failed: {e}")
# ------------------------------------------------------------------
# Platform contract: send_message / send_image
# ------------------------------------------------------------------
async def send_message(
self,
channel_id: str,
text: str,
delivery_context: Optional[Dict[str, Any]] = None,
):
"""Platform contract: send text message."""
await self._send_reply(
channel_id=channel_id,
text=text,
delivery_context=delivery_context,
)
async def send_image(
self,
channel_id: str,
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[Dict[str, Any]] = None,
):
"""Platform contract: send image (Slack files.upload)."""
try:
import aiohttp as _aiohttp
except ImportError:
self._adapter_logger().warning(
"aiohttp not available; cannot upload Slack image"
)
return
ctx = dict(delivery_context or {})
thread_ts = str(ctx.get("thread_id", "") or "").strip()
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
str(ctx.get("workspace_id", "") or "").strip()
)
if not bot_token:
self._adapter_logger().warning(
"Slack image dropped: no workspace token available (workspace=%s)",
workspace_id or "legacy",
)
return
url = "https://slack.com/api/files.upload"
headers = {
"Authorization": f"Bearer {bot_token}",
}
data = _aiohttp.FormData()
data.add_field("file", image_data, filename=filename, content_type="image/png")
data.add_field("channels", channel_id)
if caption:
data.add_field("initial_comment", caption)
if thread_ts:
data.add_field("thread_ts", thread_ts)
try:
async with _aiohttp.ClientSession() as session:
async with session.post(
url,
data=data,
headers=headers,
timeout=_aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status != 200:
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=f"http_{resp.status}",
status_code=resp.status,
details={
"workspace_id": workspace_id,
"path": "files.upload",
},
)
self._adapter_logger().warning(
f"Slack file upload error: status={resp.status}"
)
else:
resp_data = await resp.json()
if not resp_data.get("ok"):
if installation_id:
self._installation_manager.mark_api_error(
installation_id,
error_code=str(resp_data.get("error", "unknown")),
details={
"workspace_id": workspace_id,
"path": "files.upload",
},
)
self._adapter_logger().warning(
f"Slack file upload error: {resp_data.get('error')}"
)
elif installation_id:
self._installation_manager.mark_installation_health(
installation_id,
health_code="ok",
reason="files_upload_ok",
details={"workspace_id": workspace_id},
)
except Exception as e:
self._adapter_logger().warning(f"Slack image upload failed: {e}")
@@ -0,0 +1,497 @@
"""Owned Slack signed-ingress and interaction transaction mixin."""
import json
import time
from typing import Any, Dict, Optional
from urllib.parse import parse_qs
from ..contract import CommandRequest
# ruff: noqa: SIM102, UP006, UP035, UP045 -- preserve frozen behavior/signatures.
# mypy: disable-error-code="attr-defined,no-any-return"
class SlackIngressMixin:
async def handle_event(self, request):
"""POST handler for Slack Events API."""
_, web = self._adapter_import_aiohttp_web()
try:
body_bytes = await request.read()
except Exception:
return self._adapter_make_response(web, status=400, text="Bad request")
# -- Step 1: Signature verification (fail-closed) --
timestamp = ""
signature = ""
if hasattr(request, "headers"):
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
signature = request.headers.get("X-Slack-Signature", "")
if not self._adapter_verify_slack_signature(
signing_secret=self.config.slack_signing_secret or "",
timestamp=timestamp,
body=body_bytes,
signature=signature,
):
self._adapter_logger().warning(
"Slack signature verification failed (rejected)"
)
return self._adapter_make_response(
web, status=401, text="Invalid signature"
)
# -- Step 2: Parse payload --
try:
payload = json.loads(body_bytes)
except json.JSONDecodeError:
return self._adapter_make_response(web, status=400, text="Bad JSON")
# -- Step 3: url_verification challenge (Webhook only) --
if payload.get("type") == "url_verification":
challenge = payload.get("challenge", "")
return self._adapter_make_json_response(web, {"challenge": challenge})
# -- Step 4: Process event --
try:
await self.process_event_payload(payload)
except ValueError:
return self._adapter_make_response(web, status=400, text="Bad Request")
return self._adapter_make_response(web, status=200, text="OK")
async def handle_interaction(self, request):
"""POST handler for Slack Block Kit interactivity callbacks."""
_, web = self._adapter_import_aiohttp_web()
try:
body_bytes = await request.read()
except Exception:
return self._adapter_make_response(web, status=400, text="Bad request")
timestamp = ""
signature = ""
if hasattr(request, "headers"):
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
signature = request.headers.get("X-Slack-Signature", "")
if not self._adapter_verify_slack_signature(
signing_secret=self.config.slack_signing_secret or "",
timestamp=timestamp,
body=body_bytes,
signature=signature,
):
self._adapter_logger().warning(
"Slack interaction signature verification failed (rejected)"
)
return self._adapter_make_response(
web, status=401, text="Invalid signature"
)
parsed = parse_qs(body_bytes.decode("utf-8"), keep_blank_values=True)
raw_payload = (parsed.get("payload") or [""])[0]
if not raw_payload:
return self._adapter_make_response(web, status=400, text="Missing payload")
try:
payload = json.loads(raw_payload)
except json.JSONDecodeError:
return self._adapter_make_response(web, status=400, text="Bad payload")
if not isinstance(payload, dict):
return self._adapter_make_response(web, status=400, text="Bad payload")
try:
routed = await self.process_interaction_payload(payload)
except ValueError:
return self._adapter_make_response(web, status=400, text="Bad Request")
except Exception as exc:
safe_text = self._adapter_safe_external_error_text(
"Slack interaction failed", exc
)
self._adapter_logger().warning("Slack interaction failed: %s", safe_text)
return self._adapter_make_response(web, status=500, text=safe_text)
# Slack requires a fast acknowledgement for interactivity requests.
# Keep the external response bounded; detailed action results are routed
# through the existing reply/deferred-response surfaces.
return self._adapter_make_json_response(
web, {"ok": True, "routed": bool(routed)}
)
async def process_event_payload(self, payload: Dict[str, Any]) -> None:
"""
Shared event processing path for both webhook and socket mode transports.
"""
if payload.get("type") != "event_callback":
return
event = payload.get("event", {})
event_id = payload.get("event_id", "")
event_type = event.get("type", "")
workspace_id = self._installation_manager.extract_workspace_id(payload)
if event_type in ("app_uninstalled", "tokens_revoked", "app_rate_limited"):
if workspace_id:
self._handle_lifecycle_event(workspace_id, event_type)
return
# -- Step 5: Replay / dedupe guard --
if not event_id:
self._adapter_logger().warning("Slack event missing event_id (rejected)")
raise ValueError("Missing event_id")
if not self._replay_guard.check_and_record(event_id):
self._adapter_logger().debug(
f"Slack duplicate event_id={event_id} (accepted, no-op)"
)
return
# -- Step 6: Bot-loop prevention --
# Resolve bot user ID from authorizations or cache.
bot_user_id = self._get_bot_user_id(payload, workspace_id)
sender_id = event.get("user", "")
if sender_id and bot_user_id and sender_id == bot_user_id:
return
if event.get("bot_id"):
return
subtype = event.get("subtype", "")
if subtype and subtype not in ("", "file_share"):
return
# -- Step 7: Event normalization --
text = event.get("text", "").strip()
channel_id = event.get("channel", "")
thread_ts = event.get("thread_ts", "")
message_ts = event.get("ts", "")
if event_type not in ("message", "app_mention"):
return
if not text or not sender_id:
return
# S67: Require mention in group channels.
is_dm = channel_id.startswith("D")
mentioned_bot = event_type == "app_mention" or (
bool(bot_user_id) and f"<@{bot_user_id}>" in text
)
if not is_dm and self.config.slack_require_mention:
if event_type != "app_mention":
if bot_user_id and f"<@{bot_user_id}>" not in text:
return
if bot_user_id:
text = text.replace(f"<@{bot_user_id}>", "").strip()
# -- Step 8: Allowlist checks (S67) --
if self._user_allowlist.entries:
user_result = self._user_allowlist.evaluate(sender_id)
if user_result.decision == "deny":
self._adapter_logger().warning(
f"Slack user {sender_id} denied by allowlist"
)
return
if self._channel_allowlist.entries and channel_id:
chan_result = self._channel_allowlist.evaluate(channel_id)
if chan_result.decision == "deny":
self._adapter_logger().warning(
f"Slack channel {channel_id} denied by allowlist"
)
return
# -- Step 9: Build CommandRequest and route --
req = CommandRequest(
platform="slack",
sender_id=sender_id,
channel_id=channel_id,
username=sender_id,
message_id=event_id,
text=text,
timestamp=float(message_ts) if message_ts else time.time(),
workspace_id=workspace_id,
thread_id=thread_ts
or (message_ts if self.config.slack_reply_in_thread else ""),
)
try:
resp = await self.router.handle(req)
resp_text = getattr(resp, "text", "")
if not isinstance(resp_text, str):
resp_text = str(resp_text) if resp_text is not None else ""
buttons = getattr(resp, "buttons", []) or []
if resp_text or buttons:
if buttons:
await self._send_interactive_reply(
channel_id=channel_id,
text=resp_text or "OpenClaw",
buttons=buttons,
thread_ts=req.thread_id,
delivery_context={
"workspace_id": workspace_id,
"thread_id": req.thread_id,
"channel_kind": self._adapter_channel_kind(channel_id),
"mentioned": mentioned_bot,
},
)
else:
await self._send_reply(
channel_id=channel_id,
text=resp_text,
thread_ts=req.thread_id,
delivery_context={
"workspace_id": workspace_id,
"thread_id": req.thread_id,
"channel_kind": self._adapter_channel_kind(channel_id),
"mentioned": mentioned_bot,
},
)
except Exception as e:
self._adapter_logger().error(
"Slack event handling failed (error_type=%s)", type(e).__name__
)
async def process_interaction_payload(self, payload: Dict[str, Any]) -> bool:
interaction_type = str(payload.get("type", "") or "").strip()
if interaction_type not in self._adapter_interaction_types():
return False
request = self._build_interaction_request(payload)
if request is None:
return False
replay_key = self._interaction_replay_key(payload, request)
if self._interaction_lifecycle is None: # pragma: no cover
if not self._replay_guard.check_and_record(replay_key):
self._adapter_logger().debug(
"Slack duplicate interaction %s (accepted, no-op)", replay_key
)
return False
claim = None
else:
claim = self._interaction_lifecycle.claim(
replay_key,
metadata={
"platform": "slack",
"workspace_id": request.workspace_id,
"interaction_type": str(payload.get("type", "") or ""),
},
)
if claim is not None and not claim.accepted:
self._adapter_logger().debug(
"Slack duplicate interaction %s state=%s code=%s (accepted, no-op)",
replay_key,
claim.record.state,
claim.code,
)
return False
# IMPORTANT: interactive run-like payloads must be routed through the same
# approval semantics as text commands. Untrusted users get approval forced
# before CommandRouter sees the request, avoiding a parallel bypass path.
if request.text.startswith("/run") and not (
self.router._is_admin(request) or self.router._is_trusted(request)
):
request.text = self._adapter_force_approval_command(request.text)
try:
response = await self.router.handle(request)
except Exception:
# IMPORTANT: only failures before router completion are retryable.
# Once router.handle returns, duplicate user actions must not reroute.
if self._interaction_lifecycle is not None:
self._interaction_lifecycle.release_retryable(
replay_key, reason="slack_interaction_failed_before_commit"
)
raise
if self._interaction_lifecycle is not None:
self._interaction_lifecycle.commit_success(replay_key, reason="routed")
response_text = str(getattr(response, "text", "") or "").strip()
response_buttons = getattr(response, "buttons", []) or []
if response_text or response_buttons:
if response_buttons:
await self._send_interactive_reply(
channel_id=request.channel_id,
text=response_text or "Action processed.",
buttons=response_buttons,
thread_ts=request.thread_id,
delivery_context={
"workspace_id": request.workspace_id,
"thread_id": request.thread_id,
},
)
elif response_text:
await self._send_reply(
channel_id=request.channel_id,
text=response_text,
thread_ts=request.thread_id,
delivery_context={
"workspace_id": request.workspace_id,
"thread_id": request.thread_id,
},
)
return True
def _build_interaction_request(
self, payload: Dict[str, Any]
) -> Optional[CommandRequest]:
interaction_type = str(payload.get("type", "") or "").strip()
command_text = self._extract_interaction_command(payload)
if not command_text:
return None
team = payload.get("team") or {}
user = payload.get("user") or {}
container = payload.get("container") or {}
channel = payload.get("channel") or {}
view = payload.get("view") or {}
message = payload.get("message") or {}
action = self._first_action(payload)
workspace_id = self._adapter_first_non_empty(
team.get("id"),
payload.get("team_id"),
(
payload.get("enterprise", {}).get("id")
if isinstance(payload.get("enterprise"), dict)
else ""
),
)
sender_id = self._adapter_first_non_empty(
user.get("id"), payload.get("user_id")
)
channel_id = self._adapter_first_non_empty(
channel.get("id"),
container.get("channel_id"),
payload.get("channel_id"),
)
message_id = self._adapter_first_non_empty(
view.get("id"),
action.get("action_ts"),
container.get("message_ts"),
payload.get("trigger_id"),
f"slack-interaction-{int(time.time())}",
)
thread_id = self._adapter_first_non_empty(
container.get("thread_ts"),
message.get("thread_ts") if isinstance(message, dict) else "",
container.get("message_ts"),
)
if not thread_id and self.config.slack_reply_in_thread:
thread_id = self._adapter_first_non_empty(
container.get("message_ts"), message.get("ts")
)
return CommandRequest(
platform="slack",
sender_id=sender_id,
channel_id=channel_id or sender_id,
username=self._adapter_first_non_empty(
user.get("username"), user.get("name"), sender_id
),
message_id=message_id,
text=command_text,
timestamp=time.time(),
workspace_id=workspace_id,
thread_id=thread_id,
metadata={
"interactive_callback": True,
"interaction_type": interaction_type,
"action_id": self._adapter_first_non_empty(
action.get("action_id"), view.get("callback_id")
),
"response_url": str(payload.get("response_url", "") or ""),
},
)
def _extract_interaction_command(self, payload: Dict[str, Any]) -> str:
interaction_type = str(payload.get("type", "") or "").strip()
if interaction_type == "block_actions":
action = self._first_action(payload)
selected = action.get("selected_option") or {}
value = self._adapter_first_non_empty(
action.get("value"),
selected.get("value") if isinstance(selected, dict) else "",
action.get("action_id"),
)
parsed = self._adapter_json_loads_safe(value)
return self._adapter_first_non_empty(
parsed.get("command"), parsed.get("value"), value
)
if interaction_type == "view_submission":
view = payload.get("view") or {}
private_meta = self._adapter_first_non_empty(view.get("private_metadata"))
parsed = self._adapter_json_loads_safe(private_meta)
if parsed:
return self._adapter_first_non_empty(
parsed.get("command"), parsed.get("value")
)
if private_meta:
return private_meta
state = (view.get("state") or {}).get("values") or {}
return self._extract_command_from_view_state(state)
if interaction_type == "workflow_step_execute":
workflow_step = payload.get("workflow_step") or {}
inputs = workflow_step.get("inputs") or {}
command = inputs.get("command") or {}
if isinstance(command, dict):
return self._adapter_first_non_empty(command.get("value"))
return self._adapter_first_non_empty(workflow_step.get("callback_id"))
return ""
def _extract_command_from_view_state(self, state: Dict[str, Any]) -> str:
if not isinstance(state, dict):
return ""
for block_value in state.values():
if not isinstance(block_value, dict):
continue
for action_value in block_value.values():
if not isinstance(action_value, dict):
continue
candidate = self._adapter_first_non_empty(
action_value.get("value"),
(
(action_value.get("selected_option") or {}).get("value")
if isinstance(action_value.get("selected_option"), dict)
else ""
),
)
parsed = self._adapter_json_loads_safe(candidate)
command = self._adapter_first_non_empty(
parsed.get("command"), parsed.get("value"), candidate
)
if command:
return command
return ""
def _first_action(self, payload: Dict[str, Any]) -> Dict[str, Any]:
actions = payload.get("actions") or []
if isinstance(actions, list) and actions and isinstance(actions[0], dict):
return actions[0]
return {}
def _interaction_replay_key(
self, payload: Dict[str, Any], request: CommandRequest
) -> str:
action = self._first_action(payload)
key_parts = [
"interaction",
str(payload.get("type", "") or ""),
request.workspace_id,
request.sender_id,
request.channel_id,
request.message_id,
str(payload.get("trigger_id", "") or ""),
str(action.get("action_id", "") or ""),
str(action.get("action_ts", "") or ""),
request.text,
]
return ":".join(key_parts)
# ------------------------------------------------------------------
# Slack Web API reply
# ------------------------------------------------------------------
@@ -0,0 +1,171 @@
"""Owned Slack installation, OAuth, and workspace-identity mixin."""
# ruff: noqa: UP006, UP035, UP045 -- preserve frozen facade annotations.
from typing import Any, Dict, Optional, Tuple
# mypy: disable-error-code="attr-defined,has-type,no-any-return"
class SlackInstallationMixin:
async def handle_oauth_install(self, request):
_, web = self._adapter_import_aiohttp_web()
if not self._installation_manager.can_handle_oauth():
return self._adapter_make_response(
web, status=503, text="Slack OAuth not configured"
)
state = self._installation_manager.issue_install_state()
return self._adapter_make_redirect_response(
web, self._installation_manager.build_install_url(state)
)
async def handle_oauth_callback(self, request):
_, web = self._adapter_import_aiohttp_web()
if not self._installation_manager.can_handle_oauth():
return self._adapter_make_response(
web, status=503, text="Slack OAuth not configured"
)
query = getattr(request, "query", {}) or {}
if query.get("error"):
return self._adapter_make_response(
web,
status=400,
text=f"Slack OAuth rejected: {query.get('error')}",
)
state = str(query.get("state", "") or "").strip()
code = str(query.get("code", "") or "").strip()
if not state or not code:
return self._adapter_make_response(
web, status=400, text="Missing OAuth callback fields"
)
if not self._installation_manager.consume_install_state(state):
return self._adapter_make_response(
web, status=400, text="Invalid or replayed OAuth state"
)
try:
payload = await self._installation_manager.exchange_code(code)
installation = self._installation_manager.upsert_from_oauth_payload(payload)
return self._adapter_make_response(
web,
status=200,
text=(
"Slack installation complete for "
f"{installation.workspace_id} ({installation.installation_id})."
),
)
except Exception as exc:
safe_text = self._adapter_safe_external_error_text(
"Slack OAuth processing failed", exc
)
self._adapter_logger().warning("Slack OAuth callback failed: %s", safe_text)
return self._adapter_make_response(
web,
status=502,
text=safe_text,
)
def _get_bot_user_id(self, payload: Dict[str, Any], workspace_id: str) -> str:
candidate = ""
if workspace_id and workspace_id in self._bot_user_ids:
return self._bot_user_ids[workspace_id]
if self._bot_user_id:
return self._bot_user_id
authorizations = payload.get("authorizations", [])
if authorizations and isinstance(authorizations, list):
candidate = str((authorizations[0] or {}).get("user_id", "") or "").strip()
if candidate:
self._bot_user_id = candidate
if workspace_id:
self._bot_user_ids[workspace_id] = candidate
return candidate
if workspace_id:
workspace_resolution, _ = (
self._installation_manager.resolve_workspace_tokens(workspace_id)
)
candidate = self._installation_manager.bot_user_id_for_installation(
workspace_resolution.installation if workspace_resolution.ok else None
)
if candidate:
self._bot_user_ids[workspace_id] = candidate
if self._bot_user_id is None:
self._bot_user_id = candidate
return candidate
def _resolve_workspace_credentials(
self, workspace_id: str
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
workspace_id = str(workspace_id or "").strip()
if workspace_id:
resolution, tokens = self._installation_manager.resolve_workspace_tokens(
workspace_id
)
if resolution.ok and resolution.installation is not None:
bot_token = tokens.get("bot_token")
if bot_token:
self._installation_manager.mark_resolution_success(
resolution.installation.installation_id, workspace_id
)
return (
resolution.installation.installation_id,
bot_token,
workspace_id,
)
self._adapter_logger().warning(
"Slack workspace %s resolved without bot token secret", workspace_id
)
return (
resolution.installation.installation_id,
None,
workspace_id,
)
if (
not self._installation_manager.oauth_enabled
and self.config.slack_bot_token
):
return (None, self.config.slack_bot_token, workspace_id)
self._adapter_logger().warning(
"Slack workspace resolution failed for %s: %s (%s)",
workspace_id,
resolution.reject_reason,
resolution.health_code,
)
return (None, None, workspace_id)
if self.config.slack_bot_token:
return (None, self.config.slack_bot_token, "")
return (None, None, workspace_id)
def _handle_lifecycle_event(self, workspace_id: str, event_type: str) -> None:
installation_id = self._installation_manager.installation_id_for_workspace(
workspace_id
)
try:
if event_type == "app_uninstalled":
self._installation_manager.mark_installation_health(
installation_id,
health_code="revoked",
reason="slack_app_uninstalled",
details={"workspace_id": workspace_id},
)
self._installation_manager.uninstall_installation(
installation_id, reason="slack_app_uninstalled"
)
elif event_type == "tokens_revoked":
self._installation_manager.mark_installation_health(
installation_id,
health_code="invalid_token",
reason="slack_tokens_revoked",
details={"workspace_id": workspace_id},
)
elif event_type == "app_rate_limited":
self._installation_manager.mark_installation_health(
installation_id,
health_code="degraded",
reason="slack_app_rate_limited",
details={"workspace_id": workspace_id},
)
except ValueError:
self._adapter_logger().warning(
"Slack lifecycle event for unbound workspace %s (%s)",
workspace_id,
event_type,
)
@@ -0,0 +1,389 @@
from __future__ import annotations
import json
import logging
import os
import secrets
import threading
import time
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
from connector.config import ConnectorConfig
try:
from services.audit import emit_audit_event
from services.connector_installation_registry import (
ConnectorInstallation,
ConnectorInstallationRegistry,
InstallationResolution,
InstallationStatus,
get_connector_installation_registry,
)
from services.safe_io import (
STANDARD_OUTBOUND_POLICY,
SafeIOHTTPError,
safe_request_json,
)
from services.secret_store import SecretStore, get_secret_store
from services.state_dir import get_state_dir
except ImportError: # pragma: no cover
from services.audit import emit_audit_event # type: ignore
from services.connector_installation_registry import ( # type: ignore
ConnectorInstallation,
ConnectorInstallationRegistry,
InstallationResolution,
InstallationStatus,
get_connector_installation_registry,
)
from services.safe_io import ( # type: ignore
STANDARD_OUTBOUND_POLICY,
SafeIOHTTPError,
safe_request_json,
)
from services.secret_store import SecretStore, get_secret_store # type: ignore
from services.state_dir import get_state_dir # type: ignore
logger = logging.getLogger(__name__)
SLACK_AUTHORIZE_URL = "https://slack.com/oauth/v2/authorize"
SLACK_OAUTH_ACCESS_URL = "https://slack.com/api/oauth.v2.access"
SLACK_OAUTH_STATE_FILE = "slack_oauth_states.json"
_INVALID_TOKEN_ERRORS = frozenset(
{"account_inactive", "invalid_auth", "not_authed", "token_revoked"}
)
_DEGRADED_TOKEN_ERRORS = frozenset({"ratelimited", "request_timeout", "fatal_error"})
class SlackInstallationManager:
def __init__(
self,
config: ConnectorConfig,
*,
registry: Optional[ConnectorInstallationRegistry] = None,
secret_store: Optional[SecretStore] = None,
state_dir: Optional[str] = None,
):
self.config = config
self._state_dir = Path(state_dir or get_state_dir())
self._state_path = self._state_dir / SLACK_OAUTH_STATE_FILE
self._registry = registry or get_connector_installation_registry(
state_dir=str(self._state_dir)
)
self._secret_store = secret_store or get_secret_store(str(self._state_dir))
self._lock = threading.RLock()
self._states: Dict[str, Dict[str, Any]] = {}
self._load_states()
@property
def oauth_enabled(self) -> bool:
return bool(self.config.slack_client_id and self.config.slack_client_secret)
def resolve_redirect_uri(self) -> str:
if self.config.slack_oauth_redirect_uri:
return str(self.config.slack_oauth_redirect_uri).strip()
if self.config.public_base_url:
base = self.config.public_base_url.rstrip("/")
path = self.config.slack_oauth_callback_path or "/slack/oauth/callback"
return f"{base}{path}"
return ""
def can_handle_oauth(self) -> bool:
return self.oauth_enabled and bool(self.resolve_redirect_uri())
def _save_states(self) -> None:
self._state_dir.mkdir(parents=True, exist_ok=True)
temp_path = self._state_path.with_suffix(".tmp")
temp_path.write_text(json.dumps(self._states, indent=2), encoding="utf-8")
os.replace(temp_path, self._state_path)
def _load_states(self) -> None:
if not self._state_path.exists():
return
try:
data = json.loads(self._state_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
self._states = data
except Exception as exc:
logger.warning("Failed to load Slack OAuth state store: %s", exc)
self._states = {}
self._prune_expired_states()
def _prune_expired_states(self) -> None:
now = time.time()
ttl = max(60, int(self.config.slack_oauth_state_ttl_sec or 600))
changed = False
for key, payload in list(self._states.items()):
created_at = float(payload.get("created_at", 0) or 0)
if not created_at or (now - created_at) > ttl:
self._states.pop(key, None)
changed = True
if changed:
self._save_states()
def issue_install_state(self) -> str:
if not self.can_handle_oauth():
raise RuntimeError("Slack OAuth flow not configured")
with self._lock:
self._prune_expired_states()
state = secrets.token_urlsafe(32)
self._states[state] = {"created_at": time.time()}
self._save_states()
return state
def consume_install_state(self, state: str) -> bool:
with self._lock:
self._prune_expired_states()
payload = self._states.pop(str(state or "").strip(), None)
if payload is None:
return False
self._save_states()
return True
def build_install_url(self, state: str) -> str:
params = {
"client_id": self.config.slack_client_id or "",
"scope": ",".join(self.config.slack_oauth_scopes or []),
"redirect_uri": self.resolve_redirect_uri(),
"state": state,
}
return f"{SLACK_AUTHORIZE_URL}?{urlencode(params)}"
async def exchange_code(self, code: str) -> Dict[str, Any]:
payload = {
"client_id": self.config.slack_client_id or "",
"client_secret": self.config.slack_client_secret or "",
"code": str(code or "").strip(),
"redirect_uri": self.resolve_redirect_uri(),
}
try:
return safe_request_json(
method="POST",
url=SLACK_OAUTH_ACCESS_URL,
raw_body=urlencode(payload).encode("utf-8"),
headers={"Accept": "application/json"},
content_type="application/x-www-form-urlencoded",
timeout_sec=15,
allow_hosts={"slack.com"},
policy=STANDARD_OUTBOUND_POLICY,
)
except SafeIOHTTPError as exc:
error_code = "unknown"
try:
body = exc.body or ""
parsed = json.loads(body) if body else {}
if isinstance(parsed, dict):
error_code = str(parsed.get("error", "") or error_code)
except Exception:
pass
raise RuntimeError(
f"slack_oauth_exchange_failed:{exc.status_code}:{error_code}"
) from exc
def _normalize_workspace_id(self, payload: Dict[str, Any]) -> str:
workspace_id = (
(payload.get("team") or {}).get("id")
or payload.get("team_id")
or (
(payload.get("enterprise") or {}).get("id")
if payload.get("enterprise")
else ""
)
)
workspace_id = str(workspace_id or "").strip()
if not workspace_id:
raise ValueError("workspace_id_missing")
return workspace_id
def installation_id_for_workspace(self, workspace_id: str) -> str:
return f"slack:{str(workspace_id or '').strip()}"
def metadata_from_oauth_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
team = dict(payload.get("team", {}) or {})
enterprise = dict(payload.get("enterprise", {}) or {})
authed_user = dict(payload.get("authed_user", {}) or {})
metadata = {
"workspace_name": str(team.get("name", "") or "").strip(),
"enterprise_id": str(enterprise.get("id", "") or "").strip(),
"enterprise_name": str(enterprise.get("name", "") or "").strip(),
"bot_user_id": str(payload.get("bot_user_id", "") or "").strip(),
"app_id": str(payload.get("app_id", "") or "").strip(),
"scope": str(payload.get("scope", "") or "").strip(),
"authed_user_id": str(authed_user.get("id", "") or "").strip(),
"token_type": str(payload.get("token_type", "") or "").strip(),
"transport_mode": self.config.slack_mode,
}
return {key: value for key, value in metadata.items() if value}
def upsert_from_oauth_payload(
self, payload: Dict[str, Any]
) -> ConnectorInstallation:
workspace_id = self._normalize_workspace_id(payload)
installation_id = self.installation_id_for_workspace(workspace_id)
token_values = {"bot_token": str(payload.get("access_token", "") or "").strip()}
if self.config.slack_app_token:
token_values["app_token"] = self.config.slack_app_token
if not token_values["bot_token"]:
raise ValueError("bot_token_missing")
metadata = self.metadata_from_oauth_payload(payload)
existing = self._registry.get_installation(installation_id)
if existing is not None:
rotated = self._registry.rotate_installation_tokens(
installation_id,
token_values,
reason="slack_oauth_reinstall",
)
inst = self._registry.upsert_installation(
platform="slack",
workspace_id=workspace_id,
installation_id=installation_id,
token_refs=rotated.token_refs,
status=rotated.status,
metadata=metadata,
status_reason="slack_oauth_reinstall",
)
else:
inst = self._registry.upsert_installation(
platform="slack",
workspace_id=workspace_id,
installation_id=installation_id,
token_values=token_values,
status=InstallationStatus.CREATED.value,
metadata=metadata,
status_reason="slack_oauth_install",
)
inst = self._registry.activate_installation(
installation_id, reason="slack_oauth_complete"
)
inst = self._registry.update_installation_health(
installation_id,
health_code="ok",
reason="slack_oauth_complete",
details={"workspace_id": workspace_id},
)
emit_audit_event(
action="connector.slack.oauth.install",
target=installation_id,
outcome="allow",
status_code=200,
details={
"workspace_id": workspace_id,
"workspace_name": metadata.get("workspace_name", ""),
"transport_mode": self.config.slack_mode,
},
)
return inst
def extract_workspace_id(self, payload: Dict[str, Any]) -> str:
if isinstance(payload.get("team_id"), str) and payload.get("team_id"):
return str(payload["team_id"]).strip()
team = payload.get("team") or {}
if isinstance(team, dict) and team.get("id"):
return str(team.get("id")).strip()
authorizations = payload.get("authorizations") or []
if isinstance(authorizations, list) and authorizations:
workspace_id = str((authorizations[0] or {}).get("team_id", "")).strip()
if workspace_id:
return workspace_id
event = payload.get("event") or {}
workspace_id = str(event.get("team", "") or "").strip()
return workspace_id
def resolve_workspace_tokens(
self, workspace_id: str
) -> Tuple[InstallationResolution, Dict[str, str]]:
resolution = self._registry.resolve_installation("slack", workspace_id)
if not resolution.ok or resolution.installation is None:
emit_audit_event(
action="connector.slack.resolve",
target=workspace_id or "unknown_workspace",
outcome="deny",
status_code=409,
details={
"workspace_id": workspace_id,
"reject_reason": resolution.reject_reason,
"health_code": resolution.health_code,
},
)
return resolution, {}
tokens: Dict[str, str] = {}
for token_name, ref in resolution.installation.token_refs.items():
secret = self._secret_store.get_secret(
ref, tenant_id=resolution.installation.tenant_id
)
if secret:
tokens[token_name] = secret
return resolution, tokens
def bot_user_id_for_installation(
self, installation: Optional[ConnectorInstallation]
) -> str:
if installation is None:
return ""
return str(
(
installation.metadata.get("bot_user_id", "")
if installation.metadata
else ""
)
or ""
).strip()
def mark_installation_health(
self,
installation_id: str,
*,
health_code: str,
reason: str,
details: Optional[Dict[str, Any]] = None,
) -> None:
self._registry.update_installation_health(
installation_id,
health_code=health_code,
reason=reason,
details=details,
)
def uninstall_installation(self, installation_id: str, *, reason: str) -> None:
self._registry.uninstall_installation(installation_id, reason=reason)
def mark_resolution_success(self, installation_id: str, workspace_id: str) -> None:
self._registry.update_installation_health(
installation_id,
health_code="ok",
reason="workspace_resolved",
details={"workspace_id": workspace_id},
)
def classify_error_health(self, error_code: str, status_code: int = 0) -> str:
normalized = str(error_code or "").strip().lower()
if normalized in _INVALID_TOKEN_ERRORS or status_code in (401, 403):
return "invalid_token"
if (
normalized in _DEGRADED_TOKEN_ERRORS
or status_code == 429
or status_code >= 500
):
return "degraded"
return "degraded"
def mark_api_error(
self,
installation_id: str,
*,
error_code: str,
status_code: int = 0,
details: Optional[Dict[str, Any]] = None,
) -> str:
health_code = self.classify_error_health(error_code, status_code=status_code)
self.mark_installation_health(
installation_id,
health_code=health_code,
reason=error_code or f"http_{status_code}",
details=details,
)
return health_code
+135
View File
@@ -0,0 +1,135 @@
"""
F57 -- Slack Socket Mode Adapter.
Implements Slack Socket Mode ingress and reuses Slack webhook processing logic
to keep transport behavior parity.
"""
import asyncio
import json
import logging
from typing import Optional
from ..config import ConnectorConfig
from ..router import CommandRouter
from .slack_webhook import SlackWebhookServer, _import_aiohttp_web
logger = logging.getLogger(__name__)
class SlackSocketModeClient(SlackWebhookServer):
"""
Socket Mode implementation for Slack.
Uses `apps.connections.open` to establish a websocket connection.
"""
def __init__(self, config: ConnectorConfig, router: CommandRouter):
super().__init__(config, router)
self.ws_task: Optional[asyncio.Task] = None
self.should_stop = False
async def start(self):
aiohttp, _ = _import_aiohttp_web()
if aiohttp is None:
logger.warning("aiohttp not installed. Skipping Slack Socket Mode.")
return
# CRITICAL: Socket Mode must fail closed when app token is absent/invalid.
if not self.config.slack_app_token:
logger.error(
"Slack Socket Mode enabled but OPENCLAW_CONNECTOR_SLACK_APP_TOKEN "
"missing. Set it to an xapp- token."
)
return
if not self.config.slack_app_token.startswith("xapp-"):
logger.error("Invalid Slack App Token (must start with xapp-).")
return
logger.info("Starting Slack Socket Mode client...")
self.should_stop = False
self.ws_task = asyncio.create_task(self._run_socket_mode_loop(aiohttp))
async def stop(self):
self.should_stop = True
if self.ws_task:
self.ws_task.cancel()
try:
await self.ws_task
except asyncio.CancelledError:
pass
await super().stop()
async def _run_socket_mode_loop(self, aiohttp):
retry_delay = 1
while not self.should_stop:
try:
wss_url = await self._get_wss_url(aiohttp)
if not wss_url:
await asyncio.sleep(retry_delay)
retry_delay = min(retry_delay * 2, 60)
continue
async with aiohttp.ClientSession() as session:
async with session.ws_connect(wss_url) as ws:
logger.info("Slack Socket Mode connected.")
retry_delay = 1
async for msg in ws:
if self.should_stop:
break
if msg.type == aiohttp.WSMsgType.TEXT:
await self._handle_socket_message(ws, msg.data)
elif msg.type == aiohttp.WSMsgType.ERROR:
logger.warning(f"Socket error: {msg.data}")
break
logger.info("Slack Socket Mode disconnected.")
except Exception as e:
logger.error(f"Slack Socket Mode loop error: {e}")
if self.should_stop:
break
await asyncio.sleep(retry_delay)
retry_delay = min(retry_delay * 2, 60)
async def _get_wss_url(self, aiohttp) -> Optional[str]:
url = "https://slack.com/api/apps.connections.open"
headers = {"Authorization": f"Bearer {self.config.slack_app_token}"}
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers) as resp:
if resp.status != 200:
logger.error(f"Failed to open connection: status={resp.status}")
return None
data = await resp.json()
if not data.get("ok"):
logger.error(f"Connection open failed: {data.get('error')}")
return None
return data.get("url")
except Exception as e:
logger.error(f"Failed to fetch WSS URL: {e}")
return None
async def _handle_socket_message(self, ws, data: str):
try:
payload = json.loads(data)
except json.JSONDecodeError:
return
envelope_id = payload.get("envelope_id")
if envelope_id:
await ws.send_json({"envelope_id": envelope_id})
msg_type = payload.get("type")
if msg_type == "hello":
logger.debug("Socket Mode hello received.")
elif msg_type == "disconnect":
logger.warning("Slack requested disconnect. Reconnecting...")
elif msg_type == "events_api":
inner_payload = payload.get("payload", {})
# IMPORTANT: use shared processing path to prevent webhook/socket drift.
await self.process_event_payload(inner_payload)
elif msg_type == "slash_commands":
# Out of scope for F57 closeout.
pass
+370
View File
@@ -0,0 +1,370 @@
"""
Slack Events API Webhook Adapter (F56).
Implements:
- Events API POST ingress with ``url_verification`` challenge response.
- Slack request authenticity via ``X-Slack-Signature`` + ``X-Slack-Request-Timestamp``
(``v0:{ts}:{raw_body}`` HMAC-SHA256).
- Replay / duplicate guard (event_id + timestamp window).
- ``message`` / ``app_mention`` event normalization with de-duplication
(avoid double-trigger when bot is mentioned in a regular message).
- CommandRequest conversion -> CommandRouter.
- Slack Web API thread or channel reply.
S67 Safety Profile:
- AllowlistPolicy for users and channels (fail-closed when configured).
- Bot-loop prevention (ignore messages from bot itself).
- Rate-limit delegation to CommandRouter (R80 authz + F32 rate limiter).
- Require-mention policy for group conversations.
Setup:
1. Create a Slack App at https://api.slack.com/apps.
2. Enable Events API; set Request URL to ``https://<host>/slack/events``.
3. Subscribe to ``message.channels``, ``message.groups``, ``message.im``,
``app_mention`` bot events.
4. Install app to workspace; copy Bot Token and Signing Secret.
5. Set env vars:
- ``OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN``
- ``OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET``
"""
import hashlib
import hmac
import json
import logging
import time
from typing import Any, Dict, Optional
from ..config import ConnectorConfig
from ..router import CommandRouter
from ..security_profile import AllowlistPolicy, ReplayGuard
from .slack_delivery_handlers import SlackDeliveryMixin
from .slack_ingress_handlers import SlackIngressMixin
from .slack_installation_handlers import SlackInstallationMixin
from .slack_installation_manager import SlackInstallationManager
try:
from services.connector_replay_lifecycle import ConnectorReplayLifecycle
except ImportError: # pragma: no cover
ConnectorReplayLifecycle = None # type: ignore
logger = logging.getLogger(__name__)
_SLACK_INTERACTION_TYPES = frozenset(
{"block_actions", "view_submission", "workflow_step_execute"}
)
def _slack_channel_kind(channel_id: str) -> str:
if str(channel_id or "").startswith("D"):
return "dm"
return "group"
# -- aiohttp compat layer (same pattern as kakao/whatsapp/wechat) -----------
def _import_aiohttp_web():
try:
import aiohttp
from aiohttp import web
except ModuleNotFoundError:
return None, None
return aiohttp, web
class _CompatResponse:
"""Minimal response shim for unit tests when aiohttp is unavailable."""
def __init__(
self,
*,
status: int = 200,
text: str = "",
content_type: str = "text/plain",
body: Optional[bytes] = None,
):
self.status = status
self.text = text
self.content_type = content_type
self.body = body if body is not None else text.encode("utf-8")
def _make_response(web_mod, *, status: int = 200, text: str = "OK"):
if web_mod is not None:
return web_mod.Response(status=status, text=text)
return _CompatResponse(status=status, text=text)
def _make_json_response(web_mod, data: dict, *, status: int = 200):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
if web_mod is not None:
return web_mod.json_response(data, status=status)
return _CompatResponse(
status=status,
text=body.decode("utf-8"),
content_type="application/json",
body=body,
)
def _make_redirect_response(web_mod, url: str):
if web_mod is not None:
raise web_mod.HTTPFound(location=url)
return _CompatResponse(status=302, text=url)
def _safe_external_error_text(default: str, _exc: Exception) -> str:
# IMPORTANT: keep Slack external failures constant. Even "short safe-looking"
# exception text remains scanner-tainted and can re-expose internal detail.
return default
def _json_loads_safe(raw: Any) -> Dict[str, Any]:
if isinstance(raw, dict):
return raw
if not isinstance(raw, str):
return {}
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except (TypeError, ValueError):
return {}
def _first_non_empty(*values: Any) -> str:
for value in values:
text = str(value or "").strip()
if text:
return text
return ""
def _force_approval_command(command_text: str) -> str:
normalized = str(command_text or "").strip()
if normalized.startswith("/run") and "--approval" not in normalized:
return f"{normalized} --approval"
return normalized
def _style_to_slack(style: str) -> str:
normalized = str(style or "").strip().lower()
if normalized in {"primary", "danger"}:
return normalized
return "primary" if normalized in {"approve", "success"} else ""
# -- Slack signature verification -------------------------------------------
# Maximum acceptable clock skew for timestamp validation (5 minutes).
SLACK_TIMESTAMP_MAX_DRIFT_SEC = 300
SLACK_SIGNING_VERSION = "v0"
def verify_slack_signature(
*,
signing_secret: str,
timestamp: str,
body: bytes,
signature: str,
) -> bool:
"""
Verify Slack ``X-Slack-Signature`` using ``v0:{ts}:{body}`` HMAC-SHA256.
Fail-closed: returns False on any missing/invalid input.
"""
if not signing_secret or not timestamp or not signature:
return False
# Timestamp freshness check
try:
ts_int = int(timestamp)
except (ValueError, TypeError):
return False
if abs(time.time() - ts_int) > SLACK_TIMESTAMP_MAX_DRIFT_SEC:
return False
# Compute expected signature
sig_basestring = f"{SLACK_SIGNING_VERSION}:{timestamp}:{body.decode('utf-8')}"
expected = (
SLACK_SIGNING_VERSION
+ "="
+ hmac.new(
signing_secret.encode("utf-8"),
sig_basestring.encode("utf-8"),
hashlib.sha256,
).hexdigest()
)
return hmac.compare_digest(expected, signature)
# -- Slack adapter ----------------------------------------------------------
class SlackWebhookServer(
SlackInstallationMixin,
SlackIngressMixin,
SlackDeliveryMixin,
):
"""
F56 -- Slack Events API adapter.
Security invariants (S67 / R124):
- CRITICAL: Reject unsigned or replay requests (fail-closed).
- CRITICAL: Ignore bot's own messages (bot-loop prevention).
- IMPORTANT: Deduplicate ``message`` + ``app_mention`` for the same event
to prevent double command execution.
- IMPORTANT: Respect ``require_mention`` policy for group channels.
"""
REPLAY_WINDOW_SEC = 300
NONCE_CACHE_SIZE = 5000
def __init__(self, config: ConnectorConfig, router: CommandRouter):
self.config = config
self.router = router
self.app = None
self.runner = None
self.site = None
# S67: Replay / dedupe guard keyed by Slack event_id
self._replay_guard = ReplayGuard(
window_sec=self.REPLAY_WINDOW_SEC,
max_entries=self.NONCE_CACHE_SIZE,
)
if ConnectorReplayLifecycle is None: # pragma: no cover
self._interaction_lifecycle = None
else:
self._interaction_lifecycle = ConnectorReplayLifecycle(
ttl_sec=self.REPLAY_WINDOW_SEC,
max_entries=self.NONCE_CACHE_SIZE,
)
# S67: Allowlists (fail-closed when configured)
self._user_allowlist = AllowlistPolicy(config.slack_allowed_users, strict=False)
self._channel_allowlist = AllowlistPolicy(
config.slack_allowed_channels, strict=False
)
self._installation_manager = SlackInstallationManager(config)
# Bot user ID (resolved on first event or set from config)
self._bot_user_id: Optional[str] = None # type: ignore[assignment]
self._bot_user_ids: Dict[str, str] = {}
# IMPORTANT: resolve facade globals at call time; integration suites and
# minimal-host shims patch these security/protocol seams directly.
@staticmethod
def _adapter_import_aiohttp_web():
return _import_aiohttp_web()
@staticmethod
def _adapter_make_response(*args, **kwargs):
return _make_response(*args, **kwargs)
@staticmethod
def _adapter_make_json_response(*args, **kwargs):
return _make_json_response(*args, **kwargs)
@staticmethod
def _adapter_make_redirect_response(*args, **kwargs):
return _make_redirect_response(*args, **kwargs)
@staticmethod
def _adapter_safe_external_error_text(*args, **kwargs):
return _safe_external_error_text(*args, **kwargs)
@staticmethod
def _adapter_verify_slack_signature(*args, **kwargs):
return verify_slack_signature(*args, **kwargs)
@staticmethod
def _adapter_json_loads_safe(*args, **kwargs):
return _json_loads_safe(*args, **kwargs)
@staticmethod
def _adapter_first_non_empty(*args, **kwargs):
return _first_non_empty(*args, **kwargs)
@staticmethod
def _adapter_force_approval_command(*args, **kwargs):
return _force_approval_command(*args, **kwargs)
@staticmethod
def _adapter_style_to_slack(*args, **kwargs):
return _style_to_slack(*args, **kwargs)
@staticmethod
def _adapter_channel_kind(*args, **kwargs):
return _slack_channel_kind(*args, **kwargs)
@staticmethod
def _adapter_interaction_types():
return _SLACK_INTERACTION_TYPES
@staticmethod
def _adapter_logger():
return logger
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def start(self):
aiohttp, web = _import_aiohttp_web()
if aiohttp is None or web is None:
logger.warning("aiohttp not installed. Skipping Slack adapter.")
return
if not self.config.slack_signing_secret:
logger.info(
"Slack adapter disabled "
"(OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET missing)"
)
return
if (
not self.config.slack_bot_token
and not self._installation_manager.can_handle_oauth()
):
logger.info(
"Slack adapter disabled "
"(legacy bot token missing and Slack OAuth flow not configured)"
)
return
logger.info(
f"Starting Slack Webhook on "
f"{self.config.slack_bind_host}:{self.config.slack_bind_port}"
f"{self.config.slack_webhook_path}"
)
self.app = web.Application()
self.app.router.add_post(self.config.slack_webhook_path, self.handle_event)
self.app.router.add_post(
self.config.slack_interactions_path, self.handle_interaction
)
if self._installation_manager.can_handle_oauth():
self.app.router.add_get(
self.config.slack_oauth_install_path, self.handle_oauth_install
)
self.app.router.add_get(
self.config.slack_oauth_callback_path, self.handle_oauth_callback
)
self.runner = web.AppRunner(self.app)
await self.runner.setup()
self.site = web.TCPSite(
self.runner, self.config.slack_bind_host, self.config.slack_bind_port
)
await self.site.start()
async def stop(self):
if self.site:
await self.site.stop()
if self.runner:
await self.runner.cleanup()
# ------------------------------------------------------------------
# Event handler
# ------------------------------------------------------------------
+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:
+49 -3
View File
@@ -32,10 +32,14 @@ import time
from typing import Optional
from xml.etree import ElementTree as ET
from defusedxml import ElementTree as DefusedET
from defusedxml.common import DefusedXmlException
from ..config import ConnectorConfig
from ..contract import CommandRequest, CommandResponse
from ..router import CommandRouter
from ..security_profile import AllowlistPolicy, ReplayGuard
from ..transport_contract import RelayResponseClassifier
logger = logging.getLogger(__name__)
@@ -264,9 +268,17 @@ def parse_wechat_xml(raw: bytes) -> dict:
f"Payload size {len(raw)} exceeds limit {XML_MAX_PAYLOAD_BYTES}"
)
lowered = raw.lower()
if b"<!doctype" in lowered or b"<!entity" in lowered:
# IMPORTANT: reject DTD / ENTITY declarations before parser entry to
# keep entity-expansion bombs fail-closed.
raise XMLBudgetExceeded("DTD/entity declarations are not allowed")
try:
root = ET.fromstring(raw)
except ET.ParseError as e:
# IMPORTANT: keep defusedxml here. Reverting to the stdlib parser path
# reopens the residual CodeQL xml-bomb finding on this ingress seam.
root = DefusedET.fromstring(raw.decode("utf-8"))
except (ET.ParseError, DefusedXmlException, UnicodeDecodeError) as e:
raise XMLBudgetExceeded(f"XML parse error: {e}") from e
# Depth check — WeChat envelopes are <xml><Tag>val</Tag></xml>, depth=2
@@ -433,6 +445,7 @@ class WeChatWebhookServer:
self._user_allowlist = AllowlistPolicy(
config.wechat_allowed_users, strict=False
)
self._session_invalid = False # R93: Track session validity
# ------------------------------------------------------------------
# Lifecycle
@@ -459,6 +472,7 @@ class WeChatWebhookServer:
)
self.session = aiohttp.ClientSession()
self._session_invalid = False # Reset on start
self.app = web.Application()
self.app.router.add_get(self.config.wechat_webhook_path, self.handle_verify)
@@ -677,13 +691,22 @@ class WeChatWebhookServer:
# Outbound: Text (Customer Service Message API)
# ------------------------------------------------------------------
async def send_message(self, recipient_openid: str, text: str):
async def send_message(
self,
recipient_openid: str,
text: str,
delivery_context: Optional[dict] = None,
):
"""
Send text via WeChat Customer Service Message API.
Requires service account with customer service permission.
Falls back silently if access_token unavailable.
"""
if self._session_invalid:
logger.warning("R93: Connector session invalid - blocking outbound")
return
if not self.session:
return
@@ -706,6 +729,13 @@ class WeChatWebhookServer:
try:
async with self.session.post(url, json=body) as resp:
if RelayResponseClassifier.is_auth_invalid(resp.status):
self._session_invalid = True
logger.error(
f"R93: Auth Invalid (WeChat {resp.status}) - Locking session"
)
return
data = await resp.json(content_type=None)
errcode = data.get("errcode", 0)
if errcode != 0:
@@ -722,6 +752,7 @@ class WeChatWebhookServer:
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[dict] = None,
):
"""
Send image via WeChat.
@@ -729,6 +760,10 @@ class WeChatWebhookServer:
Text-first: sends caption/notification text. Actual media upload
requires media API and is not implemented in phase 1.
"""
if self._session_invalid:
logger.warning("R93: Connector session invalid - blocking outbound image")
return
if caption:
await self.send_message(channel_id, caption)
else:
@@ -754,6 +789,10 @@ class WeChatWebhookServer:
if self._cached_token and now < self._token_expires:
return self._cached_token
# R93: Block if session invalid
if self._session_invalid:
return None
app_id = self.config.wechat_app_id
app_secret = self.config.wechat_app_secret
if not app_id or not app_secret:
@@ -770,6 +809,13 @@ class WeChatWebhookServer:
if not self.session:
return None
async with self.session.get(url) as resp:
if RelayResponseClassifier.is_auth_invalid(resp.status):
self._session_invalid = True
logger.error(
f"R93: Auth Invalid (WeChat Token {resp.status}) - Locking session"
)
return None
data = await resp.json(content_type=None)
token = data.get("access_token")
expires_in = data.get("expires_in", 7200)
+37 -2
View File
@@ -18,8 +18,10 @@ from typing import Optional
from ..config import ConnectorConfig
from ..contract import CommandRequest, CommandResponse
from ..media_response import build_connector_media_response
from ..router import CommandRouter
from ..security_profile import AllowlistPolicy, ReplayGuard, verify_hmac_signature
from ..transport_contract import RelayResponseClassifier
logger = logging.getLogger(__name__)
@@ -62,6 +64,7 @@ class WhatsAppWebhookServer:
self.runner = None
self.site = None
self.session = None
self._session_invalid = False # R93: Track session validity
# S32: shared replay guard (replaces inline F32 nonce cache)
self._replay_guard = ReplayGuard(
@@ -104,6 +107,7 @@ class WhatsAppWebhookServer:
f"{self.config.whatsapp_webhook_path}"
)
self.session = aiohttp.ClientSession()
self._session_invalid = False # Reset on start
self.app = web.Application()
self.app.router.add_get(self.config.whatsapp_webhook_path, self.handle_verify)
@@ -216,7 +220,7 @@ class WhatsAppWebhookServer:
if not path:
return web.Response(status=404, text="Media Not Found or Expired")
return web.FileResponse(path)
return build_connector_media_response(web, path)
# ------------------------------------------------------------------
# Message Processing
@@ -293,8 +297,17 @@ class WhatsAppWebhookServer:
# Outbound: Text
# ------------------------------------------------------------------
async def send_message(self, recipient_id: str, text: str):
async def send_message(
self,
recipient_id: str,
text: str,
delivery_context: Optional[dict] = None,
):
"""Send text message via WhatsApp Cloud API."""
if self._session_invalid:
logger.warning("R93: Connector session invalid - blocking outbound")
return
if not self.session:
return
@@ -318,6 +331,13 @@ class WhatsAppWebhookServer:
try:
async with self.session.post(url, headers=headers, json=body) as resp:
if RelayResponseClassifier.is_auth_invalid(resp.status):
self._session_invalid = True
logger.error(
f"R93: Auth Invalid (WhatsApp {resp.status}) - Locking session"
)
return
if resp.status == 429:
logger.warning("WhatsApp API Rate Limit Hit")
elif resp.status not in (200, 201):
@@ -336,11 +356,16 @@ class WhatsAppWebhookServer:
image_data: bytes,
filename: str = "image.png",
caption: Optional[str] = None,
delivery_context: Optional[dict] = None,
):
"""
Send image via WhatsApp using public media URL.
Reuses F33 media store to host the image, then sends a link message.
"""
if self._session_invalid:
logger.warning("R93: Connector session invalid - blocking outbound image")
return
if not self.config.public_base_url:
logger.warning("WhatsApp send_image: No public_base_url configured.")
text = (
@@ -372,6 +397,9 @@ class WhatsAppWebhookServer:
caption: Optional[str] = None,
):
"""Send image message via Graph API /messages endpoint."""
if self._session_invalid:
return
if not self.session:
return
@@ -395,6 +423,13 @@ class WhatsAppWebhookServer:
try:
async with self.session.post(url, headers=headers, json=body) as resp:
if RelayResponseClassifier.is_auth_invalid(resp.status):
self._session_invalid = True
logger.error(
f"R93: Auth Invalid (WhatsApp {resp.status}) - Locking session"
)
return
if resp.status not in (200, 201):
err = await resp.text()
logger.error(f"WhatsApp image send failed: {resp.status} {err}")
+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}")
+24 -758
View File
@@ -3,26 +3,35 @@ Connector Router (F29 Remediation).
Dispatches parsed commands to handlers with AST argument parsing.
"""
import logging
import shlex
from typing import Any, Dict, List, Optional
from .config import CommandClass, ConnectorConfig
from .contract import CommandRequest, CommandResponse
from .config import ConnectorConfig
from .contract import CommandRequest as CommandRequest
from .contract import CommandResponse as CommandResponse
from .llm_client import LLMClient
from .openclaw_client import OpenClawClient
from .router_admin_handlers import RouterAdminMixin
from .router_chat_handlers import RouterChatMixin
from .router_dispatch import RouterDispatchMixin
from .router_execution_handlers import RouterExecutionMixin
from .state import ConnectorState
if False: # Type hinting only
from .results_poller import ResultsPoller
from .llm_client import LLMClient
from .prompts import CHAT_STATUS_PROMPT, CHAT_SYSTEM_PROMPT
from .command_firewall import CommandFirewall
from .rate_limiter import RateLimiter
logger = logging.getLogger(__name__)
from .semantic_guard import SemanticGuard
class CommandRouter:
class CommandRouter(
RouterDispatchMixin,
RouterExecutionMixin,
RouterAdminMixin,
RouterChatMixin,
):
def _build_llm_client(self) -> LLMClient:
"""Resolve the facade dependency at call time to preserve patch seams."""
return LLMClient(self.client)
def __init__(
self,
config: ConnectorConfig,
@@ -33,755 +42,12 @@ class CommandRouter:
self.client = client
self.poller = poller
self.state = ConnectorState(path=self.config.state_path)
self._template_meta_cache: Dict[str, Dict[str, Any]] = {}
self._template_meta_cache: dict[str, dict[str, object]] = {}
# F32 WP2: Rate limiter
self._rate_limiter = RateLimiter(
user_rpm=self.config.rate_limit_user_rpm,
channel_rpm=self.config.rate_limit_channel_rpm,
)
async def handle(self, req: CommandRequest) -> CommandResponse:
"""Main dispatch loop."""
text = req.text.strip()
# NOTE: Debug-only raw message logging for troubleshooting parsing issues.
# Enable with OPENCLAW_CONNECTOR_DEBUG=1. May include sensitive user content.
if self.config.debug:
logger.info(
"DEBUG raw message: platform=%s user=%s chat=%s text=%r",
req.platform,
req.sender_id,
req.channel_id,
text,
)
# F32 WP2: Rate limiting
if not self._rate_limiter.is_allowed(str(req.sender_id), str(req.channel_id)):
return CommandResponse(
text="[Rate Limited] Too many requests. Please wait a moment."
)
# F32 WP5: Command length limit
if len(text) > self.config.max_command_length:
return CommandResponse(
text=f"[Error] Command too long ({len(text)} chars). Max: {self.config.max_command_length}."
)
try:
# IMPORTANT (recurring usability bug):
# Do not use `shlex.split()` directly for ChatOps commands that may include natural
# language. In POSIX mode, `shlex` treats apostrophes (`'`) as quote delimiters, so
# common contractions like "She's" trigger "unbalanced quotes" failures.
#
# We therefore only treat *double quotes* (`"`) as quoting characters, so users can
# still do: positive_prompt="a prompt with spaces" while apostrophes remain safe.
lexer = shlex.shlex(text, posix=True)
lexer.whitespace_split = True
lexer.commenters = ""
lexer.quotes = '"'
parts = list(lexer)
except ValueError:
return CommandResponse(
text="[Error] Parsing command arguments failed (unbalanced quotes?)."
)
if not parts:
return CommandResponse(text="Empty command.")
cmd = parts[0].lower()
args = parts[1:]
# Telegram group commands often include the bot username suffix, e.g. `/help@mybot`.
# If we don't strip it, the command won't match our dispatch table and appears "dead"
# even though polling is working.
if (
(req.platform or "").lower() == "telegram"
and cmd.startswith("/")
and "@" in cmd
):
cmd = cmd.split("@", 1)[0]
# Some users type `@bot /help` in group chats. Treat that as a command too.
if cmd.startswith("@") and args and args[0].startswith("/"):
cmd = args[0].lower()
args = args[1:]
# Dispatch Table
handlers = {
("/status", "status"): (self._handle_status, CommandClass.PUBLIC),
("/help", "help", "/start"): (self._handle_help, CommandClass.PUBLIC),
("/run", "run"): (self._handle_run, CommandClass.RUN),
("/interrupt", "interrupt", "/cancel", "cancel", "/stop"): (
self._handle_interrupt,
CommandClass.ADMIN,
), # Global interrupt => admin-only.
("/approvals", "approvals"): (
self._handle_approvals_list,
CommandClass.ADMIN,
),
("/approve", "approve"): (self._handle_approve, CommandClass.ADMIN),
("/reject", "reject"): (self._handle_reject, CommandClass.ADMIN),
("/schedules", "schedules"): (
self._handle_schedules_list,
CommandClass.ADMIN,
),
("/schedule", "schedule"): (
self._handle_schedule_subcommand,
CommandClass.ADMIN,
),
# Phase 3 Introspection
("/history", "history"): (self._handle_history, CommandClass.PUBLIC),
("/trace", "trace"): (self._handle_trace, CommandClass.ADMIN), # Admin only
("/jobs", "jobs", "queue"): (self._handle_jobs, CommandClass.PUBLIC),
# F30: Chat Assistant
("/chat", "chat"): (self._handle_chat, CommandClass.PUBLIC),
}
# Find Handler
handler = None
requires_admin = False
canonical_cmd = cmd # Fallback
for aliases, (func, cmd_class) in handlers.items():
if cmd in aliases:
handler = func
default_class = cmd_class
# R80 Remediation: Use canonical command (first alias) for policy checks
# This prevents "run" vs "/run" bypass issues.
if isinstance(aliases, tuple):
# Convention: first alias is canonical (e.g. "/run")
canonical_cmd = aliases[0]
else:
canonical_cmd = aliases
break
if not handler:
return CommandResponse(
text=f"Unknown command: {cmd}. Type /help for options."
)
# R80: Centralized Authorization Gate
# Pass canonical_cmd to ensure policy matches aliases correctly
if auth_err := self._check_command_authz(canonical_cmd, req, default_class):
return auth_err
# Execute
try:
return await handler(req, args)
except Exception as e:
logger.exception(f"Command execution error {cmd}: {e}")
return CommandResponse(text=f"[Internal Error] {str(e)}")
def _is_admin(self, user_id: str) -> bool:
return str(user_id) in self.config.admin_users
def _check_command_authz(
self, cmd: str, req: CommandRequest, default_class: CommandClass
) -> Optional[CommandResponse]:
"""
R80: Verify command authorization policy.
Returns None if allowed, or CommandResponse(text=error) if denied.
"""
policy = self.config.command_policy
# 1. Resolve Effective Class (Handle per-command overrides)
# Note: 'cmd' here is the canonical parsed command string (lowercase), e.g., "/run" or "run"
# The overrides dict might use "/run" or "run", we should check both or normalize.
# Currently, the router logic normalized `cmd` from input (lines 90-101).
# We'll check exact match against the override key.
eff_class = policy.command_overrides.get(cmd, default_class)
# 2. Check AllowFrom List (Explicit User Allow)
# If an explicit AllowFrom list exists for this class, the user MUST be in it.
# This takes precedence over role logic.
allowed_users = policy.allow_from.get(eff_class)
if allowed_users is not None and len(allowed_users) > 0:
if str(req.sender_id) not in allowed_users:
# If explicit allow-list is active, even admins must be in it?
# Decision: YES, for strict compliance. If you want admins, add them to the list.
# However, for usability, usually admins are implied.
# Let's stick to "Explicit List Wins" for R80 strict mode.
return CommandResponse(
text="[Access Denied] You are not in the allow-list for this command."
)
# If in list, proceed (bypass default role checks? No, usually allows)
return None
# 3. Default Role Logic
if eff_class == CommandClass.ADMIN:
if not self._is_admin(req.sender_id):
return CommandResponse(
text="[Access Denied] This command requires Admin privileges."
)
# PUBLIC and RUN are allowed by default (RUN checks trust internally)
return None
def _is_trusted(self, req: CommandRequest) -> bool:
"""
Trusted users can execute /run immediately.
Untrusted users are routed to approval flow.
"""
if self._is_admin(req.sender_id):
return True
platform = (req.platform or "").lower()
sender_id = str(req.sender_id)
channel_id = str(req.channel_id)
if platform == "telegram":
try:
uid = int(sender_id)
except Exception:
uid = None
try:
cid = int(channel_id)
except Exception:
cid = None
if uid is not None and uid in self.config.telegram_allowed_users:
return True
if cid is not None and cid in self.config.telegram_allowed_chats:
return True
return False
if platform == "discord":
if sender_id in self.config.discord_allowed_users:
return True
if channel_id in self.config.discord_allowed_channels:
return True
return False
if platform == "line":
if sender_id in self.config.line_allowed_users:
return True
if channel_id in self.config.line_allowed_groups:
return True
return False
if platform == "whatsapp":
if sender_id in self.config.whatsapp_allowed_users:
return True
return False
if platform == "wechat":
if sender_id in self.config.wechat_allowed_users:
return True
return False
if platform == "kakao":
if sender_id in self.config.kakao_allowed_users:
return True
return False
# Unknown platform: trust only admins
return False
# --- Handlers ---
async def _handle_status(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
health = await self.client.get_health()
queue = await self.client.get_prompt_queue()
# New standardized response handling
health_ok = health.get("ok")
status_icon = "Online" if health_ok else "Offline"
details = []
if health_ok:
data = health.get("data", {})
stats = data.get("stats", {})
details.append(f"Logs: {stats.get('logs_processed', 0)}")
details.append(f"Errors: {stats.get('errors_captured', 0)}")
q_res = queue.get("data", {})
q_rem = q_res.get("exec_info", {}).get("queue_remaining", 0)
details.append(f"Queue: {q_rem}")
else:
details.append(f"Error: {health.get('error')}")
return CommandResponse(
text=f"[{status_icon}] System Status\n"
+ "\n".join(f"- {d}" for d in details)
)
def _require_admin_token_configured(self) -> Optional[CommandResponse]:
"""
F32 WP3: Check if admin token is configured before running admin commands.
Fail-fast with clear error message instead of 403/500 later.
IMPORTANT (recurring CI failure mode):
- Admin-only commands are gated by BOTH:
(1) sender is an admin user, AND
(2) the connector admin token is configured (OPENCLAW_CONNECTOR_ADMIN_TOKEN).
- Unit tests that exercise admin command handlers MUST set `config.admin_token`,
otherwise they will correctly receive the config error response.
"""
if not self.config.admin_token:
return CommandResponse(
text="[Error] Admin token not configured. Set OPENCLAW_CONNECTOR_ADMIN_TOKEN and restart connector."
)
return None
async def _handle_run(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(
text="Usage: /run <template_id> [prompt text] [key=value ...] [--approval]"
)
# Parse flags
explicit_approval = False
clean_args = []
for arg in args:
if arg in ("--require-approval", "--approval", "-a"):
explicit_approval = True
else:
clean_args.append(arg)
if not clean_args:
return CommandResponse(text="Usage: /run <template_id> ...")
template_id = clean_args[0]
inputs: Dict[str, str] = {}
free_text_parts: List[str] = []
for arg in clean_args[1:]:
if "=" in arg:
k, v = arg.split("=", 1)
inputs[k.strip()] = v.strip()
else:
free_text_parts.append(arg)
# If user provided free text without key=value, treat it as the prompt.
# We map it to a best-effort prompt key (prefers template metadata if available).
if free_text_parts:
prompt_key = await self._resolve_prompt_key(template_id)
if prompt_key not in inputs:
inputs[prompt_key] = " ".join(free_text_parts).strip()
elif self.config.debug:
logger.info(
"DEBUG /run free-text ignored (prompt key already set): %s",
prompt_key,
)
# NOTE: Debug-only payload logging for troubleshooting prompt mismatches.
# Enable with OPENCLAW_CONNECTOR_DEBUG=1 to log template_id + inputs.
if self.config.debug:
logger.info(
"DEBUG /run payload: template=%s inputs=%s approval_flag=%s trusted=%s",
template_id,
inputs,
explicit_approval,
self._is_trusted(req),
)
trusted = self._is_trusted(req)
require_approval = explicit_approval or (not trusted)
res = await self.client.submit_job(
template_id, inputs, require_approval=require_approval
)
if res.get("ok"):
data = res.get("data", {})
trace_id = data.get("trace_id", "unknown")
if data.get("pending"):
approval_id = data.get("approval_id", "unknown")
msg = f"[Approval Requested]\nID: {approval_id}\nTrace: {trace_id}"
if "expires_at" in data:
msg += f"\nExpires: {data['expires_at']}"
if self.poller:
# IMPORTANT:
# For untrusted users, approvals are done in the OpenClaw UI.
# We must start tracking the approval_id so we can map
# approval_id -> executed_prompt_id later and auto-deliver images.
self.poller.track_approval(
approval_id, req.platform, req.channel_id, req.sender_id
)
return CommandResponse(text=msg)
else:
prompt_id = data.get("prompt_id", "unknown")
if self.poller:
self.poller.track_job(
prompt_id, req.platform, req.channel_id, req.sender_id
)
return CommandResponse(
text=f"[Job Submitted]\nID: {prompt_id}\nTemplate: {template_id}\nTrace: {trace_id}"
)
else:
err = res.get("error", "Unknown error")
return CommandResponse(text=f"[Submission Failed] Reason: {err}")
async def _resolve_prompt_key(self, template_id: str) -> str:
"""
Best-effort prompt key resolution.
Prefer template metadata (allowed_inputs), then fall back to common names.
"""
meta = await self._get_template_meta(template_id)
allowed = meta.get("allowed_inputs") or []
# If template explicitly declares a single input, use it.
if isinstance(allowed, list) and len(allowed) == 1:
return str(allowed[0])
preferred = ("positive_prompt", "prompt", "text", "positive", "caption")
if isinstance(allowed, list):
for key in preferred:
if key in allowed:
return key
# Default fallback
return "positive_prompt"
async def _get_template_meta(self, template_id: str) -> Dict[str, Any]:
if template_id in self._template_meta_cache:
return self._template_meta_cache[template_id]
try:
res = await self.client.get_templates()
if res.get("ok"):
for item in res.get("templates", []) or []:
if item.get("id") == template_id:
self._template_meta_cache[template_id] = item
return item
except Exception as e:
if self.config.debug:
logger.info(f"DEBUG template meta fetch failed: {e}")
return {}
async def _handle_interrupt(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
# Remediation: Global Interrupt
res = await self.client.interrupt_output()
if res.get("ok"):
return CommandResponse(text="[Stop] Global Interrupt sent to ComfyUI.")
else:
return CommandResponse(text=f"[Stop Failed] {res.get('error')}")
async def _handle_approvals_list(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_approvals()
if not res.get("ok"):
return CommandResponse(
text=f"[Error] Failed to list approvals: {res.get('error')}"
)
items = res.get("items", [])
if not items:
return CommandResponse(text="No pending approvals.")
pending_count = res.get("pending_count")
lines = []
for i in items:
# IMPORTANT (stability): the backend approval schema uses:
# `approval_id`, `template_id`, `status`, `requested_by`, `source`.
# Do not “simplify” these keys to `id/description/requester` unless you also
# update the backend API + all tests. This mismatch previously caused silent
# bad output and brittle regressions.
approval_id = i.get("approval_id") or i.get("id") or "unknown"
template_id = i.get("template_id") or "unknown"
status = i.get("status") or "unknown"
requested_by = i.get("requested_by") or "unknown"
source = i.get("source") or "unknown"
lines.append(
f"- {approval_id} [{status}] template={template_id} by={requested_by} source={source}"
)
header = "Pending Approvals"
if isinstance(pending_count, int):
header += f" ({pending_count})"
return CommandResponse(text=header + ":\n" + "\n".join(lines))
async def _handle_approve(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /approve <id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
# Assuming auto_execute=True by default for chat logic
res = await self.client.approve_request(args[0], auto_execute=True)
if not res.get("ok"):
return CommandResponse(text=f"[Failed] {res.get('error')}")
data = res.get("data", {})
msg = f"[Approved] {args[0]}"
# Phase 4: Show execution result
if "prompt_id" in data:
pid = data["prompt_id"]
msg += f"\nExecuted: {pid}"
if self.poller:
# Approval request might have come from different flow, but usually user invoking /approve
# wants the result. Using current req context is safest assumption for "ChatOps".
self.poller.track_job(pid, req.platform, req.channel_id, req.sender_id)
elif data.get("executed") is False:
msg += "\n(Not Executed)"
if err := data.get("execution_error"):
msg += f"\nError: {err}"
return CommandResponse(text=msg)
async def _handle_reject(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /reject <id> [reason]")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
reason = " ".join(args[1:]) if len(args) > 1 else "Rejected via chat"
res = await self.client.reject_request(args[0], reason)
if not res.get("ok"):
return CommandResponse(text=f"[Failed] {res.get('error')}")
return CommandResponse(text=f"[Rejected] {args[0]}")
async def _handle_schedules_list(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_schedules()
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
scheds = res.get("schedules", [])
if not scheds:
return CommandResponse(text="No schedules found.")
lines = []
for s in scheds:
status = "+" if s.get("enabled") else "-"
lines.append(
f"[{status}] {s.get('id')}: {s.get('cron')} - {s.get('template_id')}"
)
return CommandResponse(text="Schedules:\n" + "\n".join(lines))
async def _handle_schedule_subcommand(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if len(args) < 2:
return CommandResponse(text="Usage: /schedule <run|toggle> <id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
sub = args[0].lower()
sid = args[1]
if sub == "run":
res = await self.client.run_schedule(sid)
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
return CommandResponse(text=f"[Success] Schedule {sid} triggered manually.")
else:
return CommandResponse(text="Not implemented yet.")
async def _handle_help(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
return CommandResponse(
text=(
"OpenClaw Connector\n"
"/status - Check system health and queue\n"
"/run <template> [prompt] [k=v] - Run a generation (trusted users auto-exec; others require approval)\n"
"/stop - Global Interrupt (Admin)\n"
"/history <id> - Job details\n"
"/jobs - Queue summary\n"
"Admin Only:\n"
"/approvals - List pending approvals\n"
"/approve <id>, /reject <id>\n"
"/schedules, /schedule run <id>\n"
"/trace <id> - Execution trace"
)
)
async def _handle_history(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /history <prompt_id>")
res = await self.client.get_history(args[0])
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
# Simple format
data = res.get("data", {})
status = data.get("status", {}).get("status_str", "unknown")
# Assuming backend returns a structure we can summarise
return CommandResponse(
text=f"Job {args[0]}: {status}\nFull details: not implemented in connector view yet."
)
async def _handle_trace(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
if not args:
return CommandResponse(text="Usage: /trace <prompt_id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_trace(args[0])
if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}")
# Dump trace
return CommandResponse(
text=f"Trace {args[0]}: {str(res.get('data'))[:1000]}..."
)
async def _handle_jobs(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
# Try native /openclaw/jobs first
res = await self.client.get_jobs()
if res.get("ok"):
# Format nice summary
return CommandResponse(text=f"Default Jobs View: {res.get('data')}")
# Fallback: Queue
q = await self.client.get_prompt_queue()
if q.get("ok"):
rem = q.get("data", {}).get("exec_info", {}).get("queue_remaining", "?")
return CommandResponse(text=f"[Fallback] Queue Remaining: {rem}")
return CommandResponse(text="[Error] Could not fetch jobs or queue.")
# -------------------------------------------------------------------------
# F30: Chat LLM Assistant
# -------------------------------------------------------------------------
async def _handle_chat(
self, req: CommandRequest, args: List[str]
) -> CommandResponse:
"""
/chat [subcommand] <message>
Subcommands: run, template, status
Default: general chat
Security: Never auto-executes commands. Only suggests command text.
"""
llm = LLMClient(self.client)
if not await llm.is_configured():
return CommandResponse(
text="[Chat Error] LLM not configured. Configure in OpenClaw Settings."
)
# Parse subcommand
if not args:
return CommandResponse(
text="Usage: /chat <message> or /chat run|template|status <request>"
)
subcommand = args[0].lower()
message = " ".join(args[1:]) if len(args) > 1 else ""
trust_level = "TRUSTED" if self._is_trusted(req) else "UNTRUSTED"
if subcommand == "run":
return await self._chat_run(llm, message, trust_level)
elif subcommand == "template":
return await self._chat_template(llm, message)
elif subcommand == "status":
return await self._chat_status(llm)
else:
# General chat: first word is part of message
full_message = " ".join(args)
return await self._chat_general(llm, full_message, trust_level)
async def _chat_general(
self, llm: LLMClient, message: str, trust_level: str
) -> CommandResponse:
"""General chat with assistant."""
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level=trust_level)
response = await llm.chat(system_prompt, message)
return CommandResponse(text=response)
async def _chat_run(
self, llm: LLMClient, request: str, trust_level: str
) -> CommandResponse:
"""Suggest a /run command based on user request."""
if not request:
return CommandResponse(
text="Usage: /chat run <description of what you want>"
)
# Get available templates (simplified - could fetch from API)
templates = "txt2img, img2img, upscale (examples)"
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level=trust_level)
user_prompt = f"""User wants to run a generation. Suggest a `/run` command.
Request: {request}
Available templates: {templates}
Trust level: {trust_level}
Remember: {"add --approval flag" if trust_level == "UNTRUSTED" else "no --approval needed"}.
Output only the command in a code block."""
response = await llm.chat(system_prompt, user_prompt)
return CommandResponse(text=response)
async def _chat_template(self, llm: LLMClient, request: str) -> CommandResponse:
"""Generate a template JSON suggestion."""
if not request:
return CommandResponse(text="Usage: /chat template <description>")
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level="N/A")
user_prompt = f"""Generate a workflow template JSON for this request:
Request: {request}
Output:
1. Suggested filename
2. Template JSON in a code block
Keep it minimal."""
response = await llm.chat(system_prompt, user_prompt)
return CommandResponse(text=response)
async def _chat_status(self, llm: LLMClient) -> CommandResponse:
"""Summarize system status using LLM."""
# Fetch status data
health = await self.client.get_health()
jobs = await self.client.get_jobs()
queue = await self.client.get_prompt_queue()
status_data = {
"health": health.get("data", {}) if health.get("ok") else "unavailable",
"jobs": jobs.get("data", {}) if jobs.get("ok") else "unavailable",
"queue": queue.get("data", {}) if queue.get("ok") else "unavailable",
}
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level="N/A")
user_prompt = CHAT_STATUS_PROMPT.format(status_data=status_data)
response = await llm.chat(system_prompt, user_prompt)
return CommandResponse(text=response)
# S44/R97: Semantic Guards
self.semantic_guard = SemanticGuard()
self.command_firewall = CommandFirewall()
+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)
+220
View File
@@ -0,0 +1,220 @@
"""
S44 Semantic Guard Core.
Implements semantic policy controls for connector chat:
- intent classification and gating.
- risk scoring for injection/jailbreak patterns.
- structured output and SAFE_REPLY sanitization.
"""
import enum
import logging
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Tuple
logger = logging.getLogger(__name__)
_CODE_BLOCK_RE = re.compile(r"```(?:[a-zA-Z0-9_+-]+)?\s*(.*?)\s*```", re.DOTALL)
_COMMAND_LINE_RE = re.compile(r"(?m)^\s*/[a-zA-Z0-9_-]+(?:\s+.*)?$")
_DANGEROUS_TOKEN_RE = re.compile(r"[;|`]|\$\(")
class GuardMode(enum.Enum):
OFF = "off"
AUDIT = "audit"
ENFORCE = "enforce"
class GuardAction(enum.Enum):
ALLOW = "allow"
SAFE_REPLY = "safe_reply_only"
FORCE_APPROVAL = "force_approval"
DENY = "deny"
@dataclass
class GuardDecision:
action: GuardAction
risk_score: float
reason: str
code: str = "semantic_allow"
severity: str = "info"
metadata: Dict[str, Any] = field(default_factory=dict)
def to_contract(self) -> Dict[str, Any]:
return {
"code": self.code,
"severity": self.severity,
"action": self.action.value,
"reason": self.reason,
}
class IntentGate:
"""Classifies user intent from chat messages."""
_EXPLICIT_SUBCOMMANDS = {"run", "template", "status"}
def classify(self, message: str) -> str:
msg = (message or "").lower().strip()
if msg.startswith("/chat "):
parts = msg.split(" ", 2)
if len(parts) > 1 and parts[1] in self._EXPLICIT_SUBCOMMANDS:
return parts[1]
if any(k in msg for k in ("generate", "create", "make", "draw", "run")):
return "run"
if any(k in msg for k in ("status", "health", "queue", "jobs")):
return "status"
if any(k in msg for k in ("template", "json", "workflow")):
return "template"
return "general"
class RiskScorer:
"""Scores message risk against adversarial patterns."""
_JAILBREAK_PATTERNS = (
"ignore previous",
"ignore all",
"system prompt",
"developer message",
"override policy",
)
def score(self, message: str) -> Tuple[float, List[str]]:
msg = (message or "").lower()
score = 0.0
reasons: List[str] = []
if any(p in msg for p in self._JAILBREAK_PATTERNS):
score += 0.8
reasons.append("jailbreak_pattern")
if _DANGEROUS_TOKEN_RE.search(msg):
score += 0.5
reasons.append("shell_injection_char")
if len(message or "") > 2000:
score += 0.3
reasons.append("excessive_length")
return min(score, 1.0), reasons
class SemanticGuard:
"""Main entry point for semantic policy enforcement."""
def __init__(self, mode: str = "enforce", risk_threshold: float = 0.7):
self.mode = GuardMode(mode.lower())
self.risk_threshold = risk_threshold
self.intent_gate = IntentGate()
self.risk_scorer = RiskScorer()
def evaluate_request(self, message: str, context: Dict[str, Any]) -> GuardDecision:
if self.mode == GuardMode.OFF:
return GuardDecision(
action=GuardAction.ALLOW,
risk_score=0.0,
reason="guard_off",
code="semantic_guard_off",
severity="info",
)
intent = self.intent_gate.classify(message)
risk_score, risk_reasons = self.risk_scorer.score(message)
reasons_joined = ", ".join(risk_reasons) if risk_reasons else "none"
action = GuardAction.ALLOW
reason = "safe"
code = "semantic_allow"
severity = "info"
if risk_score >= self.risk_threshold:
action = GuardAction.DENY
reason = f"risk_threshold_exceeded: {reasons_joined}"
code = "semantic_risk_high"
severity = "high"
elif 0.4 <= risk_score < self.risk_threshold:
if intent == "run":
action = GuardAction.FORCE_APPROVAL
reason = f"risk_elevated: {reasons_joined}"
code = "semantic_risk_medium_force_approval"
severity = "medium"
else:
action = GuardAction.SAFE_REPLY
reason = f"risk_elevated_safety_enforced: {reasons_joined}"
code = "semantic_risk_medium_safe_reply"
severity = "medium"
if self.mode == GuardMode.AUDIT:
logger.info(
"S44 audit decision: action=%s score=%.2f reason=%s",
action.value,
risk_score,
reason,
)
return GuardDecision(
action=GuardAction.ALLOW,
risk_score=risk_score,
reason=f"audit_mode_({reason})",
code="semantic_audit_observe",
severity="info",
metadata={
"intent": intent,
"risk_reasons": list(risk_reasons),
"would_action": action.value,
"trust": context.get("trust"),
},
)
return GuardDecision(
action=action,
risk_score=risk_score,
reason=reason,
code=code,
severity=severity,
metadata={
"intent": intent,
"risk_reasons": list(risk_reasons),
"trust": context.get("trust"),
},
)
def validate_output(
self,
response_text: str,
intent: str,
action: GuardAction = GuardAction.ALLOW,
) -> str:
if self.mode == GuardMode.OFF:
return response_text
text = response_text or ""
if text.count("```") % 2 != 0:
raise ValueError("unclosed_code_block")
if intent == "run":
cmd = self._extract_command_candidate(text)
if not cmd:
raise ValueError("run_output_missing_command")
if action == GuardAction.SAFE_REPLY:
return self._sanitize_safe_reply(text)
return text
def _extract_command_candidate(self, text: str) -> str:
match = _CODE_BLOCK_RE.search(text)
if match:
return match.group(1).strip()
return text.strip()
def _sanitize_safe_reply(self, text: str) -> str:
# CRITICAL: SAFE_REPLY must remove executable hints to preserve no-auto-exec invariants.
sanitized = _CODE_BLOCK_RE.sub("[command removed by policy]", text)
sanitized = _COMMAND_LINE_RE.sub("[command removed by policy]", sanitized)
return sanitized.strip()
+43
View File
@@ -685,6 +685,49 @@ class RetryPolicy:
return True
# ---------------------------------------------------------------------------
# R93 — Relay Response Classifier (session invalidation)
# ---------------------------------------------------------------------------
class RelayStatus(str, enum.Enum):
"""Classification of relay/outbound HTTP response status."""
OK = "ok"
TRANSIENT = "transient" # retriable (408, 429, 5xx)
AUTH_INVALID = "auth_invalid" # 401/410 — credential revoked/expired
SERVER_ERROR = "server_error" # non-retriable server error
class RelayResponseClassifier:
"""
Classify HTTP response codes for connector relay/send paths.
auth_invalid (401, 410) signals permanent credential failure.
Connector platforms should stop retrying and mark the session
as invalid (require re-pair) when auth_invalid is returned.
"""
AUTH_INVALID_CODES: frozenset = frozenset({401, 410})
TRANSIENT_CODES: frozenset = frozenset({408, 429, 500, 502, 503, 504})
@classmethod
def classify(cls, status_code: int) -> RelayStatus:
"""Classify an HTTP status code."""
if 200 <= status_code < 300:
return RelayStatus.OK
if status_code in cls.AUTH_INVALID_CODES:
return RelayStatus.AUTH_INVALID
if status_code in cls.TRANSIENT_CODES:
return RelayStatus.TRANSIENT
return RelayStatus.SERVER_ERROR
@classmethod
def is_auth_invalid(cls, status_code: int) -> bool:
"""Return True if status code indicates credential invalidation."""
return status_code in cls.AUTH_INVALID_CODES
# ---------------------------------------------------------------------------
# Error Envelope (normalized across all transports)
# ---------------------------------------------------------------------------
+36
View File
@@ -0,0 +1,36 @@
{
"version": 1,
"default_profile": "SDXL-v1",
"profiles": [
{
"id": "SDXL-v1",
"version": "1.0",
"label": "SDXL 1.0 Base",
"description": "Standard SDXL profile",
"prompt_guidance": "Width/height should target SDXL-friendly resolutions such as 1024x1024. Keep CFG around 7.0 and steps around 20-30 unless requirements strongly justify a deviation.",
"defaults": {
"width": 1024,
"height": 1024,
"steps": 24,
"cfg": 7.0,
"sampler_name": "euler",
"scheduler": "normal"
}
},
{
"id": "Flux-Dev",
"version": "1.0",
"label": "Flux Dev",
"description": "Flux Dev profile (high steps, lower cfg)",
"prompt_guidance": "Flux variants generally prefer lower CFG than SDXL. Keep CFG around 1.0-4.0 and use moderately higher steps only when needed.",
"defaults": {
"width": 1024,
"height": 1024,
"steps": 28,
"cfg": 3.5,
"sampler_name": "euler",
"scheduler": "normal"
}
}
]
}
+24
View File
@@ -0,0 +1,24 @@
You are an expert stable diffusion prompt engineer.
Your goal is to generate a detailed JSON plan for an image generation job based on the user's requirements.
Output strict JSON only. No markdown fences.
Expected JSON structure:
{
"positive_prompt": "string",
"negative_prompt": "string",
"params": {
"width": int,
"height": int,
"steps": int,
"cfg": float,
"sampler_name": "euler" | "dpmpp_2m" | "...",
"scheduler": "normal" | "karras" | "..."
}
}
Constraint Guidelines for {{profile_id}} ({{profile_label}}):
- {{profile_description}}
- {{prompt_guidance}}
- Preferred defaults JSON: {{defaults_json}}
Never return commentary outside the JSON object.
+99
View File
@@ -0,0 +1,99 @@
# Release Checklist (DoD)
This document contains the authoritative checklist for releasing **ComfyUI-OpenClaw**.
A release candidate must pass **Gate A** to be considered for Public Release v1.
If the deployment enables remote control or bridge features, it must also pass **Gate B**.
> [!IMPORTANT]
> The validation workflow in `tests/TEST_SOP.md` is **mandatory** for all releases.
---
## Gate A: Public Release v1 Baseline (Required)
**Goal**: Safe-by-default for internet-exposed deployments (assuming they follow the deployment recipes in `docs/deploy/`).
### 1. Security & configuration
- [ ] **Admin Boundaries**:
- [ ] Server-side admin write boundary uses `OPENCLAW_ADMIN_TOKEN` (legacy `MOLTBOT_ADMIN_TOKEN`).
- [ ] Connector admin command paths use `OPENCLAW_CONNECTOR_ADMIN_TOKEN`, and must match server admin token when server admin auth is enabled.
- [ ] **Connector Ingress Defaults**: Platform adapters remain disabled unless required token/enable vars are configured (Telegram/Discord/LINE/WhatsApp/WeChat/Kakao/Slack/Feishu).
- [ ] **Connector Allowlists (Strict Posture)**: In `public` deployment or `hardened` runtime posture, active connector platforms must have allowlist coverage before startup (fail-closed; public check code `DP-PUBLIC-009`).
- [ ] **Connector Replay & Visibility**: Duplicate committed connector events are no-ops, retryable pre-commit failures can be retried, and text-only reply suppression does not hide approval/action controls.
- [ ] **Observability**: `/openclaw/logs/tail` and `/openclaw/config` require `OPENCLAW_OBSERVABILITY_TOKEN` (legacy: `MOLTBOT_OBSERVABILITY_TOKEN`) if accessed remotely, or are loopback-only.
- [ ] **SSRF**: LLM `base_url` defaults to known providers. Custom public URLs require `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1` or explicit allowlist; private/reserved IP targets require the scoped LLM private-network setting or the broader `OPENCLAW_ALLOW_INSECURE_BASE_URL=1` override.
- [ ] **Public Boundary Contract (S69)**: for `OPENCLAW_DEPLOYMENT_PROFILE=public`, set `OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1` only after reverse-proxy path allowlist + network ACL deny ComfyUI-native high-risk routes.
- [ ] **Budgets**: `OPENCLAW_MAX_INFLIGHT_SUBMITS_TOTAL` (concurrency) and `OPENCLAW_MAX_RENDERED_WORKFLOW_BYTES` (payloads) are enforced.
- [ ] **External Tools**: external tool execution is disabled unless explicitly required; if enabled, the package-owned/default or custom allowlist is reviewed, sandbox policy is explicit for hardened posture, and sandbox/interpreter/timeout/workspace diagnostics are deterministic.
- [ ] **Contracts**: API endpoints match `docs/release/api_contract.md`; Configuration follows `docs/release/config_secrets_contract.md`.
### 2. Documentation & Recipes
- [ ] **Deployment**: `docs/deploy/` contains recipes for:
- [ ] Local-only (Default)
- [ ] Tailscale (Recommended Remote)
- [ ] LAN (Restricted)
- [ ] **Security**: [SECURITY.md](SECURITY.md) is up-to-date and linked from README.
- [ ] **Feature Flags**: `docs/release/feature_flags.md` accurately reflects the codebase defaults.
### 3. Validation (Must Pass)
Run the complete OS-specific regression gate:
```powershell
# Windows
powershell -File scripts/run_full_tests_windows.ps1
```
```bash
# Linux / WSL
bash scripts/run_full_tests_linux.sh
```
These scripts execute the authoritative `tests/TEST_SOP.md` sequence, including fresh
lockfile reconciliation with `npm ci`, the blocking
`npm audit --audit-level=high` check across production and development dependencies,
secret scanning, pre-commit hooks, governance and backend lanes, adaptive adversarial
validation, and frontend Playwright E2E. A standalone `npm test` result is not a
substitute for the complete release gate.
If staged/manual execution is required, follow the explicit command order in
`tests/TEST_SOP.md`; do not maintain a shortened release-only sequence here.
---
## Gate B: Bridge / Remote Control Safety (Conditional)
**Goal**: Safe operation when `OPENCLAW_BRIDGE_ENABLED=1` or remote commands are active.
- [ ] **Explicit Enable**: Bridge features are off unless `OPENCLAW_BRIDGE_ENABLED=1` is set.
- [ ] **Auth**: Bridge endpoints require `OPENCLAW_BRIDGE_DEVICE_TOKEN` (legacy alias supported) and device pairing/scope checks.
- [ ] **CSRF**: State-changing endpoints (admin/bridge) enforce Origin checks or require Token on loopback.
- [ ] **Callback Safety**: Delivery targets are validated against DNS/IP allowlists (no internal network access).
- [ ] **DoD**: Operator docs include "Red Lines" (never expose Bridge port directly to internet without auth).
---
## Gate C: Supply Chain Provenance (R100)
**Goal**: Ensure integrity and traceability of release artifacts.
- [ ] **Provenance Generation**: Run `python scripts/generate_provenance.py dist/ dist/provenance.json` to create manifest and SBOM.
- [ ] **Verification**: Run `python scripts/verify_provenance.py dist/ dist/provenance.json` on staging environment to verify integrity and completeness.
- [ ] **Completeness**: Ensure `provenance.json` contains SHA256 for all distributed wheels/zips and correct git commit hash.
---
## Release Metadata
- [ ] **Version**: `pyproject.toml` version matches git tag.
- [ ] **Changelog**: Updated `CHANGELOG.md` (if present) with user-facing changes.
- [ ] **Migration**: If config/storage schema changed, explicit migration notes are in `docs/migration/` (Optional).
---
## Sign-off
- [ ] **Gate A Passed**: (Date/Initials)
- [ ] **Gate B Passed** (if applicable): (Date/Initials)
+316
View File
@@ -0,0 +1,316 @@
# Security Policy
## Quick Links
- Deployment profiles and checklists: [Security Deployment Guide](security_deployment_guide.md)
- Runtime startup hardening behavior: [Runtime Hardening and Startup](runtime_hardening_and_startup.md)
- Pre-exposure checklist: [Security Checklist](security_checklist.md)
- Deployment self-check command:
- `python scripts/check_deployment_profile.py --profile local|lan|public`
## Supported Versions
Only the latest version of ComfyUI-OpenClaw is supported for security updates.
| Version | Supported |
| ------------------------ | ------------------ |
| Latest published release | :white_check_mark: |
| All earlier releases | :x: |
## Reporting a Vulnerability
Please report security vulnerabilities by creating a **private** issue on GitHub if possible, or contact the maintainers directly. Do not open public issues for sensitive security flaws.
### Disclosure Workflow and SLA
Private reporting workflow:
1. Submit a private report with repro steps, affected version, and impact.
2. Maintainers triage and confirm impact.
3. Fix and mitigation guidance are prepared.
4. Advisory is published with affected-range + fixed-version metadata.
Target SLA:
- initial acknowledgement: within 72 hours
- triage status update: within 7 calendar days
- coordinated disclosure target: within 30 days after confirmed impact
- timeline may be extended for high-complexity fixes; status updates are still required
Advisory publication policy:
- advisories are tracked in `docs/release/security_advisories.json`
- Security Doctor surfaces advisory applicability (`affected`, `mitigation`) for the running version
- high-severity affected posture should be treated as priority upgrade work
### Telemetry Opt-out Contract (S9)
Security anomaly telemetry is minimal and audit-focused by default. If operators must disable this emission path, use:
```bash
export OPENCLAW_TELEMETRY_OPT_OUT=1
# Legacy compatibility:
# export MOLTBOT_TELEMETRY_OPT_OUT=1
```
Trade-off:
- with opt-out enabled, security anomaly audit events are not emitted
- use only when required by policy/privacy constraints and keep compensating controls in place
---
# Safe Deployment Guide
OpenClaw is a powerful extension that interacts with LLMs and the filesystem (via ComfyUI). **By default, it is designed for local (localhost) use.** Exposing it to the public internet requires careful configuration.
## ⚠️ Warning
**Do NOT expose your ComfyUI instance directly to the public internet** (for example via direct port-forwarding) without a secure reverse proxy or VPN.
## Shared Listener Boundary (Critical)
OpenClaw and ComfyUI share the same HTTP listener/port.
This means:
1. Protecting `/openclaw/*` routes does not automatically protect ComfyUI-native routes.
2. Public reverse-proxy policy must enforce path-level allow/deny and network ACL boundaries.
3. Public posture requires explicit operator acknowledgement that these boundaries are in place.
High-risk ComfyUI-native routes to deny on public edges unless intentionally required:
- `/prompt`, `/history*`, `/view*`, `/upload*`, `/ws`
- `/api/prompt`, `/api/history*`, `/api/view*`, `/api/upload*`, `/api/ws`
## Recommended Deployment
1. **Localhost (Default)**: Use on your own machine. No extra config needed.
2. **VPN / Tailscale**: Best for private remote access.
3. **SSH Tunnel**: `ssh -L 8188:localhost:8188 user@remote`
## Reverse Proxy Setup (Advanced)
If you must expose OpenClaw via a reverse proxy (Nginx, Caddy, Cloudflare Tunnel), you MUST configure the following:
### 1. Token Boundaries
Logs (`/openclaw/logs/tail`) and Config (`/openclaw/config`) are restricted to loopback clients by default. (Legacy `/moltbot/*` endpoints are also supported.) To allow remote access via proxy, set a secure token:
```bash
export OPENCLAW_OBSERVABILITY_TOKEN="your-secure-random-token-here"
export OPENCLAW_ADMIN_TOKEN="your-secure-random-admin-token-here"
# Legacy compatibility (optional):
# export MOLTBOT_OBSERVABILITY_TOKEN="your-secure-random-token-here"
# export MOLTBOT_ADMIN_TOKEN="your-secure-random-admin-token-here"
```
Then configure your proxy or client to send the header `X-OpenClaw-Obs-Token: your-secure-random-token-here` (legacy: `X-Moltbot-Obs-Token`).
### 1.1 Reasoning and Internal Content Redaction Boundary
Operator-facing payloads strip provider reasoning / thinking traces and explicitly marked internal maintenance/helper prompt content by default across:
- assist responses
- event / SSE payloads
- trace responses
- callback payloads
- connector trace/debug replies
- audit event payload/meta fields
Internal maintenance/helper prompt content has no public or debug reveal path. Privileged reasoning reveal is limited to provider reasoning / thinking traces only.
### 1.2 Reasoning Debug Reveal Boundary (Local-only)
There is a privileged local-debug reveal path for troubleshooting, but it is fail-closed unless **all** of the following are true:
- request explicitly opts in via `X-OpenClaw-Debug-Reveal-Reasoning: 1` or `?debug_reasoning=1`
- server-side debug switch is enabled with `OPENCLAW_DEBUG_REASONING_REVEAL=1`
- request is admin-authorized
- client IP resolves to loopback
- deployment profile is `local` or `lan`
- runtime profile is not hardened
Operational rules:
- do not enable `OPENCLAW_DEBUG_REASONING_REVEAL` on public deployments
- treat any successful reveal as privileged debugging activity and review related audit events (`reasoning.debug_reveal`)
- the reveal path appends debug reasoning payloads only for the privileged request; default operator outputs remain redacted
### 2. Trusted Proxy Attribution
If using a reverse proxy, OpenClaw needs to know the *real* client IP for rate limiting enforcement.
Configure your proxy to send `X-Forwarded-For`, then configure trusted proxy ranges:
```bash
export OPENCLAW_TRUST_X_FORWARDED_FOR=1
export OPENCLAW_TRUSTED_PROXIES="127.0.0.1,10.0.0.0/8"
# Legacy compatibility (optional):
# export MOLTBOT_TRUST_X_FORWARDED_FOR=1
# export MOLTBOT_TRUSTED_PROXIES="127.0.0.1,10.0.0.0/8"
```
### 3. Public Profile Boundary Acknowledgement
For public profile deployments, you must explicitly acknowledge that reverse-proxy path controls and network ACL boundaries are already enforced:
```bash
export OPENCLAW_DEPLOYMENT_PROFILE=public
export OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1
# Legacy compatibility (optional):
# export MOLTBOT_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1
```
If this acknowledgement is missing in public profile, deployment profile checks fail with `DP-PUBLIC-008`.
### 4. Connector Allowlist Fail-Closed (Public/Hardened)
Connector ingress posture is fail-closed in strict profiles:
- if connector platform ingress is active (Telegram/Discord/LINE/WhatsApp/WeChat/Kakao/Slack/Feishu)
- and matching allowlist variables are missing
- startup/deployment checks fail closed (`DP-PUBLIC-009` for public profile)
Operational requirement:
- never enable connector platform tokens/enable flags in public or hardened posture without platform allowlist coverage.
### 4.1 Interactive Callback Contract Baseline (Connector)
For interactive connector callbacks (actions/modals/workflow style payloads), the shared callback contract is fail-closed by default:
- signed envelope is required (`signature`, `timestamp`, `request_id`, `workspace_id`, `action_type`, `payload_hash`)
- stale timestamp, replay/duplicate request ID, payload-hash mismatch, or unknown action type are rejected
- workspace-to-installation resolution is fail-closed on missing/ambiguous/inactive/stale-token-ref binding
- policy mapping is explicit (`public`/`run`/`admin`) and untrusted `run` callbacks degrade to approval instead of direct privileged execution
- duplicate committed connector events are acknowledged without re-running completed actions, while retryable failures before action/delivery commit can be retried
- reply visibility is policy-driven; text-only silent/internal/tool-only/no-mention replies can be suppressed without suppressing approval cards, action buttons, allowlist checks, or callback replay checks
Operational note:
- treat callback decision codes/audit trails as security evidence and investigate repeated reject patterns before enabling higher-risk interactive flows.
### 4.2 Multi-tenant Boundary Model (Fail-Closed)
When `OPENCLAW_MULTI_TENANT_ENABLED=1`, OpenClaw enforces explicit tenant boundaries across API and service paths.
Boundary rules:
- tenant context is resolved from token context and/or tenant header (`X-OpenClaw-Tenant-Id` by default)
- token/header mismatch is rejected (`tenant_mismatch`)
- connector installation diagnostics/resolution, config read/write, approvals, presets, template visibility, and secret lookup are tenant-scoped
- execution budgets add per-tenant concurrency enforcement (`OPENCLAW_MAX_INFLIGHT_SUBMITS_PER_TENANT`)
Compatibility note:
- current admin/API handlers default missing tenant context to `default` for backward compatibility; stricter caller paths can enforce explicit tenant presence.
Compatibility toggles (use only during migration windows):
- `OPENCLAW_MULTI_TENANT_ALLOW_DEFAULT_FALLBACK=1`
- `OPENCLAW_MULTI_TENANT_ALLOW_CONFIG_FALLBACK=1`
- `OPENCLAW_MULTI_TENANT_ALLOW_LEGACY_SECRET_FALLBACK=1`
Security recommendation:
- keep all fallback toggles disabled for steady-state multi-tenant production.
### 4.3 Optional Local Secret-manager Path (1Password CLI)
If `OPENCLAW_1PASSWORD_ENABLED=1`, provider key lookup can use local 1Password CLI as an optional backend source.
Fail-closed requirements:
- `OPENCLAW_1PASSWORD_ALLOWED_COMMANDS` must include the command basename in use
- `OPENCLAW_1PASSWORD_VAULT` and `OPENCLAW_1PASSWORD_FIELD` must be valid
- `OPENCLAW_1PASSWORD_ITEM_TEMPLATE` must include `{provider}`
- when multi-tenant mode is enabled, the template must also include `{tenant}`
Operational note:
- this path remains backend-only; frontend surfaces stay secret-blind.
### 5. Startup Gate Behavior
Startup security gates are fail-closed. Fatal startup gate/bootstrap failures abort route/worker registration and do not continue in a partial state.
Recommended preflight:
```bash
python scripts/check_deployment_profile.py --profile public --strict-warnings
```
### 6. SSRF Protection
OpenClaw validates custom LLM `base_url` settings to prevent Server-Side Request Forgery (SSRF).
* **Default**: known providers and localhost-safe paths are allowed.
* **Pinned connect contract**: on supported CPython versions (current baseline: 3.10+), the consolidated `safe_io` outbound executor dials resolved IPs directly for HTTP/HTTPS and keeps TLS `server_hostname` on the original host; the no-skip `tests.test_s70_ssrf_pinning_regression` lane is intended to fail loudly if stdlib connect behavior drifts.
* **Redirect handling**: redirect targets are revalidated against host allowlists, private/reserved-IP blocking, and pinned-connect rules before any follow-up connection is opened.
* **Custom base URL**:
- requires explicit opt-in:
```bash
export OPENCLAW_ALLOW_CUSTOM_BASE_URL=1
# Legacy compatibility (optional):
# export MOLTBOT_ALLOW_CUSTOM_BASE_URL=1
```
- use strict allowlist:
```bash
export OPENCLAW_LLM_ALLOWED_HOSTS="api.example.com,llm.example.com"
```
- `OPENCLAW_LLM_ALLOWED_HOSTS` only permits additional exact public hosts; it does not bypass the private/reserved-IP block.
- `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1` widens to any public host only.
- `allow_private_network=true` on the LLM setting allows only the configured provider `base_url` host to resolve to a private/reserved IP while keeping exact-host allowlists, scheme/port checks, and DNS pinning.
- `OPENCLAW_ALLOW_INSECURE_BASE_URL=1` is the explicit risk-acceptance override for HTTP or private/reserved IP targets.
- the same scoped/private or insecure decision is enforced consistently for config validation, `/openclaw/llm/models`, and outbound provider requests.
- wildcard values such as `OPENCLAW_LLM_ALLOWED_HOSTS="*"` are not supported.
- avoid broad bypass flags in production (`OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST`, `OPENCLAW_ALLOW_INSECURE_BASE_URL`).
### 6.1 Audit Chain Verification
OpenClaw keeps append-only audit evidence verifiable across restart and retained-log rotation.
Recommended operator check:
```bash
python scripts/verify_audit_chain.py --json
```
Notes:
- verification covers the active `audit.log` plus retained rotated segments in the state directory
- when an audit chain key is not supplied externally, OpenClaw persists a local `audit.log.key` sidecar so the retained chain stays verifiable after restart
- treat any verification failure as an integrity incident and investigate before trusting the retained audit trail
### 7. Rate Limiting
OpenClaw enforces internal rate limits:
* Webhooks: 30/min
* Logs: 60/min
* Admin: 20/min
### 8. Sidecar Bridge
OpenClaw supports a "Sidecar Bridge" for safe interaction with external bots (Discord/Slack).
* **Default**: **DISABLED**.
* **Enable**: Set `OPENCLAW_BRIDGE_ENABLED=1` (legacy `MOLTBOT_BRIDGE_ENABLED=1`).
* **Authentication**: Requires `OPENCLAW_BRIDGE_DEVICE_TOKEN` (legacy `MOLTBOT_BRIDGE_DEVICE_TOKEN`) (shared secret).
* **Network**: Bridge endpoints (`/bridge/*`) are sensitive. **Do not expose to public internet.** Use a private network (Tailscale) or restrict access via reverse proxy.
* **SSRF**: Callback delivery blocks internal IPs. To allow specific external callback hosts, set `OPENCLAW_BRIDGE_CALLBACK_HOST_ALLOWLIST` (legacy: `MOLTBOT_BRIDGE_CALLBACK_HOST_ALLOWLIST`).
## Security Checklist
* [ ] **HTTPS + Edge Auth**: reverse proxy enforces TLS and an additional auth boundary (SSO/Basic/IP ACL).
* [ ] **No direct public bind**: never expose raw ComfyUI/OpenClaw listener directly.
* [ ] **Token boundaries**: set `OPENCLAW_ADMIN_TOKEN` and `OPENCLAW_OBSERVABILITY_TOKEN` (legacy aliases acceptable).
* [ ] **Trusted proxy config**: set `OPENCLAW_TRUST_X_FORWARDED_FOR=1` and exact `OPENCLAW_TRUSTED_PROXIES`.
* [ ] **Public shared-surface ack**: for `OPENCLAW_DEPLOYMENT_PROFILE=public`, set `OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1` only after proxy path allowlist + ACL are verified.
* [ ] **Public path deny rules**: block ComfyUI-native high-risk routes and `/api/*` equivalents unless explicitly required.
* [ ] **Connector strict-posture allowlists**: if connector ingress is active in `public` or `hardened`, ensure platform allowlists are set before startup (`DP-PUBLIC-009` for public profile).
* [ ] **External tools disabled by default**: keep `OPENCLAW_ENABLE_EXTERNAL_TOOLS=0` unless there is a reviewed need; if enabled, verify the tool allowlist, sandbox policy, and deterministic sandbox/interpreter/timeout/workspace diagnostics.
* [ ] **Multi-tenant boundary (if enabled)**: enforce one canonical tenant header path through proxy/app, keep fallback toggles disabled unless a migration window is actively in progress.
* [ ] **Audit integrity check**: run `python scripts/verify_audit_chain.py --json` after restart/rotation-sensitive maintenance and confirm retained audit logs still verify cleanly.
* [ ] **1Password guardrails (if enabled)**: require command allowlist + vault/template validation; in multi-tenant mode, include `{tenant}` in item template.
* [ ] **Startup gate preflight**: run `python scripts/check_deployment_profile.py --profile public --strict-warnings`.
* [ ] **Runtime diagnostics**: review `GET /openclaw/security/doctor` before exposure.
* [ ] **Least privilege host posture**: do not run as root/Administrator.
@@ -0,0 +1,63 @@
# ADR-0001: Configuration Surface Unification
- Status: Accepted
- Date: 2026-03-07
- Owners: OpenClaw maintainers
- Related roadmap items: `R139`, phase-2 follow-up completed 2026-03-19
## Context
OpenClaw currently has distributed configuration logic across `config.py`, `services/runtime_config.py`, and selected call sites that still read env vars directly. This increases precedence drift risk and makes behavior harder to reason about.
The unification work required a phased, backward-compatible rollout rather than a single destructive rewrite.
## Decision
Adopt one authoritative layered model for runtime LLM config resolution, exposed through a unified resolver and then through a single effective-config facade consumed by compatibility APIs and downstream readers.
Layer precedence (highest to lowest):
1. `env` (`OPENCLAW_*` first, `MOLTBOT_*` fallback)
2. `runtime_override` (in-memory only; process-local)
3. `persisted` (`OPENCLAW_STATE_DIR/config.json`)
4. `default`
Key points:
- Keep env-first semantics for operational safety and backward compatibility.
- Preserve legacy key support with explicit warning behavior.
- Keep runtime overrides non-persisted and source-attributed.
## Consequences
Positive:
- Deterministic precedence and source attribution.
- Reduced duplicated merge logic in primary runtime paths.
- Safer phased migration with compatibility facade intact.
- Shared read seams make parity testing and future deprecation work narrower.
Trade-offs:
- Temporary coexistence of migrated and non-migrated call sites during phased rollout.
- Additional adapter code until follow-up phases complete.
## Rollout Plan
Phase 1 (`R139`):
- Introduce unified resolver + runtime override registry.
- Refactor `services/runtime_config.py` effective-read path to resolver-backed flow.
- Migrate core LLM call sites to stop duplicating env precedence.
- Add precedence/compatibility regression tests.
Phase 2 (completed 2026-03-19):
- Introduced `services/effective_config.py` as the supported effective-config read facade.
- Migrated remaining mixed-path readers touched by the phase onto the shared facade/compatibility shims.
- Added parity coverage so env/runtime/persisted/default precedence is asserted once at the shared seam instead of at each consumer.
Future follow-ups:
- Continue migrating any remaining direct env readers that still overlap with the runtime config contract.
- Remove obsolete adapter/shim code once migration reaches stable completion.
## Rejected Alternatives
1. Big-bang rewrite of all config readers:
- Rejected due to blast radius and rollback difficulty.
2. Keep dual systems and patch ad hoc:
- Rejected due to ongoing precedence drift and maintenance cost.
@@ -0,0 +1,96 @@
# ADR-0002: Product Boundary And Packaging Contract
- Status: Accepted
- Date: 2026-04-23
- Owners: OpenClaw maintainers
- Related roadmap items: `R160` with follow-up execution in `R161` and `R162`
## Context
OpenClaw started as a ComfyUI-focused node pack, but the repository now also contains:
- embedded HTTP APIs and operator UI surfaces,
- a standalone Remote Admin Console route,
- connector runtime code for multiple chat platforms,
- split control-plane governance for higher-risk deployments.
That evolution made one question increasingly ambiguous: what is the supported identity of this repo/package today, and which parts are first-class versus optional attached subsystems?
Without a boundary decision, future work such as config decomposition, connector extraction, or packaging hygiene is forced to rely on repo intuition instead of an explicit contract.
## Decision
OpenClaw is defined as a **ComfyUI custom node pack** first, with two explicit first-class identities layered on top of that package:
1. **ComfyUI custom node pack**
- This is the primary distribution artifact and runtime anchor.
- `__init__.py` remains the package entrypoint loaded from `custom_nodes/`.
2. **embedded operator platform**
- In-process OpenClaw APIs, runtime/security governance, sidebar UX, and remote admin surfaces are treated as part of the shipped package, not as separate products.
3. **connector-capable control surface**
- Remote chat control is supported through the in-repo connector sidecar, but the connector remains an **optional attached subsystem**, not the primary package artifact.
## Core vs Attached Subsystems
Core to the package:
- custom node pack entrypoint and exported nodes
- embedded API/runtime governance (`/openclaw/*`, route bootstrap, control-plane policy)
- embedded operator UI surfaces (sidebar plus `/openclaw/admin`)
Optional attached subsystem:
- connector sidecar (`python -m connector`) and platform-specific adapters
This means:
- the connector is supported and intentionally in-repo,
- but the repo is **not** currently defined as a connector-first distribution,
- and the repo is **not** currently defined as a standalone generic backend independent of ComfyUI.
## Supported Topologies
Supported:
1. **embedded local/lan**
- OpenClaw runs inside the ComfyUI process as the primary package artifact.
2. **embedded package with split high-risk control plane**
- The same package stays primary, while higher-risk control surfaces are externalized according to the split-mode contract.
3. **embedded package plus optional connector sidecar**
- The connector runs as a companion process that calls the local OpenClaw APIs.
Unsupported as first-class package identities today:
1. **connector-only distribution**
2. **standalone non-ComfyUI backend package**
Those possibilities are future design questions, not current promises. Connector extraction feasibility remains explicitly deferred to `R162`.
## Consequences
Positive:
- future extraction/pruning decisions now have one explicit contract to evaluate against
- docs and contributor discussions can use the same terms instead of mixing "node pack", "server", and "sidecar" loosely
- `R161` and `R162` can narrow their scope around a known boundary instead of re-litigating product identity
Trade-offs:
- the repo still carries multiple execution surfaces inside one codebase
- connector remains intentionally attached even though it is operationally separable
- some public docs must stay careful not to imply standalone server packaging that does not exist yet
## Rejected Alternatives
1. Treat the connector as an equal primary package artifact today
- Rejected because there is no separate connector package/distribution contract yet.
2. Define OpenClaw as a generic standalone backend package
- Rejected because current runtime ownership still assumes a ComfyUI host process.
3. Keep the boundary implicit and rely on contributor convention
- Rejected because packaging and extraction follow-ups now depend on an explicit contract.
@@ -0,0 +1,89 @@
# ADR-0003: Connector Extraction Feasibility And Split-Package Seams
- Status: Accepted
- Date: 2026-04-24
- Owners: OpenClaw maintainers
- Related roadmap items: `R162` with prior boundary decision in `ADR-0002`
## Context
ADR-0002 established that the connector is an **optional attached subsystem**, not the primary published artifact of this repository. The remaining question is whether that attached subsystem should now be extracted into a separately packaged connector or separate repo.
Current code structure still mixes:
- connector platform adapters and runtime
- shared installation/token lifecycle services
- shared callback signing and replay-protection contracts
- backend delivery/result APIs that the connector calls locally
- tenant/config/auth boundaries that remain owned by the core package
That means extraction is no longer a purely packaging question. It is a shared-contract question.
## Decision
OpenClaw adopts a **no-go-for-split-now** decision for connector extraction.
Current recommendation:
1. Keep the connector **in-repo** as an **optional attached subsystem**.
2. Treat a future **optional extra package** as the only plausible next extraction target.
3. Treat both **sidecar-only distribution** and **separate repo / primary connector package** as **no-go now** options.
## Minimum Stable Seams Required Before Any Split
Any future extraction must first stabilize these seam families:
1. **installation registry and token refs**
- workspace/account binding records
- tenant-scoped token-reference ownership
- installation diagnostics and fail-closed resolution
2. **interactive callback security contract**
- signed callback envelopes
- timestamp / replay / idempotency checks
- action-policy mapping and approval downgrade semantics
3. **delivery and result bridge**
- submission/result polling contract
- callback delivery expectations
- backend result payload compatibility
4. **config/auth and tenant boundary**
- connector runtime config contract
- admin token / auth expectations
- tenant header behavior
- server-side secret/state ownership
## Why Separate Packaging Is A No-Go Now
Current blockers are concrete, not theoretical:
- shared services import connector types and connector adapters import shared services, so extraction would currently create unstable bidirectional package seams
- installation/token/state ownership still lives in shared repo services rather than a versioned connector-boundary package
- connector API/client flows still assume in-repo backend evolution instead of a versioned external backend contract
- `services/sidecar` still imports connector config/client modules directly, so even a packaging-only split would not isolate ownership yet
## Consequences
Positive:
- maintainers now have one explicit go/no-go answer instead of repeatedly re-litigating extraction
- future connector extraction work can target named seam families instead of rediscovering coupling ad hoc
- admin diagnostics can expose the same contract to future packaging or release automation
Trade-offs:
- the repo intentionally keeps connector and core package code together for now
- packaging hygiene remains a future concern rather than a solved distribution problem
- extraction pressure is deferred until shared contracts are versionable on their own
## Rejected Alternatives
1. Extract connector into a separate repo now
- Rejected because current coupling would move instability across package boundaries instead of reducing it.
2. Publish connector as a sidecar-only primary distribution now
- Rejected because current operator workflows still assume the embedded OpenClaw package/runtime remains primary.
3. Leave extraction as an undocumented future possibility
- Rejected because future packaging work needs an explicit seam map and a clear no-go baseline.
+81
View File
@@ -0,0 +1,81 @@
# Advanced Registry Sync and Constrained Transforms
This guide covers optional, high-control features that are disabled by default.
## Overview
- Remote registry sync uses quarantine and trust policy controls.
- Constrained transforms execute trusted Python modules with strict runtime limits.
- Both features are fail-closed when disabled.
- Optional operational log hygiene: `OPENCLAW_LOG_TRUNCATE_ON_START=1` clears stale `openclaw.log` at backend startup (once per process).
## Remote registry sync
Enable remote registry sync:
```bash
OPENCLAW_ENABLE_REGISTRY_SYNC=1
```
Trust policy:
- `OPENCLAW_REGISTRY_POLICY=audit` (default): records signature/provenance issues for review
- `OPENCLAW_REGISTRY_POLICY=strict`: rejects non-compliant artifacts
Behavior highlights:
- Quarantine lifecycle is persisted under the state directory:
- `registry/quarantine/index.json`
- Entries are tracked with audit trail records.
- Anti-abuse controls include bounded dedupe windows and rate limiting.
- Integrity and policy checks are enforced before activation paths.
If registry sync is not enabled, registry operations fail closed.
## Constrained transforms
Enable constrained transforms:
```bash
OPENCLAW_ENABLE_TRANSFORMS=1
```
Runtime limits:
- `OPENCLAW_TRANSFORM_TIMEOUT` (seconds, default `5`)
- `OPENCLAW_TRANSFORM_MAX_OUTPUT` (bytes, default `65536`)
- `OPENCLAW_TRANSFORM_MAX_PER_REQUEST` (default `5`)
Trusted module paths:
- Default trusted directory: `data/transforms`
- Add extra trusted directories with `OPENCLAW_TRANSFORM_TRUSTED_DIRS`
- Use OS path separator (`;` on Windows, `:` on Linux/macOS)
Security controls:
- Only `.py` modules are allowed
- Module size is capped
- Module hash is pinned at registration time
- Integrity is re-checked before execution
- Execution is bounded by timeout and output budget
If transforms are disabled, transform execution is denied and mapping-only behavior continues.
## Example hardened operator profile
```bash
OPENCLAW_ENABLE_REGISTRY_SYNC=1
OPENCLAW_REGISTRY_POLICY=strict
OPENCLAW_ENABLE_TRANSFORMS=1
OPENCLAW_TRANSFORM_TIMEOUT=3
OPENCLAW_TRANSFORM_MAX_OUTPUT=32768
OPENCLAW_TRANSFORM_MAX_PER_REQUEST=3
```
## Rollout notes
1. Enable one feature at a time in a non-production environment.
2. Review logs and operator diagnostics after startup.
3. Keep strict policies for public or multi-tenant deployments.
4. Treat trusted transform directories as code deployment boundaries.
@@ -0,0 +1,27 @@
# Service Domain Packages
Bootstrap lifecycle, route registration, and effective security posture have explicit
implementation owners:
- `services/bootstrap/lifecycle.py` owns startup phase, outcome, and optional-warmup state.
- `services/bootstrap/registration.py` owns host route registration and retry coordination.
- `services/posture/effective.py` owns the immutable process security-posture snapshot.
The historical modules remain compatibility aliases:
- `services/startup_lifecycle.py`
- `services/route_bootstrap.py`
- `services/effective_security_posture.py`
Each alias maps its module name to the implementation module object. This preserves one
process singleton and keeps existing imports and patch points compatible. Do not replace
these aliases with copied re-exports: copied module globals can diverge from the state used
by implementation functions. Type-checker-only exports may describe the legacy interface,
but they must stay behind `TYPE_CHECKING` and must not become a second runtime owner.
New implementation code should import the domain-owned modules. Existing consumers may
continue to use the compatibility paths. An implementation module must never import its
compatibility alias; the repository dependency policy enforces that direction.
Package initializers are navigation-only. They must not register routes, resolve posture,
start threads, or re-export mutable process state during import.
+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.
+513 -15
View File
@@ -1,30 +1,93 @@
# OpenClaw Connector
The **OpenClaw Connector** (`connector`) is a standalone process that allows you to control your local ComfyUI instance remotely via chat platforms like **Telegram**, **Discord**, **LINE**, **WhatsApp**, and **WeChat Official Account**.
The **OpenClaw Connector** (`connector`) is a standalone process that allows you to control your local ComfyUI instance remotely via chat platforms like **Telegram**, **Discord**, **LINE**, **WhatsApp**, **WeChat Official Account**, **KakaoTalk (Kakao i Open Builder)**, **Slack**, and **Feishu/Lark**.
Per the product boundary contract, the connector is an **optional attached subsystem**. The primary published artifact of this repo remains the **ComfyUI custom node pack**, and the connector augments that package rather than replacing it.
Current extraction decision: keep the connector **in-repo** as an optional attached subsystem for now. OpenClaw does **not** currently treat a standalone connector package/repo as a supported distribution; see [ADR-0003](adr/ADR-0003-connector-extraction-feasibility-and-seams.md).
## How It Works
The connector runs alongside ComfyUI on your machine.
1. It connects outbound to Telegram/Discord (polling/gateway).
2. LINE/WhatsApp/WeChat use inbound webhooks (HTTPS required).
2. LINE/WhatsApp/WeChat/KakaoTalk/Slack use inbound webhooks (HTTPS required), while Feishu/Lark can run in webhook mode or long-connection mode with a separate callback ingress path for interactive actions.
3. It talks to ComfyUI via `localhost`.
4. It relays commands and status updates securely.
**Security**:
- **Transport Model**: Telegram/Discord are outbound. LINE/WhatsApp/WeChat require inbound HTTPS webhook endpoints.
- **Allowlist**: Only users/chats you explicitly allow can send commands.
- **Transport Model**: Telegram/Discord are outbound. LINE/WhatsApp/WeChat/KakaoTalk/Slack require inbound HTTPS webhook endpoints. Feishu/Lark supports webhook ingress or long-connection transport, but interactive callbacks still use a bounded local HTTPS callback path.
- **Allowlist/Trust Model**: Allowlists define trusted senders/channels. Non-allowlisted senders are treated as untrusted (for example, `/run` is approval-routed instead of auto-executed).
- **Strict Profile Gate**: In `public` deployment or `hardened` runtime posture, enabling connector ingress without platform allowlist coverage is fail-closed at startup/deployment checks.
- **Local Secrets**: Bot tokens are stored in your local environment, never sent to ComfyUI.
- **Admin Boundary**: Control-plane actions call admin endpoints on the local ComfyUI instance. See `OPENCLAW_CONNECTOR_ADMIN_TOKEN` below.
- **Admin Boundary**: Control-plane actions call admin endpoints on the local OpenClaw server and require connector-side admin token configuration for admin command paths.
- **Reply Visibility**: Shared visibility policy can suppress text-only silent/internal/tool-only/no-mention replies without suppressing approval cards, action buttons, or the underlying trust checks.
### Installation and callback contract baseline
OpenClaw now includes a platform-agnostic baseline for multi-workspace connector lifecycle and interactive callback security:
- installation registry stores normalized records:
- `platform`, `workspace_id`, `installation_id`, `token_refs`, `status`, `updated_at`
- token material is kept in encrypted server-side secret storage; registry and diagnostics expose token references only
- workspace resolution is fail-closed on missing/ambiguous/inactive/stale bindings
- installation diagnostics can also surface stable health states such as `ok`, `invalid_token`, `revoked`, `workspace_unbound`, and `degraded`
- interactive callback contract enforces signed envelope checks, timestamp window, payload-hash validation, replay/idempotency guardrails, and command-policy mapping (`public`/`run`/`admin`) with explicit force-approval outcomes for untrusted `run` callbacks
- connector replay handling acknowledges duplicate committed events as no-ops while allowing retryable failures before delivery commit to be retried
- text reply visibility is resolved through one connector policy for direct-message, shared-chat, thread, internal-delivery, and tool-only contexts; suppressed text is logged/diagnostic and treated as successful no-op delivery
Admin diagnostics APIs:
- `GET /openclaw/connector/installations`
- `GET /openclaw/connector/installations/{installation_id}`
- `GET /openclaw/connector/installations/resolve?platform=<platform>&workspace_id=<workspace_id>`
- `GET /openclaw/connector/installations/audit`
- `GET /openclaw/connector/extraction-contract`
Extraction diagnostics note:
- `/openclaw/connector/extraction-contract` is an admin-only structural metadata route for maintainers and operators. It returns the current packaging recommendation, candidate extraction options, seam families, and blockers, but it does **not** expose live token or installation-state details beyond the existing diagnostics routes above.
- The extraction contract also includes the static service-env SecretRef propagation policy. It is not a live environment dump and does not expose token values.
Slack multi-workspace notes:
- Slack OAuth installs bind one workspace per installation record and persist only encrypted token refs.
- OAuth callback state is single-use and replay/invalid-state callbacks fail closed instead of reusing a prior install session.
- `GET /openclaw/connector/installations/resolve?platform=slack&workspace_id=<team_id>` returns the fail-closed resolution view for a specific Slack workspace.
- `GET /openclaw/connector/installations` diagnostics may include per-install health metadata plus aggregate `health_counts`.
- Slack lifecycle events such as `tokens_revoked`, `app_uninstalled`, and rate-limit degradation update installation health so outbound replies fail closed or degrade predictably for the affected workspace.
- In multi-workspace mode, outbound replies and delayed result deliveries resolve the bot token by workspace binding and keep Slack thread context when replying back to the originating conversation.
- Slack interactive callbacks use the configured interactions path, signature verification, replay/idempotency checks, and connector policy mapping before accepting action payloads.
- Slack text replies honor the shared reply-visibility policy when context metadata is available; channel no-mention or tool-only text can be suppressed while Block Kit/action responses remain deliverable.
Feishu / Lark notes:
- Feishu bindings can be declared with a single app pair or a multi-account `OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON` manifest; each binding resolves to one normalized installation record with account/workspace identity.
- The connector supports both `feishu` and `lark` API domains through one shared binding contract, so region-specific app hosts do not require a different adapter.
- Websocket-mode Feishu deployments still host a callback route so interactive approval cards and command buttons remain available when message ingress itself is long-connection based.
- Feishu callback actions are signed, replay-guarded, tenant-aware, and deduplicated. Untrusted actors pressing run-affecting buttons are downgraded to approval flow instead of executing directly.
- Feishu text replies honor the shared reply-visibility policy when context metadata is available; group no-mention or tool-only text can be suppressed while interactive cards remain deliverable.
### Multi-tenant boundary behavior
When backend multi-tenant mode is enabled (`OPENCLAW_MULTI_TENANT_ENABLED=1`):
- installation records are tenant-owned (`tenant_id`) and diagnostics are tenant-scoped
- resolution rejects cross-tenant matches fail-closed (`tenant_mismatch` path)
- admin diagnostics calls can pass tenant context via token context and/or `X-OpenClaw-Tenant-Id` (or your configured `OPENCLAW_TENANT_HEADER`)
- missing tenant context currently falls back to `default` tenant for compatibility unless stricter caller paths are used
## Supported Platforms
- **Telegram**: Long-polling (instant response).
- **Telegram**: Long-polling (instant response), including forum topic reply context for immediate replies and delayed result delivery.
- **Discord**: Gateway WebSocket (instant response).
- **LINE**: Webhook (requires inbound HTTPS).
- **WhatsApp**: Webhook (requires inbound HTTPS).
- **WeChat Official Account**: Webhook (requires inbound HTTPS).
- **KakaoTalk (Kakao i Open Builder)**: Webhook (requires inbound HTTPS).
- **Slack (Events API)**: Webhook (requires inbound HTTPS).
- **Feishu / Lark**: Webhook or long-connection transport; interactive callbacks require inbound HTTPS for the callback route.
## Setup
@@ -41,19 +104,31 @@ Set the following environment variables (or put them in a `.env` file if you use
- `OPENCLAW_CONNECTOR_URL`: URL of your ComfyUI (default: `http://127.0.0.1:8188`)
- `OPENCLAW_CONNECTOR_DEBUG`: Set to `1` for verbose logs.
- `OPENCLAW_CONNECTOR_ADMIN_USERS`: Comma-separated list of user IDs allowed to run admin commands (e.g. `/run`, `/stop`, approvals, schedules).
- `OPENCLAW_CONNECTOR_ADMIN_USERS`: Comma-separated list of user IDs allowed to run admin commands (for example `/stop`, `/cancel`, approvals, schedules). Admin users are also treated as trusted senders for `/run`.
- `OPENCLAW_CONNECTOR_ADMIN_TOKEN`: Admin token sent to OpenClaw (`X-OpenClaw-Admin-Token`).
- `OPENCLAW_LOG_TRUNCATE_ON_START`: Optional backend runtime flag. Set `1` to clear `openclaw.log` once at backend startup to avoid stale-history noise in UI log panels.
- `OPENCLAW_MULTI_TENANT_ENABLED`: Optional backend mode toggle. If `1`, connector diagnostics and installation resolution become tenant-scoped.
- `OPENCLAW_TENANT_HEADER`: Optional tenant header key (default `X-OpenClaw-Tenant-Id`) used when calling tenant-scoped backend APIs.
**Admin token behavior:**
- If the OpenClaw server has `OPENCLAW_ADMIN_TOKEN` configured, you must set `OPENCLAW_CONNECTOR_ADMIN_TOKEN` to the same value or admin calls will return HTTP 403.
- If the OpenClaw server is in loopback-only convenience mode (no Admin Token configured), the connector can still call admin endpoints via `localhost` without sending a token.
- Connector admin command paths require `OPENCLAW_CONNECTOR_ADMIN_TOKEN` to be set in connector runtime.
- If the OpenClaw server has `OPENCLAW_ADMIN_TOKEN` configured, `OPENCLAW_CONNECTOR_ADMIN_TOKEN` must match it or admin calls return HTTP 403.
- Without `OPENCLAW_CONNECTOR_ADMIN_TOKEN`, admin command flows (`/approve`, `/reject`, `/trace`, schedules) are blocked by connector policy before upstream calls.
**SecretRef service environment behavior:**
- Service/sidecar launch helpers may preserve structured env-backed SecretRef metadata for connector credential variables such as platform bot tokens and signing secrets.
- Diagnostics show only the config path, env var name, source, status, and reason. They do not show raw token values from the installing shell.
- Raw secret strings, legacy `secretref-env:<NAME>` markers, unsupported env names, and gateway/admin auth env vars are rejected instead of being written into service metadata.
- Runtime-only auth secrets such as `OPENCLAW_CONNECTOR_ADMIN_TOKEN`, `OPENCLAW_WORKER_TOKEN`, and bridge device tokens must be provided by the runtime environment or a local secret manager rather than persisted through the connector service-env SecretRef boundary.
**Telegram:**
- `OPENCLAW_CONNECTOR_TELEGRAM_TOKEN`: Your Bot Token (from @BotFather).
- `OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_USERS`: Comma-separated list of User IDs (e.g. `123456, 789012`).
- `OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_CHATS`: Comma-separated list of Chat/Group IDs.
- Telegram forum topics are preserved when Telegram provides `message_thread_id`; command replies and delayed result delivery are sent back to the same topic. Manually configured delivery contexts must use numeric topic/thread IDs.
**Discord:**
@@ -98,17 +173,143 @@ Set the following environment variables (or put them in a `.env` file if you use
- `OPENCLAW_CONNECTOR_WECHAT_PORT`: Port (default `8097`).
- `OPENCLAW_CONNECTOR_WECHAT_PATH`: Webhook path (default `/wechat/webhook`).
**Image Delivery (F33):**
**KakaoTalk (Kakao i Open Builder):**
*(Requires Inbound Connectivity - see below)*
- `OPENCLAW_CONNECTOR_KAKAO_ENABLED`: Set to `true` to enable Kakao webhook adapter.
- `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS`: Comma-separated Kakao user IDs (`userRequest.user.id` / botUserKey). Non-allowlisted users are treated as untrusted and sensitive actions require approval.
- `OPENCLAW_CONNECTOR_KAKAO_BIND`: Host to bind (default `127.0.0.1`).
- `OPENCLAW_CONNECTOR_KAKAO_PORT`: Port (default `8096`).
- `OPENCLAW_CONNECTOR_KAKAO_PATH`: Webhook path (default `/kakao/webhook`).
**Slack (Events API):**
*(Requires Inbound Connectivity - see below)*
- `OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN`: Optional legacy single-workspace Bot User OAuth Token (`xoxb-...`). When Slack OAuth is configured, per-workspace tokens are resolved from the installation registry instead.
- `OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET`: Signing Secret (from App Credentials).
- `OPENCLAW_CONNECTOR_SLACK_CLIENT_ID`: OAuth client ID for multi-workspace installation flow.
- `OPENCLAW_CONNECTOR_SLACK_CLIENT_SECRET`: OAuth client secret for multi-workspace installation flow.
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_REDIRECT_URI`: Explicit OAuth callback URL. If omitted, connector derives it from `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL` + callback path.
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_INSTALL_PATH`: Local install route (default `/slack/install`).
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH`: Local callback route (default `/slack/oauth/callback`).
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_SCOPES`: Comma-separated bot scopes used for install URL generation.
- `OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC`: TTL in seconds for single-use OAuth state tokens (default `600`, clamped to `60..3600`).
- `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS`: Comma-separated user IDs (e.g. `U12345, U67890`).
- `OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS`: Comma-separated channel IDs (e.g. `C12345`).
- `OPENCLAW_CONNECTOR_SLACK_BIND`: Host to bind (default `127.0.0.1`).
- `OPENCLAW_CONNECTOR_SLACK_PORT`: Port (default `8095`).
- `OPENCLAW_CONNECTOR_SLACK_PATH`: Webhook path (default `/slack/events`).
- `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION`: `true` (default) to require `@Bot` mention in public channels.
- `OPENCLAW_CONNECTOR_SLACK_REPLY_IN_THREAD`: `true` (default) to reply in threads.
**Feishu / Lark:**
*(Long connection or webhook; callback ingress still requires inbound HTTPS if interactive cards are enabled)*
- `OPENCLAW_CONNECTOR_FEISHU_APP_ID`: App ID for the default Feishu/Lark binding.
- `OPENCLAW_CONNECTOR_FEISHU_APP_SECRET`: App secret for the default binding.
- `OPENCLAW_CONNECTOR_FEISHU_VERIFICATION_TOKEN`: Verification token for webhook event ingress.
- `OPENCLAW_CONNECTOR_FEISHU_ENCRYPT_KEY`: Optional encrypt key for encrypted webhook payloads.
- `OPENCLAW_CONNECTOR_FEISHU_ACCOUNT_ID`: Explicit account ID for the default binding.
- `OPENCLAW_CONNECTOR_FEISHU_DEFAULT_ACCOUNT_ID`: Fallback account ID when binding manifest entries omit one.
- `OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_ID`: Workspace or tenant identifier associated with the default binding.
- `OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_NAME`: Human-readable workspace name used in diagnostics.
- `OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON`: JSON list of account bindings for multi-account / multi-workspace setups.
- `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS`: Comma-separated trusted user IDs.
- `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_CHATS`: Comma-separated trusted chat IDs.
- `OPENCLAW_CONNECTOR_FEISHU_BIND`: Host to bind (default `127.0.0.1`).
- `OPENCLAW_CONNECTOR_FEISHU_PORT`: Port (default `8094`).
- `OPENCLAW_CONNECTOR_FEISHU_PATH`: Event ingress route (default `/feishu/events`).
- `OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH`: Interactive callback route (default `/feishu/callback`).
- `OPENCLAW_CONNECTOR_FEISHU_DOMAIN`: API domain selector (`feishu` or `lark`).
- `OPENCLAW_CONNECTOR_FEISHU_MODE`: Transport mode (`websocket` default, or `webhook`).
- `OPENCLAW_CONNECTOR_FEISHU_REQUIRE_MENTION`: Set `false` to allow commands without explicit mention in shared chats.
- `OPENCLAW_CONNECTOR_FEISHU_REPLY_IN_THREAD`: Set `false` to disable reply threading when the source chat supports it.
**Image Delivery:**
- `OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES`: Max completed images delivered per job (default `4`, clamped to `1..16`).
- `OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES`: Per-image delivery cap in bytes (default `10485760`, clamped to `65536..52428800`).
- `OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC`: Result delivery timeout in seconds (default `600`, clamped to `30..3600`).
- `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL`: Public HTTPS URL of your connector (e.g. `https://your-tunnel.example.com`). Required for sending images.
- `OPENCLAW_CONNECTOR_MEDIA_PATH`: URL path for serving temporary media (default `/media`).
- `OPENCLAW_CONNECTOR_MEDIA_TTL_SEC`: Image expiry in seconds (default `300`).
- `OPENCLAW_CONNECTOR_MEDIA_MAX_MB`: Max image size in MB (default `8`).
- `OPENCLAW_CONNECTOR_MEDIA_TTL_SEC`: Image expiry in seconds (default `300`, clamped to `60..86400`).
- `OPENCLAW_CONNECTOR_MEDIA_MAX_MB`: Max image size in MB (default `8`, clamped to `1..64`).
**Connector numeric guardrails:**
- Platform bind ports (`OPENCLAW_CONNECTOR_LINE_PORT`, `...WHATSAPP_PORT`, `...WECHAT_PORT`, `...KAKAO_PORT`, `...SLACK_PORT`, `...FEISHU_PORT`) must stay within `1..65535`; invalid or out-of-range values fall back to the platform default port.
- `OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM`: Per-user connector rate limit (default `10`, clamped to `1..600`).
- `OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM`: Per-channel connector rate limit (default `30`, clamped to `1..600`).
- `OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH`: Max accepted command text length (default `4096`, clamped to `128..32768`).
> **Note:** Media URLs are signed with a secret derived from `OPENCLAW_CONNECTOR_ADMIN_TOKEN` or a random key.
> To ensure URLs remain valid after connector restarts, **you must set `OPENCLAW_CONNECTOR_ADMIN_TOKEN`**.
> LINE and WhatsApp also **require** `public_base_url` to be HTTPS.
> WeChat currently supports text-first control. Image/media upload delivery is not implemented in phase 1.
> Kakao currently supports text-first control and quick replies. Rich media delivery is not enabled in the default Kakao webhook flow.
> Slack supports text responses and image uploads (via `files.upload` API).
> Feishu currently supports text replies plus interactive approval/command cards; richer card templates can be added on top of the same signed callback contract.
### Command authorization policy
Connector commands are evaluated through a centralized authorization policy with three command classes:
- `public`: low-risk status/help style commands
- `run`: execution commands such as `/run` (still subject to trust/approval behavior)
- `admin`: sensitive commands such as `/trace`, `/approvals`, `/approve`, `/reject`, and schedule controls
Default behavior:
- If no explicit allow-from list is configured for a command class, class-level defaults apply.
- `admin` commands require the sender to be in `OPENCLAW_CONNECTOR_ADMIN_USERS`.
- `public` and `run` commands still pass through each platform adapter's trust/allowlist checks.
Optional policy controls:
- `OPENCLAW_COMMAND_OVERRIDES`: JSON object mapping command name to class (`public`, `run`, `admin`).
- `OPENCLAW_COMMAND_ALLOW_FROM_PUBLIC`: comma-separated sender IDs.
- `OPENCLAW_COMMAND_ALLOW_FROM_RUN`: comma-separated sender IDs.
- `OPENCLAW_COMMAND_ALLOW_FROM_ADMIN`: comma-separated sender IDs.
Normalization rules:
- Command keys in `OPENCLAW_COMMAND_OVERRIDES` are normalized to lowercase.
- Missing leading `/` is added automatically.
Example:
```bash
OPENCLAW_COMMAND_OVERRIDES='{"run":"admin","/status":"public"}'
OPENCLAW_COMMAND_ALLOW_FROM_ADMIN=alice_id,bob_id
OPENCLAW_COMMAND_ALLOW_FROM_RUN=alice_id,ops_bot_id
```
If a class-level `OPENCLAW_COMMAND_ALLOW_FROM_*` list is set and non-empty, only listed IDs can run that class.
### Authoritative jobs summary
`/jobs` and its `jobs` / `queue` aliases are Admin-class commands. They require both an
authorized connector admin user and a configured `OPENCLAW_CONNECTOR_ADMIN_TOKEN` before
the connector calls `GET /openclaw/jobs`.
The connector validates jobs contract version 1 before rendering any reply:
- output contains aggregate snapshot/page counts plus at most five job IDs and statuses;
- displayed job IDs are capped at 24 characters and the complete reply is capped at 1,000
characters;
- raw job records, prompts, workflows, outputs, errors, tracebacks, tenant identifiers,
and the upstream payload are never sent to the chat LLM or copied into error messages;
- HTTP 401/403 returns a fixed authorization message without fallback;
- only explicit HTTP 501 `jobs_host_contract_unsupported` or HTTP 503
`jobs_backend_unavailable` responses may fall back to a bounded coarse queue count;
- malformed, unknown-version, oversized, or inconsistent success payloads fail to a fixed
content-free message.
Public `/status` remains separate: it can summarize health and the coarse ComfyUI queue,
but it does not fetch or forward the Admin-only jobs snapshot.
### 3. Usage
@@ -157,7 +358,7 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
```bash
OPENCLAW_CONNECTOR_WECHAT_TOKEN=replace-with-your-wechat-token
OPENCLAW_CONNECTOR_WECHAT_APP_ID=wx1234567890abcdef
OPENCLAW_CONNECTOR_WECHAT_APP_ID=replace-with-your-wechat-app-id
OPENCLAW_CONNECTOR_WECHAT_APP_SECRET=replace-with-app-secret
OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS=openid_1,openid_2
OPENCLAW_CONNECTOR_WECHAT_BIND=127.0.0.1
@@ -165,6 +366,9 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
OPENCLAW_CONNECTOR_WECHAT_PATH=/wechat/webhook
```
Use descriptive placeholders for App IDs in docs and examples. Secret scanners can
treat AppID-shaped samples as real credentials even when they are only documentation.
2. Start the connector:
```bash
@@ -192,7 +396,7 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
- verify connector receives command and returns text reply
7. Verify trusted/untrusted behavior:
- if sender OpenID is in `OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS`, `/run` can execute directly (subject to admin rules)
- if sender OpenID is in `OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS`, `/run` can execute directly (subject to trust/approval policy and command policy)
- if not allowlisted, sensitive actions are routed to approval flow
**WeChat-specific notes:**
@@ -200,10 +404,251 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
- The adapter validates WeChat signature on every request and applies replay/timestamp checks.
- Timestamp skew outside policy window is rejected (`403 Stale Request`).
- XML payload parsing is bounded (size/depth/field caps) and fails closed on parser budget violations.
- DTD / ENTITY declarations are rejected before parser entry; the adapter does not attempt to recover from unsafe XML payloads.
- Runtime XML security gate is fail-closed: unsafe/missing parser baseline blocks ingress startup.
- Current command surface is text-first. Unsupported message/event types are ignored with success response.
- Proactive outbound API messaging requires both `OPENCLAW_CONNECTOR_WECHAT_APP_ID` and `OPENCLAW_CONNECTOR_WECHAT_APP_SECRET`.
#### KakaoTalk (Kakao i Open Builder) Webhook Setup (Detailed)
Kakao i Open Builder sends webhook requests to your connector Skill endpoint. You must expose the Kakao endpoint publicly over HTTPS.
1. Prepare the required environment variables:
```bash
OPENCLAW_CONNECTOR_KAKAO_ENABLED=true
OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS=kakao_user_id_1,kakao_user_id_2
OPENCLAW_CONNECTOR_KAKAO_BIND=127.0.0.1
OPENCLAW_CONNECTOR_KAKAO_PORT=8096
OPENCLAW_CONNECTOR_KAKAO_PATH=/kakao/webhook
```
2. Start the connector:
```bash
python -m connector
```
3. Expose the local webhook service to HTTPS (Cloudflare Tunnel or reverse proxy):
- local upstream: `http://127.0.0.1:8096`
- public path: `/kakao/webhook`
- expected public URL: `https://<your-public-host>/kakao/webhook`
4. In Kakao i Open Builder:
- create/select your bot
- create/select a Skill
- set Skill server URL to `https://<your-public-host>/kakao/webhook`
- deploy/publish the Skill scenario that calls this Skill endpoint
5. Functional test:
- chat with your Kakao bot
- send `/help` or `/status`
- verify connector receives command and returns a SkillResponse (`version: 2.0`)
6. Verify trusted/untrusted behavior:
- if sender `userRequest.user.id` is in `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS`, `/run` can execute directly (subject to trust/approval policy and command policy)
- if not allowlisted, sensitive actions are routed to approval flow
7. Optional first-time allowlist bootstrap:
- temporarily leave `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS` empty
- send a test message and check logs for `Untrusted Kakao message from user=<id>`
- add that ID to allowlist and restart connector
**Kakao-specific notes:**
- Adapter is disabled unless `OPENCLAW_CONNECTOR_KAKAO_ENABLED=true`.
- Kakao webhook ingress is `POST` only on `OPENCLAW_CONNECTOR_KAKAO_PATH`.
- Replay protection is enabled: identical payloads within the replay window are acknowledged and not re-executed.
- Kakao command requests are normalized from:
- `userRequest.user.id` -> `sender_id`
- `userRequest.utterance` -> command text
- Response format follows Kakao SkillResponse v2.0 with text-first output and optional quick replies.
- If `aiohttp` is missing, the adapter is skipped at startup.
#### Slack Webhook Setup (Detailed)
Slack uses the Events API webhook mode in OpenClaw. You must expose the endpoint publicly over HTTPS.
1. **Create the Slack App**
- Go to [api.slack.com/apps](https://api.slack.com/apps).
- Create a new app (From scratch) and select your workspace.
- In **Basic Information**, copy the **Signing Secret**.
2. **Configure OAuth Scopes and install**
- Go to **OAuth & Permissions**.
- Add bot scopes:
- `chat:write`
- `files:write`
- `app_mentions:read`
- `im:history` (DM support)
- `channels:history` (public channel messages)
- `groups:history` (private channel messages)
- For legacy single-workspace mode, click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`).
- For multi-workspace mode, configure a redirect URL and let OpenClaw handle installs through its OAuth routes.
3. **Configure connector environment variables**
```bash
OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET=your-signing-secret
OPENCLAW_CONNECTOR_SLACK_CLIENT_ID=1234567890.1234567890
OPENCLAW_CONNECTOR_SLACK_CLIENT_SECRET=replace-with-client-secret
OPENCLAW_CONNECTOR_PUBLIC_BASE_URL=https://your-public-host
OPENCLAW_CONNECTOR_SLACK_OAUTH_INSTALL_PATH=/slack/install
OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH=/slack/oauth/callback
OPENCLAW_CONNECTOR_SLACK_INTERACTIONS_PATH=/slack/interactions
OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS=U12345,U67890
OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS=C12345
OPENCLAW_CONNECTOR_SLACK_BIND=127.0.0.1
OPENCLAW_CONNECTOR_SLACK_PORT=8095
OPENCLAW_CONNECTOR_SLACK_PATH=/slack/events
OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true
OPENCLAW_CONNECTOR_SLACK_REPLY_IN_THREAD=true
OPENCLAW_CONNECTOR_ADMIN_TOKEN=replace-with-openclaw-admin-token
```
Notes:
- Legacy single-workspace fallback can still set `OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN=xoxb-...`; multi-workspace mode no longer requires that token at startup if OAuth install flow is configured.
- `OPENCLAW_CONNECTOR_ADMIN_TOKEN` must match server `OPENCLAW_ADMIN_TOKEN` if server-side admin token is enabled.
- Slack ingress is fail-closed: invalid/missing signature, stale timestamp, and replayed events are rejected.
- Slack interactive callbacks use the same signing-secret verification and route actions through the connector policy layer before executing run-affecting behavior.
- OAuth callbacks also fail closed on invalid or replayed `state` values.
- External OAuth/install failures intentionally use bounded generic text; inspect connector logs and installation diagnostics for redacted detail instead of expecting raw exception text in the callback response.
4. **Start connector and expose webhook endpoint**
- Start connector: `python -m connector`
- Expose local endpoint to public HTTPS (Cloudflare Tunnel/ngrok/reverse proxy):
- local upstream: `http://127.0.0.1:8095`
- public URL: `https://<public-host>/slack/events`
- interactions URL: `https://<public-host>/slack/interactions`
- install URL: `https://<public-host>/slack/install`
- callback URL: `https://<public-host>/slack/oauth/callback`
5. **Enable Event Subscriptions and Interactivity**
- Go to **Event Subscriptions** and enable events.
- Set **Request URL** to `https://<public-host>/slack/events`.
- Slack sends `url_verification`; connector responds automatically.
- Add bot events:
- `app_mention`
- `message.channels`
- `message.groups`
- `message.im`
- Go to **Interactivity & Shortcuts** and enable interactivity.
- Set **Request URL** to `https://<public-host>/slack/interactions`.
6. **Invite and validate**
- Open `https://<public-host>/slack/install` and complete the workspace install.
- Invite the app to target channels: `/invite @YourBot`.
- In channel: `@YourBot /status` (when `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true`).
- In DM: `/help`.
- For approval or action-capable replies, press a rendered Slack button and confirm the connector logs show a signed interaction accepted or a bounded policy rejection.
- Verify connector logs show signed ingress accepted and replies delivered.
- Verify `GET /openclaw/connector/installations` shows the Slack workspace binding and health state `ok`.
- If you test uninstall/token-revoke scenarios, verify the installation health flips to `revoked` or `invalid_token` and that subsequent replies for that workspace fail closed until reinstalled.
7. **Security checklist before production**
- Keep `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS`/`OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS` restricted.
- Keep `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true` unless intentionally running command-style channels.
- Rotate Slack signing secret and OAuth client secret on incident response.
- Do not expose connector without HTTPS termination.
#### Slack Socket Mode Setup (Optional)
Use Socket Mode when you cannot expose a public HTTPS webhook endpoint.
1. **Enable Socket Mode in Slack**
- Open your Slack App settings.
- Go to **Socket Mode** and enable it.
- Create an App-Level Token (`xapp-...`) with `connections:write`.
2. **Configure connector**
```bash
OPENCLAW_CONNECTOR_SLACK_MODE=socket
OPENCLAW_CONNECTOR_SLACK_APP_TOKEN=xapp-your-token
# Signing secret remains required for parity/security checks.
OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET=your-signing-secret
# Either configure legacy single-workspace token...
OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN=xoxb-your-token
# ...or configure multi-workspace OAuth install flow:
OPENCLAW_CONNECTOR_SLACK_CLIENT_ID=1234567890.1234567890
OPENCLAW_CONNECTOR_SLACK_CLIENT_SECRET=replace-with-client-secret
```
3. **Start connector**
- `python -m connector`
- Expect log: `Slack Socket Mode connected.`
Notes:
- Socket Mode uses outbound WebSocket, so `OPENCLAW_CONNECTOR_SLACK_BIND`, `OPENCLAW_CONNECTOR_SLACK_PORT`, and `OPENCLAW_CONNECTOR_SLACK_PATH` are ignored in this mode.
- Startup is fail-closed if `OPENCLAW_CONNECTOR_SLACK_APP_TOKEN` is missing or does not start with `xapp-`.
- In multi-workspace mode, outbound replies still resolve the workspace-specific bot token from the installation registry even though the WebSocket connection itself uses the app-level token.
#### Feishu / Lark Setup (Detailed)
Feishu support can run in either long-connection (`websocket`) mode or webhook mode. Long-connection is usually the simpler default for message ingress, but interactive cards still need a reachable callback route if you want approval buttons and other signed actions.
1. **Create the Feishu or Lark app**
- Create a bot app in the Feishu or Lark developer console.
- Record the `App ID` and `App Secret`.
- If you want webhook ingress, also configure the event subscription verification token.
- If encrypted event delivery is enabled, record the encrypt key as well.
2. **Choose transport mode**
- `OPENCLAW_CONNECTOR_FEISHU_MODE=websocket`
- Uses long connection for message ingress.
- Recommended when you do not want to expose the event route publicly.
- `OPENCLAW_CONNECTOR_FEISHU_MODE=webhook`
- Uses HTTPS webhook delivery for messages.
- Requires a public HTTPS route for `OPENCLAW_CONNECTOR_FEISHU_PATH`.
3. **Configure the default binding**
```bash
OPENCLAW_CONNECTOR_FEISHU_APP_ID=cli_xxx
OPENCLAW_CONNECTOR_FEISHU_APP_SECRET=sec_xxx
OPENCLAW_CONNECTOR_FEISHU_ACCOUNT_ID=acct-default
OPENCLAW_CONNECTOR_FEISHU_DEFAULT_ACCOUNT_ID=acct-default
OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_ID=tenant-alpha
OPENCLAW_CONNECTOR_FEISHU_WORKSPACE_NAME="Alpha Workspace"
OPENCLAW_CONNECTOR_FEISHU_DOMAIN=feishu
OPENCLAW_CONNECTOR_FEISHU_MODE=websocket
OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS=ou_xxx,ou_yyy
OPENCLAW_CONNECTOR_FEISHU_ALLOWED_CHATS=oc_xxx,oc_yyy
```
4. **Optional: multi-account binding manifest**
- Use `OPENCLAW_CONNECTOR_FEISHU_BINDINGS_JSON` when one connector runtime should host more than one Feishu/Lark app or workspace binding.
- Each entry may include:
- `account_id`
- `app_id`
- `app_secret`
- `workspace_id`
- `workspace_name`
- `verification_token`
- `encrypt_key`
- `domain`
- `mode`
5. **Configure interactive callback ingress**
- Set `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL` to your public HTTPS origin.
- Expose `OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH` (default `/feishu/callback`) through your reverse proxy or tunnel.
- In long-connection mode this callback route is still required for interactive approval cards; message ingress transport does not remove callback security requirements.
6. **Start connector**
- `python -m connector`
- Expect logs showing the chosen Feishu mode and callback/event route bindings.
7. **Verify runtime behavior**
- Run `/status` from an allowlisted Feishu/Lark user.
- Run `/approvals` and confirm the reply renders approval buttons as an interactive card.
- Click `Approve` or `Reject` on a test approval and verify the callback succeeds once, then duplicate clicks are deduped.
Notes:
- `OPENCLAW_CONNECTOR_FEISHU_DOMAIN=lark` switches outbound API host behavior without changing the rest of the connector contract.
- Untrusted users can still see bounded command responses, but run-affecting interactive actions are downgraded to approval flow instead of auto-executing.
- Callback signing secrets are resolved from the bound Feishu installation record; diagnostics expose binding state, not raw secret material.
- Callback/event wrapper failures intentionally return bounded external error codes; inspect logs and installation diagnostics for redacted detail instead of expecting stack traces in callback responses.
## Commands
**General:**
@@ -215,7 +660,8 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
| `/history <id>` | View details of a finished job. |
| `/help` | Show available commands. |
| `/run <template> [k=v] [--approval]` | Submit a job. Use `--approval` to request approval gate instead of creating job immediately. |
| `/stop` | **Global Interrupt**: Stop all running generations. |
| `/stop [job_id ...]` | Stop jobs. With no job IDs, sends an explicit global interrupt. With one or more IDs, requests targeted job cancellation through ComfyUI's jobs API; older single-job hosts may fall back to targeted interrupt. |
| `/cancel [job_id ...]`, `/interrupt [job_id ...]` | Aliases for `/stop` with the same targeted or global behavior. |
**Admin Only:**
*(Requires User ID in `OPENCLAW_CONNECTOR_ADMIN_USERS`)*
@@ -262,6 +708,58 @@ WeChat Official Account pushes webhook requests to your connector. You must expo
- Sender is not in `OPENCLAW_CONNECTOR_ADMIN_USERS`.
- Fix: Add ID to `.env` and restart connector.
- **No visible chat reply after a command**:
- The command may have completed in a context where text-only replies are intentionally suppressed, such as internal delivery, tool-only handling, or a shared chat/channel without an active mention.
- Fix: check connector logs and job/approval state. Approval cards and action buttons should still be delivered when the action requires visible operator input.
- **Duplicate platform event is acknowledged but not executed again**:
- The connector has already committed the action and treats the retry/replay as a successful no-op.
- Fix: check the original event, job, or approval record instead of resending the same action payload. Retry only failures that happened before delivery/action commit.
- **HTTP 403 (Admin Token)**:
- Connector has the right user allowlist, but the upstream OpenClaw server rejected the Admin Token.
- Fix: Ensure `OPENCLAW_CONNECTOR_ADMIN_TOKEN` matches the server's `OPENCLAW_ADMIN_TOKEN`.
- **Kakao requests not arriving**:
- `OPENCLAW_CONNECTOR_KAKAO_ENABLED` is not `true`, or Kakao Skill URL/path does not match `OPENCLAW_CONNECTOR_KAKAO_PATH`.
- Fix: set `OPENCLAW_CONNECTOR_KAKAO_ENABLED=true`, verify public HTTPS URL, and confirm Skill URL exactly matches `/kakao/webhook` (or your custom path).
- **Kakao returns empty/fallback response**:
- Payload is malformed (missing `userRequest.user.id` / `userRequest.utterance`) or the adapter rejected malformed JSON.
- Fix: validate Skill request payload shape in Open Builder test console and check connector logs for `Bad JSON` / payload errors.
- **Kakao `/run` always goes to approval**:
- Sender is not in `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS` (or allowlist is empty).
- Fix: capture `userRequest.user.id` from logs, add it to `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS`, restart connector.
- **Slack Event Subscriptions verification fails**:
- Request URL/path mismatch, connector not reachable, or `OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET` is wrong.
- Fix: confirm public URL points to `/slack/events`, verify tunnel/proxy routes to `127.0.0.1:8095`, and re-check Signing Secret.
- **Slack commands ignored in channels**:
- `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true` and message does not mention the bot.
- Fix: mention bot explicitly (`@Bot /status`) or set `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=false` if policy allows.
- **Slack OAuth callback shows only a generic install failure**:
- This is expected on current builds; callback responses intentionally suppress raw exception text.
- Fix: inspect connector logs plus `GET /openclaw/connector/installations` / `/resolve` diagnostics to find the redacted root cause.
- **I need to know whether the connector is supported as a separate package/repo yet**:
- Current builds intentionally keep the connector in-repo as an optional attached subsystem.
- Fix: review `GET /openclaw/connector/extraction-contract` or [ADR-0003](adr/ADR-0003-connector-extraction-feasibility-and-seams.md) for the current no-split recommendation and the seam blockers that still need to be versioned first.
- **Feishu callback buttons fail with signature or stale-action errors**:
- Callback route is not using the same bound app secret, request arrived too late, or the button payload was replayed.
- Fix: verify binding diagnostics, public callback route, connector clock, and that the same action is not being resent by proxy/retry middleware.
- **Feishu long-connection messages work but card actions do nothing**:
- `OPENCLAW_CONNECTOR_FEISHU_CALLBACK_PATH` is not exposed publicly, or the callback URL is not routed to the connector bind host/port.
- Fix: expose the callback route over HTTPS even when `OPENCLAW_CONNECTOR_FEISHU_MODE=websocket`.
- **Feishu `/run` action becomes approval instead of executing immediately**:
- Callback actor is untrusted under current allowlist/policy mapping.
- Fix: add the user/chat to `OPENCLAW_CONNECTOR_FEISHU_ALLOWED_USERS` or `_ALLOWED_CHATS`, or keep the approval downgrade as the intended posture.
- **Feishu callback returns a bounded code such as `callback_rejected` or `event_rejected`**:
- This is expected on current builds; callback wrappers intentionally avoid echoing raw exception detail to the caller.
- Fix: inspect connector logs and installation/binding diagnostics for the redacted failure context.
+17 -5
View File
@@ -33,10 +33,19 @@ Set these Environment Variables:
```ini
# Require a token for admin actions (Stop/Approve)
OPENCLAW_CONNECTOR_ADMIN_TOKEN=your-strong-secret-token
OPENCLAW_ADMIN_TOKEN=your-strong-secret-token
# Explicitly allow admin write actions from non-loopback LAN clients
OPENCLAW_ALLOW_REMOTE_ADMIN=1
# Require a token for Logs/Config viewing
MOLTBOT_OBSERVABILITY_TOKEN=observability-secret
OPENCLAW_OBSERVABILITY_TOKEN=observability-secret
# Keep strict localhost no-origin behavior (do not relax on LAN)
OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0
# Optional startup log hygiene (avoid stale historical error lines in UI)
OPENCLAW_LOG_TRUNCATE_ON_START=1
```
### 3. Firewall Rules (Host)
@@ -61,10 +70,13 @@ sudo ufw allow from 192.168.1.0/24 to any port 8188
- ❌ Do not forward port 8188 on your router.
- ❌ Do not use `--listen 0.0.0.0` on a laptop connected to public WiFi.
- ❌ Do not set `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=true` on LAN/shared deployments.
- ❌ Do not assume LAN Remote Admin access also permits LAN-hosted custom LLM targets; `OPENCLAW_LLM_ALLOWED_HOSTS` alone does not allow private/reserved IP `base_url` values. Use the scoped LLM private-network setting only for reviewed targets.
## Testing
1. Find your host IP (e.g., `192.168.1.10`).
2. From another device on WiFi, visit `http://192.168.1.10:8188`.
3. Open OpenClaw Settings.
4. Try to view logs. It should challenge you for the `MOLTBOT_OBSERVABILITY_TOKEN` or deny access.
2. From another device on WiFi, open the remote admin page: `http://192.168.1.10:8188/openclaw/admin`.
3. Enter `X-OpenClaw-Admin-Token` in the page and click **Save**.
4. Verify admin write actions (for example refresh runs, approval actions) are no longer denied by remote policy.
5. Open OpenClaw Settings in ComfyUI and try to view logs. It should challenge you for the `OPENCLAW_OBSERVABILITY_TOKEN` or deny access when missing.
+17 -2
View File
@@ -32,17 +32,32 @@ To see the GUI go to: http://127.0.0.1:8188
No special configuration is required.
- **Admin Token**: Not required for loopback-only operations (unless `OPENCLAW_CONNECTOR_ADMIN_TOKEN` is explicitly set).
- **Admin Token**: Not required for loopback-only operations (unless `OPENCLAW_ADMIN_TOKEN` is explicitly set).
- **Webhooks**: Disabled by default.
- **Local LLM (optional)**:
- Ollama: `http://127.0.0.1:11434/v1`
- LM Studio: `http://localhost:1234/v1`
- Keep SSRF relax flags disabled:
- `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=0`
- `OPENCLAW_ALLOW_INSECURE_BASE_URL=0`
- **No-origin convenience override (optional, local-only)**:
- default/unset keeps strict no-origin denial
- set `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=true` only when local CLI/tooling compatibility requires it
- keep it unset/disabled for normal browser-only local use
- **Optional log hygiene**:
- `OPENCLAW_LOG_TRUNCATE_ON_START=1` clears stale `openclaw.log` content once at startup.
### 3. "Red Lines" (What NOT to do)
- ❌ Do not run with `--listen 0.0.0.0` or `--listen`.
- ❌ Do not port-forward port 8188 on your router.
- ❌ Do not leave `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=true` enabled longer than needed.
## Testing
1. Open `http://127.0.0.1:8188` in your browser.
2. Open the OpenClaw tab in the sidebar.
3. Go to **Settings** -> **Health**.
4. All checks should be green.
4. If using Ollama, verify the daemon is reachable first via the native Ollama health/list surface `http://127.0.0.1:11434/api/tags`.
5. In **Settings -> LLM**, set provider to `Ollama (Local)`, leave **Base URL** empty to use the built-in `http://127.0.0.1:11434/v1` default (or set that exact loopback URL explicitly), and click **Load Models**.
6. All checks should be green.
+72 -2
View File
@@ -3,11 +3,26 @@
For power users who want to run ComfyUI behind Caddy, Nginx, or Traefik.
This adds limits, TLS, and header management.
## Shared-Boundary Warning
OpenClaw and ComfyUI share the same upstream listener.
Blocking or authenticating `/openclaw/*` alone is not enough for public posture.
If your proxy forwards broad paths to ComfyUI, native ComfyUI routes may still be reachable.
Treat reverse proxy policy as the primary boundary:
- allow only the routes you intentionally need
- deny ComfyUI-native high-risk paths and their `/api/*` forms
## Guidelines
1. **Block Sensitive Paths**: Prevent external access to admin/debug endpoints if not needed.
- Block `/openclaw/logs/*`
- Block `/openclaw/config`
- Block `/openclaw/admin` and legacy `/moltbot/admin` when remote admin UI is not required
- Block ComfyUI-native high-risk paths:
- `/prompt`, `/history*`, `/view*`, `/upload*`, `/ws`
- `/api/prompt`, `/api/history*`, `/api/view*`, `/api/upload*`, `/api/ws`
2. **Timeouts**: ComfyUI generation can take time. Increase timeouts.
- `proxy_read_timeout 600s;` (Nginx)
3. **Websockets**: ComfyUI requires WS support.
@@ -25,8 +40,12 @@ comfyui.local {
}
# Security: Block sensitive OpenClaw paths from external access
@sensitive path /openclaw/logs* /openclaw/config
respond @sensitive 403
@openclaw_sensitive path /openclaw/logs* /openclaw/config /openclaw/admin /moltbot/admin
respond @openclaw_sensitive 403
# Security: Block ComfyUI-native high-risk surfaces (direct + /api variants)
@comfy_native_sensitive path /prompt /history* /view* /upload* /ws /api/prompt /api/history* /api/view* /api/upload* /api/ws
respond @comfy_native_sensitive 403
}
```
@@ -56,5 +75,56 @@ server {
location /openclaw/logs {
deny all;
}
location = /openclaw/admin {
deny all;
}
# Block ComfyUI native high-risk routes (direct + /api variants)
location = /prompt { deny all; }
location /history { deny all; }
location /view { deny all; }
location /upload { deny all; }
location = /ws { deny all; }
location = /api/prompt { deny all; }
location /api/history { deny all; }
location /api/view { deny all; }
location /api/upload { deny all; }
location = /api/ws { deny all; }
}
```
## Recommended Pattern: Allowlist-First Routing
If you do not need full ComfyUI UI exposure, prefer explicit allowlist routing:
- allow only required OpenClaw routes (for example `/openclaw/admin`, selected `/openclaw/*` APIs)
- deny everything else by default
This reduces accidental exposure from ComfyUI route changes or API shim behavior differences.
## If You Intentionally Expose Remote Admin Console
Only do this on trusted/private access planes and keep backend protection enabled:
- `OPENCLAW_ADMIN_TOKEN=<strong-secret>`
- `OPENCLAW_ALLOW_REMOTE_ADMIN=1`
- `OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1` (when `OPENCLAW_DEPLOYMENT_PROFILE=public`, set only after proxy allowlist + network ACL boundary controls are enforced)
- `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0` (keep strict no-origin posture on shared/remote planes)
- `OPENCLAW_LOG_TRUNCATE_ON_START=1` (optional, startup log hygiene)
Use one more auth boundary at proxy layer (IP allowlist, SSO, or basic auth), for example:
```nginx
location = /openclaw/admin {
allow 10.0.0.0/8;
allow 192.168.0.0/16;
deny all;
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://127.0.0.1:8188/openclaw/admin;
}
```
-17
View File
@@ -1,17 +0,0 @@
# /etc/default/openclaw.env
# Secure environment configuration for OpenClaw
# Admin Token (Required for remote ops)
OPENCLAW_CONNECTOR_ADMIN_TOKEN=change-me-to-a-strong-secret
# Observability Token (Required for remote logs)
# (Legacy: MOLTBOT_OBSERVABILITY_TOKEN)
OPENCLAW_OBSERVABILITY_TOKEN=change-me-too
# Bridge (Default: 0/Disabled)
OPENCLAW_BRIDGE_ENABLED=0
# OPENCLAW_BRIDGE_TOKEN=
# Network
# Bind to localhost by default
COMFYUI_LISTEN=127.0.0.1
+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

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